Designing settlement logic for Solana prediction markets
The MarketFactory and MarketSettlement programs are the smallest possible programs that do their job. Here is what's in them and why.
The on-chain piece of Mentat is two Anchor programs: MarketFactory instantiates markets, MarketSettlement resolves them. Together they are smaller than people expect for a prediction market protocol, and that smallness is intentional. This post walks through the design choices, the account layout, and a few things that surprised us during M3 buildout.
What’s in the programs
MarketFactory owns market creation. It allocates a Market account, validates the spec hash matches the off-chain extended metadata, accepts the creator stake, and emits a MarketCreated event for the indexer.
MarketSettlement owns resolution. It accepts proof artifacts (or proof references for circuits too large for on-chain verification), verifies them against the market’s declared trigger condition, advances the state machine, and distributes payouts.
Trading itself — order book, positions, fills — lives in a separate program that we treat as swappable infrastructure. Several teams have built solid Solana DEX patterns and we did not feel obliged to reinvent. The settlement programs declare the trading program ID via CPI hooks so a deployment can substitute its own.
The account layout
The Market account is the system’s central data structure. Here is the layout, stripped to essentials:
#[account]
pub struct Market {
pub id: u64, // monotonic from factory
pub creator: Pubkey, // staked party
pub version: u32, // immutable after deploy
pub state: MarketState, // enum
pub spec_hash: [u8; 32], // BLAKE2 of off-chain spec
pub rationale_hash: [u8; 32], // BLAKE2 of AI rationale
pub outcome_set: OutcomeSet, // Binary | Enum(Vec<String>)
pub primary_sources: Vec<String>, // domain allowlist
pub trigger_kind: TriggerKind, // JsonPath | PhraseMatch | ...
pub trigger_payload: Vec<u8>, // serialized trigger spec
pub timestamp_window_start: i64,
pub timestamp_window_end: i64,
pub creator_stake: u64,
pub fees: FeeConfig,
pub created_at: i64,
pub open_at: i64,
pub lock_at: Option<i64>,
pub resolution_deadline: i64,
pub dispute_window_secs: u32,
pub resolved_outcome: Option<u8>, // index into outcome_set
pub proof_hash: Option<[u8; 32]>,
}
A few notes on the choices:
spec_hash and rationale_hash are stored as BLAKE2 fingerprints of the off-chain extended metadata (the full markdown summary, the AI rationale text, the curator audit trail). On-chain accounts stay small; rich content lives in content-addressed storage (Arweave or IPFS-pinned through Web3.Storage) with the hash on-chain as a tamper detector. This is a deliberate split: Solana account rent is real money and we do not want to pay it for blog-post-length rationale text.
trigger_kind plus trigger_payload is a small ABI. The settlement program switches on the kind enum and deserializes the payload accordingly. We started with two trigger kinds — JsonPath and PhraseMatch — and the design lets us add more (RegexMatch, NumericThreshold, CompositeTrigger) without breaking deployed markets, because each market commits to a specific trigger kind at creation time.
primary_sources is a Vec<String> of domain names. The settlement program checks that the proof artifact’s transcript commitment references a domain in this list. Off-allowlist proofs are rejected. The allowlist is per-market, not global, which lets specialized deployments support specialized sources without exposing them everywhere.
fees is a FeeConfig struct holding trading fee bps, settlement fee bps, LP/Creator/Treasury splits, and proof bounty parameters. We cap aggregate fees at 5% in the constructor (soft cap, governance-overridable). Predictable fee economics matter for trader UX and we did not want a market accidentally shipping with a 20% take rate.
The settlement flow
The settlement program is a small state machine over the MarketState enum:
Draft → PendingLaunch → Active → (Locked?) → Resolved | Invalid | Disputed
PendingLaunch exists because curator approval and on-chain deploy are decoupled. A curator approves an off-chain draft; a separate “deploy” transaction allocates the on-chain account. The brief intermediate state lets the indexer track approvals without false positives for queued markets.
Active is the trading state. Trading is gated on state == Active. The lock-at timestamp transitions to Locked if set, which suspends trading without resolving.
The interesting transitions are into Resolved and Invalid. The submit_proof instruction takes:
- A proof artifact (TLSNotary-style proof bytes plus public inputs).
- The market PDA.
- A submitter signature.
Verification logic:
pub fn submit_proof(ctx: Context<SubmitProof>, artifact: ProofArtifact) -> Result<()> {
let market = &mut ctx.accounts.market;
require!(market.state == MarketState::Active || market.state == MarketState::Locked, MentatError::WrongState);
require!(Clock::get()?.unix_timestamp <= market.resolution_deadline, MentatError::DeadlinePassed);
// 1. Verify the proof binds to a declared source.
let source_domain = artifact.public_inputs.domain.as_str();
require!(market.primary_sources.iter().any(|s| s == source_domain), MentatError::SourceNotAllowed);
// 2. Verify the proof attests a TLS session within the window.
let session_ts = artifact.public_inputs.session_timestamp;
require!(
session_ts >= market.timestamp_window_start && session_ts <= market.timestamp_window_end,
MentatError::OutsideWindow
);
// 3. Verify the zk proof itself (Groth16 verifier or similar).
let verifier = match market.trigger_kind {
TriggerKind::JsonPath => &JSONPATH_VERIFIER_VK,
TriggerKind::PhraseMatch => &PHRASE_VERIFIER_VK,
};
require!(verifier.verify(&artifact.proof, &artifact.public_inputs), MentatError::InvalidProof);
// 4. Decode the outcome from the public inputs and advance state.
let outcome_index = artifact.public_inputs.outcome_index;
market.resolved_outcome = Some(outcome_index);
market.proof_hash = Some(hash::blake2(&artifact.proof));
market.state = MarketState::Resolved;
emit!(MarketResolvedEvent {
market_id: market.id,
outcome: outcome_index,
proof_hash: market.proof_hash.unwrap(),
});
Ok(())
}
There are four checks here doing the load-bearing work:
- The source domain must be in the per-market allowlist.
- The TLS session timestamp must be inside the spec’s acceptance window.
- The cryptographic proof must verify against the verifier key for the trigger kind.
- The outcome derives from the public inputs of the proof, not from the submitter’s claim.
If any check fails, the transaction reverts and the market remains in its prior state. No partial state changes, no half-resolved markets.
On-chain verification cost
The honest version is that on-chain Groth16 verification on Solana is not free. Solana’s BPF execution model is fast but not magical; verifier circuits compile to bounded compute units and the bigger the circuit the more units you spend.
Our M3 approach:
- For small trigger circuits (PhraseMatch, simple NumericThreshold), full on-chain verification fits in a single instruction within Solana’s compute unit budget.
- For larger circuits (complex JsonPath queries, RegexMatch), we use a hybrid model: the proof is verified off-chain by a permissioned verifier service, which signs an attestation of verification that is checked on-chain. The market state stores the proof hash so anyone can re-verify off-chain.
We do not love the hybrid path. It reintroduces a small trust layer for some markets. M4 work is reducing the set of markets that need the hybrid path by tightening the circuit families we support. The long-term target is full on-chain verification for all market types, contingent on Solana’s compute unit budget evolution and on circuit optimization on our side.
Things that surprised us
Anchor’s account size accounting matters more than you think. The Market struct’s variable-length fields (primary_sources, trigger_payload) push us into the realm of dynamically-sized accounts with realloc semantics. We initially allocated generous fixed sizes; we now compute exact sizes at creation time and pay only for what each market needs. Saves rent at scale.
PDA seed design has long-term consequences. We seed Market PDAs by (b"market", market_id_le_bytes). We considered seeding by (creator, market_id) for better composability but discarded it because indexers benefit massively from a flat ID space. The decision was unanimous in review and we are glad we made it early.
Event emission is not free but is worth it. Every state transition emits an event. The indexer consumes events instead of polling accounts. The cost is per-instruction compute units; the benefit is real-time indexer responsiveness and a clean audit trail. Worth it.
Reverting on partial validation is critical. Early prototypes had subtle bugs where some checks passed and some failed, leaving the market in an ambiguous state. We refactored to make the entire validation atomic: every check first, state update only if all pass. The Anchor require! macro plus explicit transaction reversion makes this natural but you have to design for it.
Compute unit budget is a real constraint. Solana’s per-transaction compute unit cap means certain proof systems are genuinely too big to verify on-chain today. We picked Groth16 over Plonk for the first verifier because Groth16’s verification is cheaper at the cost of more expensive proof generation — and proof generation happens off-chain where cost is a CapEx problem, not a hot-path issue.
What is not in scope
We did not build a trading program. We did not build an order book. We did not build position management. All three live in adjacent programs that we treat as swappable infrastructure. The trading program in our reference deployment is loosely modeled on existing Solana DEX patterns; a deployment that wants to swap in its own book can do so by changing one config struct.
We did not build a governance program. The dispute path lands at a governance hook that the deployment configures. Mentat itself is agnostic on governance design because different deployments will want different things — token-weighted DAO vote, signature multisig, off-chain arbitration, whatever fits. The protocol just provides the hook.
We did not build a cross-chain bridge. Mentat is Solana-native today. EVM is an interesting future direction but not on the M3-M5 roadmap. The settlement programs assume a single Solana runtime and we are not pretending otherwise.
The summary
MarketFactory is a couple of hundred lines of Anchor. MarketSettlement is a thousand-ish lines including the verifier glue. Together they are the smallest programs that can do this job correctly. The smallness is on purpose: smaller programs are easier to audit, easier to upgrade, and easier to verify formally if we get there.
The hard work of the protocol is not in the on-chain programs. It is in the authoring pipeline that produces specs the on-chain programs can resolve cleanly. That is why this post is shorter than the Scout/Draft/Validator one.