Parlay (accumulator) & system bets — considered and REJECTED
Status: rejected as a consensus primitive (owner decision 2026-08-19, q#603=A). The full design below is kept as an archived record of why the idea does not fit the protocol, so the next "let's add parlays" proposal starts from the argued rejection, not from scratch.
Why rejected
The platform's core trust invariant is that liquidity providers and the Lazy Pool are principal-protected by construction: betting is zero-sum between bettors (winners split the losers' pool), and the pool/LPs only collect fees and floors. A depositor does not have to trust market creators or oracles with their principal. That invariant is what makes a decentralized prediction market with permissionless market creation and competing oracles viable at all.
A real parlay needs a counterparty holding directional risk at odds fixed at bet time. Making the Lazy Pool that counterparty breaks the invariant — depositors become hostages of every market creator's quality — and it is not fixable with parameters:
- Leg correlation is a structural adverse-selection hole.
W = S·(1−m)/Π p_iis only fair for independent legs. In a permissionless world, market creators can construct correlated legs at will ("X wins the match" + "X wins map 2", the same real-world fact wrapped by two different oracles).Π p_isystematically underprices such combos, giving the attacker a persistent +EV against the pool. Correlation between markets is real-world semantics — undetectable on-chain in principle. Centralized bookmakers solve this with human traders and per-combo limits; the protocol has no such layer. - Leg prices come from manipulable curves. Execution-price quoting (q#600=A) defends against one-shot curve manipulation right before opening, but a thin parimutuel curve is still not an honest probability. Fixed odds against a price source the attacker can influence means the pool pays for someone else's control of the source.
- What it took to make the one existing pool-fronted product safe (leverage, F1/#300). Leverage is the only place where the pool fronts funds, and it produced exactly this failure class: position profit exceeding the losers' pool, shortfall landing on LPs. It is solved — the early-exit reward cap plus the deferred outcome-contingent claim make
winners_pool ≥ (1−cap)·losers − fees ≥ 0, i.e.uncovered == 0by construction, with the LP-charge path kept only as a defensive fallback behind a loud invariant-violation log. The point is what that guarantee costs: leverage is a bounded, collateralized loan with liquidation sweeps, and it still took a dedicated cap, a deferred-claim design and an always-on invariant to bound. A parlay book has multiplicative payouts, no collateral to liquidate and no per-leg bound to cap against — the same guarantee has nothing to hang on. - Consensus complexity vs. one UX feature. Ten new median parameters, new objects, new settlement paths (void re-pricing, dispute interactions, escrow FIFO) — all of it money-path attack surface to audit before mainnet.
A parlay book works when the market maker is a centralized, fully trusted party. That is explicitly not this protocol's trust model.
What replaces it
- The coupon (one transaction, N independent
pm_place_betops) already ships in the Forecaster client — a multi-bet without a counterparty. - A client-side auto-roll ("sequential parlay") can deliver the accumulator feel with zero consensus changes: the client re-stakes a leg's winnings on the next leg after it resolves. Counterparty = the ordinary parimutuel pools; odds are not fixed upfront, which is honest under parimutuel pricing. May later be hardened with a small "conditional bet after market X resolves" operation if on-chain execution guarantees are wanted.
- If a fully trusted market maker ever emerges, a parlay book could run as a separate opt-in risk fund (explicitly not the Lazy Pool), where depositors knowingly accept bookmaker risk. Out of scope until then.
Archived design (pre-rejection, 2026-08-18)
Everything below this line documents the design as it stood before the rejection, including the q#600/q#601 scope decisions that were locked while it was still a candidate. It is retained for reference only — none of it is planned work.
Problem
The coupon shipped in the Forecaster client (one transaction carrying N independent pm_place_bet operations) is a multi-bet, not a parlay: each leg settles on its own, wins and losses are independent. A real parlay (accumulator/экспресс) is a single stake on the conjunction of N outcomes: it pays only if every leg wins, and the potential payout multiplies the legs' odds. A system bet "M of N" is the standard generalization: the stake is split across all C(N,M) M-leg sub-parlays, so the ticket survives up to N−M losing legs.
Parimutuel markets have no fixed odds — a leg's final coefficient is known only when its pool closes. So a naive "multiply final parimutuel coefficients" parlay cannot be funded by the legs' own pools: a cross-market conjunction payout is not backed by any single market's losers. The parlay needs an explicit counterparty and a price fixed at bet time.
Design summary
- Counterparty: the Lazy Pool — the same inventory-bearing fund that already fronts leverage loans. A parlay is a side bet against the pool at curve prices; it does not touch the legs' curves or pools.
- Price fixed at bet time from each leg's live curve (CPMM for binary, LMSR-softmax for multi): combo price
P = Π p_i, potential payoutW = S · (1 − pm_parlay_margin) / P, capped. - All-or-nothing settlement driven by the legs' regular oracle resolutions: any leg lost → ticket dead immediately; a voided (no-contest) leg is excluded (its
p_imultiplied back in — the bookmaker standard); all remaining legs won → pool paysWautomatically after the last leg settles. No claim operation, consistent withpm_payoutauto-payout philosophy. - Worst-case escrow: the pool locks
W − Sat open, so every open ticket is fully funded by construction; the stakeSenterspool.free_balanceimmediately.
Mechanics
Opening: pm_parlay_open
pm_parlay_open {
account,
legs: [ { market_id, side (binary) | outcome_index (multi) }, ... ],
amount, // stake S, liquid VIZ
min_payout, // slippage guard on W (curve may move between quote and inclusion)
extensions
}Validation / evaluator gates (all loud FC_ASSERTs):
2 ≤ legs.size() ≤ pm_parlay_max_legs; allmarket_iddistinct.- Every leg market: status 1 (active), betting still open with at least
pm_parlay_min_time_leftseconds to that leg'sbetting_expiration(anti-sniping: parlays are priced on the live curve, so late steam on a nearly-closed leg is the cheapest attack). - Every leg market allows instant bets (
allow_instant_bet), is not hidden below the oracle risk-floor, and its curve depth passes the manipulation gate (below). pm_parlay_enabledmedian kill-switch is on; the pool has capacity (below).S ≥ pm_min_bet; account has liquidS(same funding rules aspm_place_bet).
Leg price p_i is the execution price of the leg's proportional virtual size, not the mid: quote the curve for a hypothetical instant bet of S on that side/outcome and use the resulting average price. Mid-price quoting hands an attacker the spread for free; execution pricing makes moving a thin curve before opening a parlay pay the mover's own slippage first. The virtual quote does not mutate the curve.
Combo payout:
P = Π p_i (0 < p_i < 1, so P ∈ (0,1))
W_raw = S · (1 − pm_parlay_margin) / P
W = min(W_raw, pm_parlay_max_payout, S · pm_parlay_max_multiplier)
FC_ASSERT(W ≥ min_payout) // user slippage guard
FC_ASSERT(W > S) // a parlay that cannot profit is a mis-click, rejectFunding at open (single balanced move, conservation-exact):
account.balance -= S
pool.free_balance += S
pool.parlay_fund_used += (W − S) // worst-case escrow, W − S > 0 by the assert above
pool.free_balance -= (W − S)Capacity gate: parlay_fund_used + (W − S) ≤ free-only base × pm_parlay_fund_percent — the same free-only base rule the owner fixed for leverage (q#566=A): obligations are measured against free_balance only, never NAV.
Object
pm_parlay_object {
id, account,
legs: [ { market_id, side, outcome_index, price_ppm, // p_i fixed at open, parts-per-million
state } ], // 0 pending | 1 won | 2 lost | 3 void
stake, payout, // S, W (asset)
margin_ppm_at_open,
opened_at,
status, // 0 open | 1 won(paid) | 2 lost | 3 refunded(all-void)
last_settled_leg_count
}Indexes: by_id, by_account, and by_market_leg (market_id → parlay ids) so per-market resolution can find affected tickets without scanning. The per-market fan-out is bounded by pm_parlay_max_open_per_market (cap enforced at open via bounded index probe — counter-free, see the commit-cap precedent M4 and the computed-vs-counter rule).
Settlement
Hooked into the same per-block process_pm_markets() walk that already finalizes payouts — parlay legs react to the leg market reaching settled state (post dispute-grace), not to the raw resolve, so dispute reversals are automatically respected:
- Leg lost → ticket
status = 2immediately: release the escrow (parlay_fund_used -= (W − S),free_balance += (W − S)). The stake already sits in the pool — it is the pool's revenue on lost tickets. Emitpm_parlay_lostvirtual op. - Leg void (no-contest / missed-resolution void) →
state = 3; payout shrinks:W' = W · p_i(multiply the excluded leg's price back in), clampedW' = max(W', S); release the escrow delta. If all legs void → refundS(status = 3, pool pays back the stake, full escrow released). Emitpm_parlay_leg_void. - Leg won →
state = 1; when the last pending leg settles won: paypool.free_balance -= W; account.balance += W;release escrow bookkeeping (parlay_fund_used -= (W − S); the extraW − Swas already carved out of free at open, so payingWnets free_balance−Sversus pre-open — exactly the pool's loss on a won ticket).status = 1, emitpm_parlay_won(per-account virtual op for account_history).
Work per settled market is bounded: at most pm_parlay_max_open_per_market tickets touched, each O(legs) ≤ pm_parlay_max_legs. No unbounded per-block loops (audit class H3/M3).
Invariants (debug-asserted, snapshot-import verified like the TOKEN anchor):
parlay_fund_used == Σ_open (W_i − S_i)— recomputable by walking open tickets.pool.free_balance ≥ 0always (FIFO-queue rule untouched; parlay payouts go through the same "never below zero" discipline — escrow guarantees the funds exist).- Ticket terminal states are absorbing;
last_settled_leg_countmonotonic.
System bets "M of N"
One operation, pm_system_open, same leg rules plus 2 ≤ M < N ≤ pm_parlay_max_legs and C(N,M) ≤ pm_system_max_combos (e.g. 256 — keeps worst-case settlement work and escrow math trivially bounded). Semantics: stake S splits into C(N,M) equal sub-stakes, each sub-parlay priced/capped exactly as above from the same fixed price_ppm set; escrow = Σ over combos. Stored as one object (legs + M + per-combo derived data computed on settlement, not stored). "7 из 8" = M=7, N=8, 8 combos. Settlement: on last leg settle, count won/void legs, enumerate combos arithmetically (no recursion), pay Σ of winning combos' payouts. Refund/void/shrink rules apply per combo. Deferred to phase 2 of implementation but specified now so the object layout and params don't churn (snapshot-layout lesson: batch B → redeploy-only-by-snapshot).
Adversarial review (pre-implementation)
| Attack / failure class | Vector here | Mitigation in this design |
|---|---|---|
| Curve manipulation (the main one) | Push a thin leg's curve, buy the parlay at distorted p_i, unwind | Execution-price quoting (mover pays own slippage), pm_parlay_min_depth gate per leg (min curve liquidity), pm_parlay_margin house edge, hard caps max_payout/max_multiplier, min_time_left window |
| Unbounded accumulation (#141 class) | parlay_fund_used grows, later subtracted | Escrow released on every terminal transition, recomputable invariant 1, clamp at 0 with loud ilog on mismatch |
| Sign-flip / underflow | W − S, W' = W·p_i shrink, refunds | W > S asserted at open; void-shrink clamped at S; all subtractions clamped max(x,0) + debug-assert |
| Missing floor/assert | "escrow covers payout by construction" | Explicit debug-assert on invariant 1 each maintenance block + snapshot-import re-check (anchor pattern) |
| DoS / per-block work | Many tickets on one market; many legs | max_open_per_market (bounded index probe), max_legs, max_combos, settlement O(tickets×legs) bounded |
| Governance extremes (F3 class) | Median sets margin=0 / multiplier=10^9 | validate() bounds on every new parameter (margin ≤ 20%, multiplier ≤ 10000×, legs ≤ 16, combos ≤ 1024, percent params bp-checked ≤10000) — and every param wired into the median loop (retention-param lesson) |
| Oracle/dispute interplay | Pay before dispute settles, then reversal | Legs react to settled (post-grace) state only, same cutoff as pm_payout sweep |
| Self-dealing LP | Bettor is also pool depositor | No special path needed: pool P&L is socialized exactly like leverage; margin + caps bound extraction |
| Snapshot round-trip | New object/fields lost on import | Full-reflect export; import with contains() guards; forward-only counters get seeds or are recomputable (invariant 1 is recomputable — preferred) |
New governance parameters (chain_properties, next version bump)
pm_parlay_enabled (kill-switch, default off — leverage precedent), pm_parlay_margin (bp, default 500 = 5%, bound ≤ 2000), pm_parlay_max_legs (default 8, bound 2..16), pm_parlay_max_multiplier (default 1000×, bound ≤ 10000), pm_parlay_max_payout (VIZ, default 100k), pm_parlay_fund_percent (bp of pool free, default 2000, bound ≤ 5000), pm_parlay_min_depth (VIZ, default 1000), pm_parlay_min_time_left (sec, default 3600), pm_parlay_max_open_per_market (default 1000, bound ≤ 10000), pm_system_max_combos (default 256, bound ≤ 1024).
All ten must appear in: validate() with bounds, the median-vote loop, get_pm_chain_properties, serializers (C++ ⇄ js ⇄ php ⇄ python lock-step — vop/param drift lesson from P1), and the snapshot export/import of chain_properties_pm.
Client surface (after node lands)
Coupon screen grows a mode switch: Multi (today's N independent bets) / Экспресс (one pm_parlay_open) / Система M из N (phase 2). The coupon already collects legs in exactly the right shape; the parlay quote (Π p_i, potential payout, caps) is computable client-side from the same curve reads the bet form uses, with min_payout as the slippage guard. Read API: get_account_parlays, get_market_parlays (newest-first default per q#383=A), parlay card in activity (History/Active tabs).
Decisions log
- q#600=A (2026-08-18): leg price = execution price of the stake's virtual size on the live curve (not mid) — the curve manipulator pays their own slippage first.
- q#601=A (2026-08-18): first round = binary legs + plain parlay; M-of-N systems and multi (LMSR) legs are phase 2. Object layout for systems is still specified above so the state shape doesn't churn between rounds.
- Launch defaults for the new
pm_parlay_*parameters (margin 500 bp, max_payout 100k VIZ, max_multiplier 1000×, max_legs 8, kill-switch default off) stand as proposed unless the owner overrides specific values before implementation; all are median-votable post-launch anyway, the defaults only seed the very first median.