DEV Community

AGON
AGON

Posted on • Originally published at agon.markets

AGON — CPMM binary markets

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) )
Enter fullscreen mode Exit fullscreen mode

Two things fall out immediately, and they are the whole point:

  1. price(YES) + price(NO) = 1 by construction — the prices are a normalized probability distribution.
  2. 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%
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
  • splitPosition — deposit amount of USDC, mint amount of 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 pins price(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 calls reportPayouts to 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
Enter fullscreen mode Exit fullscreen mode

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


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

Top comments (0)