Skip to content

Latest commit

 

History

History
90 lines (74 loc) · 4.25 KB

File metadata and controls

90 lines (74 loc) · 4.25 KB

Payout math — why "If YES wins" needs the pro-rata clamp

This doc captures a non-obvious detail of the 1v1 vault flow that made the client-side prediction lie by 10–15% until we fixed it. Keep it in mind when reusing this codebase for any flow that has vault commits + AMM trading + pro-rata redemption.

The chain of events

  1. Commit phase (commit.rs) — creator deposits $A on YES, challenger deposits $B on NO. The collateral lands in the vault account, both committers' positions are recorded in vault.yes_total / vault.no_total. No outcome tokens are minted yet.

  2. Launch (launch_vault_market.rs) — vault collateral ($A + $B) is moved into the pool's collateral_reserve. The pool is initialized at P_initial = A / (A+B) with L = A + B (= the actual collateral). Reserves (x*, y*) = compute_reserves(P_initial, L) are minted into pool.yes_reserve and pool.no_reserve.

    **Then a separate batch of YES and NO is minted to vault_yes and vault_no for committers to claim later — both sides receive `total = A

    • B` extra tokens, on top of the AMM's own reserves.** This is the detail that breaks naive payout predictions:
    • yes_mint.supply = x* + total
    • no_mint.supply = y* + total
    • collateral = total
  3. Audience bet (buy_outcome_tokens + swap) — user puts $X in, receives (X − fee) × 0.99² of YES + NO tokens (1% off-chain platform fee, 1% on-chain LP fee), then swaps the unwanted side into the pool for extra tokens of the chosen side. The swap moves price along the Gaussian curve; total user holding = mint + swap_bonus.

  4. Resolution (redeem.rs:144-154) — for the winning side:

    circulating = winning_mint.supply − winning_pool_reserve
    if circulating == 0 || collateral >= circulating:
        payout_per_token = $1                                   # solvent
    else:
        payout_per_token = collateral / circulating              # pro-rata
    

    In the 1v1 flow the launch step inflates winning_mint.supply by total (the committer claim mint) without adding any new collateral, so circulating > collateral is the default path — every winning token redeems for less than $1.

Worked example (the regression that triggered this fix)

  • Mathis commits $10 YES, MathisNo commits $10 NO → total = $20
  • Launch: L = $20, (x*, y*) ≈ ($7.98, $7.98). Vault gets 20 YES + 20 NO claimable; yes_mint.supply = $27.98, collateral = $20.
  • Tanf bets $5 YES (1% platform fee + 1% LP fee). She mints $4.9005 of each side, swaps $4.9005 NO → $3.51 YES through the pool. Final Tanf holdings: 8.41 YES.
  • After Tanf's bet:
    • yes_mint.supply ≈ 32.88, pool.yes_reserve ≈ 4.46, circulating ≈ 28.42
    • collateral ≈ $24.95
    • circulating > collateral → pro-rata
    • payout/token = 24.95 / 28.42 ≈ $0.878
  • Tanf's actual cash-out: 8.41 × 0.878 = $7.39

The naive UI showed "If YES wins: $8.41" — overpromising by $1.02 (13.7%).

What the fix does (src/lib/pm-math.ts + bet/[id]/page.tsx)

  1. payoutPerToken(mintSupply, poolReserve, collateral) — pure helper that mirrors redeem.rs:144-154.
  2. simulateBuyOutcome returns yesReserveAfter / noReserveAfter so the caller can project pool.yes_reserve post-swap.
  3. The bet page now fetches getMint(yes_mint) and getMint(no_mint) alongside the pool state, projects the post-bet supply (+= mintTokens), then runs payoutPerToken for both winning scenarios.
  4. The "current position" P&L block (shown when no amount is typed) also applies payoutPerToken to existing user holdings, so an OG committer staring at the leaderboard sees their realistic redemption, not the inflated 1:1 number.

Reusing this code

Any new product built on the same on-chain primitives should adopt the same client-side accounting. The pure-AMM Viber flow (commits removed — Mathis bootstraps the pool via add_liquidity) does not add to mint.supply, so circulating == pool_reserve at launch and the pro-rata clamp never kicks in unless very large audience trades drain the pool. We keep payoutPerToken because it's still the correct formula; it just becomes a no-op in the typical case there.