A binary prediction market asks one question — will X happen, yes or no? — and lets people trade two outcome tokens, YES and NO, against a pool of collateral. The interesting engineering question is: with no order book and no human market maker, how does the price get set, and why does that price behave like a probability?
The answer most on-chain prediction markets reach for is the same automated-market-maker (AMM) math that powers token swaps: the constant-product rule, x · y = k. This post walks the full path — pricing, the YES + NO ≈ 1 identity, slippage, the ERC-1155 conditional-token plumbing underneath, USDC's 6-decimal accounting, and settlement — with code you can actually run. I'm building this stack on AGON, a permissionless prediction-market app on Base (currently public testnet on Base Sepolia), so the conventions below mirror a real deployment rather than a toy.
1. The invariant: x · y = k
Uniswap v2 formalized the constant-product market maker: a pool holds reserves x and y of two tokens, and every trade must keep their product k = x · y constant (it only grows from fees). The reference getAmountOut from the v2 periphery library is the canonical statement of the rule, fee included:
// Uniswap v2 — UniswapV2Library.getAmountOut
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut)
internal pure returns (uint amountOut)
{
uint amountInWithFee = amountIn * 997; // 0.30% fee: 997/1000
uint numerator = amountInWithFee * reserveOut;
uint denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
The 997/1000 is a 0.30% fee skimmed off the input before the swap. Set the fee aside (997 → 1000) and you get the clean invariant: (reserveIn + amountIn) · (reserveOut − amountOut) = reserveIn · reserveOut.
A naive way to build a binary market is one such pool — YES against NO — but prediction markets have a cleaner construction.
2. From two reserves to a probability
Gnosis adapted the constant-product idea specifically for prediction markets in its Fixed Product Market Maker (FPMM). Instead of pricing one token against another, it holds an inventory of every outcome token and keeps the product of all outcome balances constant. The neat consequence is the pricing formula (straight from the Gnosis docs):
oddsWeightForOutcome_i = product( balance_j for every j ≠ i )
price_i = oddsWeightForOutcome_i / Σ_k oddsWeightForOutcome_k
For a binary market with just YES and NO balances, the products collapse to a single term each, and the price of an outcome is simply the other side's balance over the total:
price(YES) = balance(NO) / ( balance(YES) + balance(NO) )
price(NO) = balance(YES) / ( balance(YES) + balance(NO) )
Two things fall out immediately, and they are the whole point:
-
price(YES) + price(NO) = 1by construction — the prices are a normalized probability distribution. - Each price is the market's implied probability of that outcome. A YES trading at 0.65 USDC is the crowd saying "≈ 65% likely."
Here it is in TypeScript, with prices and the implied probability derived from raw on-chain balances:
type Wei = bigint;
interface BinaryPool {
yes: Wei; // outcome-token balance held by the AMM
no: Wei;
}
/** Spot prices for a binary FPMM. price(YES)+price(NO) === 1. */
function spotPrices(pool: BinaryPool): { yes: number; no: number } {
const total = pool.yes + pool.no;
if (total === 0n) throw new Error("empty pool");
// price(YES) = balanceNO / total ; price(NO) = balanceYES / total
const yes = Number(pool.no) / Number(total);
return { yes, no: 1 - yes };
}
// Inventory: more NO than YES held => YES is the scarcer, pricier side.
const pool: BinaryPool = { yes: 60_000_000n, no: 140_000_000n };
const p = spotPrices(pool);
console.log(p.yes.toFixed(4), p.no.toFixed(4)); // 0.7000 0.3000
console.log(`Implied P(YES) = ${(p.yes * 100).toFixed(1)}%`); // 70.0%
Note the inversion: the AMM is long the outcome it holds more of, so a large NO inventory makes YES the scarcer, more expensive side. That is the mechanism by which buying pushes a price up — you remove tokens from the side you buy, shrinking its balance and raising its price toward 1.
3. Slippage and price impact
Spot price is the marginal price for an infinitesimal trade. Real fills move along the curve, so the average price you pay is worse than spot — that gap is price impact. Because the FPMM keeps the product of balances invariant, you can compute the exact tokens received for a given USDC stake. For a binary market, buying YES adds your (post-fee) collateral to both outcome pools (the collateral is split into a complete set), then drains YES until the product is restored:
const ONE = 10n ** 18n; // fixed-point scale used by Gnosis FPMM math
/**
* Outcome tokens received when buying YES with `investment` of collateral.
* Mirrors Gnosis FixedProductMarketMaker.calcBuyAmount for the 2-outcome case.
*/
function calcBuyYes(pool: BinaryPool, investment: Wei, feeBps: bigint): Wei {
const fee = (investment * feeBps) / 10_000n; // e.g. 200 bps = 2%
const dx = investment - fee; // collateral after fee
// A complete set adds `dx` to every outcome pool; YES is then sold down.
// ending = yes' such that yes' * (no + dx) == yes * no (product preserved
// across the other outcome). Scaled by ONE for precision, as in the contract.
const ending = (pool.yes * ONE * (pool.no)) / (pool.no + dx) / ONE;
// Tokens out = collateral you put into the YES pool, minus what's left.
return pool.yes + dx - ending;
}
const stake = 50_000_000n; // 50 USDC (6 decimals) — see §4
const out = calcBuyYes(pool, stake, 200n);
const avgPrice = Number(stake) / Number(out);
console.log(`bought ${out} YES shares, avg price ${avgPrice.toFixed(4)} USDC`);
The takeaway is structural: price impact scales inversely with pool depth. A market seeded with deep liquidity moves little on a 50-USDC trade; a thin one lurches. Anyone sizing a position — human or bot — has to read depth, not just the headline price.
4. USDC and 6-decimal accounting
The collateral is USDC, which has 6 decimals, not 18. This is the single most common footgun in this codebase, so it's worth being explicit: 1 USDC == 1_000_000 base units. Outcome-token "shares" are denominated to redeem 1:1 against collateral, so one winning share pays out exactly 1_000_000 USDC base units.
const USDC = 6;
const usdc = (human: number): bigint => BigInt(Math.round(human * 10 ** USDC));
usdc(50); // 50_000_000n
usdc(0.7); // 700_000n → the cost of one YES share at price 0.70
// A winning ticket: shares * 1 USDC each.
const shares = 71_400_000n; // ~71.4 shares (6dp)
const payout = shares; // 1 share -> 1 USDC base unit
console.log(`redeems for ${Number(payout) / 10 ** USDC} USDC`); // 71.4 USDC
Mixing 18-decimal AMM scaling (the ONE factor) with 6-decimal collateral is exactly why production code keeps the fixed-point math in 1e18 space and only converts at the collateral boundary.
5. The tokens: ERC-1155 conditional tokens
YES and NO aren't bespoke ERC-20s. The clean construction is Gnosis Conditional Tokens (CTF) on top of ERC-1155. EIP-1155 is the multi-token standard whose whole premise is that "a single deployed contract may include any combination of fungible tokens, non-fungible tokens or other configurations" — so every outcome of every market is just another token ID in one contract, instead of a fresh ERC-20 deploy per market. (A common convention, used on AGON, is tokenId(YES) = marketId·2, tokenId(NO) = marketId·2 + 1.)
The CTF gives you three operations, with these signatures:
// Gnosis ConditionalTokens (abbreviated)
function splitPosition(IERC20 collateral, bytes32 parentCollectionId,
bytes32 conditionId, uint[] calldata partition, uint amount) external;
function mergePositions(IERC20 collateral, bytes32 parentCollectionId,
bytes32 conditionId, uint[] calldata partition, uint amount) external;
function redeemPositions(IERC20 collateral, bytes32 parentCollectionId,
bytes32 conditionId, uint[] calldata indexSets) external;
-
splitPosition— depositamountof USDC, mintamountof each outcome token (a "complete set"). A complete set always costs face value because YES + NO together are guaranteed to pay 1. -
mergePositions— the inverse: burn one of each outcome token, get your collateral back. This is the no-arbitrage anchor that pinsprice(YES) + price(NO)to 1: if the pair ever traded above 1, you'd mint sets and sell; below 1, you'd buy and merge. -
redeemPositions— settlement. After the oracle callsreportPayoutsto write the result vector, holders of the winning outcome convert tokens to collateral; the losing tokens are worth 0.
6. Settlement
Resolution is the moment the probabilistic price snaps to a binary truth. The oracle reports a payout vector — [1, 0] for YES, [0, 1] for NO — and redeemPositions pays each winning share exactly 1 USDC while losing shares go to zero.
type Outcome = "YES" | "NO";
/** Payout (USDC base units) for a holder at resolution. */
function redeem(shares: bigint, held: Outcome, winner: Outcome): bigint {
return held === winner ? shares : 0n; // 1 share -> 1 USDC base unit, else 0
}
redeem(usdc(71.4), "YES", "YES"); // 71_400_000n -> 71.4 USDC
redeem(usdc(71.4), "NO", "YES"); // 0n
That's the entire lifecycle: collateral splits into a complete set, the CPMM curve prices the two sides so they sum to one, traders push the implied probability around, and at resolution the winner redeems 1-for-1 while the loser zeroes out. No order book, no spread desk — the curve is the order book, and the math is small enough to fit in one file.
References
- Uniswap v2
getAmountOut(constant-product + 0.30% fee),UniswapV2Library.sol— https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol - Uniswap v2 protocol overview (the
x · y = kinvariant) — https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works - Uniswap v2 whitepaper — https://uniswap.org/whitepaper.pdf
- Gnosis "Automated Market Makers for Prediction Markets" (FPMM odds formula, prices sum to 1) — https://conditionaltokens-docs.dev.gnosisdev.com/conditionaltokens/docs/introduction3/
- Gnosis
FixedProductMarketMaker.sol(calcBuyAmount/calcSellAmount) — https://github.com/gnosis/conditional-tokens-market-makers/blob/master/contracts/FixedProductMarketMaker.sol - Gnosis Conditional Tokens developer guide (
splitPosition/mergePositions/redeemPositions) — https://conditional-tokens.readthedocs.io/en/latest/developer-guide.html - EIP-1155: Multi Token Standard — https://eips.ethereum.org/EIPS/eip-1155
Building this on AGON, a permissionless prediction-market app on Base — currently public testnet on Base Sepolia. No live token, no mainnet real-money betting yet; forward-looking features are planned. Nothing here is financial advice. agon.markets
Sources
- Uniswap v2 periphery —
UniswapV2Library.getAmountOut(verified source): https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol - Uniswap v2 docs — "How Uniswap works" (
x · y = k): https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works - Uniswap v2 whitepaper: https://uniswap.org/whitepaper.pdf
- Gnosis Developer Portal — "Automated Market Makers for Prediction Markets" (FPMM odds = product of other balances / sum; prices sum to 1): https://conditionaltokens-docs.dev.gnosisdev.com/conditionaltokens/docs/introduction3/
- Gnosis
FixedProductMarketMaker.sol(constant-productcalcBuyAmount/calcSellAmount): https://github.com/gnosis/conditional-tokens-market-makers/blob/master/contracts/FixedProductMarketMaker.sol - Gnosis Conditional Tokens — developer guide (split/merge/redeem signatures +
reportPayoutssettlement): https://conditional-tokens.readthedocs.io/en/latest/developer-guide.html - EIP-1155 Multi Token Standard (official spec): https://eips.ethereum.org/EIPS/eip-1155
Top comments (0)