From a3e370a02382093bd1e21eae21c00b2170403cd1 Mon Sep 17 00:00:00 2001 From: Anatoly Piskunov Date: Tue, 16 Jun 2026 19:33:09 +0400 Subject: [PATCH 01/37] feat(pm plan): add opt-in batch and commit-reveal betting modes for front-running protection - Introduce two optional execution modes per bet: batch (mode 1) and commit-reveal (mode 2) - Preserve instant per-bet execution (mode 0) as default for best UX - Define global and per-market parameters for configuration and governance control - Implement batch epochs with unified uniform-price settlement to prevent intra-batch front-running - Add commit-reveal operations: bet commitment, reveal, penalty for no-reveal with transfer into winners' pool - Design batch settlement math preserving liquidity provider invariant and fairness via canonical CPMM aggregation - Add database schema changes for bets, commitments, and market flags supporting new modes and states - Extend market logs to track batch-related actions: commit, reveal, batch settlement, and forfeits - Specify layered defenses against front-running including epoch snapshot pricing and min_tokens enforcement - Define phased rollout plan: batch mode first, then commit-reveal, followed by tiering and client policies - Document open questions and future improvements like encrypted sealed bids (mode 3) and cancellation policy --- .qoder/plans/pm-plan/README.md | 104 +++ .qoder/plans/pm-plan/batch-commit-reveal.md | 338 ++++++++++ .../pm-plan/chain-properties-governance.md | 235 +++++++ .qoder/plans/pm-plan/lazy-pool-properties.md | 549 +++++++++++++++ .qoder/plans/pm-plan/lmsr-fixed-point-spec.md | 406 ++++++++++++ .qoder/plans/pm-plan/parimutuel-settlement.md | 106 +++ .qoder/plans/pm-plan/reference/lmsr_fixed.php | 561 ++++++++++++++++ .../pm-plan/reference/lmsr_fixed_smoke.php | 145 ++++ .../pm-plan/reference/lmsr_fixed_vectors.json | 625 ++++++++++++++++++ .../reference/lmsr_fixed_vectors_verify.js | 82 +++ .../reference/lmsr_fixed_vectors_verify.php | 92 +++ .../reference/lmsr_vectors_generate.php | 181 +++++ .qoder/plans/pm-plan/reference/market_math.js | 394 +++++++++++ .qoder/plans/pm-plan/security-threat-model.md | 296 +++++++++ ...viz-dlt-prediction-market-protocol-spec.md | 464 +++++++++++++ 15 files changed, 4578 insertions(+) create mode 100644 .qoder/plans/pm-plan/README.md create mode 100644 .qoder/plans/pm-plan/batch-commit-reveal.md create mode 100644 .qoder/plans/pm-plan/chain-properties-governance.md create mode 100644 .qoder/plans/pm-plan/lazy-pool-properties.md create mode 100644 .qoder/plans/pm-plan/lmsr-fixed-point-spec.md create mode 100644 .qoder/plans/pm-plan/parimutuel-settlement.md create mode 100644 .qoder/plans/pm-plan/reference/lmsr_fixed.php create mode 100644 .qoder/plans/pm-plan/reference/lmsr_fixed_smoke.php create mode 100644 .qoder/plans/pm-plan/reference/lmsr_fixed_vectors.json create mode 100644 .qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.js create mode 100644 .qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.php create mode 100644 .qoder/plans/pm-plan/reference/lmsr_vectors_generate.php create mode 100644 .qoder/plans/pm-plan/reference/market_math.js create mode 100644 .qoder/plans/pm-plan/security-threat-model.md create mode 100644 .qoder/plans/pm-plan/viz-dlt-prediction-market-protocol-spec.md diff --git a/.qoder/plans/pm-plan/README.md b/.qoder/plans/pm-plan/README.md new file mode 100644 index 0000000000..df6f62aeb8 --- /dev/null +++ b/.qoder/plans/pm-plan/README.md @@ -0,0 +1,104 @@ +# Onix Prediction-Market — VIZ DLT Implementation Bundle + +Self-contained handoff package for the **C++ VIZ DLT plugin** that adds prediction markets (binary CPMM + multi-outcome LMSR) to the VIZ chain. Everything you need to start building consensus code is in this directory; nothing references the parent forecaster repo. + +--- + +## Read order + +1. **[viz-dlt-prediction-market-protocol-spec.md](viz-dlt-prediction-market-protocol-spec.md)** — the protocol spec. + - §1 ChainBase objects (`pm_oracle`, `pm_market`, `pm_outcome`, `pm_bet`, `pm_liquidity`, `pm_commit`, `pm_dispute`, `pm_dispute_vote`, lazy pool) + - §2 Object type registry additions + - §3 Regular operations (op IDs 64..) + - §4 Virtual operations + - §5 Chain properties added to the median-vote set + - §6 Settlement (binary + multi parimutuel) + - §7 Open issues — **read carefully**, several still need a decision before code can land: + - §7.2 (LMSR float determinism) — **RESOLVED** by `lmsr-fixed-point-spec.md` + - §7.1, 7.3, 7.4, 7.5, 7.6, 7.7, 7.10, 7.11, 7.12 — still open, see "Status of open items" below. + +2. **[lmsr-fixed-point-spec.md](lmsr-fixed-point-spec.md)** — frozen Q96 fixed-point LMSR algorithm (`exp_q`, `ln_q`, `lse_q`, `lmsr_*`). §6 has the C++ implementation guide (use `boost::multiprecision::int256_t`, no `-ffast-math`, frozen iteration counts). The C++ port MUST reproduce every value in `reference/lmsr_fixed_vectors.json` with strict equality. + +3. **[parimutuel-settlement.md](parimutuel-settlement.md)** — unified parimutuel settlement applied to both binary CPMM (weights from reserves) and multi LMSR (weights = bought tokens). One `settle()` math, two market types. + +4. **[batch-commit-reveal.md](batch-commit-reveal.md)** — Phase-2 anti-MEV path (`pm_commit_object`, batch settlement). Not required for binary-only Phase 1. + +5. **[lazy-pool-properties.md](lazy-pool-properties.md)** — Phase-2 lazy liquidity pool: passive LPs auto-allocate to high-volume markets. + +6. **[chain-properties-governance.md](chain-properties-governance.md)** — full list of ~25 PM chain properties added to the validator median set (caps, floors, fees, periods). + +7. **[security-threat-model.md](security-threat-model.md)** — attacker scenarios, oracle abuse, dispute griefing, MEV, etc. Use it to derive negative-path tests. + +--- + +## Reference implementations (in `reference/`) + +The PHP and JS implementations are **normative**: the C++ port must produce bit-identical outputs. + +| File | Role | +|---|---| +| `lmsr_fixed.php` | PHP+GMP reference — source of truth for the test vectors | +| `market_math.js` | JavaScript BigInt port — independent re-implementation that confirms PHP determinism | +| `lmsr_fixed_vectors.json` | Frozen test vectors (Q96 primitives + public LMSR API). **C++ must pass every one with strict equality.** | +| `lmsr_fixed_vectors_verify.php` | Asserts `lmsr_fixed.php` matches the JSON. `php reference/lmsr_fixed_vectors_verify.php` | +| `lmsr_fixed_vectors_verify.js` | Same for JS. `node reference/lmsr_fixed_vectors_verify.js` | +| `lmsr_vectors_generate.php` | Regenerates the JSON. Re-run only when the spec changes (bump `version`). | +| `lmsr_fixed_smoke.php` | 24 invariants (`exp(LN2) = 2·ONE` etc.) — handy sanity gate. | + +### Quick verify + +```bash +# PHP (needs gmp extension) +php reference/lmsr_fixed_vectors_verify.php +# expected: PHP verifier: 77 / 77 passed + +# Node +node reference/lmsr_fixed_vectors_verify.js +# expected: JS verifier: 74 / 74 passed +``` + +### Acceptance criterion for the C++ port + +```cpp +// Pseudocode for the C++ unit test: +auto V = parse_json("reference/lmsr_fixed_vectors.json"); +for (auto& t : V["lmsr_cost"]) REQUIRE(lmsr_cost(t.q, t.b) == t.expected); +for (auto& t : V["lmsr_prices"]) REQUIRE(lmsr_prices(t.q, t.b) == t.expected); +// ...same for buy_cost, sell_return, tokens_for_amount, b_from_liquidity, +// exp_q, ln_q, mul_q, div_q. +``` +**No tolerance.** Any deviation = fork risk. + +--- + +## Status of open items in §7 (what still needs a product decision) + +| § | Topic | Required decision | +|---|---|---| +| 7.1 | Chain-property surface | Pick (a) median-vote all / (b) median-vote core + hardfork rest / (c) `pm_params_object` | +| 7.3 | Batch / commit-reveal ordering | Define exact in-block op ordering and per-block caps | +| 7.4 | Committee dispute voting | Quorum policy; vote-storage cap; integer tally formula; commit-reveal for votes? | +| 7.5 | Locked-funds custody | Pick (a) explicit fields on objects (recommended) / (b) escrow subsystem | +| 7.6 | Time-driven processing | Per-block cap N + fairness rule | +| 7.7 | Bandwidth cost class | Reuse generic class, or add `pm_operations_cost_*` | +| 7.10 | Dust routing | Where remainders go (committee fund? next round? oracle?) | +| 7.11 | LMSR sell churn | Anti-churn argument or per-block sell cap | +| 7.12 | Byte-length caps | Freeze `MAX_PM_*` table — **consensus-mechanical, not anti-spam** | + +§§7.2, 7.8, 7.9 are either resolved or client-side / standard hardfork hygiene. + +--- + +## Phasing (recap of §8 of the protocol spec) + +- **Phase 1 (binary-only, integer-safe):** all objects, oracle lifecycle, create/accept, instant bet, liquidity, resolve, both dispute modes (oracle + committee), auto-payout, missed-resolution penalty. **No LMSR**, **no batch/commit-reveal**. Ships a usable consensus market with no §7.2-class risk. +- **Phase 2:** wire in the deterministic LMSR (per `lmsr-fixed-point-spec.md`) → multi markets; add batch + commit-reveal (`batch-commit-reveal.md`); lazy pool (`lazy-pool-properties.md`). +- **Phase 3:** shared/category liquidity, automated data oracles, advanced governance of PM params. + +Recommendation: build Phase 1 to mainnet first, freeze, then add Phase 2 behind a separate hardfork. + +--- + +## Off-chain reference (informational) + +A working off-chain prototype (PHP backend + Telegram-WebApp frontend + the same `lmsr_fixed.php` and `market_math.js`) exists at the parent `forecaster/` repo. It is **not** required to read it, but it can serve as a behavioural oracle: any edge case you're unsure about, run the same scenario through the prototype and compare. diff --git a/.qoder/plans/pm-plan/batch-commit-reveal.md b/.qoder/plans/pm-plan/batch-commit-reveal.md new file mode 100644 index 0000000000..5702dfd484 --- /dev/null +++ b/.qoder/plans/pm-plan/batch-commit-reveal.md @@ -0,0 +1,338 @@ +# Plan: Opt-in Batch & Commit-Reveal Betting (Front-Running Protection) + +> Implementation plan to extend the Onix Protocol with **optional** anti-front-running execution, while keeping the current **instant per-bet** execution as the default. To be folded into [onix-protocol-specification.md](onix-protocol-specification.md). +> +> 🇷🇺 Russian: [plan_batch_commit_reveal_betting-ru.md](plan_batch_commit_reveal_betting-ru.md). Background: [.qoder/docs/theory_concepts/FORECASTER-FIT.md](../.qoder/docs/theory_concepts/FORECASTER-FIT.md) → "Mitigations for the reflexivity family". + +## 1. Goal + +The current CPMM is **path-dependent**: a bet's tokens depend on reserves at execution time, so a public mempool tx can be sandwiched/front-run. We add **two opt-in execution modes** that the bettor chooses *per bet* (checkboxes in UI), without changing the default flow or the LP guarantee: + +- **Instant (mode 0, default)** — unchanged. Immediate per-bet execution against the live `a·b=k` curve. Best UX, no protection. +- **Batch (mode 1)** — bet is deferred to the next epoch boundary and settled together with all other batch bets at a **single uniform price**. Solves intra-batch ordering/front-running. +- **Commit-Reveal (mode 2)** — bet is **hidden** (hash commit) then revealed; revealed bets settle in the batch. Strongest protection: a front-runner can't even see direction/size. If the commit is **not revealed**, a penalty is taken and **added to the winners' pool** at resolution. + +> // NOTE: mode 2 is mode 1 + confidentiality. Both share the same batch-settlement engine. + +## 2. UI + +At bet time, two checkboxes (default OFF → instant): + +- ☐ **Protect from front-running (batch)** → `mode = 1` +- ☐ **Hide my bet until reveal (commit-reveal)** → `mode = 2` (implies batch). Surfaces the salt/escrow flow. + +> // NOTE: For high-`endogeneity_tier` markets (tier 3, political/social) the client MAY default commit-reveal ON. Protocol stays neutral. + +## 3. New / changed parameters + +### Global (governance, `settings` table) + +| Key | Default | Unit | Description | +|-----|---------|------|-------------| +| `batch_epoch_blocks` | 20 | blocks | Epoch length (~60s at 3s blocks). Batch settlement runs at each boundary. | +| `reveal_window_blocks` | 200 | blocks | Grace margin to reveal after commit (~10 min). NOT a deliberation window — reveal is normally auto-immediate; this is liveness slack for a dropped tx / offline client. Generous on purpose (see §5.3 NOTE). | +| `commit_no_reveal_penalty_permille` | 200 | ‰ | No-reveal fee (**20%**) on escrow → **winners' pool**. Delegate-voted; the value is **carried in `pm_commit_bet` and consensus-checked** (see §5.2). | +| `min_batch_bet` | 1,000 | mVIZ | Minimum amount for batch/commit-reveal (anti-dust spam in queues). | +| `commit_reveal_enabled` | 1 | 0/1 | Global kill-switch for hidden mode. | +| `batch_price_band_permille` | 20 | ‰ | **Binary only**: max deviation of a batch fill's live execution price from the epoch-open snapshot price; beyond it the fill auto-refunds (anti-manipulation). See §6.1. | + +### Per-market (creator-set, oracle-confirmed) + +| Param | Type | Description | +|-------|------|-------------| +| `allow_batch` | 0/1 | Enable batch & commit-reveal for this market (default 1). Set 0 for instant-only markets. | +| `allow_instant_bet` | 0/1 | Enable instant `mode=0` for this market (default 1). Set 0 to force every order through batch / commit-reveal (anti-MEV). At creation we require `allow_instant_bet OR allow_batch == 1`, otherwise the market is unbettable. Multi-outcome (LMSR) markets force this back to 1 server-side because batch settlement is binary-only today. | +| `endogeneity_tier` | 1/2/3 | Reflexive-risk tag (1 econ-data, 2 sports/scheduled, 3 political/social). Display + client policy. | + +## 4. Epochs & lifecycle + +``` +Epoch N (batch_epoch_blocks long) + ├─ INSTANT bets: execute live against current reserves (unchanged) + ├─ BATCH bets: queued with epoch=N + ├─ COMMIT bets: escrow locked, commitment stored, reveal_deadline = now + reveal_window_blocks + └─ REVEAL ops: attach revealed bet to the batch of the FIRST epoch boundary at/after the reveal + (clients SHOULD reveal a few blocks BEFORE that boundary — see §5.3 / §11.1) +Epoch boundary (block % batch_epoch_blocks == 0) + └─ VIRTUAL pm_batch_settle(market, N): settle all queued bets for the market in one curve update +reveal_deadline reached without reveal + └─ VIRTUAL pm_commit_forfeit(commit_id): penalty → market.forfeit_pool, refund the rest +``` + +> // NOTE: batch settlement runs **per market that has a non-empty queue**, not globally — keeps virtual-op cost bounded. + +## 5. Operations + +### 5.1 `pm_place_bet` — add `mode` field + +``` +pm_place_bet { market_id, side, amount, min_tokens, mode } + mode = 0 (INSTANT): current behavior, unchanged. + Reject if market.allow_instant_bet == 0 (creator opted out of instant settlement). + mode = 1 (BATCH): require market.allow_batch == 1, amount >= min_batch_bet. + Lock `amount`, enqueue { bet, side, amount, min_tokens, submit_time, epoch }. + Bet status = 5 (queued). No reserve change yet. +``` + +### 5.2 `pm_commit_bet` — commit-reveal phase 1 + +``` +pm_commit_bet { market_id, commitment, escrow_amount, no_reveal_fee_permille } + require commit_reveal_enabled == 1 && market.allow_batch == 1 + require escrow_amount >= min_batch_bet + // CONSENSUS CHECK (agreement with the chain): the user must declare the exact penalty rate + // currently mandated by delegates. Reject the tx if it disagrees. + require no_reveal_fee_permille == settings.commit_no_reveal_penalty_permille + commitment = H(market_id || account || side || amount || min_tokens || salt) // binds everything + Lock escrow_amount. + Store { commit_id, market, account, commitment, escrow_amount, commit_time, + no_reveal_fee_permille, // RATE LOCKED at commit time + reveal_deadline = now + reveal_window_blocks, status = COMMITTED } +``` + +> // NOTE: `commitment` binds account + market so a commit can't be replayed on another market/user. +> // `escrow_amount` must be ≥ the eventual `amount`; surplus is refunded at reveal. +> // WHY carry the fee in the tx: it makes the penalty an explicit *agreement with consensus* — the +> user signs the rate they accept, the node validates it equals the live delegate-voted value, and the +> rate is then **snapshotted on the commitment** so a later governance change can't alter it retroactively. + +### 5.3 `pm_reveal_bet` — commit-reveal phase 2 + +``` +pm_reveal_bet { commit_id, side, amount, salt, min_tokens } + require status == COMMITTED && now <= reveal_deadline + require H(market_id || account || side || amount || min_tokens || salt) == commitment // verify + require amount <= escrow_amount + refund (escrow_amount - amount) to account + enqueue { bet, side, amount, min_tokens, submit_time = commit_time, epoch = FIRST boundary at/after reveal } + status = REVEALED +``` + +> // INVARIANT: a mismatched reveal (wrong hash) is rejected; the commit then forfeits at deadline (§5.5). +> // NOTE (reveal timing — matters for front-run resistance, see §11.1): the public reveal→settlement gap is +> the ONLY residual front-run window, so the client SHOULD reveal a few blocks BEFORE the next boundary to +> keep that gap ≈ 1–2 blocks (NOT immediately after commit — that maximises the exposed window). +> // `reveal_window_blocks` (200) is a liveness FALLBACK only: if the client was offline and missed its target +> boundary it can still reveal later (settling at a subsequent boundary, accepting more exposure) instead of +> forfeiting. The **20% no-reveal fee** (not the window length) is what deters "commit-then-decide" optionality, +> and `min_tokens` (baked into the hash) auto-refunds the bet if price drifted past the floor during the gap. + +### 5.4 `pm_batch_settle` — VIRTUAL, at epoch boundary + +See §6 for the math. Deterministic, generated at block time, validated by every node. + +### 5.5 `pm_commit_forfeit` — VIRTUAL, at reveal_deadline if unrevealed + +``` +// use the rate LOCKED on the commitment, not the current chain value +penalty = floor(escrow_amount * commitment.no_reveal_fee_permille / 1000) +market.forfeit_pool += penalty // boosts the winners' pool at resolution (per design) +refund (escrow_amount - penalty) to account +status = FORFEITED +``` + +## 6. Batch settlement math (uniform price, LP-safe) + +**Requirements:** (a) one uniform price per side within a batch (no ordering edge), (b) order-independent / deterministic, (c) the LP invariant `reserve_a + reserve_b ≥ L` MUST still hold. + +**Method — aggregate each side into ONE CPMM op, in a fixed canonical order.** Because each step is a standard CPMM transition, the AM-GM proof (spec §5) and `k`-preservation are **inherited for free**. + +``` +// pm_batch_settle(market, epoch) +(Ra, Rb, k) = market.reserves // AFTER any instant bets in this epoch +queued = bets where market_id == market && epoch == epoch && status in {5 queued, REVEALED} +A_in = Σ amount for queued on side A +B_in = Σ amount for queued on side B + +// Canonical order → deterministic & order-independent. Process larger aggregate first; tie → A. +order = (A_in >= B_in) ? [A, B] : [B, A] + +for side in order where side_total > 0: + if side == A: + new_Rb = Rb + A_in; new_Ra = floor(k / new_Rb); T = Ra - new_Ra + else: + new_Ra = Ra + B_in; new_Rb = floor(k / new_Ra); T = Rb - new_Rb + (Ra, Rb) = (new_Ra, new_Rb) // k unchanged: betting, not a liquidity event + uniform_price = side_total / T // identical for every bettor on this side + + // Slippage pre-pass (single recompute): drop bettors whose share < min_tokens, refund them, + // then recompute T/uniform_price once over the survivors. + // TODO: confirm single-pass is sufficient; alternative = iterate to fixpoint (costlier). + for bettor i on side: + tokens_i = floor(T * amount_i / side_total) + if tokens_i < bettor_i.min_tokens: reject & refund (bet not placed) + else: record bet { weight = tokens_i, time_penalty by submit_time } // §8 + dust = T - Σ tokens_i → DAO fund // consistent with spec §7 rounding + +market.reserves = (Ra, Rb) +// INVARIANT (assert): Ra + Rb >= L — holds by inheritance from standard CPMM steps +``` + +> // NOTE (fairness): the side processed second is priced after the first side moved the curve. +> Within each side everyone is uniform, and **no external actor can insert between individual bets** → +> front-running is eliminated. Cross-side price impact within a batch is accepted as a minor tradeoff. +> // OPTIONAL (advanced, later): internally MATCH opposing flow at the clearing price and push only the +> net residual through the curve, for tighter pricing. Leave as future work — must re-prove the invariant. + +**Onix Multi (LMSR):** identical idea — aggregate per-outcome `Δ` and settle via one combined `C(q+Δ)−C(q)` cost step per epoch, uniform price per outcome. `b` and the parimutuel settlement are untouched. + +### 6.1 Reconciling instant and batch flow (epoch-open snapshot) + +> **✅ [Unified Parimutuel Settlement](plan_unified_parimutuel_binary.md) is now implemented** (binary settles parimutuel in code), so this section simplifies: payout is capped at `losers_pool` regardless of weights for **both** types → **both use snapshot pricing** for full manipulation immunity, and `min_tokens` gates slippage. The binary-specific "live curve + price band" carve-out (and `batch_price_band_permille`) below is **no longer needed** — kept only as historical context for the pre-unification design. + +To stop instant bets from front-running the batch, the batch is priced against a **snapshot of the curve taken at the epoch's open** (= the previous epoch's closing reserves), frozen *before* any of this epoch's instant flow — so no intra-epoch instant bet can move the batch's clearing price. This is the user's intuition: **the batch fires on the previous epoch's parameters.** + +``` +boundary (E-1 → E): snapshot S_E = (A0,B0) / q0 // FROZEN — batch of E prices here +during E: instant bets run live on C_live (k preserved); batch bets queue +boundary (E → E+1): pm_batch_settle prices the queue at S_E, reconciles into C_live, opens E+1 +``` + +Merging the two flows (instant on `C_live`, batch on `S_E`) into one E+1 curve **without breaking the LP invariant** is done differently per market type: + +**Onix Multi — snapshot pricing is fully safe.** Parimutuel solvency is *independent of token counts*: +`disbursed = winning_bets + (losing_bets − fees) + subsidy = all_VIZ_held − fees ≤ held`, for ANY token totals. Snapshot mispricing only shifts the *ratio* that splits `winners_pool`; it never threatens solvency or the (unconditionally returned) LP subsidy. So: +- price each batch outcome's `Δ` at snapshot `q0`, mint tokens, collect the real VIZ into the pool; +- open E+1 with `q = q_live_after_instant + batch_Δ`. +→ Full intra-epoch manipulation immunity, zero invariant risk. + +**Onix Binary — `weight` is a claim funded by losers, so settle on the live curve (do NOT force snapshot tokens).** There is no "token emission": `weight` is the bettor's claim — their own principal plus a profit (`weight − amount`) **paid by the losers**. `k` is constant under betting (spec §5; it only changes on liquidity add/withdraw) and maker liquidity `L` keeps `k>0`, so bets just slide the reserves along the curve — there is no "k→0". The real point: the snapshot (pre-move) price is **better than the live price**, so handing it to a batch bettor creates a **larger winner claim than curve-pricing would** — and on-curve pricing is exactly what guarantees the spec's surplus condition `loser_bets − fees ≥ winner_weights − winner_amounts` (betting-rules §Resolution). Off-curve claims can exceed what losers funded, forcing either pro-rating winners (no longer "payout = weight" — breaks the binary model) or tapping LP principal (breaks the guarantee). (Harmless for Multi, where `weight` only sets the split ratio of the losers' pool.) So for binary: +- settle the batch as a **standard aggregated CPMM op on the live curve** (§6) → reserves stay valid, `k` unchanged, AM-GM invariant inherited automatically; +- filter each order by its committed **`min_tokens`**: orders whose floor isn't met at the live settlement price drop out and refund; survivors are aggregated and applied (`reserve_b += Σamount`, `reserve_a −= Σtokens`). +- This gives manipulation **resistance**, not immunity: an attacker who moved the price before the boundary either (a) only degrades the fill within the victim's own slippage tolerance, or (b) gets the victim's bet refunded and is left holding a pumped position with no victim flow to exit against (unprofitable). Optional extra: a **snapshot price band** `batch_price_band_permille` to refund fills that deviate too far from the epoch-open price (largely redundant with `min_tokens`). + +> // RESULT: Multi = full snapshot immunity. Binary = invariant-safety + band-bounded manipulation +> (+ the ~1-block reveal window of §11.1 + min_tokens). Full binary immunity needs mode 3 (encrypted bids). +> // TODO (binary): formal invariant proof IF we ever want a pure-snapshot binary variant (full immunity w/o crypto). + +**Liquidity ops:** `k`/`b`-changing ops apply to `C_live` and are picked up by the NEXT epoch's snapshot; they do not retroactively alter a frozen `S_E` (an in-flight batch prices at pre-add reserves — acceptable, minor). // see §8 block-ordering TODO + +## 7. Non-reveal → winners' pool (resolution change) + +Add `forfeit_pool` to the market; merge it into the winners' pool at resolution: + +``` +winners_pool = losers_sum − oracle_fee − creator_fee − liq_fee + market.forfeit_pool +// distributed pro-rata by winning tokens, exactly like losers_sum +``` + +> // NOTE: no profitable attack — a forfeiter pays the penalty and receives **zero tokens**, so they can't +> recapture it; the penalty only helps the (other) winners. This also deters the "commit optionality, +> reveal only the favorable side" game. +> // EDGE: if there are no winning tokens (edge cases in spec §6), `forfeit_pool` follows the same path as +> undistributed `winners_pool` (LP bonus / DAO). Align with the existing edge-case table. + +## 8. Interaction with existing mechanics + +| Mechanic | Behavior | +|----------|----------| +| **Time penalty (spec §8)** | Computed from **submit/commit time**, NOT settlement time → users aren't penalized for the batch/reveal delay. // important | +| **Slippage** | `min_tokens` enforced at settlement; failure → refund (bet not placed). For mode 2 it's inside the commitment hash → un-changeable. | +| **Cancellation (spec §11)** | A queued batch bet (status 5) MAY be cancelled before settlement. A COMMITTED bet CANNOT be free-cancelled before `reveal_deadline` (else commit-reveal = free optionality); the only exits are reveal+settle or forfeit. // TODO confirm policy | +| **Liquidity add/withdraw** | `k`-changing events still only on liquidity ops; must be sequenced **before** `pm_batch_settle` within a block to keep `k` consistent for the batch. // TODO define block-level op ordering | +| **Dispute / resolution** | Unaffected, except `winners_pool` now includes `forfeit_pool`. | +| **Self-oracle / lazy pool** | Unaffected. | + +## 9. Database schema changes + +```sql +-- bets: execution mode + queueing +ALTER TABLE `bets` + ADD COLUMN `mode` tinyint NOT NULL DEFAULT 0, -- 0 instant, 1 batch, 2 commit-reveal + ADD COLUMN `epoch` int NOT NULL DEFAULT 0, -- settlement epoch + ADD COLUMN `submit_time` int NOT NULL DEFAULT 0; -- for time-penalty basis + -- status extended: 5 = queued, 6 = revealed-pending (existing: 0 active,1 cancelled,...) + +-- commitments (commit-reveal phase 1) +CREATE TABLE `bet_commitments` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `market` bigint NOT NULL, + `account` bigint NOT NULL, + `commitment` char(64) NOT NULL, -- hex hash + `escrow_amount` bigint NOT NULL, + `no_reveal_fee_permille` smallint NOT NULL, -- rate snapshotted at commit (consensus-checked in tx) + `commit_time` int NOT NULL, + `reveal_deadline` int NOT NULL, + `status` tinyint NOT NULL DEFAULT 0, -- 0 committed, 1 revealed, 2 forfeited + `revealed_bet_id` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `market_status` (`market`,`status`), + KEY `reveal_deadline` (`reveal_deadline`) +); + +-- markets: forfeit accumulator + opt-in flags + tier +ALTER TABLE `markets` + ADD COLUMN `forfeit_pool` bigint NOT NULL DEFAULT 0, + ADD COLUMN `allow_batch` tinyint NOT NULL DEFAULT 1, + ADD COLUMN `allow_instant_bet` tinyint NOT NULL DEFAULT 1, + ADD COLUMN `endogeneity_tier` tinyint NOT NULL DEFAULT 2, + ADD COLUMN `current_epoch` int NOT NULL DEFAULT 0; +``` + +## 10. Audit trail + +`market_log` gains actions: `commit`, `reveal`, `batch_settle` (with `A_in`, `B_in`, before/after reserves, dust), `commit_forfeit` (penalty, refund). Keeps the full before/after-reserves discipline of spec §3. + +## 11. Attack & corner cases + +| Case | Handling | +|------|----------| +| Commit on both sides, reveal only favorable | Reveals don't apply until epoch close (nothing to react to); non-reveal penalty makes optionality costly. | +| Spam empty commits | `min_batch_bet` escrow + non-reveal penalty. | +| Settle an empty queue | No-op; no virtual op emitted. | +| Front-runner uses instant bets to move curve before a batch settles | The residual vector — see §11.1 for the full threat model and the layered defenses. | +| Reveal after deadline | Rejected; commit forfeits. | +| min_tokens fails for some batch bettors | Single recompute pre-pass over survivors (§6). | + +### 11.1 What commit-reveal actually protects (threat model) + +The hash hides a bet **only during commit → reveal**. After reveal, side+amount are public and the bet **does** enter the batch. So the protection is layered, not "the batch hides it": + +1. **No mempool sandwich of your specific order** — while it is a hash, no one can read it or execute immediately ahead of it. (Primary protection.) +2. **Uniform-price batch** — no intra-batch ordering edge; a front-runner can't get a strictly better price than you inside the same batch (§6). +3. **Batch-composition uncertainty** — other hidden commits (possibly on the opposite side) may be in the same batch, so an instant front-run is a *bet under uncertainty*, not a sure sandwich. + +**Residual vector (real):** between reveal and settlement the bet is public, and an **instant** bet executes against the live curve immediately → it can move the price the batch settles at. A profitable sandwich requires ALL of: (a) a long reveal→settlement gap, (b) `allow_cancellation=1` (attacker can exit), (c) a loose victim `min_tokens`. + +**Defenses, by increasing strength:** + +- **Shrink the gap.** Settle at the first boundary at/after reveal; clients reveal a few blocks before the boundary (§5.3) → gap ≈ 1–2 blocks. `reveal_window_blocks` is only a liveness fallback. +- **`min_tokens` (in the hash).** A pump pushes the victim's fill below the floor → the bet is auto-rejected/refunded, so the attacker's pump backfires (left long with no victim flow). +- **Non-cancellable markets.** `allow_cancellation=0` → attacker cannot exit → front-run becomes pure directional risk, not a riskless sandwich. +- **STRUCTURAL — epoch-open snapshot (specified in §6.1):** price the batch against the reserves frozen at the epoch's open, so same-epoch instant bets cannot move its clearing price. Fully manipulation-immune & solvency-safe for **Multi**; for **Binary** use live-curve settlement + a snapshot price band (token=VIZ claim makes pure-snapshot pricing unsafe). Removes the instant-front-run vector without cryptography. +- **FULL elimination (mode 3, roadmap):** **threshold/timelock-encrypted sealed bids** (Shutter/Penumbra-style) — the preimage is decrypted only at the boundary, so there is no public actionable window at all. + +> // BOTTOM LINE: commit-reveal+batch turns front-running from a *trivial mempool sandwich* into "guess the +> hidden batch, race a ~1-block window, bear directional risk, and risk the victim's min_tokens canceling +> the bet." A true zero requires an encrypted mempool (mode 3). + +## 12. Open questions (// TODO) + +- [ ] Block-level ordering of `liquidity_*`, instant `pm_place_bet`, and `pm_batch_settle` within one block. +- [ ] Slippage rejection: single pre-pass vs. iterate-to-fixpoint. +- [ ] Cancellation policy for COMMITTED (not yet revealed) bets. +- [ ] Optional cross-side internal matching at clearing price (advanced pricing) + invariant re-proof. +- [x] Epoch-open snapshot reconciliation — specified in §6.1 (Multi: snapshot pricing; Binary: live + price band). Remaining: formal invariant proof for a pure-snapshot *binary* variant, only if full crypto-free binary immunity is wanted. +- [ ] Mode 3: threshold/timelock-encrypted sealed bids — whether/when to add (fully closes the residual). +- [ ] Whether `endogeneity_tier` is purely advisory or can *force* commit-reveal at protocol level. + +## 13. Phased rollout + +1. **Phase 1 — Batch (mode 1).** Epochs + `pm_batch_settle` + uniform-price aggregation. Biggest front-running win, no crypto. +2. **Phase 2 — Commit-Reveal (mode 2).** `pm_commit_bet`/`pm_reveal_bet` + `pm_commit_forfeit` + `forfeit_pool`. Adds confidentiality (endogeneity mitigation). +3. **Phase 3 — Tiering & client policy.** `endogeneity_tier` defaults, optional advanced cross-side matching. + +## 14. Sections to add/modify in `onix-protocol-specification.md` + +| Spec section | Change | +|--------------|--------| +| §3 System Parameters | Add the 5 global + 2 per-market params (this doc §3). | +| §4 Market State Machine | Add bet statuses 5 (queued) / 6 (revealed-pending); add `pm_batch_settle` & `pm_commit_forfeit` virtual ops to transitions. | +| §5 Onix Binary | Add §5.x "Batch settlement" with the uniform-price aggregation math (§6). | +| §6 Onix Multi | Add the LMSR batch analogue. | +| §7 Fee Structure | Add `forfeit_pool` to the `winners_pool` formula. | +| §8 Time Penalty | Clarify basis = submit/commit time for deferred modes. | +| §10 Resolution & Payout | Include `forfeit_pool` in winners distribution + edge cases. | +| §11 Bet Cancellation | Add cancellation rules for queued/committed bets. | +| New §18 "Execution Modes" | Full description of instant/batch/commit-reveal + ops §5. | +| §17 Database Schema | Add the tables/columns from this doc §9. | diff --git a/.qoder/plans/pm-plan/chain-properties-governance.md b/.qoder/plans/pm-plan/chain-properties-governance.md new file mode 100644 index 0000000000..bcf19f2f8a --- /dev/null +++ b/.qoder/plans/pm-plan/chain-properties-governance.md @@ -0,0 +1,235 @@ +# Chain Properties: How Validators Govern Network Parameters + +## Overview + +In VIZ, there is no central authority that sets network fees, block sizes, inflation rates, or other critical parameters. Instead, **every active validator publishes their preferred values**, and the blockchain automatically calculates the **median** — the middle value that represents the consensus of all elected validators. + +Since validators are elected by stake-weighted voting from all SHARES holders, chain properties are ultimately governed by the community: users vote for validators whose parameter choices align with their vision of the public good. + +--- + +## How It Works + +### Step 1: Validators Publish Their Preferences + +Each validator publishes their preferred chain properties using the `versioned_chain_properties_update_operation`: + +```json +["versioned_chain_properties_update", { + "owner": "validator1", + "props": [3, { + "account_creation_fee": "1.000 VIZ", + "maximum_block_size": 131072, + "create_account_delegation_ratio": 10, + "create_account_delegation_time": 2592000, + "min_delegation": "1.000 VIZ", + "min_curation_percent": 0, + "max_curation_percent": 10000, + "bandwidth_reserve_percent": 1000, + "bandwidth_reserve_below": "500.000000 SHARES", + "flag_energy_additional_cost": 0, + "vote_accounting_min_rshares": 5000000, + "committee_request_approve_min_percent": 1000, + "inflation_validator_percent": 2000, + "inflation_ratio_committee_vs_reward_fund": 5000, + "inflation_recalc_period": 806400, + "data_operations_cost_additional_bandwidth": 0, + "validator_miss_penalty_percent": 100, + "validator_miss_penalty_duration": 86400, + "create_invite_min_balance": "10.000 VIZ", + "committee_create_request_fee": "100.000 VIZ", + "create_paid_subscription_fee": "100.000 VIZ", + "account_on_sale_fee": "10.000 VIZ", + "subaccount_on_sale_fee": "100.000 VIZ", + "validator_declaration_fee": "10.000 VIZ", + "withdraw_intervals": 28 + }] +} +``` + +The `[3, {...}]` format indicates the version — `3` means `chain_properties_hf9` (the latest). Older versions (`0` = init, `1` = hf4, `2` = hf6) are accepted for backward compatibility. + +### Step 2: Median Calculation + +Every time the validator schedule is updated, the blockchain runs `update_median_validator_props()`. For **each property independently**: + +1. Collect the property value from every active validator +2. Sort the values +3. Pick the **median** (the middle value) + +``` +Example: 5 validators set account_creation_fee to: + 0.5 VIZ, 1.0 VIZ, 1.0 VIZ, 2.0 VIZ, 5.0 VIZ + ↑ + median = 1.0 VIZ +``` + +The algorithm uses `std::nth_element` with position `active.size() / 2`, which selects the value at the middle index after partial sorting. + +**Why median?** The median is resistant to extremes. A single validator cannot push a parameter to an absurdly high or low value — they can only shift the median by one position. To change a parameter significantly, a **majority of active validators** must agree. + +### Step 3: Application + +The calculated `median_props` is stored in the `validator_schedule_object` and used across the entire blockchain to enforce rules. + +--- + +## All Governable Properties + +### Account & Delegation Rules + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `account_creation_fee` | asset (VIZ) | 1.000 VIZ | Minimum fee to create a new account | +| `create_account_delegation_ratio` | uint32 | 10 | Multiplier: delegation = ratio × fee | +| `create_account_delegation_time` | uint32 (sec) | 30 days | How long creation delegation is locked | +| `min_delegation` | asset (VIZ) | 1.000 VIZ | Minimum amount for any delegation | + +**How it's used**: When someone creates a new account, they must pay at least `account_creation_fee` and provide delegation of at least `ratio × fee` in SHARES equivalent. The delegation is locked for `create_account_delegation_time`. This prevents cheap mass account creation (Sybil attacks) while keeping the network accessible. + +### Block Size & Bandwidth + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `maximum_block_size` | uint32 (bytes) | 131072 | Maximum block size — controls network throughput | +| `bandwidth_reserve_percent` | int16 (bp) | 1000 (10%) | Extra bandwidth for small accounts | +| `bandwidth_reserve_below` | asset (SHARES) | 500.000000 | Threshold for bandwidth reserve | +| `data_operations_cost_additional_bandwidth` | uint32 (%) | 0 | Extra bandwidth cost for data-heavy operations | + +**How it's used**: Transaction bandwidth is allocated proportionally to SHARES. Accounts below `bandwidth_reserve_below` get an additional `bandwidth_reserve_percent` reserve so they can still transact. `maximum_block_size` directly controls how many transactions the network can process per block. + +### Inflation & Economics + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `inflation_validator_percent` | int16 (bp) | 2000 (20%) | Validator share of block inflation | +| `inflation_ratio_committee_vs_reward_fund` | int16 (bp) | 5000 (50%) | How remaining inflation is split between committee fund and reward fund | +| `inflation_recalc_period` | uint32 (blocks) | 806400 (28 days) | How often inflation parameters are recalculated | + +**How it's used**: Each block creates new tokens (inflation). First, `inflation_validator_percent` goes to the block-producing validator. The remainder is split: `inflation_ratio_committee_vs_reward_fund` percent goes to the committee DAO fund, the rest to the reward fund (used for awards). Validators directly control how the economy works. + +### Reward System + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `min_curation_percent` | int16 (bp) | 500 (5%) | Minimum curation reward share | +| `max_curation_percent` | int16 (bp) | 500 (5%) | Maximum curation reward share | +| `vote_accounting_min_rshares` | uint32 | 5000000 | Minimum rshares for an award to have effect | +| `flag_energy_additional_cost` | int16 (bp) | 0 | Extra energy cost for downvoting | + +**How it's used**: When content receives awards, curation rewards are bounded by `[min_curation_percent, max_curation_percent]`. Awards with fewer than `vote_accounting_min_rshares` rshares produce zero reward (dust filter). `flag_energy_additional_cost` can make downvotes more expensive than upvotes. + +### Validator Accountability + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `validator_miss_penalty_percent` | int16 (bp) | 100 (1%) | Vote reduction for missing a block | +| `validator_miss_penalty_duration` | uint32 (sec) | 86400 (1 day) | How long the penalty lasts | + +**How it's used**: When a validator misses their scheduled block, their effective votes are reduced by `validator_miss_penalty_percent` for `validator_miss_penalty_duration` seconds. This is self-governing accountability: validators vote on how harshly missed blocks are punished. + +### Fee Structure + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `committee_create_request_fee` | asset (VIZ) | 100.000 VIZ | Fee to create a DAO proposal | +| `create_paid_subscription_fee` | asset (VIZ) | 100.000 VIZ | Fee to create a paid subscription | +| `account_on_sale_fee` | asset (VIZ) | 10.000 VIZ | Fee to list an account for sale | +| `subaccount_on_sale_fee` | asset (VIZ) | 100.000 VIZ | Fee to list subaccounts for sale | +| `validator_declaration_fee` | asset (VIZ) | 10.000 VIZ | One-time fee for new validator registration | +| `create_invite_min_balance` | asset (VIZ) | 10.000 VIZ | Minimum balance to create an invite | + +**How it's used**: All fees go to the **committee fund** (DAO treasury). Validators control how expensive various network operations are. Higher fees discourage spam; lower fees improve accessibility. The community decides the balance through validator elections. + +### Vesting Withdrawal + +| Property | Type | Default | What It Controls | +|---|---|---|---| +| `withdraw_intervals` | uint16 | 28 | Number of daily installments for unstaking | + +**How it's used**: When a user unstakes SHARES, the withdrawal happens over `withdraw_intervals` days (one installment per day). Validators can make unstaking faster or slower, affecting how liquid the network's governance token is. + +--- + +## The Governance Loop: Users → Validators → Parameters + +### Users Shape the Network Through Validator Selection + +Users cannot directly set chain properties. Instead, they **vote for validators** whose published properties match their preferences. This creates a representative governance system: + +``` +Users (SHARES holders) + │ + ├── Vote for validators who want LOW fees + │ → More validators with low fee props get elected + │ → Median fees decrease + │ + ├── Vote for validators who want HIGH inflation to reward fund + │ → More reward-focused validators get elected + │ → Inflation shifts toward reward fund + │ + └── Vote for validators who want STRICT miss penalties + → More accountability-focused validators get elected + → Miss penalties increase +``` + +### Why This Is a Public Good Mechanism + +Traditional blockchains set parameters through hard-coded values or foundation decisions. VIZ makes **every parameter a public good decision**: + +1. **Transparency**: every validator's preferred properties are on-chain and publicly visible +2. **Accountability**: if a validator sets harmful parameters, users can unvote them +3. **Gradual change**: the median shifts slowly — no single validator can cause sudden parameter swings +4. **No single point of failure**: even if some validators are compromised, the median protects the network +5. **Aligned incentives**: validators earn block rewards, so they're incentivized to keep the network healthy + +### Example: How a Fee Change Happens + +Suppose the community wants to lower the `committee_create_request_fee` from 100 VIZ to 50 VIZ: + +1. Users discuss in community channels that the fee is too high +2. Some validators update their properties: `committee_create_request_fee: "50.000 VIZ"` +3. Users shift votes to validators who support lower fees +4. As more low-fee validators enter the active set, the **median shifts down** +5. Once more than half of active validators publish 50 VIZ or less, the median becomes 50 VIZ +6. The new fee takes effect automatically — no hardfork, no governance proposal, no vote counting + +### Comparing Governance Models + +| Approach | VIZ Median Properties | Token Voting (e.g., Snapshot) | Foundation Governance | +|---|---|---|---| +| **Who decides** | Elected validators (indirectly: all SHARES holders) | Token holders directly | Core team / foundation | +| **Resistance to extremes** | Strong (median) | Weak (whale dominance) | N/A (centralized) | +| **Speed of change** | Gradual (median shifts slowly) | Fast (single vote) | Fast or slow (depends on team) | +| **Parameter granularity** | Every parameter independently | Usually binary proposals | Any | +| **Sybil resistance** | Built-in (stake-weighted Fair-DPOS) | Depends on implementation | N/A | +| **Transparency** | Full (all validator props on-chain) | Partial (off-chain voting) | Low | + +--- + +## Versioning and Hardfork Compatibility + +Properties were introduced in stages: + +| Version | Hardfork | Properties Added | +|---|---|---| +| `chain_properties_init` | Genesis | account_creation_fee, maximum_block_size, delegation params, curation, bandwidth, flag cost, vote min rshares, committee threshold | +| `chain_properties_hf4` | HF4 | inflation_validator_percent, inflation_ratio_committee_vs_reward_fund, inflation_recalc_period | +| `chain_properties_hf6` | HF6 | data_operations_cost_additional_bandwidth, validator_miss_penalty_percent, validator_miss_penalty_duration | +| `chain_properties_hf9` | HF9 | create_invite_min_balance, committee_create_request_fee, create_paid_subscription_fee, account_on_sale_fee, subaccount_on_sale_fee, validator_declaration_fee, withdraw_intervals | + +Validators publish properties using `versioned_chain_properties` — a variant that accepts any version. The evaluator validates the version against the current hardfork (you can't publish HF9 properties before HF9 activates). Properties from older versions use default values for newer fields. + +--- + +## Summary + +Chain properties governance in VIZ is a **continuous, median-based, representative system** where: + +- **Validators** are the direct governors who publish their preferred parameters +- **Users** are the ultimate governors who elect validators based on their published properties +- **The median** ensures no single actor can impose extreme values +- **Every parameter** — from fees to inflation to bandwidth — is a public good decision made collectively +- **Changes happen organically**: as community preferences shift, validator elections shift, and the median follows + +This creates a self-regulating network where the "rules of the game" are constantly optimized by the people who have the most at stake. diff --git a/.qoder/plans/pm-plan/lazy-pool-properties.md b/.qoder/plans/pm-plan/lazy-pool-properties.md new file mode 100644 index 0000000000..83235be0a5 --- /dev/null +++ b/.qoder/plans/pm-plan/lazy-pool-properties.md @@ -0,0 +1,549 @@ +# Lazy Pool: Properties, Accounting & State Transitions + +## 1. Overview + +The Lazy Liquidity Pool is a **singleton** smart-contract-like entity (single DB row, `id=1`) that pools VIZ from multiple depositors and automatically deploys it as: + +1. **LP liquidity** — provides market-making capital to active prediction markets +2. **Leverage loans** — loans VIZ to bettors opening boosted (leveraged) positions + +All monetary values use **milli-VIZ precision** (1 VIZ = 1000 internal units, `PRECISION = 1000`). + +The pool uses **Lazy Accounting** (MasterChef/Compound pattern): a single global accumulator (`reward_per_share`) enables O(1) profit distribution regardless of participant count. When profit arrives, **only** `reward_per_share` is updated — no per-user writes. Users read their rewards via `shares × (rps − snapshot) / 10^9`. + +--- + +## 2. Pool Properties + +### 2.1 Stored Properties (Database Columns) + +| Column | Type | Precision | Description | +|--------|------|-----------|-------------| +| `total_shares` | bigint | milli-VIZ | Total share tokens outstanding. 1:1 on first deposit; subsequent deposits: `new_shares = amount × total_shares / free_balance` | +| `free_balance` | bigint | milli-VIZ | VIZ not currently deployed — available for new allocations, leverage loans, and withdrawals. This is the **working capital** of the pool | +| `allocated_balance` | bigint | milli-VIZ | VIZ currently deployed as LP liquidity on active markets. Returned to `free_balance` on market resolution or recall | +| `earned_balance` | bigint | milli-VIZ | Cumulative total VIZ earned by the pool from all sources (LP profits, leverage profits, penalties, fees). **Monotonically non-decreasing** — only grows, never shrinks | +| `leverage_fund_used` | bigint | milli-VIZ | Total active leverage loans outstanding. A sub-allocation **cap** on `free_balance`, not a separate pool | +| `reward_per_share` | bigint | 10^9 | Accumulated reward per share (LAZY_POOL_PRECISION). **Monotonically non-decreasing** — only grows. The Lazy Accounting accumulator | + +### 2.2 Computed Properties (Derived at Query Time) + +| Property | Formula | Description | +|----------|---------|-------------| +| `total_value` | `free_balance + allocated_balance` | Total VIZ controlled by the pool | +| `invested_liquidity` | `allocated_balance` | VIZ deployed as LP on active markets | +| `invested_leverage` | `leverage_fund_used` | VIZ deployed as leverage loans | +| `free_amount` | `free_balance − leverage_fund_used` | Truly free VIZ — not on markets, not loaned for leverage. Available for new allocations and leverage loans | +| `leverage_fund_total` | `free_balance × F% / 100` | Cap on how much of `free_balance` may be used for leverage loans | +| `leverage_fund_available` | `leverage_fund_total − leverage_fund_used` | Remaining leverage loan capacity | +| `per_share_value` | `free_balance / total_shares` | Current VIZ value of one share (withdrawal exchange rate) | +| `pool_profit_ratio` | `earned_balance / total_value` | Lifetime pool profitability (monitoring metric) | + +### 2.3 Example State + +``` +lazy_pool: + free_balance = 10,000,000 mVIZ (10,000 VIZ) + allocated_balance = 7,000,000 mVIZ ( 7,000 VIZ) + earned_balance = 1,500,000 mVIZ ( 1,500 VIZ) ← cumulative earnings + leverage_fund_used = 100,000 mVIZ ( 100 VIZ) ← active leverage loans + total_shares = 8,000,000 ( 8,000 shares) + reward_per_share = 187,500,000 (high-precision accumulator) + +Computed: + total_value = 10,000,000 + 7,000,000 = 17,000,000 mVIZ (17,000 VIZ) + invested_liquidity = 7,000,000 mVIZ (7,000 VIZ on active markets) + invested_leverage = 100,000 mVIZ ( 100 VIZ in leverage loans) + free_amount = 10,000,000 − 100,000 = 9,900,000 mVIZ (9,900 VIZ) + per_share_value = 10,000,000 / 8,000,000 = 1.25 VIZ/share + +Leverage fund (F% = 10%): + leverage_fund_total = 10,000,000 × 10% = 1,000,000 mVIZ (1,000 VIZ) + leverage_fund_available = 1,000,000 − 100,000 = 900,000 mVIZ (900 VIZ) +``` + +--- + +## 3. Invariants + +These must hold after every state transition: + +``` +1. free_balance + allocated_balance = total_value (conservation of value) +2. free_balance ≥ leverage_fund_used (loans cannot exceed free capital) +3. earned_balance is monotonically non-decreasing (cumulative earnings only grow) +4. reward_per_share is monotonically non-decreasing (accumulated rewards only grow) +5. total_shares > 0 iff free_balance > 0 (no shares without capital) +``` + +--- + +## 4. User-Level Properties + +Each user has the following lazy pool fields on the `users` table: + +| Field | Type | Description | +|-------|------|-------------| +| `lazy_pool_shares` | bigint | User's share tokens (sum across all deposits) | +| `lazy_pool_balance` | bigint | User's principal VIZ deposited (not including earnings) | +| `lazy_pool_reward_snapshot` | bigint | Snapshot of `pool.reward_per_share` at last user action | +| `lazy_pool_pending_rewards` | bigint | Settled but unclaimed rewards (accumulated at deposit/withdraw) | + +**Computed per-user:** + +``` +live_unsettled_reward = lazy_pool_shares × (pool.reward_per_share − lazy_pool_reward_snapshot) / 10^9 +total_user_reward = lazy_pool_pending_rewards + live_unsettled_reward +user_share_value = lazy_pool_shares × pool.free_balance / pool.total_shares +user_total_value = user_share_value + total_user_reward +user_earned = pool.earned_balance × (lazy_pool_shares / pool.total_shares) +user_principal = user_total_value − user_earned +``` + +--- + +## 5. Share Calculation + +### 5.1 Deposit — New Shares + +``` +First depositor: new_shares = amount (1:1 ratio) +Subsequent: new_shares = amount × total_shares / free_balance +``` + +The exchange rate (`free_balance / total_shares`) increases as the pool earns profit, so later depositors get fewer shares per VIZ — this is how early LPs capture returns. + +### 5.2 Withdrawal — Share Value + +``` +share_value = shares_to_burn × free_balance / total_shares +``` + +Since `free_balance` includes accumulated profit (via `reward_per_share` distributions are separate, but `free_balance` grows when profit arrives), the per-share value drifts upward over time. + +--- + +## 6. State Transitions + +All state changes are **atomic** (single SQL transaction). Each transition is shown with its effect on every pool column. + +### Transition 1: User Deposit + +``` +User deposits amount A into the pool. + +BEFORE: settle user rewards (pending += shares × (rps − snapshot) / 10^9; snapshot = rps) + +lazy_pool: + free_balance += A + total_shares += new_shares (A × total_shares / free_balance before deposit) + allocated_balance — no change + earned_balance — no change + leverage_fund_used — no change + reward_per_share — no change + +Fund flow: user.balance → lazy_pool.free_balance +``` + +### Transition 2: Auto-Allocation to Market + +``` +Pool allocates alloc_amount to a new market (when oracle accepts). + +lazy_pool: + free_balance −= alloc_amount + allocated_balance += alloc_amount + total_shares — no change + earned_balance — no change + leverage_fund_used — no change + reward_per_share — no change + +Fund flow: free_balance → market CPMM reserves (as LP position, user=0) +Effect: invested_liquidity increases. total_value unchanged. +``` + +### Transition 3: Market Resolves — Principal Return (No Profit) + +``` +Market resolves, pool LP position returns exactly the allocation amount. + +lazy_pool: + free_balance += alloc_amount + allocated_balance −= alloc_amount + total_shares — no change + earned_balance — no change + leverage_fund_used — no change + reward_per_share — no change + +Fund flow: market CPMM reserves → lazy_pool.free_balance +Effect: invested_liquidity decreases. total_value unchanged. +``` + +### Transition 4: Market Resolves — With Profit + +``` +Market resolves, pool earns profit on LP position. + lp_return = amount returned from market (principal + profit) + lp_profit = lp_return − alloc_amount + +lazy_pool: + free_balance += lp_return (principal + profit both return to free) + allocated_balance −= alloc_amount (no longer allocated) + earned_balance += lp_profit (track cumulative earnings) + reward_per_share += lp_profit × 10^9 / total_shares (distribute to LP investors) + total_shares — no change + leverage_fund_used — no change + +Fund flow: market CPMM reserves → lazy_pool.free_balance + profit portion also recorded in earned_balance + distributed via reward_per_share +Effect: principal: invested_liquidity → free + profit: adds to free, earned, and reward_per_share +``` + +### Transition 5: Graduated Recall (Idle Market) + +``` +Cron recalls recall_amount from an idle market's allocation. + +lazy_pool: + free_balance += recall_amount + allocated_balance −= recall_amount + total_shares — no change + earned_balance — no change + leverage_fund_used — no change + reward_per_share — no change + +Fund flow: market CPMM reserves → lazy_pool.free_balance +Effect: Partial invested_liquidity → free. total_value unchanged. +``` + +### Transition 6: Leverage Position Opened + +``` +Bettor opens a boosted position with loan L from the pool. + C = bettor's collateral, L = pool's loan + +lazy_pool: + free_balance −= (C + L) (collateral C + loan L placed into AMM) + leverage_fund_used += L (track active loans — only L, not C) + total_shares — no change + earned_balance — no change + allocated_balance — no change + reward_per_share — no change + +Fund flow: lazy_pool.free_balance → market CPMM reserves +Note: Only L counts toward leverage_fund_used (not C, the bettor's own collateral). + free_balance decreases by full (C+L), but leverage_fund_used only increases by L. + The bettor's collateral C reduces free_amount but is not a leverage obligation. +``` + +### Transition 7: Leverage Position Closed (Liquidation / Voluntary Close / Force-Close) + +``` +Pool recovers loan + profit from a closed leverage position. + pool_received = loan_return + pool_profit + pool_profit = pool_received − L + +lazy_pool: + free_balance += pool_received (loan + profit return to free) + leverage_fund_used −= L (free up loan capacity) + earned_balance += pool_profit (track cumulative earnings) + reward_per_share += pool_profit × 10^9 / total_shares (distribute to LP investors) + total_shares — no change + allocated_balance — no change + +Fund flow: market CPMM reserves → lazy_pool.free_balance +Effect: invested_leverage decreases (leverage_fund_used −= L) + profit adds to free, earned, and reward_per_share + leverage_fund_used decreases, freeing capacity for new loans +``` + +### Transition 7a: Leverage Position Liquidated After Cancel-Bet (Bad Debt) + +When a cancel-bet on the same outcome as a leveraged position pushes it below the liquidation threshold, the cancel-bet executes first (transaction initiator gets expected price), then the position is liquidated at post-cancel-bet reserves. The pool may absorb a shortfall. + +**Cascade behavior:** Liquidating one position changes AMM reserves (tokens returned, VIZ removed), which can push other same-side positions below their threshold. The system uses a recursive cascade loop (`leverage_cascade_liquidate`) that re-evaluates all remaining positions after each liquidation until no more positions qualify. This prevents hidden bad debt from positions that become underwater due to the reserve changes caused by earlier liquidations in the same cascade. See [Liquidation Cascade](../.qoder/docs/leverage-risk-off-strategy.md#liquidation-cascade-long-squeeze) for full analysis. + +``` + cancel_value < liquidation_threshold (position pushed below by cancel-bet) + pool_received = cancel_value (entire amount to pool) + shortfall = liquidation_threshold − cancel_value (bad debt) + +lazy_pool: + free_balance += cancel_value (remaining value returns to free) + free_balance −= shortfall (bad debt absorbed by pool) + leverage_fund_used −= L (free up loan capacity) + earned_balance — no change (no earnings on a loss) + reward_per_share — no change (no profit to distribute) + total_shares — no change + allocated_balance — no change + +Effect: The pool absorbs the shortfall from free_balance. This is a bounded, rare event: + - Bounded by SL% (cancel-bet's price impact cap) + - Rare: only when position near threshold + cancel-bet on same outcome is the trigger + - Fairness: the cancel-bettor (transaction initiator) is not penalized + See Leverage Risk-Off Strategy, Section 5 (Case B) for full specification. +``` + +### Transition 8: Leverage Position Resolved (Loss — Outcome Risk) + +``` +Bettor's outcome loses at resolution. Pool loses the loan L. + +lazy_pool: + leverage_fund_used −= L (loan gone, free up capacity) + free_balance — no change (loan was already in AMM, now lost) + earned_balance — no change (no earnings from this position) + total_shares — no change + allocated_balance — no change + reward_per_share — no change + +Effect: invested_leverage decreases. The loss is absorbed by the pool's total_value + (which implicitly decreased when the bet was placed into the AMM and the + outcome lost). free_balance doesn't change because the loan was already + removed from free_balance at opening — it was in the AMM reserves and is now + distributed to winning bettors at resolution. +``` + +### Transition 8a: Leverage Position Resolved (Win — Outcome Reward) + +Bettor's outcome wins at resolution. Pool receives loan + profit. + +**Note:** When multiple leveraged positions are force-closed together (e.g., Cron Job 11 or Case A opposing bet), the system uses a recursive cascade loop. Each force-close changes AMM reserves, potentially affecting subsequent positions. See Transition 7a cascade behavior. + +``` +pool_obligation = L × (1 + R%/100) (= liquidation_threshold) +payout = redeem(tokens, outcome=winning) +pool_received = min(payout, pool_obligation) = pool_obligation (payout ≥ threshold) +bettor_received = payout − pool_obligation +pool_profit = pool_received − L = L × R%/100 + +lazy_pool: + free_balance += pool_received (loan + profit return to free) + leverage_fund_used −= L (free up loan capacity) + earned_balance += pool_profit (track cumulative earnings) + reward_per_share += pool_profit × 10^9 / total_shares (distribute to LP investors) + total_shares — no change + allocated_balance — no change + +Fund flow: market AMM → lazy_pool.free_balance (via resolution payout) +Effect: invested_leverage decreases. Pool earns L × R% profit. + Bettor receives (payout − pool_obligation) from resolution. +``` + +### Transition 9: User Withdrawal (Planned) + +``` +User withdraws shares from the pool. + share_value = shares_to_burn × free_balance / total_shares + reward_portion = pending_rewards × withdraw_percent / 100 + total_payout = share_value + reward_portion + +BEFORE: settle user rewards (pending += shares × (rps − snapshot) / 10^9; snapshot = rps) + +lazy_pool: + free_balance −= total_payout (share value + rewards paid out) + total_shares −= shares_to_burn (shares burned) + allocated_balance — no change + earned_balance — no change (cumulative earnings never decrease) + leverage_fund_used — no change + reward_per_share — no change + +Fund flow: lazy_pool.free_balance → user.balance +Effect: User receives proportional share of free_balance + accumulated rewards. +``` + +### Transition 10: Convert Leveraged Position to Normal Bet + +A bettor converts their boosted position into a normal (non-leveraged) bet, paying the pool from their balance. This allows holding the position to resolution and capturing the full outcome payout. + +``` +Bettor converts leveraged position to normal bet. + pool_obligation = L × (1 + R%/100) (= liquidation_threshold) + current_profit = cancel_value − pool_obligation (bettor's unrealized gain) + conversion_fee = current_profit × conversion_profit_cost% / 100 + total_user_payment = pool_obligation + conversion_fee + pool_profit = (pool_obligation − L) + conversion_fee + +lazy_pool: + free_balance += total_user_payment (obligation + fee from bettor's balance) + leverage_fund_used −= L (free up loan capacity) + earned_balance += pool_profit (track cumulative earnings) + reward_per_share += pool_profit × 10^9 / total_shares (distribute to LP investors) + total_shares — no change + allocated_balance — no change + +User: + balance −= total_user_payment + +Position: + status → converted (5) + Bet record updated: remove leveraged_position, keep as normal bet (100% bettor-owned) + +Fund flow: user.balance → lazy_pool.free_balance +Effect: Pool receives full loan repayment + profit + conversion fee. + The bettor assumes 100% outcome risk at resolution. + conversion_profit_cost is committee-configurable (default 50%). +``` + +See [Leverage Risk-Off Strategy](../.qoder/docs/leverage-risk-off-strategy.md) — Convert to Normal Bet (`pm_leverage_convert`) for full specification. + +### Transition 11: Emergency Withdrawal (With Penalty) + +``` +User emergency-withdraws with penalty on locked portion's profit. + penalty = profit × (locked_shares / total_shares) × emergency_penalty / 100 + +BEFORE: settle user rewards + +lazy_pool: + free_balance −= (total_value − penalty) (pay out to user) + total_shares −= user_total_shares (all shares burned) + earned_balance — no change + reward_per_share += penalty × 10^9 / remaining_shares (penalty redistributed) + allocated_balance — no change + leverage_fund_used — no change + +Fund flow: lazy_pool.free_balance → user.balance (minus penalty) + penalty stays in pool → redistributed to remaining LP investors via reward_per_share +Effect: Penalty incentivizes planned withdrawals and compensates remaining LPs. +``` + +--- + +## 7. Leverage Fund Allocation + +The leverage fund is a **sub-allocation (cap)** on `free_balance`, not a separate pool with its own balance. It defines how much of the free balance may be used for leverage loans. + +``` +leverage_fund_total = free_balance × F% / 100 (cap, not a separate balance) +leverage_fund_available = leverage_fund_total − leverage_fund_used +max_loan_per_position = leverage_fund_available × P% / 100 +``` + +**Key principle:** All leverage returns (loan repayment + profit) flow into `lazy_pool.free_balance`. The `leverage_fund_used` counter only tracks outstanding loan obligations — decrementing it frees capacity for new loans but does not move money to a separate account. The profit (L × R%) is distributed to lazy pool investors via `reward_per_share`, exactly like any other pool earnings. + +**`leverage_fund_used` tracking** — updated atomically within the same transaction as the position status change: + +| Event | `leverage_fund_used` | Fund flow | +|-------|----------------------|-----------| +| `pm_leverage_open` | `+= loan` | `free_balance −= (C + L)` (bet placed into AMM) | +| `pm_leverage_liquidate` | `−= loan` | `free_balance += pool_received` (loan + profit); `reward_per_share += profit` | +| `pm_leverage_resolve` (win) | `−= loan` | `free_balance += pool_received` (loan + profit); `reward_per_share += profit` | +| `pm_leverage_resolve` (loss) | `−= loan` | Pool loses L (absorbed by pool); `leverage_fund_used` still decremented | +| `pm_leverage_close` (voluntary) | `−= loan` | `free_balance += pool_received` (loan + profit); `reward_per_share += profit` | + +--- + +## 8. earned_balance and Withdrawal + +`earned_balance` tracks the **cumulative lifetime earnings** of the pool. It is used during withdrawal to determine what portion of a user's payout is **earned profit** (withdrawable immediately) vs. **principal return** (subject to lock period rules). + +``` +User's earned portion at withdrawal: + user_earned = earned_balance × (user_shares / total_shares) + user_principal = user_total_value − user_earned + +Where user_total_value = user_shares × free_balance / total_shares + pending_rewards +``` + +This separation matters for: + +- **Tax reporting**: earned income vs. capital return may have different tax treatment +- **Emergency withdrawal penalty**: penalty applies only to profit on locked shares +- **Pool health monitoring**: `earned_balance / total_value` indicates pool profitability over time + +### When earned_balance Changes + +| Transition | earned_balance change | Source | +|------------|----------------------|--------| +| Market resolves with profit | `+= lp_profit` | LP fee earnings on market | +| Leverage position closed with profit | `+= pool_profit` | Leverage interest (L × R%) | +| Emergency withdrawal penalty | — no change | Penalty goes to `reward_per_share`, not `earned_balance` | +| User deposit | — no change | | +| User withdrawal | — no change | Cumulative: never decreases | + +--- + +## 9. Precision Reference + +| Constant | Value | Used For | +|----------|-------|----------| +| `PRECISION` | 1000 | Milli-VIZ (all monetary values in DB) | +| `LAZY_POOL_PRECISION` | 10^9 (1,000,000,000) | `reward_per_share` accumulator integer math | +| `PRICE_PRECISION` | 1,000,000 | Probability display (6 decimal places) | + +**Why 10^9 for reward_per_share?** + +Shares are denominated in milli-VIZ (1 VIZ = 1000 units), displayed as `1.000000`. The 10^9 multiplier is an internal integer math precision ensuring that even tiny profits distribute non-zero increments: + +``` +Example: 1 mVIZ profit on 1,000,000 shares + reward_per_share increment = 1 × 10^9 / 1,000,000 = 1000 (non-zero) + +If we used 10^6 instead: + reward_per_share increment = 1 × 10^6 / 1,000,000 = 1 (non-zero, but very coarse) + +If we used 10^3 (same as PRECISION): + reward_per_share increment = 1 × 10^3 / 1,000,000 = 0 (DUST — profit lost!) +``` + +The 10^9 factor provides 6 orders of magnitude between the smallest unit (1 mVIZ) and the accumulator, ensuring accurate distribution across up to ~10^6 shares without dust loss. + +--- + +## 10. Auxiliary Tables + +### 10.1 lazy_pool_deposits (Per-Deposit Lock Tracking) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | Auto-increment | +| `user` | bigint | User ID | +| `time` | int | Deposit unixtime | +| `amount` | bigint | Principal (milli-VIZ) | +| `shares` | bigint | Share tokens received (milli-VIZ) | +| `unlock_time` | int | Unixtime when deposit unlocks | +| `status` | tinyint | 0=locked, 1=unlocked, 2=withdrawn, 3=emergency_withdrawn | + +Each user has at most: **1 unlocked record** (status=1, consolidated) + **N locked records** (status=0). When locked deposits expire, they merge into the single unlocked record. + +### 10.2 lazy_pool_allocations (Per-Market Allocation Tracking) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigint PK | Auto-increment | +| `market` | bigint | Market ID (unique key) | +| `amount` | bigint | Current allocated amount (milli-VIZ, decreases with recalls) | +| `original_amount` | bigint | Original allocated amount before recalls | +| `time` | int | Allocation unixtime | +| `status` | tinyint | 0=active, 1=returned | +| `returned_amount` | bigint | Amount returned including profit | +| `bets_sum_at_check` | bigint | Cumulative bets_sum at last recall check | +| `check_step` | int | Which 10% duration step (0=just allocated, 1..10) | +| `last_check_time` | int | Unix timestamp of last recall check | +| `recalled_amount` | bigint | Total amount recalled so far | + +--- + +## 11. Complete Transition Matrix + +Summary of all pool column changes across transitions: + +| # | Transition | `free_balance` | `allocated_balance` | `earned_balance` | `leverage_fund_used` | `reward_per_share` | `total_shares` | +|---|-----------|----------------|---------------------|------------------|----------------------|--------------------|----------------| +| 1 | Deposit | `+= A` | — | — | — | — | `+= new_shares` | +| 2 | Allocate | `−= alloc` | `+= alloc` | — | — | — | — | +| 3 | Resolve (no profit) | `+= alloc` | `−= alloc` | — | — | — | — | +| 4 | Resolve (with profit) | `+= return` | `−= alloc` | `+= profit` | — | `+= profit×10^9/N` | — | +| 5 | Recall | `+= recall` | `−= recall` | — | — | — | — | +| 6 | Leverage open | `−= (C+L)` | — | — | `+= L` | — | — | +| 7 | Leverage close | `+= received` | — | `+= profit` | `−= L` | `+= profit×10^9/N` | — | +| 7a | Leverage close (bad debt) | `+= cancel·−shortfall` | — | — | `−= L` | — | — | +| 8 | Leverage resolve (loss) | — | — | — | `−= L` | — | — | +| 8a | Leverage resolve (win) | `+= received` | — | `+= profit` | `−= L` | `+= profit×10^9/N` | — | +| 9 | Withdraw | `−= payout` | — | — | — | — | `−= burned` | +| 10 | Leverage convert | `+= total_payment` | — | `+= pool_profit` | `−= L` | `+= profit×10^9/N` | — | +| 11 | Emergency withdraw | `−= (payout−penalty)` | — | — | — | `+= penalty×10^9/N'` | `−= all_shares` | + +Legend: `N` = total_shares, `N'` = remaining_shares after burn, `—` = no change diff --git a/.qoder/plans/pm-plan/lmsr-fixed-point-spec.md b/.qoder/plans/pm-plan/lmsr-fixed-point-spec.md new file mode 100644 index 0000000000..fc78088dfa --- /dev/null +++ b/.qoder/plans/pm-plan/lmsr-fixed-point-spec.md @@ -0,0 +1,406 @@ +> **Status:** Normative reference for the deterministic LMSR pricing math used by: +> - the PHP back-end ([module/lmsr_fixed.php](../module/lmsr_fixed.php)), +> - the in-browser front-end ([market_math.js](../market_math.js)), +> - the future C++ VIZ-DLT consensus implementation. +> +> All three implementations **MUST** produce **bit-identical** outputs given identical inputs. This document is the single source of truth; it resolves [§7.2 of the VIZ-DLT protocol spec](viz-dlt-prediction-market-protocol-spec.md#72-floating-point-determinism-in-consensus-hardest). +> +> If you change any number in §3, §4 or §5 you change consensus. Any change MUST be gated behind a hardfork. + +--- + +# Onix LMSR — Canonical Fixed-Point Specification + +## 0. Scope + +The Logarithmic Market Scoring Rule (LMSR) priced multi-outcome markets through `exp` and `ln`. IEEE-754 `double` is **not** bit-deterministic across compilers, CPUs and `libm` versions, so the existing `module/lmsr_math.php` and `market_math.js` cannot be used to compute on-chain consensus values. This document specifies a fixed-point integer algorithm — using only `+`, `−`, `×`, `÷` (truncating), and bit shifts on big integers — that is reproducible to the **last bit**. + +The CPMM (binary) path is already integer-clean and is **not** changed by this document. + +## 1. Domain & Number Formats + +### 1.1 Public units (unchanged) + +| Symbol | Meaning | Type | Precision | +|--------|---------|------|-----------| +| `q[i]` | Outstanding tokens of outcome `i` | int64 | milli-VIZ (×1000) | +| `b` | LMSR liquidity parameter | int64 | milli-VIZ (×1000) | +| `amount` | VIZ committed to a bet | int64 | milli-VIZ (×1000) | +| `price[i]` | Outcome probability | int64 | ×10⁶ (1e6 = 100%) | +| `cost`, `buy_cost`, `sell_return`, `tokens` | Returned amounts | int64 | milli-VIZ (×1000) | + +`LMSR_PRECISION = 1000` and `LMSR_PRICE_PRECISION = 1_000_000` are unchanged from the existing PHP/JS API. + +### 1.2 Internal fixed-point format `Q96` + +All transcendental work happens in **signed fixed-point with 96 fractional bits** stored in a wide signed integer (≥ 192 bits). We name this format `Q96`. + +``` +value_real = value_int / 2^96 // value_int is a signed bigint +``` + +Constants used throughout: + +``` +ONE = 1 << 96 // = 2^96 +HALF = 1 << 95 // = 2^95 +LN2 = 0xB17217F7D1CF79ABC9E3B398 // ⌊ln(2) · 2^96⌋ — 24 hex digits = 96 bits + // = 54916777467707473351141471128 (decimal) + // Verified: 54916777467707473351141471128 / 2^96 + // = 0.693147180559945309417232121457 + // true ln(2) ≈ 0.693147180559945309417232121458 (next bit rounds down → truncation, by §3.5) +MAX_Q96 = (1 << 191) - 1 // hard cap; over → overflow error +MIN_Q96 = -(1 << 191) // hard cap +``` + +> **Why 96 bits?** With Q96, `exp(x)` has ≤ 1 ULP of relative error after the polynomial in §3.1 with 24 Taylor terms (`x ≤ ln(2)/2 ≈ 0.347`). After 30 terms the residual is `< 2^-100`, far below Q96. ULPs and rounding rules are fixed in §3.5 so the same residual is dropped on every platform. + +> **Why not Q64?** Q64 leaves 64 fractional bits; LMSR needs `exp` of values up to ≈ `q_max / b`. For our domain (see §1.4) `q/b` reaches a few hundred. Pricing differences below `2^-30 · b` are economically meaningless, but rounding **inside** `log-sum-exp` accumulates: `Σ exp(x_j)` with N=10 terms each having ≤ 1 ULP of error makes the final `ln(·)` carry ≤ N ULPs of error. We need ≥ 80 fractional bits to guarantee that the LMSR cost rounded back to milli-VIZ matches across implementations. **Q96 has comfortable head-room.** + +> **Why not Q128 / decimal-18?** Bigger words, more `mul/div` cost, no measurable gain. Q96 fits in a 192-bit accumulator (3 × `uint64`) which is a natural width on 64-bit hardware (C++ uses `boost::multiprecision::int256_t` or a 4-limb intrinsic; PHP uses `gmp`; JS uses native `BigInt`). + +### 1.3 Required big-integer width + +| Operation | Minimum signed-integer width | +|-----------|------------------------------| +| Q96 storage / accumulator | **192 bits** | +| Q96 × Q96 → Q96 (intermediate) | **256 bits** | +| `exp` Taylor accumulator | 256 bits | +| `lse` exponent shift `x − max_x` | 256 bits | + +C++ reference implementation **MUST** use `boost::multiprecision::int256_t` (or a hand-rolled 4×u64 type) for the multiplier, then truncate to 192 bits when storing. + +### 1.4 Input domain & overflow guards + +LMSR is undefined for `b ≤ 0`; the spec rejects with `lmsr_b_invalid` (no fallback). + +For sanity, **before** any internal Q96 conversion the implementation MUST check: + +``` +0 < b ≤ MAX_B (MAX_B = 2^53, ~ 9·10^15 milli-VIZ) +0 ≤ q[i] ≤ MAX_Q (MAX_Q = 2^53) +N (outcomes) in [2 .. 16] +``` + +These limits keep `q/b` ≤ `MAX_Q / 1` = `2^53` in the worst case. We additionally cap the LSE exponent shift (§3.2) so `|x_max − x_min| < EXP_DOMAIN_LIMIT = 200 · ONE` (≈ `e^200 ≈ 7e86`, well below the bigint width). Anything beyond returns the canonical overflow code. + +## 2. Conversion Helpers + +```text +to_q96(x_milli) := (int)(x_milli) << 96 / 1000 // divide by LMSR_PRECISION inside Q96 + = ((int)x_milli * ONE) / 1000 // truncated toward 0 (signed-floor for ≥0 inputs) +from_q96(v_q) := (v_q * 1000) >> 96 // back to milli-VIZ, truncated toward 0 +``` + +Both use **truncation toward zero** (a.k.a. C99 integer division, not Python floor). Implementations on platforms where `/` rounds differently (some big-integer libraries) MUST manually emulate truncation. + +`mul_q(a, b) := (a * b) >> 96` — multiplied in the wide accumulator first, then arithmetic right shift. Implementations MUST use **arithmetic right shift** (sign-extending) to preserve sign. + +`div_q(a, b) := (a << 96) / b` — left-shift to 192-bit accumulator, divide once. Truncating toward zero. + +## 3. Core Transcendentals + +### 3.1 `exp_q(x)` — exponential in Q96 + +**Domain:** `x` ∈ Q96, with `|x| ≤ 130 · ONE` (≈ `e^130 ≈ 2·10^56`, fits in 192 bits with head-room). Beyond the domain the implementation returns `LMSR_OVERFLOW`. + +**Algorithm:** + +``` +1. Range reduction: + k = round_to_nearest_even(x / LN2) // integer (use signed div with banker rounding spec'd in §3.5) + r = x - k * LN2 // |r| ≤ LN2/2 ≈ 0.3466 in real units +2. Polynomial: + acc = ONE // term[0] = 1 + term = ONE + for n in 1..30: // FIXED iteration count = 30 + term = mul_q(term, r) / n // term · r / n (n is small int; do mul first) + acc = acc + term // signed add +3. Scaling: + result = acc << k if k ≥ 0 + = acc >> (-k) if k < 0 // arithmetic right shift +4. Return result. +``` + +**Constants:** +- `LN2` is exactly the integer in §1.2 (frozen). Implementations **MUST NOT** recompute it. +- The Taylor loop bound is fixed at **30 iterations**, regardless of `r`. Empirically iterations 25–30 contribute < 2⁻¹⁰⁰ of the result, but every implementation must run all 30 to guarantee identical truncation behaviour. +- `term · r / n`: do the `mul_q` first (`term * r >> 96`), THEN integer-divide by `n` (truncating toward zero). Order matters: dividing first loses bits. + +**Rounding:** every intermediate keeps Q96; only `mul_q` introduces a single arithmetic right-shift truncation per iteration. No double-rounding. + +### 3.2 `ln_q(x)` — natural logarithm in Q96 + +**Domain:** `x > 0` in Q96. For `x ≤ 0` return `LMSR_OVERFLOW`. + +**Algorithm (Briggs/atanh, deterministic):** + +``` +1. Range reduction to m ∈ [1, 2): + p = msb(x) - 96 // integer; m = x / 2^p ∈ [1,2) + m = (p ≥ 0) ? (x >> p) : (x << -p) +2. Substitute z = (m - ONE) / (m + ONE) // |z| < 1/3 in Q96 + num = m - ONE + den = m + ONE + z = div_q(num, den) +3. atanh series: + z2 = mul_q(z, z) + acc = z // term = z + term = z + for k in 1..50: // FIXED iteration count = 50 + term = mul_q(term, z2) // term *= z^2 + denom = (2*k + 1) // 3, 5, 7, ... + acc = acc + term / denom // truncated divide +4. ln_m = 2 * acc // ln(m) = 2 · atanh((m-1)/(m+1)) +5. ln(x) = p * LN2 + ln_m // signed; p can be negative +``` + +**Iteration count = 50** is fixed. With `|z| < 1/3` the residual after 50 odd-power terms is `< 3^-100 ≈ 2^-158`, well below Q96's resolution. + +**`msb(x)`** = position of the highest set bit (0-indexed). Big-int libraries provide this directly (`gmp`: `mpz_sizeinbase(_, 2) - 1`; `BigInt`: bit-length helper; C++: `__builtin_clzll`-based ladder over limbs). The function MUST NOT call any floating-point primitive. + +### 3.3 `lse_q(xs)` — log-sum-exp with shift + +``` +1. m = max(xs) // signed Q96 +2. acc = 0 + for x in xs: + d = x - m // d ≤ 0 + if d < -EXP_DOMAIN_LIMIT: continue // contributes < 2^-200, drop deterministically + acc += exp_q(d) +3. return m + ln_q(acc) +``` + +**Important:** the "drop tiny terms" rule with cutoff `−EXP_DOMAIN_LIMIT = −200·ONE` is **mandatory** and MUST be applied in every implementation in the same loop order. We drop on `d < -EXP_DOMAIN_LIMIT` (strict `<`), keep on `d ≥ -EXP_DOMAIN_LIMIT`. + +The loop iterates over `xs` in **input order** (the order in which `q[]` was supplied). Re-ordering changes summation rounding. + +### 3.4 `lmsr_cost_q(q[], b)` and helpers + +``` +ratios[i] = (q[i] * ONE) / b // truncated toward zero. ratios[i] ∈ Q96. + // Direct one-step form: avoids double-truncation + // that to_q96/to_q96/div_q would introduce for + // q[i] / b not a multiple of 1/1000. +lse = lse_q(ratios) // Q96 +cost_milli= (b * lse) / ONE // truncated toward zero. Result is int64 milli-VIZ. + // Equivalent to: arithmetic right shift by 96 of + // a wide signed product, with sign correction so + // the rule is "round toward 0" (NOT toward -∞). +return cost_milli +``` + +Note: `q[]` and `b` are int64 milli-VIZ throughout; `ratios[i]` and `lse` are Q96; `cost_milli` is int64 milli-VIZ. The two `1000` factors that would appear if we round-tripped through `to_q96` cancel exactly in the algebra, so we never write them. The output matches `b * ln(Σ exp(q_j/b))` (in real units) truncated to integer milli-VIZ. + +`lmsr_buy_cost_q` and `lmsr_sell_return_q` are subtractions of two `lmsr_cost_q` results; the `max(0, ...)` guard remains (it can only fire on adversarial inputs that violate §1.4 domain bounds). + +### 3.5 Rounding rules (frozen) + +| Operation | Rule | +|-----------|------| +| `mul_q(a, b)` | Arithmetic right shift by 96 = round toward `-∞` for negative results. Implementations **MUST** use ASR, not LSR. | +| `div_q(a, b)` | C99 integer division: round toward zero. | +| `to_q96`, `from_q96` | Round toward zero. | +| `exp_q` Taylor `term / n` | Round toward zero. | +| `ln_q` atanh `term / (2k+1)` | Round toward zero. | +| `range reduction k = x / LN2` | **Round to nearest, ties to even** (banker's). One single round-to-nearest in the entire pipeline; everywhere else is truncation. | + +> "Round to nearest even" for the `k` in `exp_q`'s range reduction is implemented as: +> ``` +> q = x / LN2 // truncated +> r = x - q * LN2 +> if 2*r > LN2 or (2*r == LN2 and (q & 1)): +> q = q + 1 +> if 2*r < -LN2 or (2*r == -LN2 and (q & 1)): +> q = q - 1 +> k = q +> ``` +> All comparisons are signed bigint comparisons. Identical on every platform. + +### 3.6 Error codes + +A single global enum is exposed by every implementation: + +``` +LMSR_OK = 0 +LMSR_OVERFLOW = 1 // x outside §1.4 domain in any internal step +LMSR_INVALID_B = 2 // b ≤ 0 +LMSR_INVALID_Q = 3 // q[i] < 0 or > MAX_Q +LMSR_INVALID_N = 4 // outcomes < 2 or > 16 +LMSR_DOMAIN_LN = 5 // ln_q called with x ≤ 0 (should be unreachable; guard) +``` + +In off-chain (PHP / JS) code these surface as exceptions; in on-chain (C++) code they fail the operation. + +## 4. Public API (identical signatures) + +The PHP / JS / C++ implementations expose the same 6 functions. Names are PHP-convention; the JS module re-exports with the same identifiers, the C++ code uses `lmsr::cost(...)` etc. + +| Function | Inputs | Output | Notes | +|----------|--------|--------|-------| +| `lmsr_cost(q[], b)` | int64 milli-VIZ | int64 milli-VIZ | §3.4 | +| `lmsr_price(q[], b, i)` | int64 milli-VIZ, idx | int (×10⁶) | `from_q96(div_q(exp_q(x_i − m), Σ exp_q(x_j − m))) * 10^6` | +| `lmsr_prices(q[], b)` | int64 milli-VIZ | int[] (×10⁶) | All N prices | +| `lmsr_buy_cost(q[], b, i, Δ)` | int64 milli-VIZ | int64 milli-VIZ | `max(0, cost(q+Δ·e_i) − cost(q))` | +| `lmsr_sell_return(q[], b, i, Δ)` | int64 milli-VIZ | int64 milli-VIZ | `max(0, cost(q) − cost(q-Δ·e_i))` | +| `lmsr_tokens_for_amount(q[], b, i, amount)` | int64 milli-VIZ | int64 milli-VIZ | Binary search: largest Δ with `buy_cost ≤ amount` | + +`lmsr_b_from_liquidity(L, N)` is **not** consensus-critical (it's chosen at market creation and stored). It still uses `ln(N)` but is computed once per market, on the creator's client; the resulting `b` is part of the on-chain market object and frozen. Implementations MUST use `ln_q` for it as well so reference values match. + +### 4.1 `lmsr_tokens_for_amount` — deterministic binary search + +``` +lo = 0 +hi = clamp(amount * 10, 0, MAX_Q) // upper bound, identical to current PHP +best = 0 +for iter in 1..100: // FIXED iteration count + if hi - lo <= 1: break + mid = (lo + hi) >> 1 // integer floor + if lmsr_buy_cost(q, b, i, mid) <= amount: + best = mid; lo = mid + else: + hi = mid +return best +``` + +**Iteration count = 100** is fixed. Even when the search converges in fewer steps, all 100 iterations execute (the early break is allowed because `hi - lo ≤ 1` is symmetric and platform-independent). Empirically 60 iterations suffice, 100 is conservative. + +## 5. Test Vectors (consensus-fixing) + +Every implementation MUST pass `tests/lmsr_fixed_vectors.json` (Phase 4). The JSON contains **all** the values an implementer needs to verify primitives and end-to-end LMSR. Format is fixed: + +```json +{ + "version": 1, + "exp_q": [ + { "x": "0", "expected": "79228162514264337593543950336" }, + { "x": "54916777467707473351141471128", "expected": "158456325028528675187087900672" }, + { "x": "-54916777467707473351141471128", "expected": "39614081257132168796771975168" } + ], + "ln_q": [ ... ], + "mul_q": [ ... ], + "div_q": [ ... ], + "lmsr_cost": [ + { "q": [1000, 1000, 1000], "b": 100000, "expected": 109861 }, + { "q": [10000, 0, 0], "b": 100000, "expected": 13500 } + ], + "lmsr_buy_cost": [ ... ], + "lmsr_tokens_for_amount": [ ... ] +} +``` + +All numeric inputs / outputs are **decimal strings** (because Q96 values exceed 2⁵³). Equality is exact (`a === b`, no tolerance). The vector file is generated once by the PHP reference (Phase 2) and **frozen** — JS, PHP and C++ all compare against it. + +### 5.1 Tooling (this repo) + +- `tests/lmsr_vectors_generate.php` — regenerates `tests/lmsr_fixed_vectors.json` from the PHP reference (only re-run when the spec changes; bump `version` in the JSON header). +- `tests/lmsr_fixed_vectors_verify.php` — asserts the PHP reference still matches every vector. Exit 0 on success. +- `tests/lmsr_fixed_vectors_verify.js` — same, for the JS reference (`node tests/lmsr_fixed_vectors_verify.js`). +- A C++ port MUST add an analogous verifier as part of its CI. + +## 6. C++ Implementation Guide + +This section is non-normative reference for the future VIZ DLT plugin author. The PHP/JS reference implementations are normative. + +### 6.1 Types + +```cpp +#include +using namespace boost::multiprecision; + +using q96 = int256_t; // store; uses 192 bits, room to spare +using q96_wide = int512_t; // multiplier accumulator, 256 bits used +constexpr q96 ONE = q96(1) << 96; +constexpr q96 LN2 = q96("54916777467707473351141471128"); // §1.2 +constexpr q96 EXP_DOMAIN_LIMIT = q96(200) * ONE; +``` + +Use `int256_t` (not `int128_t`) because §1.3 requires 256-bit accumulators for `mul_q` and `exp` partial products. + +### 6.2 mul_q / div_q + +```cpp +inline q96 mul_q(q96 a, q96 b) { + q96_wide t = q96_wide(a) * q96_wide(b); + return q96(t >> 96); // boost ASR; sign-preserving +} +inline q96 div_q(q96 a, q96 b) { + q96_wide t = q96_wide(a) << 96; + return q96(t / b); // boost: truncation toward 0 +} +``` + +Verify with vector file: any deviation from the test vectors indicates the boost ASR/truncation behaviour differs from the reference and MUST be fixed before merge. + +### 6.3 Determinism caveats specific to C++ + +1. **Compiler flags:** prohibit `-ffast-math`, `-funsafe-math-optimizations`, `-Ofast`. Plugin Makefile MUST set `-fno-fast-math -ffp-contract=off` (defensive even though we do not call float). +2. **No FP library calls:** confirm with `nm` that the plugin object has no references to `exp`, `log`, `pow`, `sqrt`, `expf`, `logf`, etc. CI MUST grep for these symbols and fail the build if any appear. +3. **Endianness:** all our math is over big-integer values that boost handles abstractly. No byte-order code paths. +4. **Bigint library version:** pin boost version in `CMakeLists.txt`. A boost upgrade must re-run the test vector suite before merge. +5. **`mpz_*` is forbidden** in the consensus path. Boost is the only big-int dependency. + +### 6.4 Consensus integration + +LMSR is invoked from: +- `pm_place_bet` evaluator (instant mode) — computes `tokens` for a buy. +- `pm_batch_settle` virtual op (per-epoch) — computes uniform-price weights. +- `pm_cancel_bet` evaluator (multi only) — computes `lmsr_sell_return`. +- `pm_create_market` evaluator — computes `b` from `liquidity` (one-time). + +All four call sites MUST go through the spec'd functions. **Replay determinism**: if the spec changes, a new hardfork constant gates the new constants/iteration counts; old blocks replay against the old constants. + +### 6.5 Performance + +A worst-case `lmsr_tokens_for_amount` call: 100 binary-search iterations × 2 `lmsr_cost` × (10 outcomes × 30 Taylor steps + 50 atanh steps) ≈ 160k 256-bit `mul`s. On modern CPUs that is sub-millisecond, comparable to a single ECDSA verify, so it is acceptable inside an evaluator. Per-block cap on `pm_place_bet` count (already discussed in §7.6 of the protocol spec) keeps total cost bounded. + +## 7. Spec Review — items still open + +These do not block the implementation, but I flag them for your review: + +### 7.1 Iteration counts (30 Taylor / 50 atanh / 100 binary) + +The numbers were chosen with 5–10× safety margin against the Q96 noise floor. **Question:** drop to 24/40/80 to save ~25% CPU? Cost is only "must be ≥ N" for correctness; choosing tighter risks one-ULP differences if a test vector ever lands on a boundary. **Recommendation:** keep current values; they are negligible cost and the safety margin is cheap insurance against an undiscovered edge case. + +### 7.2 Q96 vs Q64 + +Q64 would halve the bigint width and roughly halve CPU cost. **Risk:** as discussed in §1.2, log-sum-exp accumulation can lose ~log2(N) bits, so for N=16 outcomes the safety margin in Q64 shrinks to ~50 bits — still enough but tight. **Recommendation:** Q96. If a future hardfork wants more outcomes (N>16), Q96 still works; Q64 wouldn't. + +### 7.3 Round-to-nearest-even in `exp` range reduction + +The "one banker's rounding" rule (§3.5) is the single non-truncating step. **Alternative:** use truncation everywhere, accepting that `exp(x)` near `x = k·ln(2)` rounds slightly worse on one side. Empirically this affects the last 1–2 milli-VIZ on `lmsr_buy_cost ≈ b/1000`. **Recommendation:** keep banker's; it's symmetric, free to implement, and avoids a pathological accumulation when `x` happens to be a multiple of `ln(2)/N` (an attacker can construct such inputs). + +### 7.4 Test-vector generation source + +Phase 2 (PHP+GMP) will generate `tests/lmsr_fixed_vectors.json`. PHP `gmp` uses `mpn_*` from GMP, which IS bit-deterministic and available on all platforms. We then verify JS produces the same. **Question:** do we want a third independent generator (e.g. Python `gmpy2`) as a tie-breaker? **Recommendation:** add it as a CI sanity check, but the PHP output is canonical. + +### 7.5 What to do with `module/lmsr_math.php` after migration + +Phase 5 replaces all call sites. The float file becomes dead code. **Recommendation:** delete it (single commit, easy to revert if anything breaks). Do NOT keep both implementations — that's the worst possible state because `float-LMSR` and `fixed-LMSR` will silently diverge for some `q`. + +### 7.6 Front-end performance (mobile) + +JS `BigInt` is ~50× slower than `Number`. A single `lmsr_buy_cost` is ~3000 `BigInt` ops ≈ 1–3 ms on desktop, ~10 ms on a low-end phone. **For UI estimates** (live odds while typing) this is acceptable. **For batch UI updates** (recompute all 10 outcome prices on every block) it might lag. **Mitigation:** debounce, or compute only the touched outcome, or fall back to a `Number`-based "estimate" that explicitly tells the user "displayed value, on-chain value may differ by ≤ 1 milli-VIZ". I prefer the BigInt path everywhere — see §7.5; mixing breeds bugs. **Recommendation: BigInt only**, debounce in app.js where needed. + +### 7.7 `lmsr_b_from_liquidity` consensus status + +This function is called once per market at creation; the result is stored on the market object. Strictly speaking the **node** can compute it deterministically and the **client** doesn't have to; the on-chain `b` is what matters. **Recommendation:** require the client to send `b` directly, validated by `ln_q` on the node: +``` +require: ⌊liquidity / ln_q(N)⌋ == b +``` +This way the consensus rule is just an integer equality check, not a re-computation. Off-chain (PHP/JS) we still expose `lmsr_b_from_liquidity` for the create-market UI. + +## 8. Implementation Phasing + +1. **Phase 1 (this document).** Spec frozen. ✓ +2. **Phase 2.** [`module/lmsr_fixed.php`](../module/lmsr_fixed.php) using `gmp_*`. Implements §2–§4. ✓ +3. **Phase 3.** [`market_math.js`](../market_math.js) BigInt port. Same names, same algorithm. ✓ +4. **Phase 4.** Cross-runner: [`tests/lmsr_vectors_generate.php`](../tests/lmsr_vectors_generate.php) + [`tests/lmsr_fixed_vectors_verify.php`](../tests/lmsr_fixed_vectors_verify.php) + [`tests/lmsr_fixed_vectors_verify.js`](../tests/lmsr_fixed_vectors_verify.js). All 77/77 PHP and 74/74 JS vectors pass with strict equality. ✓ +5. **Phase 5.** [`module/api.php`](../module/api.php), [`module/cron_worker.php`](../module/cron_worker.php), [`tests/workflow_test.php`](../tests/workflow_test.php), [`module/test_parimutuel.php`](../module/test_parimutuel.php) switched to `lmsr_fixed.php`; the float file `module/lmsr_math.php` deleted. ✓ +6. **Phase 6.** Update [protocol spec §7.2](viz-dlt-prediction-market-protocol-spec.md#72-floating-point-determinism-in-consensus-hardest) to "**Resolved** — see [lmsr-fixed-point-spec.md](lmsr-fixed-point-spec.md)". The C++ plugin author follows §6 of this document. + +After Phase 5 the prediction-market backend has no `float`/`double` in any pricing path; the only remaining FP code is in unrelated UI helpers. + +--- + +🇷🇺 Russian translation will be added as `lmsr-fixed-point-spec-ru.md` after the English version is reviewed and frozen — translating drafts wastes effort. diff --git a/.qoder/plans/pm-plan/parimutuel-settlement.md b/.qoder/plans/pm-plan/parimutuel-settlement.md new file mode 100644 index 0000000000..3d40b81abf --- /dev/null +++ b/.qoder/plans/pm-plan/parimutuel-settlement.md @@ -0,0 +1,106 @@ +# Plan: Unified Parimutuel Settlement (make Binary settle like Multi) + +> **STATUS: ✅ IMPLEMENTED.** Binary now settles parimutuel in [api.php](module/api.php) (resolve block ~1661 + dispute-recalc block ~2105), and the docs (spec §5/§8/§10, whitepaper §2.2, betting-rules, governance roadmap) are updated. This file is retained as the design rationale. +> +> Make **Onix Binary** use the same **parimutuel settlement** as Onix Multi: a winner's payout becomes a *proportional share of the losers' pool by weight*, instead of the absolute `payout = weight`. The CPMM stays as the **pricing/probability engine**; only settlement changes. +> +> 🇷🇺 Russian: [plan_unified_parimutuel_binary-ru.md](plan_unified_parimutuel_binary-ru.md). Related: [plan_batch_commit_reveal_betting.md](plan_batch_commit_reveal_betting.md) §6.1 (this change makes binary snapshot-safe too). + +## 1. Motivation + +Today the two market types settle differently (verified in code): + +| | Current Binary — [api.php:1664](module/api.php#L1664) | Multi — [lmsr_math.php:249](module/lmsr_math.php#L249) | +|---|---|---| +| Payout | `payout = weight` (absolute) | `payout = amount + winners_pool × weight/Σweight` | +| Solvency basis | curve invariant `Σweight ≤ winning_reserve + winning_bets` | parimutuel (capped at `losers_sum`) | + +Problems with binary's absolute-weight model: +- **Residual is not routed to bettors.** Winners get `Σweight`, LP gets `L + fees`; the leftover (`losers' stakes − winners' profit`) is not distributed to anyone (see [betting-rules](betting-rules-and-system-overview.md) §785 — "surplus = 19 mVIZ" goes uncredited). +- **A lone/edge winner can receive *less* than their stake** (betting-rules:788 — bet 1000 → weight 981). +- **Absolute weight must be curve-priced**, so snapshot/batch pricing is unsafe for binary → blocks front-run protection (the entire binary branch of [batch plan §6.1](plan_batch_commit_reveal_betting.md)). + +## 2. The change + +Binary keeps the CPMM for **pricing** (probability display + assigning `weight = tokens_received`), and switches **settlement** to parimutuel — byte-for-byte the Multi model: + +``` +// On resolution (side A wins): +losers_sum = b_bets_sum // all losing-side bets +oracle_fee = floor(losers_sum × oracle_fee‰ / 1000) +creator_fee = floor(losers_sum × creator_fee‰ / 1000) +liq_fee = floor(losers_sum × liquidity_fee‰ / 1000) +winners_pool = losers_sum − oracle_fee − creator_fee − liq_fee +total_winning_weight = Σ weight over winning-side bets + +for each winning bet i: + profit_share = floor(winners_pool × weight_i / total_winning_weight) + penalty = floor(profit_share × time_penalty_i / 1_000_000) // profit only + payout_i = bet_amount_i + profit_share − penalty + +LP: principal returned UNCONDITIONALLY + time-weighted share of liq_fee (+ penalty pool) +``` + +`weight` is now purely a **relative claim** (a ratio device); reserves/`k` are a **pure pricing engine** (the analogue of `q` in LMSR). This is literally the existing `lmsr_math.php` `settle()` applied to weights that come from the CPMM instead of LMSR. + +## 3. What it fixes + +1. **One settlement model for both types:** *"the AMM assigns weights; losers fund winners pro-rata by weight."* Binary uses CPMM weights, Multi uses LMSR weights. Same `settle()`. +2. **Exact money conservation, no leak:** `out = L + winning_bets + winners_pool + fees = L + winning_bets + losing_bets = L + all_bets = in`. The previously-uncredited residual now goes to winners. +3. **Trivial LP guarantee** (same as Multi): total payout is capped at `losers_sum`; LP principal is untouched. The curve invariant `Σweight ≤ reserves` is **no longer needed** for solvency. +4. **Fixes the weird edge:** all bets on the winner → `winners_pool = 0` → each winner gets their `bet_amount` back (refund), never less. +5. **Snapshot/batch pricing becomes safe for binary** → full front-run immunity for binary too (see §6). + +## 4. Conservation proof + +``` +Money IN = L (LP) + a_bets + b_bets +Money OUT = L (LP principal) + Σ(winning bet_amount) + winners_pool + (oracle+creator+liq fees) + = L + winning_bets + (losers_sum − fees) + fees + = L + winning_bets + losing_bets + = L + a_bets + b_bets = Money IN ✓ (exact, for any weights) +``` +Solvency holds for **any** weights → off-curve (snapshot) weights are harmless. This is the property binary currently lacks. + +## 5. Edge cases (mirror Multi / spec §6) + +| Scenario | Outcome | +|----------|---------| +| All bets on the winner (`losers_sum = 0`) | `winners_pool = 0` → every winner refunded `bet_amount`. LP principal returned. | +| No bets on the winner | `winners_pool` undistributed → LP bonus. | +| Single winner | Receives `bet_amount + winners_pool`. | +| Zero volume | LP principal returned; no fees, no payouts. | + +## 6. Impact on batch / commit-reveal ([batch plan §6.1](plan_batch_commit_reveal_betting.md)) + +The binary/multi split collapses: since payout is now capped at `losers_pool` regardless of weights, **both types can use epoch-open snapshot pricing for the batch** → full intra-epoch manipulation immunity for binary too. `min_tokens` still gates slippage. The "Binary = live curve + price band" carve-out and the `batch_price_band_permille` param become unnecessary. + +## 7. Tradeoff (must be acknowledged) + +| | Current Binary | After (parimutuel) | +|---|---|---| +| Payout known | **at bet time** (fixed odds — `weight` locked) | at resolution (floating share) | +| Model | fixed-odds | parimutuel (horse-racing / Multi style) | + +The only real cost: a bettor's payout is no longer fixed at bet time — it depends on the final `total_winning_weight` and `losers_sum`. This is the standard parimutuel tradeoff, already accepted in Multi; it rewards early/underdog bettors with a larger weight share. **Decision needed:** accept the move from fixed-odds to floating-odds binary. + +## 8. Reserves & k + +- Reserves are still updated on each bet for **pricing only** (probability + `weight`); `k` stays constant under betting (unchanged from spec §5). +- Reserves no longer gate payout. LP principal is returned as a separate unconditional line (like the Multi subsidy), not "reconstructed from reserves." + +## 9. Code changes + +- [api.php](module/api.php) binary resolve block (1655–1742) and the dispute recalculation block (2086–2166): replace `raw_payout = weight` with the parimutuel block from [lmsr_math.php:224-280](module/lmsr_math.php#L224) (`settle()`), passing CPMM `weight` as the token field. Both binary and multi can share one `settle()` helper. +- Apply the same edge-case handling already present in `lmsr_math.php`. +- Remove the now-unneeded binary-specific surplus/solvency assumptions. + +## 10. Spec sections to update + +| Section | Change | +|---------|--------| +| §5 Onix Binary | Settlement is now parimutuel; `weight` = relative claim, not VIZ-denominated payout. | +| §6 Onix Multi | Note both types share one settlement model. | +| §7 Fee Structure | Already losers-funded; unify wording across types. | +| §10 Resolution & Payout | Binary payout formula → `bet_amount + winners_pool × weight/Σweight`. | +| batch plan §6.1 | Binary now snapshot-safe; drop the live-curve/price-band carve-out and `batch_price_band_permille`. | diff --git a/.qoder/plans/pm-plan/reference/lmsr_fixed.php b/.qoder/plans/pm-plan/reference/lmsr_fixed.php new file mode 100644 index 0000000000..6078a6a646 --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_fixed.php @@ -0,0 +1,561 @@ + $one, + 'HALF' => $half, + 'LN2' => $ln2, + 'NEG_LN2' => gmp_neg($ln2), + 'EXP_DOMAIN_LIMIT' => gmp_mul($one, gmp_init(200)), // 200 · ONE + 'MAX_B' => gmp_pow(2, 53), // §1.4 domain caps + 'MAX_Q' => gmp_pow(2, 53), + 'ZERO' => gmp_init(0), + 'ONE_INT' => gmp_init(1), + 'TWO_INT' => gmp_init(2), + 'THOUSAND' => gmp_init(1000), + 'PRICE_PRECISION' => gmp_init(1000000), + ]; + return $c; +} + +// ===================================================================== +// Q96 primitives (§2 of spec) +// ===================================================================== + +/** + * Truncated integer division (round toward zero). GMP's gmp_div_q with + * GMP_ROUND_ZERO is exactly this; we wrap for clarity. + */ +function _q_trunc_div($a, $b) { + return gmp_div_q($a, $b, GMP_ROUND_ZERO); +} + +/** + * Arithmetic right shift by `bits`. Floor division by 2^bits, NOT truncation. + * For non-negative values ASR == truncation; for negative values ASR rounds + * toward -∞ (this is the spec rule for `mul_q`, §3.5). + */ +function _q_asr($n, $bits) { + return gmp_div_q($n, gmp_pow(2, $bits), GMP_ROUND_MINUSINF); +} + +/** + * mul_q(a, b) = (a · b) >> 96 with arithmetic right shift (§3.5). + * Wide accumulator: GMP integers are unbounded, so a 256-bit intermediate + * is naturally available. + */ +function _mul_q($a, $b) { + return _q_asr(gmp_mul($a, $b), 96); +} + +/** + * div_q(a, b) = (a << 96) / b, truncated toward zero (§3.5). + * Result is Q96. + */ +function _div_q($a, $b) { + return _q_trunc_div(gmp_mul($a, gmp_pow(2, 96)), $b); +} + +/** + * MSB position (0-indexed) of a positive GMP integer. Returns -1 for 0. + * Used by ln_q range reduction (§3.2). No FP calls. + */ +function _msb($x) { + if (gmp_cmp($x, 0) <= 0) return -1; + return strlen(gmp_strval($x, 2)) - 1; +} + +/** + * Test whether a GMP integer is odd (works for negatives too). + * Used by exp_q's banker's rounding (§3.5). + */ +function _is_odd($q) { + // gmp_mod for negatives: gmp_mod(-3, 2) returns 1 (PHP GMP follows sign-of-divisor). + // Either way the result is 0 or non-zero; we only need parity. + return gmp_cmp(gmp_abs(gmp_mod($q, 2)), 0) !== 0; +} + +// ===================================================================== +// Range reduction for exp_q — round-to-nearest-even (banker's), §3.5 +// ===================================================================== + +/** + * Round x/LN2 to nearest integer, ties to even. The single non-truncating + * rounding step in the entire pipeline. + */ +function _round_div_ln2($x) { + $C = _lmsr_q96_const(); + $LN2 = $C['LN2']; + $NEG = $C['NEG_LN2']; + + $q = _q_trunc_div($x, $LN2); // truncated toward zero + $r = gmp_sub($x, gmp_mul($q, $LN2)); // |r| < LN2 (sign matches truncation residual) + $two_r = gmp_mul($r, 2); + + // Positive-side: 2r > LN2 → bump up. 2r == LN2 and q odd → bump up (ties to even). + $cmp_pos = gmp_cmp($two_r, $LN2); + if ($cmp_pos > 0 || ($cmp_pos === 0 && _is_odd($q))) { + $q = gmp_add($q, 1); + } + // Negative-side: 2r < -LN2 → bump down. 2r == -LN2 and q odd → bump down. + // Note: at most one of (cmp_pos>=0) and (cmp_neg<=0) can be strict for any single r. + $cmp_neg = gmp_cmp($two_r, $NEG); + if ($cmp_neg < 0 || ($cmp_neg === 0 && _is_odd($q))) { + $q = gmp_sub($q, 1); + } + return $q; +} + +// ===================================================================== +// exp_q — Taylor with range reduction (§3.1) +// ===================================================================== + +function _exp_q($x) { + $C = _lmsr_q96_const(); + // Domain check: |x| ≤ 130 · ONE + $abs_x = gmp_abs($x); + $limit = gmp_mul($C['ONE'], gmp_init(130)); + if (gmp_cmp($abs_x, $limit) > 0) { + throw new Exception('LMSR_OVERFLOW: exp_q domain exceeded'); + } + + // 1. Range reduction + $k = _round_div_ln2($x); // signed integer + $r = gmp_sub($x, gmp_mul($k, $C['LN2'])); // |r| ≤ LN2/2 + + // 2. Polynomial — exactly 30 iterations + $acc = $C['ONE']; // term[0] = 1 + $term = $C['ONE']; + for ($n = 1; $n <= 30; $n++) { + // term = mul_q(term, r) / n (mul_q first, then truncated div by integer n) + $term = _q_trunc_div(_mul_q($term, $r), gmp_init($n)); + $acc = gmp_add($acc, $term); + } + + // 3. Scale by 2^k (signed). k is small (|k| ≤ ⌈130/ln(2)⌉ ≈ 188). + $k_int = gmp_intval($k); + if ($k_int >= 0) { + return gmp_mul($acc, gmp_pow(2, $k_int)); // exact left shift + } else { + // Arithmetic right shift; for non-negative `acc` (true here since exp > 0) + // ASR == truncation, but we use ASR by spec for consistency. + return _q_asr($acc, -$k_int); + } +} + +// ===================================================================== +// ln_q — atanh series with range reduction (§3.2) +// ===================================================================== + +function _ln_q($x) { + $C = _lmsr_q96_const(); + if (gmp_cmp($x, 0) <= 0) { + throw new Exception('LMSR_DOMAIN_LN: ln_q called with x ≤ 0'); + } + + // 1. p = msb(x) - 96 ; m = x scaled into [ONE, 2·ONE) + $p = _msb($x) - 96; + if ($p >= 0) { + // m = x >> p, ASR (x is positive, == truncation) + $m = _q_asr($x, $p); + } else { + // m = x << (-p), exact left shift + $m = gmp_mul($x, gmp_pow(2, -$p)); + } + + // 2. z = (m - ONE) / (m + ONE) in Q96, |z| < 1/3 + $num = gmp_sub($m, $C['ONE']); + $den = gmp_add($m, $C['ONE']); + $z = _div_q($num, $den); + + // 3. atanh series, 50 iterations + $z2 = _mul_q($z, $z); + $acc = $z; // first term = z (k=0 → 2k+1 = 1) + $term = $z; + for ($k = 1; $k <= 50; $k++) { + $term = _mul_q($term, $z2); // term · z² + $denom = gmp_init(2 * $k + 1); // 3, 5, 7, ... + $acc = gmp_add($acc, _q_trunc_div($term, $denom)); + } + + // 4. ln(m) = 2 · acc + $ln_m = gmp_mul($acc, 2); + + // 5. ln(x) = p · LN2 + ln(m) + $p_gmp = gmp_init($p); + return gmp_add(gmp_mul($p_gmp, $C['LN2']), $ln_m); +} + +// ===================================================================== +// log-sum-exp (§3.3) +// ===================================================================== + +function _lse_q(array $ratios) { + $C = _lmsr_q96_const(); + if (empty($ratios)) return $C['ZERO']; + + // Find max — straightforward, deterministic + $max = $ratios[0]; + foreach ($ratios as $r) { + if (gmp_cmp($r, $max) > 0) $max = $r; + } + + $acc = $C['ZERO']; + $cutoff = gmp_neg($C['EXP_DOMAIN_LIMIT']); // -200 · ONE + foreach ($ratios as $r) { // input order — DO NOT sort + $d = gmp_sub($r, $max); // d ≤ 0 + if (gmp_cmp($d, $cutoff) < 0) continue; // strict < (per spec §3.3) + $acc = gmp_add($acc, _exp_q($d)); + } + if (gmp_cmp($acc, 0) <= 0) { + // Defensive — should be unreachable: at least the d=0 term contributes ONE. + return $max; + } + return gmp_add($max, _ln_q($acc)); +} + +// ===================================================================== +// Domain validation (§1.4) +// ===================================================================== + +function _validate_domain(array $q, $b) { + $C = _lmsr_q96_const(); + if (count($q) < 2 || count($q) > 16) { + throw new Exception('LMSR_INVALID_N: outcomes must be 2..16'); + } + if (gmp_cmp(gmp_init((string)$b), 0) <= 0) { + throw new Exception('LMSR_INVALID_B: b must be > 0'); + } + if (gmp_cmp(gmp_init((string)$b), $C['MAX_B']) > 0) { + throw new Exception('LMSR_INVALID_B: b exceeds MAX_B (2^53)'); + } + foreach ($q as $qi) { + $g = gmp_init((string)$qi); + if (gmp_cmp($g, 0) < 0) { + throw new Exception('LMSR_INVALID_Q: q[i] < 0'); + } + if (gmp_cmp($g, $C['MAX_Q']) > 0) { + throw new Exception('LMSR_INVALID_Q: q[i] exceeds MAX_Q (2^53)'); + } + } +} + +/** + * Convert int64 milli-VIZ q[]/b into Q96 ratios = q[i] · ONE / b (truncated). + * Direct one-step form per spec §3.4 — avoids double-truncation. + */ +function _ratios_q96(array $q, $b) { + $C = _lmsr_q96_const(); + $b_gmp = gmp_init((string)$b); + $ratios = []; + foreach ($q as $qi) { + $num = gmp_mul(gmp_init((string)$qi), $C['ONE']); + $ratios[] = _q_trunc_div($num, $b_gmp); + } + return $ratios; +} + +// ===================================================================== +// Public API (§4) — same signatures as module/lmsr_math.php +// ===================================================================== + +/** + * LMSR cost C(q) = b · ln(Σ exp(q_j / b)) + * @return int milli-VIZ, truncated toward zero + */ +function lmsr_cost(array $q, int $b): int { + if ($b <= 0) return 0; + _validate_domain($q, $b); + $C = _lmsr_q96_const(); + $ratios = _ratios_q96($q, $b); + $lse = _lse_q($ratios); // Q96 + // cost_milli = (b · lse) / ONE (truncated toward zero — see §3.4) + $product = gmp_mul(gmp_init((string)$b), $lse); + $cost = _q_trunc_div($product, $C['ONE']); + return intval(gmp_strval($cost)); +} + +/** + * Probability of outcome i, returned as integer ×10^6 (1e6 = 100%). + */ +function lmsr_price(array $q, int $b, int $i): int { + if ($b <= 0 || !isset($q[$i])) return 0; + _validate_domain($q, $b); + $prices = lmsr_prices($q, $b); + return $prices[$i] ?? 0; +} + +/** + * All N prices in one pass. Sum of returned prices is in [LMSR_PRICE_PRECISION-N, LMSR_PRICE_PRECISION] + * (truncation residual ≤ N). Caller MUST tolerate this — it is consensus-fixed. + */ +function lmsr_prices(array $q, int $b): array { + $n = count($q); + if ($b <= 0) return array_fill(0, $n, 0); + _validate_domain($q, $b); + $C = _lmsr_q96_const(); + + $ratios = _ratios_q96($q, $b); + // Find max + $max = $ratios[0]; + foreach ($ratios as $r) if (gmp_cmp($r, $max) > 0) $max = $r; + + $cutoff = gmp_neg($C['EXP_DOMAIN_LIMIT']); + $exps = []; + $sum = $C['ZERO']; + foreach ($ratios as $r) { + $d = gmp_sub($r, $max); + if (gmp_cmp($d, $cutoff) < 0) { + $exps[] = $C['ZERO']; + } else { + $e = _exp_q($d); + $exps[] = $e; + $sum = gmp_add($sum, $e); + } + } + if (gmp_cmp($sum, 0) <= 0) { + return array_fill(0, $n, 0); + } + // price_i_q = exp_i / sum (Q96 division ↦ Q96) + // price_i_int = (price_i_q * PRICE_PRECISION) / ONE truncated + $out = []; + foreach ($exps as $e) { + $pq = _div_q($e, $sum); // Q96 + $scaled= gmp_mul($pq, $C['PRICE_PRECISION']); + $int = _q_trunc_div($scaled, $C['ONE']); + $out[] = intval(gmp_strval($int)); + } + return $out; +} + +/** + * buy_cost = max(0, C(q + Δ·e_i) − C(q)) + */ +function lmsr_buy_cost(array $q, int $b, int $i, int $delta): int { + if ($b <= 0 || $delta <= 0 || !isset($q[$i])) return 0; + $q_after = $q; + $q_after[$i] += $delta; + return max(0, lmsr_cost($q_after, $b) - lmsr_cost($q, $b)); +} + +/** + * sell_return = max(0, C(q) − C(q − Δ·e_i)) + */ +function lmsr_sell_return(array $q, int $b, int $i, int $delta): int { + if ($b <= 0 || $delta <= 0 || !isset($q[$i])) return 0; + if ($q[$i] - $delta < 0) return 0; // can't sell more than the outcome holds + $q_after = $q; + $q_after[$i] -= $delta; + return max(0, lmsr_cost($q, $b) - lmsr_cost($q_after, $b)); +} + +/** + * Largest Δ such that buy_cost(Δ) ≤ amount. Deterministic binary search, + * fixed 100-iteration bound (§4.1). + */ +function lmsr_tokens_for_amount(array $q, int $b, int $i, int $amount): int { + if ($b <= 0 || $amount <= 0 || !isset($q[$i])) return 0; + _validate_domain($q, $b); + + $lo = 0; + // Upper bound: amount × 10, clamped to MAX_Q. Mirrors the legacy heuristic + // and guarantees a feasible search interval (cost ≥ amount at hi). + $hi = $amount * 10; + $C = _lmsr_q96_const(); + $max_q = intval(gmp_strval($C['MAX_Q'])); + if ($hi > $max_q) $hi = $max_q; + $best = 0; + + for ($iter = 0; $iter < 100; $iter++) { + if ($hi - $lo <= 1) break; + $mid = intdiv($lo + $hi, 2); + $cost = lmsr_buy_cost($q, $b, $i, $mid); + if ($cost <= $amount) { + $best = $mid; + $lo = $mid; + } else { + $hi = $mid; + } + } + return $best; +} + +/** + * b = liquidity / ln(N), in milli-VIZ. Computed once per market at creation. + * Output truncated toward zero. + */ +function lmsr_b_from_liquidity(int $liquidity, int $n): int { + if ($liquidity <= 0 || $n <= 1) return 0; + if ($n > 16) throw new Exception('LMSR_INVALID_N'); + $C = _lmsr_q96_const(); + // ln(n) in Q96 — n is small int, scale to Q96 then ln_q + $n_q96 = gmp_mul(gmp_init($n), $C['ONE']); + $ln_n = _ln_q($n_q96); // Q96 + // b_milli = floor(liquidity · ONE / ln_n) + $num = gmp_mul(gmp_init((string)$liquidity), $C['ONE']); + $b_q = _q_trunc_div($num, $ln_n); + return intval(gmp_strval($b_q)); +} + +/** + * Maximum theoretical LMSR loss. Reference only — not used in settlement. + */ +function lmsr_max_loss(int $b, int $n): int { + if ($b <= 0 || $n <= 1) return 0; + $C = _lmsr_q96_const(); + $n_q96 = gmp_mul(gmp_init($n), $C['ONE']); + $ln_n = _ln_q($n_q96); + $product = gmp_mul(gmp_init((string)$b), $ln_n); + $loss = _q_trunc_div($product, $C['ONE']); + return intval(gmp_strval($loss)); +} + +// ===================================================================== +// Settlement and leverage helpers — pass-through from lmsr_math.php +// (these don't call exp/ln, so they're already deterministic; we copy the +// exact functions to make this file a single drop-in replacement) +// ===================================================================== + +/** + * Onix Multi parimutuel settlement. Identical to lmsr_math.php — kept here + * so callers only need to require_once one file. + */ +function lmsr_settlement(array $bets, int $winning_outcome, int $oracle_fee_permille, int $creator_fee_permille, int $liquidity_fee_permille): array { + $losers_sum = 0; + $winning_bets = []; + $total_winning_tokens = 0; + foreach ($bets as $bet) { + if (intval($bet['outcome_index']) === $winning_outcome) { + $winning_bets[] = $bet; + $total_winning_tokens += intval($bet['weight']); + } else { + $losers_sum += intval($bet['amount']); + } + } + $oracle_fee = intval($losers_sum * $oracle_fee_permille / 1000); + $creator_fee = intval($losers_sum * $creator_fee_permille / 1000); + $liquidity_fee = intval($losers_sum * $liquidity_fee_permille / 1000); + $winners_pool = $losers_sum - $oracle_fee - $creator_fee - $liquidity_fee; + if ($winners_pool < 0) $winners_pool = 0; + $payouts = []; + $total_distributed = 0; + foreach ($winning_bets as $bet) { + $bet_amount = intval($bet['amount']); + $tokens = intval($bet['weight']); + $profit_share = ($total_winning_tokens > 0) ? intval($winners_pool * $tokens / $total_winning_tokens) : 0; + $time_penalty_ratio = intval($bet['time_penalty']); // ×10^6 + $penalty_deduction = intval($profit_share * $time_penalty_ratio / 1000000); + $net_profit = $profit_share - $penalty_deduction; + $payout = $bet_amount + $net_profit; + $total_distributed += $payout; + $payouts[] = [ + 'user' => $bet['user'], + 'bet_id' => $bet['id'] ?? 0, + 'amount' => $bet_amount, + 'tokens' => $tokens, + 'profit_share' => $profit_share, + 'penalty' => $penalty_deduction, + 'payout' => $payout, + ]; + } + $undistributed = max(0, $winners_pool - ($total_distributed - array_sum(array_map(function($b){ return intval($b['amount']); }, $winning_bets)))); + return [ + 'losers_sum' => $losers_sum, + 'oracle_fee' => $oracle_fee, + 'creator_fee' => $creator_fee, + 'liquidity_fee' => $liquidity_fee, + 'winners_pool' => $winners_pool, + 'total_winning_tokens' => $total_winning_tokens, + 'payouts' => $payouts, + 'undistributed' => $undistributed, + 'total_penalty_pool' => array_sum(array_column($payouts, 'penalty')), + ]; +} + +/** + * Leverage helper: max bet amount that keeps slippage within `slippage_pct`. + * Uses the deterministic lmsr_price / lmsr_tokens_for_amount internally, + * so its output is now also bit-deterministic. + */ +function lmsr_max_bet_amount(array $q, int $b, float $slippage_pct): int { + if ($b <= 0 || $slippage_pct <= 0 || empty($q)) return 0; + $n = count($q); + if ($n < 2) return 0; + $C = _lmsr_q96_const(); + $max_amount = 0; + // slippage_pct → integer ×1e6 of fraction (e.g. 10% → 100_000 of 1_000_000 = 0.1) + // We compare price-change fraction (price_after - price_before)/price_before * 100 ≤ slippage_pct + // Implemented in integer ×10^6 price space: |dp| · 100 / p_before ≤ slippage_pct + // → |dp| · 100 · 1e6 ≤ slippage_pct · 1e6 · p_before + $sl_scaled = (int) round($slippage_pct * 1000000); // slippage in ppm of percent — note: this float + // is non-consensus (only an off-chain risk gate) + foreach ($q as $i => $qi) { + $lo = 0; + $hi = $b * 10; + if ($hi > intval(gmp_strval($C['MAX_Q']))) $hi = intval(gmp_strval($C['MAX_Q'])); + $best = 0; + for ($iter = 0; $iter < 60; $iter++) { + if ($hi - $lo <= LMSR_PRECISION) break; + $mid = intdiv($lo + $hi, 2); + if ($mid <= 0) break; + $price_before = lmsr_price($q, $b, $i); + if ($price_before <= 0) { $hi = $mid; continue; } + $tokens = lmsr_tokens_for_amount($q, $b, $i, $mid); + if ($tokens <= 0) { $hi = $mid; continue; } + $q_after = $q; + $q_after[$i] += $tokens; + $price_after = lmsr_price($q_after, $b, $i); + $dp = abs($price_after - $price_before); + // |dp|·100·1e6 ≤ slippage_pct·1e6·price_before + // (multiply explicitly in int64-safe range — prices are ≤ 1e6, dp ≤ 1e6) + $lhs = $dp * 100; // ≤ 1e8 + $rhs_per_million = $sl_scaled; // = slippage_pct · 1e6 + // lhs · 1e6 / price_before ≤ rhs_per_million + $threshold = intdiv($rhs_per_million * $price_before, 1000000); + if ($lhs <= $threshold) { + $best = $mid; $lo = $mid; + } else { + $hi = $mid; + } + } + if ($best > $max_amount) $max_amount = $best; + } + return $max_amount; +} diff --git a/.qoder/plans/pm-plan/reference/lmsr_fixed_smoke.php b/.qoder/plans/pm-plan/reference/lmsr_fixed_smoke.php new file mode 100644 index 0000000000..bb07ecc90b --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_fixed_smoke.php @@ -0,0 +1,145 @@ + $pi) { + check("balanced 3-way price[$i] ≈ 333333", $pi, 333333, /*tol*/ 1); +} + +// Imbalanced: q=[10000,0,0] +$p_imb = lmsr_prices([10000, 0, 0], 100000); +$sum_p2 = array_sum($p_imb); +check('imbalanced prices sum ≈ 1e6', $sum_p2, 1000000, /*tol*/ 3); +// price[0] should be ≈ exp(0.1)/(exp(0.1)+2·exp(0)) = 1.1052/(1.1052+2) = 0.3559... → ≈ 355900 +check('imbalanced price[0] ≈ 355900', + $p_imb[0], 355900, /*tol*/ 100); // small margin for rounding chain + +// --------------------------------------------------------------------- +// 7. buy_cost monotone, tokens_for_amount ≤ amount in cost +// --------------------------------------------------------------------- +$bc1 = lmsr_buy_cost([1000, 1000], 100000, 0, 1000); +$bc2 = lmsr_buy_cost([1000, 1000], 100000, 0, 2000); +check('buy_cost monotone', ($bc2 > $bc1) ? 1 : 0, 1); + +$tok = lmsr_tokens_for_amount([1000, 1000], 100000, 0, 5000); +$cost_back = lmsr_buy_cost([1000, 1000], 100000, 0, $tok); +check('tokens_for_amount(5000): cost ≤ amount', + ($cost_back <= 5000) ? 1 : 0, 1); +check('tokens_for_amount(5000): cost(tok+1) > amount', + (lmsr_buy_cost([1000,1000], 100000, 0, $tok+1) > 5000) ? 1 : 0, 1); + +// --------------------------------------------------------------------- +// 8. lmsr_b_from_liquidity sanity +// --------------------------------------------------------------------- +// liquidity = 100000, N = 2 → b = 100000 / ln(2) = 100000 / 0.6931 = 144269 (truncated 144269) +$b_from_l = lmsr_b_from_liquidity(100000, 2); +check('b_from_liquidity(100000, 2) ≈ 144269', + $b_from_l, 144269, /*tol*/ 1); + +// liquidity = 100000, N = 4 → b = 100000 / ln(4) = 100000 / 1.3862... = 72134 +$b_from_l4 = lmsr_b_from_liquidity(100000, 4); +check('b_from_liquidity(100000, 4) ≈ 72134', + $b_from_l4, 72134, /*tol*/ 1); + +// --------------------------------------------------------------------- +echo "\n---\n"; +printf("PASSED: %d FAILED: %d\n", $passed, $failed); +exit($failed === 0 ? 0 : 1); diff --git a/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors.json b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors.json new file mode 100644 index 0000000000..8da076220c --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors.json @@ -0,0 +1,625 @@ +{ + "version": 1, + "spec": "docs/lmsr-fixed-point-spec.md", + "generated_by": "module/lmsr_fixed.php (PHP+GMP, normative)", + "numbers_are_decimal_strings": true, + "note": "All numeric values, including Q96 intermediates, are decimal strings. Equality is exact (===); no tolerance is permitted.", + "constants": { + "ONE": "79228162514264337593543950336", + "HALF": "39614081257132168796771975168", + "LN2": "54916777467707473351141471128", + "EXP_DOMAIN_LIMIT": "15845632502852867518708790067200", + "MAX_B": "9007199254740992", + "MAX_Q": "9007199254740992" + }, + "mul_q": [ + { + "a": "79228162514264337593543950336", + "b": "79228162514264337593543950336", + "expected": "79228162514264337593543950336" + }, + { + "a": "79228162514264337593543950336", + "b": "39614081257132168796771975168", + "expected": "39614081257132168796771975168" + }, + { + "a": "54916777467707473351141471128", + "b": "54916777467707473351141471128", + "expected": "38065409467179368169621602747" + }, + { + "a": "237684487542793012780631851008", + "b": "79228162514264337593543950336", + "expected": "237684487542793012780631851008" + }, + { + "a": "-79228162514264337593543950336", + "b": "39614081257132168796771975168", + "expected": "-39614081257132168796771975168" + } + ], + "div_q": [ + { + "a": "79228162514264337593543950336", + "b": "79228162514264337593543950336", + "expected": "79228162514264337593543950336" + }, + { + "a": "79228162514264337593543950336", + "b": "158456325028528675187087900672", + "expected": "39614081257132168796771975168" + }, + { + "a": "54916777467707473351141471128", + "b": "79228162514264337593543950336", + "expected": "54916777467707473351141471128" + }, + { + "a": "554597137599850363154807652352", + "b": "237684487542793012780631851008", + "expected": "184865712533283454384935884117" + }, + { + "a": "-79228162514264337593543950336", + "b": "316912650057057350374175801344", + "expected": "-19807040628566084398385987584" + } + ], + "exp_q": [ + { + "x": "0", + "expected": "79228162514264337593543950336" + }, + { + "x": "54916777467707473351141471128", + "expected": "158456325028528675187087900672" + }, + { + "x": "-54916777467707473351141471128", + "expected": "39614081257132168796771975168" + }, + { + "x": "79228162514264337593543950336", + "expected": "215364474464724850177511348332" + }, + { + "x": "-79228162514264337593543950336", + "expected": "29146412150787779157341161346" + }, + { + "x": "158456325028528675187087900672", + "expected": "585421337433093623126455912472" + }, + { + "x": "-396140812571321687967719751680", + "expected": "533835159856043089203045521" + }, + { + "x": "27458388733853736675570735564", + "expected": "112045541949572279837463876445" + }, + { + "x": "7922816251426433759354395033", + "expected": "87560661103336128385318961295" + }, + { + "x": "12345678901234567890", + "expected": "79228162526610016495740397074" + }, + { + "x": "-98765432109876543210", + "expected": "79228162415498905545227654557" + } + ], + "ln_q": [ + { + "x": "79228162514264337593543950336", + "expected": "0" + }, + { + "x": "158456325028528675187087900672", + "expected": "54916777467707473351141471128" + }, + { + "x": "237684487542793012780631851008", + "expected": "87041032946764879767665216836" + }, + { + "x": "792281625142643375935439503360", + "expected": "182429585950654714090129938590" + }, + { + "x": "39614081257132168796771975168", + "expected": "-54916777467707473351141471128" + }, + { + "x": "7922816251426433759354395033", + "expected": "-182429585950654714090129938636" + }, + { + "x": "79228162514264337593543950", + "expected": "-547288757851964142270389816164" + }, + { + "x": "79228162514264337593543950336000000", + "expected": "1094577515703928284540779631608" + } + ], + "lmsr_cost": [ + { + "q": [ + 0, + 0 + ], + "b": 100000, + "expected": 69314 + }, + { + "q": [ + 0, + 0, + 0 + ], + "b": 100000, + "expected": 109861 + }, + { + "q": [ + 10000, + 0, + 0 + ], + "b": 100000, + "expected": 113306 + }, + { + "q": [ + 1000, + 2000, + 3000 + ], + "b": 5000, + "expected": 7559 + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "expected": 52538 + }, + { + "q": [ + 5000, + 5000, + 5000, + 5000 + ], + "b": 200000, + "expected": 282258 + }, + { + "q": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "b": 100000, + "expected": 230264 + }, + { + "q": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "b": 100000, + "expected": 277258 + }, + { + "q": [ + 1000000, + 0 + ], + "b": 50000, + "expected": 1000000 + }, + { + "q": [ + 2500, + 2500 + ], + "b": 5000, + "expected": 5965 + } + ], + "lmsr_prices": [ + { + "q": [ + 0, + 0 + ], + "b": 100000, + "expected": [ + 500000, + 500000 + ] + }, + { + "q": [ + 0, + 0, + 0 + ], + "b": 100000, + "expected": [ + 333333, + 333333, + 333333 + ] + }, + { + "q": [ + 10000, + 0, + 0 + ], + "b": 100000, + "expected": [ + 355913, + 322043, + 322043 + ] + }, + { + "q": [ + 1000, + 2000, + 3000 + ], + "b": 5000, + "expected": [ + 269307, + 328932, + 401759 + ] + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "expected": [ + 880797, + 119202 + ] + }, + { + "q": [ + 5000, + 5000, + 5000, + 5000 + ], + "b": 200000, + "expected": [ + 250000, + 250000, + 250000, + 250000 + ] + }, + { + "q": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "b": 100000, + "expected": [ + 99995, + 99996, + 99997, + 99998, + 99999, + 100000, + 100001, + 100002, + 100003, + 100004 + ] + }, + { + "q": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "b": 100000, + "expected": [ + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500, + 62500 + ] + }, + { + "q": [ + 1000000, + 0 + ], + "b": 50000, + "expected": [ + 999999, + 0 + ] + }, + { + "q": [ + 2500, + 2500 + ], + "b": 5000, + "expected": [ + 500000, + 500000 + ] + } + ], + "lmsr_buy_cost": [ + { + "q": [ + 1000, + 2000, + 3000 + ], + "b": 5000, + "i": 0, + "delta": 1000, + "expected": 290 + }, + { + "q": [ + 1000, + 2000, + 3000 + ], + "b": 5000, + "i": 1, + "delta": 1000, + "expected": 351 + }, + { + "q": [ + 0, + 0, + 0 + ], + "b": 8000, + "i": 0, + "delta": 5000, + "expected": 2034 + }, + { + "q": [ + 0, + 0, + 0 + ], + "b": 8000, + "i": 2, + "delta": 50000, + "expected": 41242 + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "i": 0, + "delta": 1000, + "expected": 883 + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "i": 1, + "delta": 100000, + "expected": 58433 + } + ], + "lmsr_sell_return": [ + { + "q": [ + 10000, + 5000, + 2000 + ], + "b": 8000, + "i": 0, + "delta": 1000, + "expected": 510 + }, + { + "q": [ + 10000, + 5000, + 2000 + ], + "b": 8000, + "i": 1, + "delta": 2500, + "expected": 628 + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "i": 0, + "delta": 25000, + "expected": 19801 + } + ], + "lmsr_tokens_for_amount": [ + { + "q": [ + 1000, + 2000, + 3000 + ], + "b": 5000, + "i": 0, + "amount": 10000, + "expected": 16039 + }, + { + "q": [ + 0, + 0, + 0 + ], + "b": 8000, + "i": 1, + "amount": 50000, + "expected": 58778 + }, + { + "q": [ + 50000, + 10000 + ], + "b": 20000, + "i": 0, + "amount": 30000, + "expected": 31999 + }, + { + "q": [ + 0, + 0 + ], + "b": 10000, + "i": 0, + "amount": 1000, + "expected": 1909 + }, + { + "q": [ + 0, + 0 + ], + "b": 10000, + "i": 0, + "amount": 100000, + "expected": 106931 + }, + { + "q": [ + 1000000, + 0 + ], + "b": 50000, + "i": 1, + "amount": 50000, + "expected": 499999 + } + ], + "lmsr_b_from_liquidity": [ + { + "liquidity": 100000, + "n": 2, + "expected": 144269 + }, + { + "liquidity": 100000, + "n": 3, + "expected": 91023 + }, + { + "liquidity": 100000, + "n": 4, + "expected": 72134 + }, + { + "liquidity": 100000, + "n": 10, + "expected": 43429 + }, + { + "liquidity": 100000, + "n": 16, + "expected": 36067 + }, + { + "liquidity": 1000000, + "n": 5, + "expected": 621334 + }, + { + "liquidity": 1234567, + "n": 7, + "expected": 634441 + } + ] +} diff --git a/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.js b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.js new file mode 100644 index 0000000000..75b2a24cad --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.js @@ -0,0 +1,82 @@ +/* + * tests/lmsr_fixed_vectors_verify.js — asserts that the JS implementation + * (market_math.js, BigInt) reproduces every value in + * tests/lmsr_fixed_vectors.json with strict equality. + * + * Run: node tests/lmsr_fixed_vectors_verify.js + * Exit: 0 = all pass, 1 = at least one mismatch + */ +'use strict'; + +var path = require('path'); +var fs = require('fs'); +var M = require(path.join(__dirname, 'market_math.js')); + +var vectorsPath = path.join(__dirname, 'lmsr_fixed_vectors.json'); +if (!fs.existsSync(vectorsPath)) { + console.error('[FAIL] missing ' + vectorsPath + ' — run `php tests/lmsr_vectors_generate.php` first'); + process.exit(1); +} +var V = JSON.parse(fs.readFileSync(vectorsPath, 'utf8')); + +var pass = 0, fail = 0; +function check(label, expected, actual) { + var ok; + if (Array.isArray(expected) && Array.isArray(actual)) { + ok = expected.length === actual.length; + for (var k = 0; ok && k < expected.length; k++) { + ok = String(expected[k]) === String(actual[k]); + } + } else { + ok = String(expected) === String(actual); + } + if (ok) { pass++; return; } + fail++; + console.log(' FAIL ' + label); + console.log(' expected: ' + JSON.stringify(expected)); + console.log(' actual: ' + (typeof actual === 'bigint' ? actual.toString() : JSON.stringify(actual))); +} + +// ---- constants ---- +var Q = M._Q96; +check('const ONE', V.constants.ONE, Q.ONE.toString()); +check('const HALF', V.constants.HALF, Q.HALF.toString()); +check('const LN2', V.constants.LN2, Q.LN2.toString()); + +// ---- primitives ---- +V.mul_q.forEach(function (t, i) { + check('mul_q[' + i + ']', t.expected, Q.mul_q(BigInt(t.a), BigInt(t.b)).toString()); +}); +V.div_q.forEach(function (t, i) { + check('div_q[' + i + ']', t.expected, Q.div_q(BigInt(t.a), BigInt(t.b)).toString()); +}); +V.exp_q.forEach(function (t, i) { + check('exp_q[' + i + '] x=' + t.x, t.expected, Q.exp_q(BigInt(t.x)).toString()); +}); +V.ln_q.forEach(function (t, i) { + check('ln_q[' + i + '] x=' + t.x, t.expected, Q.ln_q(BigInt(t.x)).toString()); +}); + +// ---- public API ---- +V.lmsr_cost.forEach(function (t, i) { + check('lmsr_cost[' + i + ']', t.expected, M.lmsr_cost(t.q, t.b)); +}); +V.lmsr_prices.forEach(function (t, i) { + check('lmsr_prices[' + i + ']', t.expected, M.lmsr_prices(t.q, t.b)); +}); +V.lmsr_buy_cost.forEach(function (t, i) { + check('lmsr_buy_cost[' + i + ']', t.expected, M.lmsr_buy_cost(t.q, t.b, t.i, t.delta)); +}); +V.lmsr_sell_return.forEach(function (t, i) { + check('lmsr_sell_return[' + i + ']', t.expected, M.lmsr_sell_return(t.q, t.b, t.i, t.delta)); +}); +V.lmsr_tokens_for_amount.forEach(function (t, i) { + check('lmsr_tokens_for_amount[' + i + ']', t.expected, M.lmsr_tokens_for_amount(t.q, t.b, t.i, t.amount)); +}); +V.lmsr_b_from_liquidity.forEach(function (t, i) { + check('lmsr_b_from_liquidity[' + i + ']', t.expected, M.lmsr_b_from_liquidity(t.liquidity, t.n)); +}); + +var total = pass + fail; +console.log('\nJS verifier: ' + pass + ' / ' + total + ' passed' + (fail ? ' (' + fail + ' FAILED)' : '')); +process.exit(fail ? 1 : 0); diff --git a/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.php b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.php new file mode 100644 index 0000000000..871eb1161b --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_fixed_vectors_verify.php @@ -0,0 +1,92 @@ + $t) { + $r = _mul_q(gmp_init($t['a']), gmp_init($t['b'])); + check("mul_q[$i] $t[a]·$t[b]", $t['expected'], gmp_strval($r)); +} +foreach ($V['div_q'] as $i => $t) { + $r = _div_q(gmp_init($t['a']), gmp_init($t['b'])); + check("div_q[$i] $t[a]/$t[b]", $t['expected'], gmp_strval($r)); +} +foreach ($V['exp_q'] as $i => $t) { + $r = _exp_q(gmp_init($t['x'])); + check("exp_q[$i] x=$t[x]", $t['expected'], gmp_strval($r)); +} +foreach ($V['ln_q'] as $i => $t) { + $r = _ln_q(gmp_init($t['x'])); + check("ln_q[$i] x=$t[x]", $t['expected'], gmp_strval($r)); +} + +// ---- public API ---- +foreach ($V['lmsr_cost'] as $i => $t) { + check("lmsr_cost[$i]", (int)$t['expected'], lmsr_cost($t['q'], (int)$t['b'])); +} +foreach ($V['lmsr_prices'] as $i => $t) { + check("lmsr_prices[$i]", array_map('intval', $t['expected']), + lmsr_prices($t['q'], (int)$t['b'])); +} +foreach ($V['lmsr_buy_cost'] as $i => $t) { + check("lmsr_buy_cost[$i]", (int)$t['expected'], + lmsr_buy_cost($t['q'], (int)$t['b'], (int)$t['i'], (int)$t['delta'])); +} +foreach ($V['lmsr_sell_return'] as $i => $t) { + check("lmsr_sell_return[$i]", (int)$t['expected'], + lmsr_sell_return($t['q'], (int)$t['b'], (int)$t['i'], (int)$t['delta'])); +} +foreach ($V['lmsr_tokens_for_amount'] as $i => $t) { + check("lmsr_tokens_for_amount[$i]", (int)$t['expected'], + lmsr_tokens_for_amount($t['q'], (int)$t['b'], (int)$t['i'], (int)$t['amount'])); +} +foreach ($V['lmsr_b_from_liquidity'] as $i => $t) { + check("lmsr_b_from_liquidity[$i]", (int)$t['expected'], + lmsr_b_from_liquidity((int)$t['liquidity'], (int)$t['n'])); +} + +$total = $pass + $fail; +echo "\nPHP verifier: $pass / $total passed"; +if ($fail) { echo " ($fail FAILED)\n"; exit(1); } +echo "\n"; +exit(0); diff --git a/.qoder/plans/pm-plan/reference/lmsr_vectors_generate.php b/.qoder/plans/pm-plan/reference/lmsr_vectors_generate.php new file mode 100644 index 0000000000..3ba545c623 --- /dev/null +++ b/.qoder/plans/pm-plan/reference/lmsr_vectors_generate.php @@ -0,0 +1,181 @@ + 1, + 'spec' => 'docs/lmsr-fixed-point-spec.md', + 'generated_by' => 'module/lmsr_fixed.php (PHP+GMP, normative)', + 'numbers_are_decimal_strings' => true, + 'note' => 'All numeric values, including Q96 intermediates, are decimal strings. Equality is exact (===); no tolerance is permitted.', + 'constants' => [ + 'ONE' => gstr($ONE), + 'HALF' => gstr($C['HALF']), + 'LN2' => gstr($LN2), + 'EXP_DOMAIN_LIMIT' => gstr($C['EXP_DOMAIN_LIMIT']), + 'MAX_B' => gstr($C['MAX_B']), + 'MAX_Q' => gstr($C['MAX_Q']), + ], + 'mul_q' => array_map(function($p){ + return [ + 'a' => gstr($p[0]), + 'b' => gstr($p[1]), + 'expected' => gstr(_mul_q($p[0], $p[1])), + ]; + }, $mul_q_cases), + 'div_q' => array_map(function($p){ + return [ + 'a' => gstr($p[0]), + 'b' => gstr($p[1]), + 'expected' => gstr(_div_q($p[0], $p[1])), + ]; + }, $div_q_cases), + 'exp_q' => array_map(function($x){ + return [ 'x' => gstr($x), 'expected' => gstr(_exp_q($x)) ]; + }, $exp_q_cases), + 'ln_q' => array_map(function($x){ + return [ 'x' => gstr($x), 'expected' => gstr(_ln_q($x)) ]; + }, $ln_q_cases), + 'lmsr_cost' => array_map(function($c){ + return [ 'q' => $c[0], 'b' => $c[1], 'expected' => lmsr_cost($c[0], $c[1]) ]; + }, $lmsr_cost_cases), + 'lmsr_prices' => array_map(function($c){ + return [ 'q' => $c[0], 'b' => $c[1], 'expected' => lmsr_prices($c[0], $c[1]) ]; + }, $lmsr_prices_cases), + 'lmsr_buy_cost' => array_map(function($c){ + return [ + 'q' => $c[0], 'b' => $c[1], 'i' => $c[2], 'delta' => $c[3], + 'expected' => lmsr_buy_cost($c[0], $c[1], $c[2], $c[3]), + ]; + }, $lmsr_buy_cost_cases), + 'lmsr_sell_return' => array_map(function($c){ + return [ + 'q' => $c[0], 'b' => $c[1], 'i' => $c[2], 'delta' => $c[3], + 'expected' => lmsr_sell_return($c[0], $c[1], $c[2], $c[3]), + ]; + }, $lmsr_sell_return_cases), + 'lmsr_tokens_for_amount' => array_map(function($c){ + return [ + 'q' => $c[0], 'b' => $c[1], 'i' => $c[2], 'amount' => $c[3], + 'expected' => lmsr_tokens_for_amount($c[0], $c[1], $c[2], $c[3]), + ]; + }, $lmsr_tokens_for_amount_cases), + 'lmsr_b_from_liquidity' => array_map(function($c){ + return [ 'liquidity' => $c[0], 'n' => $c[1], 'expected' => lmsr_b_from_liquidity($c[0], $c[1]) ]; + }, $lmsr_b_from_liq_cases), +]; + +$path = __DIR__ . '/lmsr_fixed_vectors.json'; +$json = json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; +file_put_contents($path, $json); +fwrite(STDERR, "wrote " . strlen($json) . " bytes to $path\n"); diff --git a/.qoder/plans/pm-plan/reference/market_math.js b/.qoder/plans/pm-plan/reference/market_math.js new file mode 100644 index 0000000000..9e73d6a7bb --- /dev/null +++ b/.qoder/plans/pm-plan/reference/market_math.js @@ -0,0 +1,394 @@ +/* + * market_math.js — pure market math for Onix prediction markets. + * + * All amounts are integers in milli-VIZ (precision 1000). LMSR pricing is + * bit-deterministic via Q96 fixed-point BigInt arithmetic: this file is a + * 1:1 mirror of module/lmsr_fixed.php (PHP+GMP) and the future C++ VIZ DLT + * plugin. Every implementation MUST produce identical outputs. + * + * Two market types, ONE settlement model: the AMM assigns weights + * (CPMM for binary, LMSR for multi) and losers fund winners pro-rata. + * + * Loaded as a browser global (before app.js) AND as a CommonJS module (Node tests). + * + * @see docs/lmsr-fixed-point-spec.md + */ +(function (root) { + 'use strict'; + + // ============================================================ + // Constants — frozen by docs/lmsr-fixed-point-spec.md + // ============================================================ + var LMSR_PRECISION = 1000; // 1 VIZ = 1000 milli-VIZ + var LMSR_PRICE_PRECISION = 1000000; // price ×10^6, 1e6 = 100% + + // Q96 anchors as BigInt + var ONE = 1n << 96n; // 2^96 + var HALF = 1n << 95n; // 2^95 + var LN2 = 0xB17217F7D1CF79ABC9E3B398n; // ⌊ln 2 · 2^96⌋ + var NEG_LN2 = -LN2; + var EXP_DOMAIN_LIMIT = 200n * ONE; // §3.3 cutoff + var EXP_INPUT_LIMIT = 130n * ONE; // §3.1 hard cap + var MAX_B = 1n << 53n; + var MAX_Q = 1n << 53n; + var THOUSAND = 1000n; + var PRICE_PRECISION = 1000000n; + + // ============================================================ + // Q96 primitives (§2) + // JS BigInt: `/` truncates toward 0; `>>` is arithmetic right shift + // (rounds toward -∞ for negatives). These are the spec-mandated rules. + // ============================================================ + + function mul_q(a, b) { + // (a · b) >> 96, ASR — matches §3.5 + return (a * b) >> 96n; + } + + function div_q(a, b) { + // (a << 96) / b, truncated toward 0 + return (a << 96n) / b; + } + + function trunc_div(a, b) { + // truncation toward zero. JS BigInt `/` already truncates, BUT for negative + // dividend with negative remainder it returns the truncating quotient. To be + // identical to PHP gmp_div_q(_, _, GMP_ROUND_ZERO), we wrap. + return a / b; + } + + function isOdd(n) { + // works for negative bigints too: parity is invariant under sign + return ((n % 2n) !== 0n); + } + + function abs(n) { return n < 0n ? -n : n; } + + // MSB position (0-indexed) of a positive bigint. Returns -1 for 0. + // Used by ln_q range reduction. No FP calls. + function msb(x) { + if (x <= 0n) return -1; + return x.toString(2).length - 1; + } + + // ============================================================ + // Range reduction for exp_q — round-to-nearest-even, §3.5 + // ============================================================ + function round_div_LN2(x) { + // q = trunc(x / LN2) + var q = x / LN2; + var r = x - q * LN2; + var two_r = r * 2n; + + // Positive side: 2r > LN2 → bump up. 2r == LN2 and q odd → bump up (ties to even). + if (two_r > LN2 || (two_r === LN2 && isOdd(q))) { + q = q + 1n; + } + // Negative side: 2r < -LN2 → bump down. 2r == -LN2 and q odd → bump down. + if (two_r < NEG_LN2 || (two_r === NEG_LN2 && isOdd(q))) { + q = q - 1n; + } + return q; + } + + // ============================================================ + // exp_q — Taylor with range reduction (§3.1) + // ============================================================ + function exp_q(x) { + if (abs(x) > EXP_INPUT_LIMIT) { + throw new Error('LMSR_OVERFLOW: exp_q domain exceeded'); + } + // 1. Range reduction + var k = round_div_LN2(x); + var r = x - k * LN2; + + // 2. Polynomial — exactly 30 iterations + var acc = ONE; + var term = ONE; + for (var n = 1; n <= 30; n++) { + // term = mul_q(term, r) / n (mul_q first; truncated divide by integer n) + term = mul_q(term, r) / BigInt(n); + acc = acc + term; + } + + // 3. Scale by 2^k + if (k >= 0n) { + // Number(k) is safe: |k| ≤ ⌈130/ln 2⌉ ≈ 188 + return acc << k; + } else { + return acc >> (-k); + } + } + + // ============================================================ + // ln_q — atanh series with range reduction (§3.2) + // ============================================================ + function ln_q(x) { + if (x <= 0n) { + throw new Error('LMSR_DOMAIN_LN: ln_q called with x ≤ 0'); + } + // 1. p = msb(x) - 96; m = x scaled into [ONE, 2·ONE) + var p = msb(x) - 96; + var m; + if (p >= 0) { + m = x >> BigInt(p); // ASR; positive ⇒ truncation + } else { + m = x << BigInt(-p); + } + + // 2. z = (m - ONE) / (m + ONE) in Q96, |z| < 1/3 + var num = m - ONE; + var den = m + ONE; + var z = div_q(num, den); + + // 3. atanh series, 50 iterations + var z2 = mul_q(z, z); + var acc = z; + var term = z; + for (var k = 1; k <= 50; k++) { + term = mul_q(term, z2); + var denom = BigInt(2 * k + 1); + acc = acc + (term / denom); // truncated divide + } + + // 4. ln(m) = 2 · acc + var ln_m = acc * 2n; + + // 5. ln(x) = p · LN2 + ln(m) + return BigInt(p) * LN2 + ln_m; + } + + // ============================================================ + // log-sum-exp (§3.3) + // ============================================================ + function lse_q(ratios) { + if (ratios.length === 0) return 0n; + var max = ratios[0]; + for (var i = 1; i < ratios.length; i++) { + if (ratios[i] > max) max = ratios[i]; + } + var acc = 0n; + var cutoff = -EXP_DOMAIN_LIMIT; + for (var j = 0; j < ratios.length; j++) { // input order, do NOT sort + var d = ratios[j] - max; + if (d < cutoff) continue; // strict < + acc = acc + exp_q(d); + } + if (acc <= 0n) return max; + return max + ln_q(acc); + } + + // ============================================================ + // Domain validation (§1.4) + // ============================================================ + function validate_domain(q, b) { + if (q.length < 2 || q.length > 16) { + throw new Error('LMSR_INVALID_N: outcomes must be 2..16'); + } + var bb = BigInt(b); + if (bb <= 0n) throw new Error('LMSR_INVALID_B: b must be > 0'); + if (bb > MAX_B) throw new Error('LMSR_INVALID_B: b exceeds MAX_B (2^53)'); + for (var i = 0; i < q.length; i++) { + var qi = BigInt(q[i]); + if (qi < 0n) throw new Error('LMSR_INVALID_Q: q[i] < 0'); + if (qi > MAX_Q) throw new Error('LMSR_INVALID_Q: q[i] exceeds MAX_Q (2^53)'); + } + } + + function ratios_q96(q, b) { + var bb = BigInt(b); + var out = []; + for (var i = 0; i < q.length; i++) { + var num = BigInt(q[i]) * ONE; + out.push(num / bb); // truncated toward 0 + } + return out; + } + + // ============================================================ + // Public API (§4) — same signatures as module/lmsr_fixed.php + // Inputs and outputs are Number (int milli-VIZ) for ergonomics; the + // BigInt math is internal. Caller never sees BigInt. + // ============================================================ + + function lmsr_cost(q, b) { + if (b <= 0) return 0; + validate_domain(q, b); + var ratios = ratios_q96(q, b); + var lse = lse_q(ratios); + var cost = (BigInt(b) * lse) / ONE; // truncated toward 0 + return Number(cost); + } + + function lmsr_prices(q, b) { + var n = q.length; + if (b <= 0) return new Array(n).fill(0); + validate_domain(q, b); + var ratios = ratios_q96(q, b); + var max = ratios[0]; + for (var i = 1; i < n; i++) if (ratios[i] > max) max = ratios[i]; + var cutoff = -EXP_DOMAIN_LIMIT; + var exps = []; + var sum = 0n; + for (var j = 0; j < n; j++) { + var d = ratios[j] - max; + if (d < cutoff) { + exps.push(0n); + } else { + var e = exp_q(d); + exps.push(e); + sum = sum + e; + } + } + if (sum <= 0n) return new Array(n).fill(0); + var out = []; + for (var k = 0; k < n; k++) { + var pq = div_q(exps[k], sum); // Q96 + var scaled= pq * PRICE_PRECISION; + var pi = scaled / ONE; // truncated toward 0 + out.push(Number(pi)); + } + return out; + } + + function lmsr_price(q, b, i) { + if (b <= 0 || q[i] === undefined) return 0; + var p = lmsr_prices(q, b); + return p[i] || 0; + } + + function lmsr_buy_cost(q, b, i, delta) { + if (b <= 0 || delta <= 0 || q[i] === undefined) return 0; + var qa = q.slice(); qa[i] = qa[i] + delta; + return Math.max(0, lmsr_cost(qa, b) - lmsr_cost(q, b)); + } + + function lmsr_sell_return(q, b, i, delta) { + if (b <= 0 || delta <= 0 || q[i] === undefined) return 0; + if (q[i] - delta < 0) return 0; + var qa = q.slice(); qa[i] = qa[i] - delta; + return Math.max(0, lmsr_cost(q, b) - lmsr_cost(qa, b)); + } + + function lmsr_tokens_for_amount(q, b, i, amount) { + if (b <= 0 || amount <= 0 || q[i] === undefined) return 0; + validate_domain(q, b); + var lo = 0; + var hi = amount * 10; + var maxq = Number(MAX_Q); + if (hi > maxq) hi = maxq; + var best = 0; + for (var iter = 0; iter < 100; iter++) { + if (hi - lo <= 1) break; + var mid = Math.floor((lo + hi) / 2); + if (lmsr_buy_cost(q, b, i, mid) <= amount) { best = mid; lo = mid; } + else { hi = mid; } + } + return best; + } + + function lmsr_b_from_liquidity(liquidity, n) { + if (liquidity <= 0 || n <= 1) return 0; + if (n > 16) throw new Error('LMSR_INVALID_N'); + var nq = BigInt(n) * ONE; + var ln_n = ln_q(nq); + var num = BigInt(liquidity) * ONE; + return Number(num / ln_n); + } + + function lmsr_max_loss(b, n) { + if (b <= 0 || n <= 1) return 0; + var nq = BigInt(n) * ONE; + var ln_n = ln_q(nq); + return Number((BigInt(b) * ln_n) / ONE); + } + + // Legacy float-style log_sum_exp helper kept for compatibility with any + // caller that imported it directly. Implemented via the deterministic + // pipeline so old call sites quietly become deterministic too. + function lmsr_log_sum_exp(ratios) { + // expects an array of floats q_j/b — we round-trip through Q96 to honour + // the spec. Caller MUST migrate to lmsr_cost / lmsr_prices for new code. + if (!ratios.length) return 0; + var qratios = ratios.map(function(r){ + // scale float to Q96 with truncation toward 0 (best-effort; not + // consensus, since the float input is non-deterministic anyway) + var sign = r < 0 ? -1n : 1n; + var abs_r = Math.abs(r); + var int_part = BigInt(Math.trunc(abs_r)); + var frac = abs_r - Math.trunc(abs_r); + var frac_q96 = BigInt(Math.floor(frac * 1e15)) * (ONE / 1000000000000000n); + return sign * (int_part * ONE + frac_q96); + }); + var lse = lse_q(qratios); + // return as Number (real units) — non-consensus convenience + return Number(lse) / Number(ONE); + } + + // ============================================================ + // Parimutuel settlement (binary AND multi) — INTEGER ONLY + // Unchanged from the previous market_math.js; already deterministic. + // ============================================================ + + function parimutuel_winners_pool(winning_bets_sum, total_bets_sum, fee_permille) { + var losers = Math.max(0, (parseInt(total_bets_sum) || 0) - (parseInt(winning_bets_sum) || 0)); + var wp = Math.floor(losers * (1000 - (parseInt(fee_permille) || 0)) / 1000); + return wp > 0 ? wp : 0; + } + + function parimutuel_payout(weight, bet_amount, winning_bets_sum, total_bets_sum, total_winning_weight, fee_permille) { + var wp = parimutuel_winners_pool(winning_bets_sum, total_bets_sum, fee_permille); + var tw = parseInt(total_winning_weight) || 0; + var share = (tw > 0) ? Math.floor(wp * (parseInt(weight) || 0) / tw) : 0; + return { winners_pool: wp, share: share, payout: (parseInt(bet_amount) || 0) + share, profit: share }; + } + + function market_fee_permille(m) { + return (parseInt(m.oracle_fee) || 0) + (parseInt(m.creator_fee) || 0) + (parseInt(m.liquidity_fee) || 0); + } + + function parimutuel_estimate(m, outcome, weight, bet_amount, is_new) { + var fee = market_fee_permille(m), w = parseInt(weight) || 0; + if (parseInt(m.market_type) == 1) { + var oc = (m.outcomes || [])[parseInt(outcome)] || {}; + var win_bets = parseInt(oc.bets_sum) || 0; + var total = parseInt(m.bets_sum) || 0; + var win_w = (parseInt(oc.weight_sum) || 0) + (is_new ? w : 0); + return parimutuel_payout(w, bet_amount, win_bets, total, win_w, fee); + } + var a_b = parseInt(m.a_bets_sum) || 0, b_b = parseInt(m.b_bets_sum) || 0; + var win_bets2 = (0 == outcome) ? a_b : b_b; + var win_w2 = ((0 == outcome) ? (parseInt(m.a_weight_sum) || 0) : (parseInt(m.b_weight_sum) || 0)) + (is_new ? w : 0); + return parimutuel_payout(w, bet_amount, win_bets2, a_b + b_b, win_w2, fee); + } + + // ============================================================ + // Exports + // ============================================================ + var api = { + LMSR_PRECISION: LMSR_PRECISION, + LMSR_PRICE_PRECISION: LMSR_PRICE_PRECISION, + // LMSR (deterministic Q96) + lmsr_cost: lmsr_cost, + lmsr_prices: lmsr_prices, + lmsr_price: lmsr_price, + lmsr_buy_cost: lmsr_buy_cost, + lmsr_sell_return: lmsr_sell_return, + lmsr_tokens_for_amount: lmsr_tokens_for_amount, + lmsr_b_from_liquidity: lmsr_b_from_liquidity, + lmsr_max_loss: lmsr_max_loss, + lmsr_log_sum_exp: lmsr_log_sum_exp, // legacy compat + // Q96 primitives — exported for tests / advanced clients + _Q96: { ONE: ONE, HALF: HALF, LN2: LN2, exp_q: exp_q, ln_q: ln_q, mul_q: mul_q, div_q: div_q, msb: msb, lse_q: lse_q }, + // Parimutuel (already integer) + parimutuel_winners_pool: parimutuel_winners_pool, + parimutuel_payout: parimutuel_payout, + market_fee_permille: market_fee_permille, + parimutuel_estimate: parimutuel_estimate + }; + + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } + for (var key in api) { root[key] = api[key]; } + root.MarketMath = api; + +})(typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this)); diff --git a/.qoder/plans/pm-plan/security-threat-model.md b/.qoder/plans/pm-plan/security-threat-model.md new file mode 100644 index 0000000000..83912f6701 --- /dev/null +++ b/.qoder/plans/pm-plan/security-threat-model.md @@ -0,0 +1,296 @@ +# Onix Protocol: Security and Threat Model + +**Purpose:** Threat model for the Onix Protocol prediction market system. Structured for security audit review. Each threat follows: Attack → Preconditions → Impact → Mitigation → Residual Risk. + +--- + +## 1. Asset Inventory + +| Asset | Location | Value at Risk | +|-------|----------|--------------| +| User bet balances | Market reserves (CPMM/LMSR) | Full bet amount | +| Oracle insurance deposits | Oracle account balance | Min 5,000 VIZ per oracle | +| LP principal | Market reserves | Full deposit amount | +| LP fee earnings | Fee pool (computed at resolution) | Variable (losers' pool × fee‰) | +| Dispute escrow | Dispute record | 1,000 VIZ per dispute | +| Lazy Pool deposits | Pool free balance + market allocations | Total pool value | +| DAO fund | System account | Accumulated fees and penalties | + +## 2. Trust Assumptions + +| Assumption | Scope | Consequence if Violated | +|------------|-------|------------------------| +| CPMM `x * y = k` invariant holds through all operations | Core protocol | LP principal guarantee fails | +| `floor()` rounding is consistent across all fee calculations | Core protocol | Rounding dust exploits | +| Oracle insurance ≥ potential manipulation profit | Economic security | Oracle manipulation becomes rational | +| Dispute resolver acts honestly within 14 days | Dispute system | Auto-close fallback activates (bounded damage) | +| Validators validate all `pm_*` operations correctly | VIZ DLT consensus | State divergence between nodes | +| Virtual operations are deterministic across all nodes | VIZ DLT consensus | Consensus fork | + +## 3. Threat Actors + +| Actor | Motivation | Capabilities | +|-------|-----------|-------------| +| **Malicious Oracle** | Profit from misresolution | Controls resolution outcome; can create alt accounts for self-betting | +| **Griefing Bettor** | Disrupt oracle/market operations | Can place bets, file disputes, inflate risk scores | +| **Compromised Resolver** | Profit or sabotage | Can issue incorrect verdicts, delay indefinitely | +| **Front-Runner** (VIZ DLT) | Extract value from pending transactions | Can observe mempool, submit transactions with favorable ordering | +| **LP Sniper** | Disproportionate fee extraction | Can deposit small amounts immediately after market creation | +| **Opportunity-Cost Attacker** | Lock Lazy Pool capital in idle markets | Can create markets as oracle, never generate volume | + +--- + +## 4. Threats + +### 4.1 Oracle Manipulation (Self-Betting + Misresolution) + +**Attack:** Oracle accepts a market, bets heavily on one side via alt accounts, then resolves the market in favor of that side regardless of the actual outcome. + +**Preconditions:** +- Oracle's potential bet winnings exceed their insurance bond +- Market has a dispute resolver, but the resolver may be slow or compromised +- Oracle can create alt accounts (sybil) + +**Impact:** Bettors on the honest side lose their full stakes. Oracle profits from misresolution minus insurance penalty. + +**Mitigation:** +- Insurance slashing: committee can seize the oracle's full insurance (up to entire bond) +- Dispute system: any bettor can challenge within 12h grace period +- Reputation scoring: dispute losses permanently damage the oracle's reliability score +- Committee ban powers: permanent or time-limited oracle ban +- Risk score system: markets where oracle insurance < total bets are flagged/hidden + +**Residual risk:** If `bet_winnings > oracle_insurance`, manipulation is economically rational. The protocol relies on the dispute resolver catching the manipulation and the insurance bond being proportional to market volume. **Markets without adequate insurance-to-volume ratio remain vulnerable.** + +### 4.2 Oracle Collusion with Resolver + +**Attack:** Oracle and dispute resolver collude. Oracle misresolves; resolver rejects any disputes (oracle was "right"), causing disputers to lose their fees. + +**Preconditions:** +- Resolver is a single account (not a genuine multisig) +- Resolver and oracle share economic interests + +**Impact:** Bettors lose stakes AND dispute fees. Oracle and resolver split profits. + +**Mitigation:** +- Recommended multisig for resolver (collegial decision) +- Platform-curated resolver whitelist (prototype) +- Delegate-curated on-chain registry (VIZ DLT) +- 14-day auto-close: if resolver is truly inactive, dispute auto-closes with refunds +- Community can vote out delegates who approve colluding resolvers + +**Residual risk:** A compromised multisig (majority of signers colluding) defeats this protection. The protocol has no algorithmic defense against resolver corruption — it relies on social accountability and governance. + +### 4.3 Risk Score Griefing + +**Attack:** Adversary inflates `total_bets` on an oracle's markets to push the global risk score (`oracle_insurance / total_bets_all_markets`) below the listing threshold (default 2.5×), hiding all of the oracle's markets from default listing. + +**Preconditions:** +- Attacker has capital to bet (locked until resolution, not necessarily lost) +- Oracle's insurance is not vastly larger than existing bets + +**Impact:** All of the target oracle's active markets are hidden from default listing. Censorship attack on competitor oracles. + +**Mitigation:** +- Markets are hidden, not deleted — "Show risky markets" checkbox reveals them +- Oracle can counter by depositing more insurance (instant, no fee) +- Attacker's capital is locked until resolution +- Bet cancellation reverses the attack effect +- Only active markets (status=1) are filtered + +**Residual risk:** Attack reduces discoverability. Sophisticated users are unaffected (they can toggle the filter), but casual users may not find the markets. **Planned mitigation:** per-market risk score floor, rate-limiting via EMA, reputation-weighted listing exemptions. + +### 4.4 Front-Running (VIZ DLT) + +**Attack:** Attacker observes a pending `pm_place_bet` in the mempool, submits their own bet first (at a better price), then allows the victim's bet to execute (at a worse price). Classic sandwich attack. + +**Preconditions:** +- VIZ DLT mempool is observable +- Attacker can submit transactions with favorable ordering (e.g., via delegate collusion or network latency advantage) + +**Impact:** Victim receives fewer tokens than expected. Attacker profits from the price difference. + +**Mitigation:** +- `min_tokens` parameter on `pm_place_bet`: rejects if tokens received < user's minimum +- `min_return` parameter on `pm_cancel_bet`: rejects if returned amount < user's minimum +- **Planned:** commit-reveal scheme (commit hash first, reveal bet after block confirmation) +- **Under evaluation:** batch auction model (collect bets over N seconds, execute at uniform clearing price) + +**Residual risk:** `min_tokens` mitigates but doesn't prevent — attacker can still extract value within the user's slippage tolerance. **Commit-reveal is essential for on-chain fairness** and is high priority for VIZ DLT implementation. + +### 4.5 LP Sniping (Time-Weight Farming) + +**Attack:** Alt accounts deposit small amounts immediately after market creation to capture disproportionate time-weighted fee shares (high `sec_to_expiration` with minimal capital). + +**Preconditions:** +- Market is newly created (maximum `sec_to_expiration`) +- No minimum LP lock period + +**Impact:** Small deposits earn disproportionate absolute fees per VIZ relative to their capital commitment. + +**Mitigation:** +- Market creator is already the first LP with maximum time-weight +- Capital is locked for the full market duration (opportunity cost) +- Fee earnings are proportional to `amount × sec_to_expiration` — small deposits earn small absolute fees +- Minimum liquidity floor (100 VIZ) prevents dust deposits from fragmenting the pool + +**Residual risk:** The attack is self-limiting (small capital = small absolute returns), but it does dilute the creator's fee share. **Planned mitigations:** minimum LP lock period, sigmoid time-weight curve, maximum time-weight multiplier cap. + +### 4.6 LP Withdrawal Timing Attack + +**Attack:** LP observes the likely outcome (based on price movement near expiration) and withdraws liquidity to avoid being in the pool for an unfavorable resolution. + +**Preconditions:** +- LP can withdraw while betting is open +- LP has information about likely outcome + +**Impact:** Remaining participants face reduced liquidity depth. LP avoids potential losses. + +**Mitigation:** +- **Hard block after betting expiration:** LP withdrawal is only allowed while `time < betting_expiration`. Once betting closes, all LP positions are locked until resolution. +- **No impermanent loss:** LP principal is safe regardless of outcome in both Onix Binary and Multi — there is no incentive to withdraw based on odds shifting. +- **Time-ratio penalty:** Early exit receives only `time_ratio`-discounted fees. +- **Minimum liquidity floor:** Cannot reduce `liquidity_sum` below 100 VIZ. +- **Reserve depletion guard:** Cannot withdraw if reserves would reach 0. + +**Residual risk:** During the betting period (before expiration), LPs can still withdraw. However, since LP principal is guaranteed regardless of outcome, the rational motivation to withdraw is limited to opportunity cost, not loss avoidance. + +### 4.7 Dispute Denial-of-Resolution + +**Attack:** Compromised or inactive resolver never acts on disputes, freezing all payouts indefinitely. + +**Preconditions:** +- Resolver is inactive, compromised, or deliberately stalling +- Dispute is filed and payouts are frozen + +**Impact:** All funds (bets + LP) frozen for the duration of the dispute. + +**Mitigation:** +- **14-day auto-close:** `dispute_auto_close_days` guarantees disputes are resolved automatically if the resolver is inactive +- Auto-close: all bets and LP refunded, oracle penalized, disputer's fee returned +- Oracle is penalized even in auto-close (the dispute wouldn't exist without a questionable resolution) + +**Residual risk:** Funds are frozen for up to 14 days. This is a bounded inconvenience, not a permanent attack. The 14-day parameter is delegate-adjustable. + +### 4.8 Oracle Insurance Depletion + +**Attack:** Multiple simultaneous disputes across different markets drain the oracle's insurance to zero. Subsequent disputes have no insurance to slash. + +**Preconditions:** +- Oracle has many active markets +- Multiple disputes filed simultaneously + +**Impact:** Later disputes have reduced or zero reward pool (insurance exhausted). Dispute incentives break down. + +**Mitigation:** +- Insurance checks use current balance at dispute resolution time +- `reward_pool = min(dispute_fee × multiplier, oracle_insurance)` — capped at available insurance +- Oracle cannot withdraw insurance while active markets exist +- Low insurance triggers risk score warnings and listing filters + +**Residual risk:** An oracle with many markets and marginal insurance can have their economic security diluted across disputes. **Users should evaluate the oracle's insurance-to-total-volume ratio** (shown as risk score) before betting. + +### 4.9 Cancellation Slippage Exploitation + +**Attack:** Not a protocol attack, but a user-experience risk. Users cancel bets after price movement and receive significantly less than their original stake, perceiving this as a platform error. + +**Preconditions:** +- Market price has moved since the user's bet (other bets placed) +- User cancels expecting a full refund + +**Impact:** User receives less than original stake. Generates support tickets and trust erosion. + +**Mitigation:** +- Mandatory confirmation modal showing exact return amount, slippage %, and loss +- Red loss warning when slippage exceeds 2% +- `min_return` parameter prevents cancellation if price moved since preview +- Explicit explanation: "This is price movement (slippage), not a platform error" + +**Residual risk:** Users who don't read the modal may still be surprised. This is inherent to market-priced position exit and exists in every AMM and exchange. + +### 4.10 Lazy Pool Opportunity-Cost Attack + +**Attack:** Malicious oracle creates N long-duration markets with subjective questions, attracting lazy pool allocations that lock pool capital in zero-volume markets. + +**Preconditions:** +- Oracle can create markets that the pool auto-allocates to +- Markets have long expiration and generate no betting volume + +**Impact:** Pool capital locked in idle markets, earning zero fees. Depositors suffer opportunity cost. + +**Mitigation:** +- **Graduated early recall:** Idle markets lose 10% allocation per 10% of duration with insufficient volume +- **Active market penalty:** 5% recursive reduction per active market from the same oracle +- **Fault penalty stamps:** Zero-volume resolutions generate stamps that reduce future allocations (expire after 10 days) +- `max_total_allocation` (70%) ensures pool always retains reserves + +**Residual risk:** Pool capital is still locked temporarily (until recall kicks in). A determined attacker can force the pool to hold ~40% of allocation on idle markets for their full duration. The graduated recall limits but does not eliminate the opportunity cost. + +### 4.11 Resolver Trust and Self-Judging + +**Attack:** Market created with `committee_id=0` (no resolver) or with a resolver controlled by the oracle. + +**Preconditions:** +- `committee_id=0` is allowed, or resolver is not genuinely independent + +**Impact:** No dispute recourse. Oracle can misresolve with impunity. + +**Mitigation:** +- **`committee_id=0` is forbidden:** Every market must have a valid resolver +- **Platform-curated whitelist:** UI only shows trusted, pre-approved resolvers +- **Default resolver:** `predict-market-resolver` multisig operated by the platform +- **No self-judging:** Resolver is always a third party with no financial stake in market outcome + +**Residual risk:** In the prototype, the whitelist is centrally managed. On VIZ DLT, it transitions to a delegate-curated on-chain registry. The trust anchor shifts from platform admin to elected delegates. + +### 4.12 VIZ DLT Consensus Security + +**Attack:** Malicious delegate(s) include invalid `pm_*` operations or produce incorrect virtual operations. + +**Preconditions:** +- Attacker controls one or more delegates (validators) +- Other nodes do not validate correctly + +**Impact:** Incorrect market state, invalid payouts, consensus fork. + +**Mitigation:** +- Every `pm_*` operation is validated by **every** node, not just the validator +- Invalid operations are rejected at block inclusion — a single corrupt delegate cannot force invalid state +- Virtual operations are deterministic (same code, same state → same output on every node) +- Stakeholders can vote out compromised delegates + +**Residual risk:** If a **majority** of delegates collude, they could theoretically alter consensus rules via a coordinated hard fork. This is the fundamental DPoS trust assumption — identical to EOS, Hive, Tron, and every other DPoS chain. + +--- + +## 5. Security Invariants + +The following invariants should be verified in a security audit: + +| Invariant | Scope | Verification | +|-----------|-------|-------------| +| CPMM `k = reserve_a × reserve_b` maintained through all bet/cancel operations | Onix Binary | k only changes on liquidity add/withdraw | +| `reserve_a + reserve_b ≥ initial_liquidity` after any sequence of bets | Onix Binary (LP safety) | AM-GM inequality | +| LP subsidy returned unconditionally at resolution | Onix Multi | Subsidy is architecturally separate from payout pool | +| `Σ price(i) = 1` for all market states | Onix Multi | Softmax property | +| No integer overflow/underflow in fee calculations | All | All amounts use `intval()` and milli-VIZ precision | +| Time penalty applies only to profit, never principal | All | `net_payout ≥ bet_amount` for all winners | +| Dispute recalculation produces identical results to fresh resolution with correct outcome | Dispute system | Mathematical equivalence | +| LP withdrawal cannot deplete reserves below zero | Liquidity | Safety check: `new_reserve_a > 0 && new_reserve_b > 0` | +| Oracle insurance cannot be withdrawn while active markets exist | Oracle | Enforced in `oracle-withdraw-insurance` | +| Multiple disputes cannot drain insurance below zero | Oracle | Insurance checks use current balance | +| `committee_id=0` markets cannot be created | Resolver trust | Enforced at market creation | +| Bet cancellation cannot return more than total reserves | Cancellation | Safety floor: `amount_returned = max(0, ...)` | +| All `floor()` rounding is consistent; dust goes to DAO fund | Fee calculation | Verified per payout cycle | +| Virtual operations produce identical results on all nodes | VIZ DLT | Deterministic consensus code | + +--- + +## 6. Audit Trail + +Every state-changing operation on a market is recorded in the `market_log` table with before/after snapshots: + +- Fields: reserves, k value, liquidity sum, operation type, amounts, user IDs +- Logged actions: `bet`, `cancel`, `liquidity_add`, `liquidity_withdraw`, `accept`, `reject`, `resolution`, `dispute`, `dispute_resolve`, `payout`, `penalty` +- Purpose: complete, immutable audit trail for dispute investigation and post-mortem analysis +- On VIZ DLT: all operations and virtual operations appear in the block stream, providing native auditability diff --git a/.qoder/plans/pm-plan/viz-dlt-prediction-market-protocol-spec.md b/.qoder/plans/pm-plan/viz-dlt-prediction-market-protocol-spec.md new file mode 100644 index 0000000000..5d1bdb9681 --- /dev/null +++ b/.qoder/plans/pm-plan/viz-dlt-prediction-market-protocol-spec.md @@ -0,0 +1,464 @@ +# Onix Prediction Market — VIZ DLT Protocol Specification + +> Node-side (consensus-level) design for implementing the Onix Protocol as **first-class operations and ChainBase objects** on VIZ DLT — not `custom_json`, not smart contracts. Grounded in the VIZ node docs: [data-types](../viz-cpp-node/docs/protocol/data-types.md), [operations overview](../viz-cpp-node/docs/protocol/operations/overview.md), [escrow](../viz-cpp-node/docs/protocol/operations/escrow.md), [committee](../viz-cpp-node/docs/governance/committee.md), [chain-properties](../viz-cpp-node/docs/governance/chain-properties.md), [database-schema](../viz-cpp-node/docs/advanced/database-schema.md), [virtual-operations](../viz-cpp-node/docs/protocol/virtual-operations.md), [plugin-development](../viz-cpp-node/docs/development/plugin-development.md). +> +> Settlement model (both market types): **the AMM assigns weights (CPMM binary / LMSR multi); losers fund winners pro-rata by weight** — see [betting-rules](betting-rules-and-system-overview.md), [plan_unified_parimutuel_binary](plan_unified_parimutuel_binary.md). Front-running mitigation (instant default + opt-in batch/commit-reveal): [plan_batch_commit_reveal_betting](plan_batch_commit_reveal_betting.md). + +## 0. Conventions + +- **Amounts:** `asset` (VIZ, 3 decimals) in operation params; stored internally as `share_type` (int64 satoshi). Permille fees are `uint16` basis-of-1000. +- **Accounts:** `account_name_type` (≤16 bytes). **Stake weight** for any voting = `effective_vesting_shares = vesting_shares − delegated_vesting_shares + received_vesting_shares` (same as committee voting). +- **Auth levels** (mirroring VIZ): financial actions → `active`; stake-weighted dispute voting → `regular` (same as `committee_vote_request`). +- **Time:** `time_point_sec`. Deterministic deadlines processed during block application via `by_