Settlement spec
The deterministic resolution contract — precise enough to audit.
Settlement is deterministic (a pure function of on-chain accounts and a supplied proof), permissionless (no privileged signer), and immutable once written.
What "settled" means
A market is settled when a MatchFacts or Verdict account exists that a Merkle proof
of the relevant stat produced. No account, no settlement. There is no code path — for
any signer, including the deployer — that sets an outcome without a proof.
The proof chain
TxODDS accumulates score events into 5-minute batches and publishes a Merkle root of
each batch to its txoracle program on Solana. A single stat connects to that root
through three levels:
ScoreStat ──statProof──▶ eventStatRoot ──subTreeProof──▶ fixture events root ──mainTreeProof──▶ batch root (on-chain)
(leaf) (per event) (per fixture) (per 5-min batch)Verified hashing scheme
Reproduced byte-for-byte from a real full-time proof (locked by reference tests):
- Leaf:
sha256(borsh(ScoreStat))whereborsh(ScoreStat) = key:u32-LE ‖ value:i32-LE ‖ period:i32-LE(12 bytes). An already-computed sub-tree root is fed in raw (not re-hashed). - Node: at each proof step,
is_right_sibling ? sha256(node ‖ sibling) : sha256(sibling ‖ node).
The final level (batch root inside the roots account) is delegated to the oracle CPI; SettleKit independently verifies the two documented levels (dual attestation).
The canonical Question
Every verdict answers a canonical Question:
Question {
version, fixture_id,
stat_keys: [u32], // soccer: 1/2 goals, 3/4 yellows, 5/6 reds, 7/8 corners
predicates: [
Single { stat_index, threshold, cmp: Gt|Lt|Eq } |
Binary { index_a, index_b, op: Add|Subtract, threshold, cmp } // e.g. corners A+B > 10
],
phase_req, // required ScoreStat.period; 5 = FT, 10 = after ET, 13 = after pens
batch_bound: None | AtOrBefore(ts) | AtOrAfter(ts),
}question_hash = sha256(borsh(Question)) keys the Verdict PDA, and the oracle
strategy is always derived on-chain from the question. A verdict account therefore
provably corresponds to exactly the predicate that was verified.
The gate sequence
post_verdict / post_match_facts execute these in order; each failure has a dedicated
error code:
| # | Gate | Rule | Error |
|---|---|---|---|
| 1 | G-HASH | question_hash == sha256(borsh(question)) | QuestionHashMismatch |
| 2 | shape | version supported; 1–8 stat keys; predicates non-empty; indices in range | QuestionShapeInvalid |
| 3 | G-FIXTURE | payload fixture + stat keys match the question | FixtureMismatch / StatKeyMismatch |
| 4 | G-PHASE | if phase_req != ANY, every proven ScoreStat.period == phase_req | PhaseGateFailed |
| 5 | G-TIME | payload.ts == summary.min_timestamp; batch_bound satisfied | BatchTsMismatch / BatchTooLate / BatchTooEarly |
| 6 | G-MONO | time-bounded questions restricted to monotone stats (keys 1–8) | StatNotMonotone |
| 7 | G-ROOT | roots account = canonical txoracle PDA for the epoch day; owner = txoracle | OracleRootAccountInvalid |
| 8 | CPI | invoke(txoracle::validate_stat_v2) with the derived strategy | — |
| 9 | G-RETURN | return data present, from txoracle, 1 byte ≤ 1 | OracleReturn… |
outcome = (return byte == 1). For post_verdict, false is stored as data (a
failure-to-prove, not a proven negation). For post_match_facts, a FALSE proof aborts —
MatchFacts is only ever written from a TRUE proof at a final phase.
Why G-PHASE is load-bearing. Verdict PDAs are init-once. Without the phase gate an
attacker could post a valid-but-premature FALSE verdict at minute 10 and permanently
squat the PDA the true full-time score needs. Because ScoreStat.period lives inside
the Merkle-authenticated leaf, a mid-match proof carries period 4 and fails the gate.
Dual attestation
attest_native upgrades a CPI-verified verdict to DUAL. When a verdict is posted, the
same transaction persists a BatchAnchor PDA holding the events_sub_tree_root the
oracle CPI just validated (written only on a TRUE validation). attest_native then
independently recomputes — with its own sha256 syscalls — the fixture events sub-tree
root straight from the raw stat leaf, and requires it to equal that anchor. No
caller-supplied reference value appears anywhere in the instruction: two independent
verifiers meet at an on-chain, oracle-authenticated commitment. Failure-to-prove
verdicts leave their anchor rootless and can never be attested.
Market resolution (pure functions of the proof)
WinnerFT: home>away → Home | home<away → Away | level at FT → Draw
level after ET/pens → VOID (goal facts cannot name a pens winner)
TotalGoalsOU: (home+away)*10 > param → Over | else Under (half-lines ⇒ no push)
BTTS: home≥1 && away≥1 → Yes | else No
DrawNoBet: home/away win → that side | draw → VOID (refund)
CorrectScore: exact scoreline bucket | "Any other"
GoalsBands: 0–1 / 2–3 / 4+ OddEven: (home+away) parity
SumThreshold: a TRUE Binary Verdict "statA + statB > K at FT" → Yes | "< K+1" → No
ThresholdBefore: in-play — YES proven AtOrBefore(T), NO proven AtOrAfter(T)Payouts, solvency, and the no-oracle backstop
- Claim is permissionless (any cranker) but funds only move to the position owner's token account. Winning shares redeem at 1 USDC; voided markets unwind at seed prices.
- Solvency is structural. LMSR seeds are normalized so
min(q0) = 0; the required subsidyceil(b·ln(1/p_min))is prefunded at creation, so the vault always covers the worst-case payout. Property-tested over 2,048 random trade sequences. - No-oracle backstop. If no proof ever arrives, anyone can void the market 48h after kickoff and holders refund at seed prices. The refund path touches no oracle.