Skip to main content

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.

info

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):

tolk/contracts/market-storage.tolk
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:

  1. state.oracleAssertion != null — we have an assertion bound
  2. sender == state.oracleAssertion! — message is from THAT assertion (anti-spoof)
  3. 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';

Long enough that disputer-watchers can react; short enough that markets settle quickly. 1 hour minimum, 6-24h for high-stakes events. 2% of total collateral by default. Floor + cap so it stays postable on both tiny and huge markets. Status guard + sender check + (for your own retry safety) a settled-flag in storage. See Patterns. If you go M_AWAITING, stamp a deadline + provide an escape op (CancelAwaiting). Don't let users lock funds on a dead factory hop.

Where next

  • Patterns — production patterns for dedupe, error handling, gas budgeting
  • Reference — every opcode, every constant
  • Troubleshooting — common errors during integration