import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
Tutorial — Build a prediction market
In this tutorial you'll build a complete binary prediction market on top of OMY: users buy YES or NO shares with USDT, the oracle decides the outcome, and winners claim $1 per share. By the end you'll understand how every piece of the SDK fits into a real consumer contract.
This tutorial mirrors the reference Market.tolk
contract in the OMY repo — read the source alongside.
What you're building
User Market OMY
─────────────────────────────────────────────────────────────────────
1. split USDT ─────► YES + NO shares
2. trade YES/NO (constant-product AMM)
3. creator: request ──────► CreateAssertion → OracleFactory
◄────── AssertionCreated{id, assertion}
4. someone proposes ─────────► Assertion.propose(answer)
(liveness window — usually no one disputes)
5. finalize ───► Assertion.finalize()
◄────── OracleResult{id, answer}
6. winners redeem ◄───── Market.Redeem (1:1 in USDT)
The full Market contract is ~330 lines of Tolk. We'll walk through the 7 message handlers that matter.
Step 1 — Storage
The Market's state has three layered cells (TON cells max out at 1023 bits, so we split):
struct MarketConfig {
id: uint64
creator: address
oracle: Cell<OracleParams> // factory + resolver + bond + liveness + question
}
struct MarketState {
status: uint8 = M_OPEN
jettonWallet: address? // market's USDT wallet, bound once
oracleAssertion: address? // the assertion that reports the result
winningYes: bool
totalCollateral: coins = 0 // USDT held; == total YES == total NO supply
pool: Cell<MarketPool> // YES/NO reserves for the AMM
}
The OracleParams snapshot pinned at deploy time is the contract address
of the OracleFactory, the resolver to use, the bond size, the liveness window,
and the question text. Once the Market deploys, those terms are immutable.
Step 2 — Splitting USDT into shares
When a user sends d USDT with the SPLIT op, you credit them d YES and
d NO shares:
TransferNotificationForRecipient => {
// ... wallet-auth guards ...
if (op == M_OP_SPLIT) {
st.yesBalance.set(owner, balanceOf(st.yesBalance, owner) + msg.jettonAmount);
st.noBalance.set(owner, balanceOf(st.noBalance, owner) + msg.jettonAmount);
state.totalCollateral += msg.jettonAmount;
...
}
}
The math: 1 USDT collateral → 1 YES share + 1 NO share. At resolution, only one side is worth $1. The other is worth $0. The user is free to sell either side independently via the AMM.
:::tip Invariant
totalCollateral == total YES outstanding == total NO outstanding — always.
Every code path that mutates balances must preserve this. The Market contract's
test suite asserts the invariant after every state transition.
:::
Step 3 — The constant-product AMM
Once a liquidity provider seeds the pool with equal YES + NO reserves, traders
can buy either side. Classic x * y = k:
} else if (op == M_OP_BUY_YES) {
val swapFee = (msg.jettonAmount * SWAP_FEE_BPS) / 10000; // 0.5%
val takeFee = state.treasuryAddress != null && swapFee > 0;
val d = (takeFee ? msg.jettonAmount - swapFee : msg.jettonAmount) as coins;
val k = pool.poolYes * pool.poolNo;
val newNo = pool.poolNo + d;
val newYes = (k + newNo - 1) / newNo; // ceil — pool gets the rounding
val bought = pool.poolYes + d - newYes;
pool.poolYes = newYes as coins;
pool.poolNo = newNo as coins;
state.totalCollateral += d;
// ... credit `bought` YES shares to the buyer ...
if (takeFee) {
sendUsdt(state.jettonWallet!, state.treasuryAddress!, swapFee);
}
}
The constant-product invariant is preserved with a ceil-rounding twist that gives the pool a 1-unit edge on every trade — protects LPs from arbitrage that exploits rounding.
Step 4 — Requesting resolution
When the event is settled in the real world, the market creator calls
RequestResolution. The market sizes its bond proportionally to TVL, then
sends CreateAssertion to the OracleFactory:
RequestResolution => {
assert (in.senderAddress == cfg.creator) throw MarketErr.OnlyCreator;
state.status = M_AWAITING;
state.awaitingDeadline = (blockchain.now() + AWAITING_TIMEOUT) as uint32;
st.save();
val op = cfg.oracle.load();
// bond > profit from lying — scale to TVL
val scaledBond = state.totalCollateral * BOND_BPS / 10000;
val bond = min(max(scaledBond, op.oracleBond), MAX_BOND_CAP) as coins;
createMessage({
dest: op.factory,
value: ORACLE_CREATE_VALUE,
body: CreateAssertion {
id: cfg.id,
resolver: op.resolver,
bondAmount: bond,
liveness: op.oracleLiveness,
meta: ...,
},
}).send(SEND_MODE_IGNORE_ERRORS);
}
The market enters M_AWAITING status and starts a timeout. If the factory
hop drops (rare but possible with SendIgnoreErrors), the creator can call
CancelAwaiting after 1 day to recover.
Step 5 — Binding the spawned assertion
The factory replies asynchronously with AssertionCreated{id, assertion}. The
market verifies the message came from THE factory (not a spoof) and binds:
AssertionCreated => {
val cfg = st.config.load();
val op = cfg.oracle.load();
assert (in.senderAddress == op.factory) throw MarketErr.NotOurFactory; // anti-spoof
assert (msg.id == cfg.id) throw MarketErr.WrongId;
state.oracleAssertion = msg.assertion;
...
}
:::warning Anti-spoof matters
A random sender could try to deliver a fake AssertionCreated pointing at
a malicious assertion they control. The check sender() == factory is what
blocks this. Always check the sender on inbound messages. This applies to
your consumer too — verify OracleResult comes from state.oracleAssertion
that you previously bound.
:::
Step 6 — Receiving the result (with dedupe)
When the assertion resolves, it sends OracleResult{questionId, answer} to
its bound callbackRecipient (the market). The market settles and skims the
1% silent resolution fee:
OracleResult => {
var state = st.state.load();
assert (state.oracleAssertion != null) throw MarketErr.OracleNotSet;
assert (in.senderAddress == state.oracleAssertion!) throw MarketErr.NotOurOracle; // anti-spoof
assert (msg.questionId == cfg.id) throw MarketErr.WrongQuestion;
assert (state.status == M_OPEN || state.status == M_AWAITING) throw MarketErr.AlreadyResolved; // dedupe
state.status = M_RESOLVED;
state.winningYes = msg.answer;
// ... skim 1% to treasury, save state ...
}
Three guards together = idempotent at-least-once delivery:
state.oracleAssertion != null— we have an assertion boundsender == state.oracleAssertion!— message is from THAT assertion (anti-spoof)state.status == M_OPEN || M_AWAITING— first resolve wins; second attempt throws
A ReemitResult triggered by anyone after resolution gets AlreadyResolved
and the state stays sane.
Step 7 — Redeem with the silent skim
Winners claim 0.99 USDT per winning share. The 1% resolution-skim happens
silently at resolve time, so users see clean payout numbers (no visible "fee"
line):
Redeem => {
assert (state.status == M_RESOLVED) throw MarketErr.NotResolved;
var rawShares: coins = state.winningYes
? balanceOf(st.yesBalance, in.senderAddress)
: balanceOf(st.noBalance, in.senderAddress);
assert (rawShares > 0) throw MarketErr.NothingToRedeem;
val payout = (state.resolutionFeeApplied
? rawShares * (10000 - RESOLUTION_FEE_BPS) / 10000
: rawShares) as coins;
// ... clear balances, pay out ...
}
What you just shipped
You implemented 7 message handlers (Split, BuyYes, BuyNo, Merge,
RequestResolution, AssertionCreated, OracleResult, Redeem,
CancelAwaiting) and reused OMY for the part that matters: deciding
the outcome.
The whole Market reference contract — including the AMM, the bond sizing math, the dispute path, the timeout escape — is ~330 lines of Tolk.
Production checklist before mainnet
import {DocFeatures, DocFeature} from '@site/src/components/docs/DocFeatures';
Where next
- Patterns — production patterns for dedupe, error handling, gas budgeting
- Reference — every opcode, every constant
- Troubleshooting — common errors during integration