diff --git a/.qoder/docs/onix-protocol-paper-ru.md b/.qoder/docs/onix-protocol-paper-ru.md index 3fd104a67e..0d5bfd6df8 100644 --- a/.qoder/docs/onix-protocol-paper-ru.md +++ b/.qoder/docs/onix-protocol-paper-ru.md @@ -137,7 +137,7 @@ $$W = S_{\text{lose}} - f_{\text{oracle}} - f_{\text{creator}} - f_{\text{liq}}$ $$q_0 \xrightarrow{\text{оракул принимает}} q_1 \xrightarrow{t \geq t_{\text{bet}}} q_2 \xrightarrow{\text{оракул разрешает}} q_3 \xrightarrow{\text{льготный период}} \text{выплачено}$$ -с путём отклонения $q_0 \xrightarrow{\text{оракул отклоняет}} q_{-1}$ (удалён, ликвидность возвращена). +Из $q_0$ ведут два пути в терминальное удалённое состояние $q_{-1}$, оба возвращают начальную ликвидность создателю (антиспам-комиссия за создание, уже уплаченная в фонд DAO при создании, *не* возвращается): явное отклонение $q_0 \xrightarrow{\text{оракул отклоняет}} q_{-1}$ и *таймаут* $q_0 \xrightarrow{t \geq t_0 + \tau_{\text{accept}}} q_{-1}$, срабатывающий поблочным кроном, когда оракул не принимает и не отклоняет рынок в течение заданного управлением окна акцепта $\tau_{\text{accept}}$ (по умолчанию один час). Таймаут ограничивает, как долго неотвечающий оракул может удерживать начальный капитал создателя. --- @@ -304,6 +304,8 @@ $$a_{\text{alloc}} = B_{\text{free}} \cdot \alpha / 100$$ при условиях $a_{\text{alloc}} \geq a_{\min}$ и $B_{\text{alloc}} + a_{\text{alloc}} \leq B_{\text{total}} \cdot \alpha_{\max} / 100$, где $\alpha$ — процент размещения на рынок, а $\alpha_{\max}$ — потолок суммарного размещения. +Размещение дополнительно ограничено **порогом вознаграждения**: пул предоставляет капитал рынку, только если его LP-комиссия $f_{\text{liq}}$ достигает управляемого минимума $f_{\text{liq}}^{\min}$ (по умолчанию $2\%$). Рынок, предлагающий LP меньше этого, не окупает альтернативные издержки пула, поэтому пул размещает ноль, и единственная глубина рынка — собственный сид создателя. Это делает LP-комиссию явной ценой, которую рынок платит за автоматическую пуловую ликвидность, и не даёт пулу субсидировать рынки, настроенные направлять всю комиссионную выручку мимо поставщиков ликвидности. + Формула размещения включает поправки на качество оракула: $$a_{\text{alloc}} = B_{\text{free}} \cdot \frac{\alpha}{100} \cdot (1 - \beta)^{n_o} \cdot (1 - \gamma)^{f_o}$$ @@ -397,7 +399,7 @@ $$\Omega = \lambda m \,(1 + r),$$ Эти две комиссии входят в систему в разных точках, и их нельзя смешивать. **Процентная комиссия** $f_{\text{oracle}}$ удерживается из $S_{\text{lose}}$ при разрешении и фигурирует в денежном потоке Теоремы 1 (§5.2). **Фиксированная комиссия** $f_{\text{fixed}}$ — это прямой перевод создатель→оракул, выполняемый при *принятии* рынка, до каких-либо ставок; поэтому она *не* является частью денежного потока ставок/LP — она не добавляется к начальной ликвидности $L$ и не черпается из $S_{\text{lose}}$, а значит не появляется в Теореме 1. (Она опущена в векторе комиссий $\boldsymbol{\theta}$ из §3.3 по той же причине: $\boldsymbol{\theta}$ собирает только процентные комиссии на момент разрешения, финансируемые проигравшими.) Для рынков с самооракулом перевод не происходит и $f_{\text{fixed}} = 0$. -Процесс принятия оракулом реализует механизм «оферта–котировка»: создатель публикует потолки комиссий, а оракул фиксирует свои фактические условия (ограниченные как потолком создателя, так и потолком управления) при принятии. +Процесс принятия оракулом реализует механизм «оферта–котировка»: создатель публикует потолки комиссий, а оракул фиксирует свои фактические условия (ограниченные как потолком создателя, так и потолком управления) при принятии. Принятие ограничено во времени окном $\tau_{\text{accept}}$ (§3.4): рынок, оставшийся в ожидании дольше этого срока, авто-аннулируется поблочным кроном, который возвращает начальную ликвидность создателя, удерживая антиспам-комиссию за создание, — так неотвечающий оракул не может бесконечно замораживать сид. ### 7.2 Двухрежимная система споров diff --git a/.qoder/docs/onix-protocol-paper-ru.pdf b/.qoder/docs/onix-protocol-paper-ru.pdf index 95a7b4a9ae..f0300b9d70 100644 Binary files a/.qoder/docs/onix-protocol-paper-ru.pdf and b/.qoder/docs/onix-protocol-paper-ru.pdf differ diff --git a/.qoder/docs/onix-protocol-paper.md b/.qoder/docs/onix-protocol-paper.md index 55cdb2f0ef..aba1f723b4 100644 --- a/.qoder/docs/onix-protocol-paper.md +++ b/.qoder/docs/onix-protocol-paper.md @@ -136,7 +136,7 @@ The lifecycle of $\mathcal{M}$ follows a finite state machine: $$q_0 \xrightarrow{\text{oracle accepts}} q_1 \xrightarrow{t \geq t_{\text{bet}}} q_2 \xrightarrow{\text{oracle resolves}} q_3 \xrightarrow{\text{grace period}} \text{paid}$$ -with a rejection path $q_0 \xrightarrow{\text{oracle rejects}} q_{-1}$ (deleted, liquidity returned). +Two paths lead out of $q_0$ back to the terminal deleted state $q_{-1}$, both returning the seed liquidity to the creator (the anti-spam creation fee, already paid to the DAO fund at creation, is *not* refunded): an explicit rejection $q_0 \xrightarrow{\text{oracle rejects}} q_{-1}$, and a *timeout* $q_0 \xrightarrow{t \geq t_0 + \tau_{\text{accept}}} q_{-1}$ triggered by the per-block cron when the oracle neither accepts nor rejects within the governance-defined acceptance window $\tau_{\text{accept}}$ (default one hour). The timeout bounds how long an unresponsive oracle can hold a creator's seed capital hostage. --- @@ -302,6 +302,8 @@ $$a_{\text{alloc}} = B_{\text{free}} \cdot \alpha / 100$$ subject to $a_{\text{alloc}} \geq a_{\min}$ and $B_{\text{alloc}} + a_{\text{alloc}} \leq B_{\text{total}} \cdot \alpha_{\max} / 100$, where $\alpha$ is the per-market allocation percentage and $\alpha_{\max}$ is the maximum total allocation cap. +Allocation is further gated by a **reward floor**: the pool provides capital to a market only if its LP fee $f_{\text{liq}}$ meets a governance minimum $f_{\text{liq}}^{\min}$ (default $2\%$). A market that offers LPs less than this earns nothing worth the pool's opportunity cost, so the pool allocates zero and the market's only depth is the creator's own seed. This makes the LP fee an explicit price the market pays for automated pool liquidity, and prevents the pool from subsidizing markets configured to route all fee revenue away from liquidity providers. + The allocation formula includes oracle quality adjustments: $$a_{\text{alloc}} = B_{\text{free}} \cdot \frac{\alpha}{100} \cdot (1 - \beta)^{n_o} \cdot (1 - \gamma)^{f_o}$$ @@ -395,7 +397,7 @@ Each oracle $o$ must maintain an insurance bond $I_o \geq I_{\min}$. The bond cr These two fees enter the system at different points and must not be conflated. The **percentage fee** $f_{\text{oracle}}$ is deducted from $S_{\text{lose}}$ at resolution and appears in the money flow of Theorem 1 (§5.2). The **fixed fee** $f_{\text{fixed}}$ is a direct creator→oracle transfer executed at market *acceptance*, before any betting; it is therefore *not* part of the betting/LP money flow — it neither adds to the seed liquidity $L$ nor draws from $S_{\text{lose}}$, and so does not appear in Theorem 1. (It is omitted from the fee vector $\boldsymbol{\theta}$ of §3.3 for the same reason: $\boldsymbol{\theta}$ collects only the resolution-time, losers-funded percentage fees.) For self-oracle markets no transfer occurs and $f_{\text{fixed}} = 0$. -The oracle acceptance flow implements an offer-quote mechanism: the creator publishes fee ceilings, and the oracle freezes its actual terms (bounded by both the creator ceiling and a governance cap) at acceptance. +The oracle acceptance flow implements an offer-quote mechanism: the creator publishes fee ceilings, and the oracle freezes its actual terms (bounded by both the creator ceiling and a governance cap) at acceptance. Acceptance is time-bounded by the window $\tau_{\text{accept}}$ (§3.4): a market left pending beyond it is auto-expired by the per-block cron, which refunds the creator's seed liquidity while retaining the anti-spam creation fee — so an unresponsive oracle cannot indefinitely freeze the seed. ### 7.2 Two-Mode Dispute System diff --git a/.qoder/docs/onix-protocol-paper.pdf b/.qoder/docs/onix-protocol-paper.pdf index e146fd777f..ad743784e5 100644 Binary files a/.qoder/docs/onix-protocol-paper.pdf and b/.qoder/docs/onix-protocol-paper.pdf differ diff --git a/.qoder/docs/pm-workflow/README.md b/.qoder/docs/pm-workflow/README.md new file mode 100644 index 0000000000..225253b6e6 --- /dev/null +++ b/.qoder/docs/pm-workflow/README.md @@ -0,0 +1,177 @@ +# Prediction Market — Workflow by Role (HF14 Onix) + +One **canonical scenario** traced through every participant. Each role has its own folder with an +interaction diagram, the **signed operations** it sends, the **virtual operations** that touch it, +and a **tokens sent / received** table for two outcomes: + +- **Normal resolve** — oracle resolves, grace passes, `pm_auto_payout` settles. No dispute. +- **Disputed resolve** — oracle resolves **A**, a dispute **overturns to B**, then settlement runs. + +All amounts are abstract **VIZ** units (ignore the 3 decimals). All percents are **bp** (10000 = 100.00%). +Settlement is strictly **zero-sum** — no tokens are ever minted; `current_supply` is untouched: + +``` +Σ winner_payout + oracle_take + creator_take + lp_bonus + LP_principal + == Σ all bet amounts + LP_principal + forfeit_pool (+ insurance slash, in a dispute) +``` + +--- + +## Canonical market **M** (binary CPMM, A vs B) + +| Item | Value | +|------|-------| +| Engine | binary CPMM (`x·y=k`), `weight = tokens_out` | +| Seed liquidity (marketmaker) | **2000** → reserves A=1000 / B=1000 | +| `oracle_fee_percent` (oracle quote) | **1000** (10%) | +| `creator_fee_percent` | **500** (5%) | +| `liquidity_fee_percent` | **500** (5%) | +| `oracle_fixed_fee` (oracle quote) | **10** | +| `dispute_penalty_percent` | **+10000** (slash up to 100% of insurance ×consensus) | + +Illustrative chain props: `pm_market_creation_fee` 5, `pm_oracle_registration_fee` 10, +`pm_min_oracle_insurance` 5000, `pm_dispute_fee` 1000, `pm_dispute_reward_multiplier` 30000 (**3×**), +`pm_no_contest_penalty_percent` 5000 (50% of dispute fee), `pm_oracle_penalty_percent` 500 (5% of +insurance on a missed deadline), `pm_lazy_emergency_penalty_percent` 5000 (50% of profit), +`pm_leverage_pool_profit_percent` **R = 10%**, `pm_lazy_alloc_percent` 2000 (20%). + +### Roster + +| Actor | Role | Stake / action | +|-------|------|----------------| +| **maker** | creator + first LP | seeds 2000 liquidity | +| **orac** | external oracle | insurance 5000; quotes fee 10% + fixed 10 | +| **LP1** | in-market liquidity provider | adds 1000 liquidity | +| **A** | bettor — winner | 100 on **A**, early; weight 100 | +| **C** | bettor — late winner | 100 on **A** at T+85%; weight 100; time-penalty **50%** | +| **B** | bettor — loser | 200 on **B**; weight 200 | +| **D** | leverage **×10** winner | collateral 10 + loan 90 (market **L**) | +| **E** | leverage **×5** liquidated | collateral 20 + loan 80 (market **L**) | +| **LZ1** | lazy-pool depositor | deposits 1000 | +| **disp** | disputer | escrows dispute fee 1000 | + +> Curve weights (100 / 100 / 200) are written explicitly to keep the parimutuel arithmetic readable; +> a real CPMM hands out slightly less weight as reserves shift. + +--- + +## Master ledger — NORMAL resolve (A wins) + +`losers_sum = 200` (B). Fees off the losers' pool: +`oracle_fee = 200×10% = 20`, `creator_fee = 200×5% = 10`, `liq_fee = 200×5% = 10`, `oracle_fixed = 10`. +`winners_pool = 200 − 20 − 10 − 10 − 10 = 150`. `Σ winning weight = 200` (A 100 + C 100). + +- **A**: profit `150×100/200 = 75`, penalty 0 → **payout 175**. +- **C**: profit 75, time-penalty `75×50% = 37` (→ LP) → **payout 138**. +- **LP bonus** = `liq_fee 10 + penalties 37 = 47`, split by time in market: **maker ~31 / LP1 ~16**. +- **oracle_take** = `oracle_fee 20 + fixed 10 = 30`. **creator_take** = `creator_fee 10`. + +| Actor | sends | receives | net (this market) | +|-------|-------|----------|-------------------| +| maker | 2000 liquidity + 5 creation-fee | 2000 principal + 10 creator-fee + 31 LP-bonus | **+36** | +| orac | (10 reg-fee, 5000 insurance locked) | 30 oracle-take | **+30** | +| LP1 | 1000 liquidity | 1000 principal + 16 LP-bonus | **+16** | +| A | 100 | 175 | **+75** | +| C | 100 | 138 | **+38** | +| B | 200 | 0 | **−200** | + +**Zero-sum:** in `= bets 400 + LP principal 3000 = 3400`; out `= 175+138+0 + 30 + 10 + 47 + 3000 = 3400`. ✔ +The 5 creation-fee + 10 reg-fee leave to the **DAO fund** (not part of the market pool). + +--- + +## Master ledger — DISPUTED resolve (oracle said A → overturned to B) + +`disp` escrows `dispute_fee 1000`. The verdict overturns to **B**; the oracle is slashed. +With `dispute_penalty_percent = 10000` and consensus strength **100%**: `slash = 5000×100%×100% = 5000`. +Reward carve-out: `reward_target = fee×3 = 3000` → `bonus = 3000 − 1000 = 2000` (≤ slash). Disputer gets +`fee 1000 + bonus 2000 = 3000`. Remainder `slash − bonus = 3000 → forfeit_pool`. + +Now **B wins**. `losers_sum = 200` (A 100 + C 100). Fees 20/10/10 + fixed 10. +`winners_pool = 200 − 50 + forfeit 3000 = 3150`. `Σ winning weight = 200` (B). +- **B**: profit `3150×200/200 = 3150` → **payout 3350**. +- **oracle_take** still `30` (the *market* fee is paid from the frozen config even when overturned — the + punishment is the **insurance slash**, separate money). **creator_take** 10. **LP bonus** = liq 10. + +| Actor | sends | receives | net (this market) | +|-------|-------|----------|-------------------| +| maker | 2000 + 5 | 2000 principal + 10 creator-fee + ~6 LP-bonus | **+11** | +| orac | insurance −**5000** slashed | 30 oracle-take | **−4970** | +| LP1 | 1000 | 1000 principal + ~4 LP-bonus | **+4** | +| A | 100 | 0 | **−100** | +| C | 100 | 0 | **−100** | +| B | 200 | 3350 | **+3150** | +| disp | 1000 dispute-fee | 3000 (fee back + 2000 reward) | **+2000** | + +**Zero-sum:** in `= bets 400 + LP principal 3000 + dispute_fee 1000 + insurance slash 5000 = 9400`; +out `= B 3350 + oracle 30 + creator 10 + lp_bonus 10 + LP principal 3000 + disputer 3000 = 9400`. ✔ +The slash 5000 splits into disputer bonus 2000 + forfeit 3000 (→ B via the winners' pool). + +--- + +## Implementation status (verified against the code) + +**Regular operations — all 21 present** in the `operation` variant (`operations.hpp`), validated + +evaluated in `pm_evaluator.cpp`: +`pm_oracle_register`, `pm_oracle_update`, `pm_create_market`, `pm_oracle_accept_market`, `pm_place_bet`, +`pm_commit_bet`, `pm_reveal_bet`, `pm_cancel_bet`, `pm_add_liquidity`, `pm_withdraw_liquidity`, +`pm_resolve_market`, `pm_no_contest`, `pm_dispute_create`, `pm_dispute_vote`, `pm_dispute_resolve`, +`pm_transfer_position`, `pm_lazy_deposit`, `pm_lazy_withdraw`, `pm_leverage_open`, `pm_leverage_close`, +`pm_leverage_convert`. ✔ + +**Virtual operations** — emitted by `database::process_pm_markets()` / the evaluators: + +| Virtual op | Fires? | Trigger (code) | +|------------|--------|----------------| +| `pm_market_accepted` | ✔ | on `pm_oracle_accept_market` (accept) **and** self-oracle `pm_create_market` | +| `pm_payout` | ✔ | **per active bet** at settle — carries `account`, `market_id`, `bet_id`, `side`/`outcome_index`, `amount` (stake), `payout` (**0 on a loss**) | +| `pm_auto_payout` | ✔ | **once per market** at settle — a summary marker (`bets_sum`) alongside the per-bet `pm_payout`s | +| `pm_commit_forfeit` | ✔ | unrevealed commit past `reveal_deadline` | +| `pm_dispute_finalize` | ✔ | committee `voting_end_time` | +| `pm_dispute_auto_close` | ✔ | `auto_close_time` (anti-freeze) | +| `pm_oracle_missed_penalty` | ✔ | oracle missed `result_expiration` | +| `pm_lazy_recall` | ✔ | idle-allocation graduated recall step | +| `pm_batch_settle` | ✔ | epoch boundary | +| `pm_leverage_liquidate` | ✔ | mid-market liquidation: reason **0** opposing-bet, **1** cancel-bet (`cascade_liquidate`) | +| `pm_leverage_resolve` | ✔ | **settlement** of a leveraged position (reason=expiration in `force_close_positions`): carries `market_id`, `outcome_index`, `won` (solvent ⇒ positive), `pool_received`/`bettor_received`, and `leverage` (= `total_bet/collateral`) | + +> A leveraged position is force-closed at its `cancel_value` at settlement: the pool takes +> `min(cv, obligation)`, the bettor gets the rest. **`pm_leverage_resolve`** marks that close (positive +> if `cv ≥ obligation`, else collateral lost); **`pm_leverage_liquidate`** is only for the *mid-market* +> opposing-bet / cancel-bet cascades. See [bettor D](bettor-d-leverage-winner/bettor-d-leverage-winner.md). + +**API plugin `prediction_market_api.*`** — **21** read methods (`prediction_market_api.hpp`): +`get_market`, `list_markets`, `list_markets_by_oracle`, `list_markets_by_creator`, `get_market_outcomes`, +`get_market_weight_sums`, `get_market_bets`, `get_account_positions`, `get_market_liquidity`, +**`get_account_leverage_positions`**, **`get_market_leverage_positions`**, **`get_creator_ban`**, +`get_oracle`, `list_oracles`, `get_dispute`, `get_dispute_votes`, `get_lazy_pool`, `get_lazy_deposit`, +`get_pm_chain_properties`, `get_market_meta`, `list_markets_by_category`. + +> Per-bettor results (`pm_payout`) and leverage settlements (`pm_leverage_resolve`) appear in +> `account_history`; leverage **positions** are now also queryable via `get_account_leverage_positions` / +> `get_market_leverage_positions`, and creator bans via `get_creator_ban`. + +--- + +## Index + +| Folder | Role | +|--------|------| +| [marketmaker/](marketmaker/marketmaker.md) | Market maker (creator + first LP) | +| [oracle/](oracle/oracle.md) | Oracle — register → accept (quote) → resolve | +| [oracle-banned/](oracle-banned/oracle-banned.md) | Banned oracle | +| [oracle-dispute-winner/](oracle-dispute-winner/oracle-dispute-winner.md) | Oracle upheld in a dispute | +| [oracle-dispute-loser/](oracle-dispute-loser/oracle-dispute-loser.md) | Oracle overturned + slashed | +| [bettor-a-winner/](bettor-a-winner/bettor-a-winner.md) | Bettor A — early winner | +| [bettor-b-loser/](bettor-b-loser/bettor-b-loser.md) | Bettor B — loser | +| [bettor-c-late-winner/](bettor-c-late-winner/bettor-c-late-winner.md) | Bettor C — late winner (time penalty) | +| [bettor-d-leverage-winner/](bettor-d-leverage-winner/bettor-d-leverage-winner.md) | Bettor D — leverage ×10 winner | +| [bettor-e-leverage-liquidated/](bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md) | Bettor E — leverage ×5 liquidated | +| [lp-market/](lp-market/lp-market.md) | Liquidity provider in the market | +| [lazy-pool/](lazy-pool/lazy-pool.md) | The lazy liquidity pool itself | +| [lp-lazy-pool/](lp-lazy-pool/lp-lazy-pool.md) | Liquidity provider in the lazy pool | +| [disputer-winner/](disputer-winner/disputer-winner.md) | Disputer — vindicated | +| [disputer-loser/](disputer-loser/disputer-loser.md) | Disputer — fee forfeited | +| [resolver-committee/](resolver-committee/resolver-committee.md) | Committee resolver (stake-weighted) | +| [resolver-account/](resolver-account/resolver-account.md) | Account-mode resolver (centralized) | +| [dispute-auto-close/](dispute-auto-close/dispute-auto-close.md) | Dispute forced to end (anti-freeze) | diff --git a/.qoder/docs/pm-workflow/bettor-a-winner/bettor-a-winner.md b/.qoder/docs/pm-workflow/bettor-a-winner/bettor-a-winner.md new file mode 100644 index 0000000000..af04133550 --- /dev/null +++ b/.qoder/docs/pm-workflow/bettor-a-winner/bettor-a-winner.md @@ -0,0 +1,52 @@ +# Bettor A — early winner + +Part of [PM Workflow](../README.md). **A** stakes **100 on side A early** (no time penalty) and wins +when the market resolves to A. Payout = stake back + a weight-proportional share of the winners' pool. + +## Interaction diagram + +```mermaid +flowchart LR + A -->|pm_place_bet side=A 100| BET[(pm_bet_object
weight 100)] + BET --> M[(market M
reserves shift)] + M ==>|grace passes| VP[[pm_auto_payout]] + VP -->|payout 175| A +``` + +## Operations it sends (signed) +- `pm_place_bet` — `side=0 (A)`, `amount=100`, `mode=0` (instant). Debits 100; mints `weight` from the CPMM. +- (optional) `pm_transfer_position` to reassign part of the weight; `pm_cancel_bet` if the market allows it. + +## Virtual operations that touch it +- `pm_auto_payout` — credits the final payout once the dispute grace elapses with no open dispute. + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| 100 | **175** | **+75** | + +`profit = winners_pool 150 × weight 100 / Σweight 200 = 75`; no time penalty → `payout = 100 + 75`. + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| 100 | **0** | **−100** | + +The overturn makes A the **losing** side; A's stake funds the new winners' pool. (A could itself file +the dispute if it believed A was correct — see [disputer-winner](../disputer-winner/disputer-winner.md) / [disputer-loser](../disputer-loser/disputer-loser.md).) + +## Notes +- Winnings come **only** from losers' stakes (+ forfeit pool), never from emission. +- If **A had bet late**, its profit would be docked by the time penalty — that's [bettor C](../bettor-c-late-winner/bettor-c-late-winner.md). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_place_bet` (instant, CPMM weight, `allow_instant_bet` gate) | ✔ | `pm_place_bet_evaluator` | +| `pm_transfer_position` / `pm_cancel_bet` (optional) | ✔ | their evaluators | +| winner credit at settle | ✔ | `settle_market` pays `amount + profit − penalty` via `adjust_balance` | +| vop `pm_payout` (per bet) | ✔ | carries `amount` (100), `side` (A), `payout` (175) | +| vop `pm_auto_payout` | ✔ | one per market (summary marker) | + +**Observe via plugin:** `get_account_positions` (your bets + `expected_payout` / `expected_payout_approx`), +`get_market_bets`, `get_market_weight_sums` (denominator for your share); the realized `pm_payout` is in `account_history`. diff --git a/.qoder/docs/pm-workflow/bettor-b-loser/bettor-b-loser.md b/.qoder/docs/pm-workflow/bettor-b-loser/bettor-b-loser.md new file mode 100644 index 0000000000..ea4fb7ddaa --- /dev/null +++ b/.qoder/docs/pm-workflow/bettor-b-loser/bettor-b-loser.md @@ -0,0 +1,50 @@ +# Bettor B — loser + +Part of [PM Workflow](../README.md). **B** stakes **200 on side B**. When the market resolves to **A**, +B's stake funds the winners and B receives **nothing**. (In the disputed path B becomes the winner.) + +## Interaction diagram + +```mermaid +flowchart LR + B -->|pm_place_bet side=B 200| BET[(pm_bet_object
status active)] + BET --> M[(market M)] + M ==>|grace passes| VP[[pm_auto_payout]] + VP -->|status=resolved, payout 0| BET +``` + +## Operations it sends (signed) +- `pm_place_bet` — `side=1 (B)`, `amount=200`, `mode=0`. Debits 200. + +## Virtual operations that touch it +- `pm_auto_payout` — marks the losing bet `status=resolved`, `resolved_amount=0` (no credit). + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| 200 | **0** | **−200** | + +B's 200 **is** the `losers_sum`: it pays the fees (40) and the winners' pool (150 → A 75 + C 75) plus +LP bonus. Loser gets nothing back — this is the parimutuel "losers fund winners" rule. + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| 200 | **3350** | **+3150** | + +The overturn makes **B the winning side**; now A + C are the losers (200) and the oracle's slashed +insurance (forfeit 3000) is injected into B's winners' pool. `payout = 200 + 3150`. + +## Notes +- A loser who believes the oracle is wrong can dispute. If vindicated it becomes this very B-wins + ledger; if not, it also loses the dispute fee — see [disputer-loser](../disputer-loser/disputer-loser.md). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_place_bet` | ✔ | `pm_place_bet_evaluator` | +| losing bet → `status=resolved`, `resolved_amount=0` (no credit) | ✔ | `settle_market` (loser branch) | +| vop `pm_payout` (per bet, **payout=0**) | ✔ | losers also get a record — `amount` (200), `side` (B), `payout` 0 | + +**Observe via plugin:** `get_account_positions` (your bet shows `expected_payout=0` once resolved), +`get_market_bets` (status flips to resolved), `account_history` (`pm_payout` with payout 0). Dispute path via `get_dispute`. diff --git a/.qoder/docs/pm-workflow/bettor-c-late-winner/bettor-c-late-winner.md b/.qoder/docs/pm-workflow/bettor-c-late-winner/bettor-c-late-winner.md new file mode 100644 index 0000000000..c8cd75cd6c --- /dev/null +++ b/.qoder/docs/pm-workflow/bettor-c-late-winner/bettor-c-late-winner.md @@ -0,0 +1,55 @@ +# Bettor C — late winner (time penalty) + +Part of [PM Workflow](../README.md). **C** stakes **100 on side A** but **late** — at T+85% of the +betting window — so a **time penalty** is applied to its *profit only* (not its principal). C still +wins when A resolves, but earns less than the equally-weighted early [bettor A](../bettor-a-winner/bettor-a-winner.md); +the docked amount flows to the LPs. + +## Interaction diagram + +```mermaid +flowchart LR + C -->|pm_place_bet side=A 100
at T+85%| BET[(pm_bet_object
weight 100
time_penalty 50%)] + BET --> M[(market M)] + M ==>|grace passes| VP[[pm_auto_payout]] + VP -->|payout 138| C + VP -. penalty 37 .-> LPb[LP bonus] +``` + +## Operations it sends (signed) +- `pm_place_bet` — `side=0 (A)`, `amount=100`, `mode=0`. The node stamps `time_penalty` from the + market's penalty curve at placement time (1e6-scaled). Here ≈ **50%**. + +## Virtual operations that touch it +- `pm_auto_payout` — credits `amount + profit − penalty`; the penalty is added to the LP bonus. + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| 100 | **138** | **+38** | + +`profit = 150 × 100/200 = 75`; `penalty = profit 75 × 50% = 37` (→ LPs); `payout = 100 + 75 − 37 = 138`. +Same weight as A, but **−37** vs A's +75 because of the late entry. + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| 100 | **0** | **−100** | + +Overturned to B → C is a loser; the time penalty is moot (penalties apply only to a winner's profit). + +## Notes +- The penalty discourages last-second sniping of near-certain outcomes; it subsidises liquidity, not the protocol. +- Penalty curve + scale are market config (`time_penalty_type/value`, `penalty_curve_type`). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_place_bet` stamps `time_penalty` at placement | ✔ | `pm_place_bet_evaluator` (curve eval) | +| penalty applied to **profit only**, docked amount → LP bonus | ✔ | `compute_settlement` / `settle_market` | +| winner credit `amount + profit − penalty` | ✔ | `settle_market` `adjust_balance` | +| vop `pm_payout` (per bet) | ✔ | `amount` 100, `side` A, `payout` 138 (penalty already netted) | +| vop `pm_auto_payout` | ✔ | one per market (summary) | + +**Observe via plugin:** `get_account_positions` (your bet's `time_penalty` + reduced `expected_payout`), +`get_market_bets` (the stored `time_penalty`); the realized `pm_payout` in `account_history`. diff --git a/.qoder/docs/pm-workflow/bettor-d-leverage-winner/bettor-d-leverage-winner.md b/.qoder/docs/pm-workflow/bettor-d-leverage-winner/bettor-d-leverage-winner.md new file mode 100644 index 0000000000..efb68661b4 --- /dev/null +++ b/.qoder/docs/pm-workflow/bettor-d-leverage-winner/bettor-d-leverage-winner.md @@ -0,0 +1,71 @@ +# Bettor D — leverage ×10 winner + +Part of [PM Workflow](../README.md). **D** opens a **×10** boosted position: **10 collateral + 90 loan** +from the [lazy pool](../lazy-pool/lazy-pool.md) = **100** staked on side A. Shown in an isolated +leverage market **L** (binary, `pm_leverage_enabled=true`, `R = 10%`) so the loan/interest math stays +clean. When A wins, D keeps the upside on the full 100 after repaying the pool its loan + interest. + +Key figures: `pool_profit = loan 90 × R 10% = 9`; `liquidation_threshold (obligation) = 90 × 1.10 = 99`. + +## Interaction diagram + +```mermaid +flowchart LR + D -->|pm_leverage_open
collateral 10 + loan 90| POS[(pm_leverage_position
total_bet 100, obligation 99)] + POOL[(lazy pool)] -.loan 90.-> POS + POS --> L[(market L, side A)] + L ==>|settle: force_close at cancel_value| VR[[pm_leverage_resolve won=true, leverage=10]] + VR -->|min(cv,obligation) 99| POOL + VR -->|cv 200 − 99 = 101| D +``` + +## Operations it sends (signed) +- `pm_leverage_open` — `collateral 10`, `loan 90`, `outcome_index 0 (A)`, `min_tokens`, `max_slippage_percent`. + Pool: `free_balance −= 90`, `leverage_fund_used += 90`. +- (optional) `pm_leverage_close` — voluntary, only if `cancel_value ≥ obligation`; or + `pm_leverage_convert` — pay off the loan + a profit-share fee to turn it into a plain bet. + +## Virtual operations that touch it +- `pm_leverage_resolve` at settlement — `force_close_positions` unwinds the position at its + `cancel_value`; the pool takes `min(cv, obligation) = 99` (loan 90 + interest 9), the bettor gets + `cv − pool_received`. The vop carries `won` (solvent ⇒ true), `outcome_index`, `pool_received`, + `bettor_received`, and **`leverage`** (= `total_bet / collateral` = 10). +- `pm_leverage_liquidate` — only if the position is liquidated **mid-market** (opposing bet / cancel + cascade) before settlement — the losing path of [bettor E](../bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md). + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| collateral **10** | cancel_value 200 − obligation 99 = **101** | **+91** | + +A profitable position closes at its `cancel_value` (here 200 — the curve value of its A-tokens after the +market moved its way). The pool reclaims its `obligation 99` (loan 90 + **9 interest**); D keeps the rest +on just 10 of its own → **+91**. Pool net: **+9**. (Not a parimutuel share — leverage settles by +liquidation, never via `pm_auto_payout`.) + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| collateral 10 | 0 | **−10** | + +The overturn flips A→B, so the winning position becomes a **loss**: it is settled lost, the pool is +repaid from D's collateral first, and D forfeits its 10. (Leverage magnifies both directions; the +`expiration_buffer` normally force-closes positions before a contested resolution can flip them.) + +## Notes +- Zero-sum (market L): in `= D 10 + pool 90 + counterparty 100 = 200`; out `= D 101 + pool 99 = 200`. ✔ +- The pool only ever fronts `pm_leverage_fund_percent` of its `free_balance`, capped per position. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_leverage_open` (collateral + pool loan, obligation, kill-switch) | ✔ | `pm_leverage_open_evaluator` | +| `pm_leverage_close` (voluntary, `cv ≥ obligation`) | ✔ | `pm_leverage_close_evaluator` | +| `pm_leverage_convert` (pay loan + fee → normal bet) | ✔ | `pm_leverage_convert_evaluator` | +| settlement = force-close at `cancel_value` | ✔ | `force_close_positions` → `liquidate_position(reason=2)` | +| vop `pm_leverage_resolve` (settlement) | ✔ | carries `market_id`, `outcome_index` (A), `won=true`, `pool_received` 99, `bettor_received` 101, `leverage` 10 | +| vop `pm_leverage_liquidate` | — | only for *mid-market* opposing/cancel cascades, not this settlement | + +**Observe via plugin:** **`get_account_leverage_positions(D, …)`** / **`get_market_leverage_positions(L, …)`** +(your `pm_leverage_position`: `collateral`, `loan`, `liquidation_threshold`, `status`, `bettor_received`); +the realized close is the `pm_leverage_resolve` vop in `account_history`; pool via `get_lazy_pool`. diff --git a/.qoder/docs/pm-workflow/bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md b/.qoder/docs/pm-workflow/bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md new file mode 100644 index 0000000000..23564ef0b3 --- /dev/null +++ b/.qoder/docs/pm-workflow/bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md @@ -0,0 +1,87 @@ +# Bettor E — leverage ×5 liquidated + +Part of [PM Workflow](../README.md). **E** opens a **×5** position: **20 collateral + 80 loan** = **100** +on side B (market **L**, `R = 10%`). Before resolution an **opposing bet** (someone backs A) moves the +curve against B; E's `cancel_value` reaches its liquidation threshold and the **cascade liquidation** +force-closes it. **E loses its collateral, but the pool is always made whole** — it recovers the loan +plus its profit. By design, an opposing-bet liquidation can **never** go negative for the pool +(strategy doc §6 "cancel_value < threshold — mathematically impossible during the betting period"). + +Key figures: `obligation = liquidation_threshold = 80 × 1.10 = 88`. The opposing bet is bounded by the +slippage cap, and the position was opened with a safety margin (Constraint 2), so at liquidation +`cancel_value ≥ loan` always — here it triggers right at `cancel_value = 88`. + +## Interaction diagram + +```mermaid +flowchart LR + E -->|pm_leverage_open
collateral 20 + loan 80| POS[(pm_leverage_position
obligation 88)] + POOL[(lazy pool)] -.loan 80.-> POS + X -->|pm_place_bet side=A| L[(market L)] + L ==>|cascade at PRE-bet reserves
cv 88 ≤ threshold| VL[[pm_leverage_liquidate
reason=opposing_bet]] + VL -->|pool_received 88 = loan 80 + profit 8| POOL + VL -->|bettor_received 0| E +``` + +## Operations it sends (signed) +- `pm_leverage_open` — `collateral 20`, `loan 80`, `outcome_index 1 (B)`. Pool: `free −= 80`, `leverage_fund_used += 80`. +- E sends nothing further — liquidation is **involuntary**, triggered by another account's bet. + +## Virtual operations that touch it +- `pm_leverage_liquidate` (`reason = opposing_bet`) — force-closes the position at the current reserves: + `pool_received = min(cancel_value, obligation) = 88`, `bettor_received = cancel_value − pool_received = 0`. + (A position that instead survives to settlement is closed by `pm_leverage_resolve` — [bettor D](../bettor-d-leverage-winner/bettor-d-leverage-winner.md).) + +## Tokens — outcome is the same in both resolve paths +E is liquidated **before** resolution, so the final A/B result (disputed or not) doesn't change E: + +| actor | sends | receives | net | +|-------|-------|----------|-----| +| **E** | collateral **20** | **0** | **−20** | +| **pool** | loan 80 | **88** (loan 80 + R% profit 8) | **+8** | + +The pool recovers its full obligation (`min(cancel_value, obligation)`); since `cancel_value ≥ loan` for +opposing-bet liquidations, the pool **never** takes a loss — it gets the loan back plus its profit. E +loses its collateral (the liquidated position pays the bettor `cancel_value − obligation = 0`). + +## Edge case — the ONLY path that can go negative: cancel-bet (Case B) +There is exactly one bounded exception where the pool can take a shortfall — a **`pm_cancel_bet` on the +same side** as a leveraged position (strategy doc §5 Case B). A cancel reverses a *prior, large* same-side +bet, which can move the curve by more than the per-bet slippage cap allows. For **fairness to the +cancel-bettor**, the cancel executes FIRST (at the price they submitted), then the cascade liquidates — +so `cancel_value` can land **below the loan**: + +``` +shortfall = obligation − cancel_value (if cancel_value < obligation) +pool_received = cancel_value (e.g. 70 < loan 80) +bettor_received = 0 +lazy_pool.free_balance −= shortfall (bad debt — bounded by SL%, rare) +``` + +This bad debt is **bounded** (`shortfall ≤ cancel_value_before × SL%`) and **rare** (the pool's R% on +every other position more than offsets it). It is the *only* negative-liquidation path; it is covered +on-chain by the `leverage_cancel_bet_cascade_bad_debt` test. Opposing-bet liquidations (this page) and +settlement force-close ([bettor D](../bettor-d-leverage-winner/bettor-d-leverage-winner.md)) are always +full-recovery. + +## Notes +- Pool protection is structural: `max_per_position`, `max_position_ratio`, `safety_margin`, the slippage + cap (`max_slippage_percent`), and the `expiration_buffer`. `pm_leverage_enabled=false` blocks **new** + opens, but the liquidation cascade is **not** gated by the flag — if delegates toggle leverage off + while positions are open, those positions are still liquidated/settled normally, so governance can + never strip the pool's protection mid-flight. Covered by `leverage_disabled_keeps_liquidation_protection`. +- A *solvent* outcome (`cancel_value > obligation`) instead pays E `cancel_value − obligation` and the + pool its interest — the winning side of the same mechanic shown in [bettor D](../bettor-d-leverage-winner/bettor-d-leverage-winner.md). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_leverage_open` | ✔ | `pm_leverage_open_evaluator` | +| opposing-bet liquidation at **pre-bet** reserves (`cv ≥ loan` ⇒ pool whole) | ✔ | `pm_place_bet` → `cascade_liquidate(reason=0)` before the bet applies | +| `pool_received = min(cv, obligation)`, `bettor_received = cv − pool_received` | ✔ | `liquidate_position` | +| bad debt **only** for cancel-bet (Case B) | ✔ | `pm_cancel_bet` → `cascade_liquidate(reason=1)` after the cancel | +| vop `pm_leverage_liquidate` (`reason` 0 opposing / 1 cancel) | ✔ | `liquidate_position` | + +**Observe via plugin:** **`get_account_leverage_positions(E, …)`** / **`get_market_leverage_positions(L, …)`** +(position `status=1` liquidated, `cancel_value_at_liquidation`, `pool_received`, `bettor_received`); the +event is the `pm_leverage_liquidate` vop in `account_history`; pool impact via `get_lazy_pool`. diff --git a/.qoder/docs/pm-workflow/dispute-auto-close/dispute-auto-close.md b/.qoder/docs/pm-workflow/dispute-auto-close/dispute-auto-close.md new file mode 100644 index 0000000000..7847a95ef5 --- /dev/null +++ b/.qoder/docs/pm-workflow/dispute-auto-close/dispute-auto-close.md @@ -0,0 +1,56 @@ +# Dispute forced to end (anti-freeze auto-close) + +Part of [PM Workflow](../README.md). A dispute that is **never decided** — the oracle stays silent and +(committee mode) no quorum forms — cannot freeze the market forever. At `auto_close_time` the +`pm_dispute_auto_close` cron force-ends it: **everyone is refunded**, the disputer's fee is **returned**, +and the unresponsive oracle is penalised. No winner is picked. + +## Interaction diagram + +```mermaid +flowchart LR + disp -->|pm_dispute_create
escrow fee 1000| D[(dispute, status open)] + D -. oracle silent / no quorum .-> WAIT[auto_close_time reached] + WAIT ==>|VIRTUAL| AC[[pm_dispute_auto_close]] + AC -->|refund all bets| bettors + AC -->|fee 1000 back| disp + AC -->|insurance slash → DAO| orac +``` + +## Operations +- None at close — it is entirely cron-driven. The only signed op was the original `pm_dispute_create`. + +## Virtual operations +- `pm_dispute_auto_close` — full refund of every active bet, LP principal returned, **disputer fee + refunded**, oracle insurance slashed → DAO (`disputes_auto_closed++`, `dispute_responses_missed++`). + +## Tokens — forced end (the only path here) +| Actor | sends | receives | net | +|-------|-------|----------|-----| +| A | 100 | 100 refund | **0** | +| B | 200 | 200 refund | **0** | +| C | 100 | 100 refund | **0** | +| maker / LP1 | liquidity | principal back | **0** (no bonus) | +| disp | dispute_fee 1000 | **1000 back** | **0** | +| orac | insurance −slash → DAO | — | **− slash** | + +This is **not** "the disputer lost": a returned fee (net 0) is different from a forfeited fee +([disputer-loser](../disputer-loser/disputer-loser.md), net −1000). Nobody profits; the market is voided +to break the freeze, and the cost falls on the oracle that didn't respond. + +## Notes +- The same void-and-refund shape also covers [oracle missed deadline](../oracle/oracle.md) + (`pm_oracle_missed_penalty`) and `pm_no_contest` — all zero-sum refunds with an oracle penalty. +- Tune `pm_dispute_auto_close_sec` (14 d) vs `pm_dispute_vote_period_sec` (3 d) so honest disputes + resolve before the anti-freeze fires. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| vop `pm_dispute_auto_close` at `auto_close_time` | ✔ | `process_pm_markets` auto-close scan | +| full bet refund + LP principal + disputer fee returned | ✔ | `refund_all_bets` + `return_liquidity` + fee credit | +| oracle penalty → DAO, `disputes_auto_closed++` / `dispute_responses_missed++` | ✔ | same | +| sibling void paths | ✔ | `pm_oracle_missed_penalty`, `pm_no_contest` (same refund shape) | + +**Observe via plugin:** `get_dispute` (status → auto-closed), `get_market` (`status`/`payout_status`), +`get_account_positions` (bets refunded), `get_oracle` (penalty counters). diff --git a/.qoder/docs/pm-workflow/disputer-loser/disputer-loser.md b/.qoder/docs/pm-workflow/disputer-loser/disputer-loser.md new file mode 100644 index 0000000000..1c603f9fe0 --- /dev/null +++ b/.qoder/docs/pm-workflow/disputer-loser/disputer-loser.md @@ -0,0 +1,52 @@ +# Disputer — fee forfeited (oracle upheld) + +Part of [PM Workflow](../README.md). **disp** disputes the oracle's **A**, escrows `pm_dispute_fee 1000`, +but the verdict **upholds the oracle**. The escrowed fee is **forfeited to the oracle** as compensation, +and the market settles exactly as originally resolved (A wins). + +## Interaction diagram + +```mermaid +flowchart LR + disp -->|pm_dispute_create proposed=B
escrow fee 1000| D[(pm_dispute_object)] + D --> VOTE{committee vote
or account resolve} + VOTE ==>|uphold oracle A| FIN[[pm_dispute_finalize / resolve]] + FIN -->|dispute_fee 1000| orac[oracle compensation] + FIN -->|market settles as A| AUTO[[pm_auto_payout]] +``` + +## Operations it sends (signed) +- `pm_dispute_create` — `proposed_outcome = B`, escrows 1000, freezes payout. + +## Virtual operations that touch it +- `pm_dispute_finalize` / `pm_dispute_resolve` — on **uphold**, transfers the dispute fee to the oracle + and unfreezes the original payout. +- `pm_auto_payout` — settles the market on the original outcome A. + +## Tokens — disputed resolve, oracle upheld (the "loss") +| sends | receives | net | +|-------|----------|-----| +| dispute_fee **1000** | **0** | **−1000** | + +The fee is the disputer's skin-in-the-game: a wrong/frivolous dispute pays the oracle for the trouble. +Bettors A/C still win, B still loses — identical to the [normal ledger](../README.md#master-ledger--normal-resolve-a-wins). + +## Tokens — normal resolve (no dispute filed) +N/A — no disputer exists without a filing. + +## Notes +- This asymmetry (lose the fee if wrong, win a multiple if right) is what keeps the dispute channel honest. +- If the dispute is never decided (oracle silent, no quorum), it is force-closed and the fee is + **returned** — see [dispute-auto-close](../dispute-auto-close/dispute-auto-close.md). That is different + from losing on the merits here. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_dispute_create` | ✔ | `pm_dispute_create_evaluator` | +| uphold → dispute fee transferred to oracle | ✔ | `pm_dispute_finalize` / `pm_dispute_resolve` (uphold branch) | +| original payout unfrozen + settled (A) | ✔ | `settle_market`, summarised by `pm_auto_payout` | +| vop on the disputer | — | none directly; the loss is the non-refunded escrow | + +**Observe via plugin:** `get_dispute` (status → oracle-right), `get_dispute_votes`, `get_oracle` +(the oracle's balance gains the fee; `disputes_won++`). diff --git a/.qoder/docs/pm-workflow/disputer-winner/disputer-winner.md b/.qoder/docs/pm-workflow/disputer-winner/disputer-winner.md new file mode 100644 index 0000000000..800c95af34 --- /dev/null +++ b/.qoder/docs/pm-workflow/disputer-winner/disputer-winner.md @@ -0,0 +1,53 @@ +# Disputer — vindicated (oracle overturned) + +Part of [PM Workflow](../README.md). **disp** thinks the oracle's **A** is wrong, files a dispute +proposing **B**, escrows `pm_dispute_fee 1000`, and the verdict **overturns to B**. disp gets its fee +back **plus** a reward carved out of the oracle's slashed insurance. + +## Interaction diagram + +```mermaid +flowchart LR + disp -->|pm_dispute_create proposed=B
escrow fee 1000| D[(pm_dispute_object
status open)] + D --> VOTE{committee vote
or account resolve} + VOTE ==>|overturn to B| FIN[[pm_dispute_finalize / resolve]] + FIN -->|fee 1000 + bonus 2000| disp + FIN -.slash 5000 from oracle.-> SPLIT[bonus 2000 → disp
3000 → forfeit_pool → winners] +``` + +## Operations it sends (signed) +- `pm_dispute_create` — `proposed_outcome = B`, escrows `pm_dispute_fee 1000`. Freezes payout (`payout_status = 3`). +- (committee mode) it does **not** vote on its own — the SHARES electorate does, via `pm_dispute_vote`. + +## Virtual operations that touch it +- `pm_dispute_finalize` (committee) **or** `pm_dispute_resolve` (account) — on overturn, refunds the fee + and credits the reward carve-out. + +## Tokens — disputed resolve (overturned to B = the "win") +| sends | receives | net | +|-------|----------|-----| +| dispute_fee **1000** | fee 1000 back + **bonus 2000** | **+2000** | + +`reward_target = fee × pm_dispute_reward_multiplier (3×) = 3000` → `bonus = 3000 − 1000 = 2000`, capped +at the slash. The bonus comes from the oracle's **slashed insurance** (5000); the remainder (3000) goes +to `forfeit_pool` → the new winners. disp risked 1000, walks away **+2000**. + +## Tokens — "normal" resolve (no dispute filed) +N/A — without filing there is no disputer. If disp had **not** disputed, the original A result would +stand and B-side money would be lost. + +## Notes +- The reward is **capped at the actual slash** — a tiny slash means a small (or zero) bonus, only the fee back. +- `dispute_penalty_percent < 0` on the market signals a *good-faith* oracle: no slash, the oracle keeps a + fee bonus, and the disputer just gets its fee back (no profit). See [resolver-committee](../resolver-committee/resolver-committee.md). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_dispute_create` (escrow fee, freeze payout) | ✔ | `pm_dispute_create_evaluator` | +| overturn → fee refund + reward carve-out (capped at slash) | ✔ | `pm_dispute_finalize` (committee) / `pm_dispute_resolve` (account) | +| remainder of slash → `forfeit_pool` → winners | ✔ | same | +| vop `pm_dispute_finalize` / settlement marker `pm_auto_payout` | ✔ | `process_pm_markets` | + +**Observe via plugin:** `get_dispute` (status → oracle-wrong), `get_dispute_votes` (committee tally), +`get_account_positions` (your refunded fee + reward land as balance; the winning bet's new payout). diff --git a/.qoder/docs/pm-workflow/lazy-pool/lazy-pool.md b/.qoder/docs/pm-workflow/lazy-pool/lazy-pool.md new file mode 100644 index 0000000000..ca96a2db1d --- /dev/null +++ b/.qoder/docs/pm-workflow/lazy-pool/lazy-pool.md @@ -0,0 +1,89 @@ +# The Lazy Liquidity Pool (system object) + +Part of [PM Workflow](../README.md). The lazy pool is a **singleton** `pm_lazy_pool_object` — not an +account. Depositors fund it once; the pool **auto-allocates** a slice to each accepted market as a +silent LP (`pm_liquidity_object` with empty `provider`), **funds leverage loans**, and **recalls** +idle allocations. It earns LP yield + leverage interest, accounted MasterChef-style +(`reward_per_share × 1e9 / total_shares`). Depositor view: [lp-lazy-pool](../lp-lazy-pool/lp-lazy-pool.md). + +## Fields +`total_shares`, `free_balance`, `allocated_balance`, `earned_balance` (monotonic), `reward_per_share`, +`leverage_fund_used` (cap on what `free_balance` may lend). + +## Interaction diagram + +```mermaid +flowchart TD + LZ1 -->|pm_lazy_deposit 1000| POOL[(pm_lazy_pool
free 1000 / shares 1000)] + POOL ==>|on market accept
alloc 20% = 200| ALLOC[(pm_lazy_allocation
+ pm_liquidity provider=∅)] + ALLOC -->|market settles| YLD[route_pool_lp_return
principal 200 + yield 20] + YLD --> POOL + POOL -->|leverage loan 90| Dpos[D position] + Dpos -->|close/resolve: 90 + interest 9| POOL + POOL -. idle market .-> VR[[pm_lazy_recall]] + VR -->|step back to free| POOL +``` + +## How money enters / leaves the pool +| Event | Effect on pool | +|-------|----------------| +| `pm_lazy_deposit` (depositor) | `free_balance += amount`, mint shares | +| market allocation (auto, on accept) | `free → allocated` (becomes a silent LP) | +| market settles | `route_pool_lp_return`: principal + yield → `free_balance`; yield → `earned_balance` & `reward_per_share` | +| leverage open (D/E) | `free_balance −= loan`, `leverage_fund_used += loan` | +| leverage close / resolve / opposing-bet liquidation | `min(cancel_value, obligation) → free_balance`; `cancel_value ≥ loan` ⇒ **never a loss** (loan + R% profit → `earned_balance`) | +| cancel-bet liquidation (Case B only) | recovers `cancel_value` which **may be < loan** → bounded **bad debt** `−= shortfall` from `free_balance` | +| `pm_lazy_recall` (cron, idle market) | recalls one 10% step of an idle allocation back to `free_balance` | +| `pm_lazy_withdraw` (depositor) | burn shares → principal + pending; emergency penalty stays in pool | + +## Tokens over the canonical scenario +| Source | Δ earned / free | +|--------|-----------------| +| Market M allocation 200 → yield | **+20** earned | +| Leverage D interest (`90 × 10%`, settlement) | **+9** earned | +| Leverage E opposing-bet liquidation (recovers obligation 88 = loan 80 + R% 8) | **+8** earned | +| **Net pool gain** | **+37** earned | + +> **The pool's leverage liquidations are never negative** for opposing bets or settlement force-close: +> the liquidation runs at pre-bet reserves where `cancel_value ≥ loan`, so `pool_received = min(cv, +> obligation)` returns at least the loan (strategy doc §6). The **only** path that can charge the pool a +> bounded shortfall is a same-side **`pm_cancel_bet` (Case B)** — the cancel executes first for fairness, +> then the cascade may liquidate below the loan (`free_balance −= shortfall`, bounded by `SL%`, rare). +> See [bettor E](../bettor-e-leverage-liquidated/bettor-e-leverage-liquidated.md) and the +> `leverage_cancel_bet_cascade_bad_debt` test. + +There is no "with/without dispute" branch for the pool itself: as a **market LP** its principal is +returned unconditionally either way; only its *bonus* yield varies with the market's fee/penalty pool. + +## Virtual operations it emits +- `pm_lazy_recall` — graduated recall of idle allocations (one 10% step per `(result−created)/10`). +- (leverage) `pm_leverage_liquidate` / `pm_leverage_resolve` settle pool loans. + +## Notes +- The pool serves **both** roles at once from a single `free_balance`: market-LP allocations + (`maybe_allocate_lazy`) **and** leverage loans (`leverage_fund_used` caps the latter). The leverage + knobs (`pm_leverage_fund_percent`, `…_max_per_position_bp`, `…_max_position_ratio_percent`, + `…_min_market_liquidity`, `pm_leverage_enabled`) are all checked **at `pm_leverage_open` time** against + the current median — so later property swings only affect *new* opens, never loans already out. +- `pm_leverage_enabled` is a kill-switch (default **off**). It blocks new opens only; the liquidation + cascade that protects the pool from **existing** positions is deliberately **not** gated by it, so + flipping the flag off can never leave open loans unprotected. +- VIZ in the lazy pool is **liquid**, not vested, so it carries **no** validator-scheduling or + committee-request weight (those use `effective_vesting_shares` only). **Exception (HF14):** for + **PM committee disputes**, a depositor's pool stake **is** counted — converted to vesting-shares via + `get_vesting_share_price()` and added to their `pm_dispute_vote` weight, so pooled DAO members aren't + disenfranchised. See [resolver-committee](../resolver-committee/resolver-committee.md). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| singleton created at HF14 | ✔ | `apply_hardfork(CHAIN_HARDFORK_14)` | +| auto-allocation on accept | ✔ | `maybe_allocate_lazy` | +| LP yield routed back | ✔ | `route_pool_lp_return` (principal + yield → `free_balance`, `reward_per_share`) | +| leverage loan / interest / bad debt | ✔ | `pm_leverage_open` / `liquidate_position` | +| vop `pm_lazy_recall` | ✔ | graduated recall step | +| vop `pm_leverage_liquidate` | ✔ | mid-market cascade (opposing/cancel) | +| vop `pm_leverage_resolve` | ✔ | settles pool loans at market resolution (force-close) | + +**Observe via plugin:** `get_lazy_pool` (`free_balance`, `allocated_balance`, `earned_balance`, +`reward_per_share`, `leverage_fund_used`). Per-market allocations have **no** dedicated API method. diff --git a/.qoder/docs/pm-workflow/lp-lazy-pool/lp-lazy-pool.md b/.qoder/docs/pm-workflow/lp-lazy-pool/lp-lazy-pool.md new file mode 100644 index 0000000000..d82c7dde46 --- /dev/null +++ b/.qoder/docs/pm-workflow/lp-lazy-pool/lp-lazy-pool.md @@ -0,0 +1,65 @@ +# Liquidity provider in the lazy pool (LZ1) + +Part of [PM Workflow](../README.md). **LZ1** deposits **1000** into the [lazy pool](../lazy-pool/lazy-pool.md) +**once** and lets the pool spread it across markets + leverage loans. It earns a share of the pool's +aggregate yield (MasterChef `reward_per_share`), not any single market's outcome. Two exit paths: +**planned** (after the lock) and **emergency** (before the lock, with a penalty). + +## What ops make you a lazy-pool participant? +- **`pm_lazy_deposit`** — `amount=1000`. Mints pool shares (first depositor 1:1 → 1000 shares), records + `principal`, `reward_snapshot`, and `unlock_time = now + pm_lazy_lock_sec` (7 d). +- **`pm_lazy_withdraw`** — burns shares for principal + accrued rewards. `emergency=true` exits before + `unlock_time` with a penalty on the *profit*. + +That is the whole interface — there is **no per-market action**; allocation/recall/leverage are automatic. + +## Interaction diagram + +```mermaid +flowchart LR + LZ1 -->|pm_lazy_deposit 1000| DEP[(pm_lazy_deposit_object
shares 1000, unlock=+7d)] + DEP --> POOL[(lazy pool)] + POOL -. yield accrues .-> RPS[reward_per_share ↑] + LZ1 -->|pm_lazy_withdraw| OUT{planned or
emergency?} + OUT -->|planned, t≥unlock| P[principal 1000 + pending 29] + OUT -->|emergency, t|pm_add_liquidity 1000| L1[(pm_liquidity_object
provider=LP1)] + L1 --> M[(market M reserves)] + M ==>|settle| SL[[settle_liquidity]] + SL -->|principal 1000 + bonus ~16| LP1 + LP1 -->|pm_withdraw_liquidity
after resolution| OUT[principal-safe exit] +``` + +## Operations it sends (signed) +- `pm_add_liquidity` — `amount=1000`. Joins the binary CPMM reserves proportionally; records `deposit_time` (the time-weight basis). +- `pm_withdraw_liquidity` — principal-safe; **locked** from `betting_expiration` until resolution. + `amount=0` withdraws the full position. Pays principal + any accrued `earned_fee`. + +## Virtual operations that touch it +- `pm_auto_payout` / `settle_liquidity` — returns principal + the time-weighted LP-bonus share. + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| 1000 | **1000 principal + ~16 bonus** | **+16** | + +LP bonus pool = `liq_fee 10 + time-penalties 37 = 47`, split by `principal × seconds-in-market` between +the earlier maker (~31) and the later LP1 (~16). Earlier + larger ⇒ bigger slice. + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| 1000 | 1000 principal + ~4 bonus | **+4** | + +Principal still safe; the bonus is smaller (the B-wins pool generated no winner time-penalties, so the +LP bonus is only `liq_fee 10`, split with the maker). + +## Notes +- **Principal guarantee** is architectural: the seed/subsidy is returned before any winner is paid; an + LP can never lose principal to bettors — only forgo bonus. +- Withdrawing during the betting window (before `betting_expiration`) is allowed; after it, the + position is locked until the market resolves. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_add_liquidity` (records `deposit_time`) | ✔ | `pm_add_liquidity_evaluator` | +| `pm_withdraw_liquidity` (principal-safe, lock gate) | ✔ | `pm_withdraw_liquidity_evaluator` | +| principal returned + time-weighted bonus | ✔ | `settle_liquidity` → `distribute_lp` (principal × seconds) | +| dedicated LP vop | — | none; settlement is summarised by the market's `pm_auto_payout` | + +**Observe via plugin:** `get_market_liquidity` (your `pm_liquidity_object`: `amount`, `earned_fee`, +`status`), `get_market_weight_sums` (reserves / `q` for client pricing). diff --git a/.qoder/docs/pm-workflow/marketmaker/marketmaker.md b/.qoder/docs/pm-workflow/marketmaker/marketmaker.md new file mode 100644 index 0000000000..84952a8cc7 --- /dev/null +++ b/.qoder/docs/pm-workflow/marketmaker/marketmaker.md @@ -0,0 +1,62 @@ +# Market maker (creator + first LP) + +Part of [PM Workflow](../README.md) — uses canonical market **M**. The maker creates market M, seeds +the **2000** liquidity (and so becomes the first `pm_liquidity_object`), and proposes the oracle's +**offer ceiling**. It does **not** resolve (that's the oracle). + +## Interaction diagram + +```mermaid +flowchart LR + maker -->|pm_create_market| M[(pm_market_object
status=0)] + maker -->|seed 2000| LP0[(pm_liquidity_object
provider=maker)] + M -. fee 5 .-> DAO[(committee_fund)] + orac -->|pm_oracle_accept_market| M2[(M status=1)] + M2 -. VIRTUAL .-> VA[[pm_market_accepted]] + M2 ==>|pm_auto_payout| RET[principal 2000 + creator_fee + LP bonus] + RET --> maker +``` + +## Operations it sends (signed) +- `pm_create_market` — sets `oracle_fee_percent` / `oracle_fixed_fee` as the **offer ceiling**, + plus its own `creator_fee_percent` (5%) and `liquidity_fee_percent` (5%). Pays `pm_market_creation_fee` 5 → DAO, locks `liquidity` 2000. +- `pm_add_liquidity` / `pm_withdraw_liquidity` — optional; principal-safe, locked from + `betting_expiration` until resolution. + +## Virtual operations that touch it +- `pm_market_accepted` — emitted when the oracle accepts (or, for a self-oracle, at creation). +- `pm_auto_payout` — returns the maker's LP principal + its time-weighted LP-bonus share at settlement. + +## Tokens — normal resolve (A wins) +| sends | receives | net | +|-------|----------|-----| +| 2000 liquidity + 5 creation-fee (→DAO) | 2000 principal + **creator_fee 10** + **LP-bonus ~31** | **+36** | + +LP principal is returned **unconditionally**; `creator_fee = losers_sum × 5% = 10`; LP-bonus = +its time-weighted slice of (liq_fee + time-penalties + dust) = ~31 of 47. + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net | +|-------|----------|-----| +| 2000 + 5 | 2000 principal + creator_fee 10 + LP-bonus ~6 | **+11** | + +The creator fee is **still paid** from the frozen market config — the dispute punishes the **oracle** +(insurance slash), not the maker. LP-bonus is smaller because the disputed pool produced fewer +time-penalties. If the maker were also the oracle (self-oracle), see [oracle-dispute-loser](../oracle-dispute-loser/oracle-dispute-loser.md). + +## Notes +- Self-oracle variant: `oracle == creator` → market is active at creation, `pm_market_accepted` + fires immediately with `self_oracle=true`, and the maker also earns the `oracle_take`. +- A banned creator cannot open new markets — see [oracle-banned](../oracle-banned/oracle-banned.md) (same `pm_creator_ban_object` mechanism for creators). + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_create_market` (offer ceiling, fees bp) | ✔ | `pm_create_market_evaluator` | +| `pm_add_liquidity` / `pm_withdraw_liquidity` | ✔ | their evaluators (principal-safe, lock gate) | +| vop `pm_market_accepted` | ✔ | on oracle accept / self-oracle create | +| vop `pm_auto_payout` | ✔ | per-market settle marker; LP principal+bonus via `settle_liquidity` (`adjust_balance`) | +| creation fee → DAO | ✔ | `committee_fund += pm_market_creation_fee` | + +**Observe via plugin:** `get_market` (status, frozen fees), `list_markets_by_creator`, +`get_market_liquidity` (your LP rows + `earned_fee`), `get_market_meta` (your JSON metadata). diff --git a/.qoder/docs/pm-workflow/oracle-banned/oracle-banned.md b/.qoder/docs/pm-workflow/oracle-banned/oracle-banned.md new file mode 100644 index 0000000000..63171fafe6 --- /dev/null +++ b/.qoder/docs/pm-workflow/oracle-banned/oracle-banned.md @@ -0,0 +1,55 @@ +# Banned oracle (and banned creator) + +Part of [PM Workflow](../README.md). A ban is a **status**, not a transfer: `pm_oracle_object.banned_until` +(and, for creators, a `pm_creator_ban_object`) blocks the actor from taking on new markets until the +timestamp passes. It usually rides along with an [overturn slash](../oracle-dispute-loser/oracle-dispute-loser.md), +but the ban itself moves no tokens. + +## Who sets it +- **Account mode:** `pm_dispute_resolve` with `ban_oracle` + `ban_oracle_until` (and/or + `ban_creator` + `ban_creator_until`). +- **Committee mode:** `pm_dispute_finalize` applies a ban scaled by consensus on an overturn. +- `banned_until = time_point_sec::maximum()` ⇒ a **permanent** ban. + +## Interaction diagram + +```mermaid +flowchart LR + resolver -->|pm_dispute_resolve ban_oracle| O[(pm_oracle_object
banned_until = T)] + orac -->|pm_create_market / accept| CHK{now < banned_until?} + CHK -->|yes| REJ[REJECTED: 'Oracle is banned'] + CHK -->|no, expired| OK[allowed again] + resolver -->|ban_creator| CB[(pm_creator_ban_object)] + maker -->|pm_create_market| CHK2{banned?} + CHK2 -->|yes| REJ2[REJECTED: 'Creator is banned'] +``` + +## Operations affected +- `pm_create_market` — rejected while the **creator** is banned (`pm_creator_ban_object.banned_until > now`). +- `pm_create_market` (as oracle) / `pm_oracle_accept_market` — rejected while the **oracle** is banned. +- Existing markets the oracle already serves continue; the ban only blocks **new** engagements. + +## Tokens +| Event | tokens | +|-------|--------| +| the ban itself | **0** — pure status change | +| the slash that usually accompanies it | see [oracle-dispute-loser](../oracle-dispute-loser/oracle-dispute-loser.md) (−5000 insurance) | +| insurance still locked | refundable on `pm_oracle_update` withdraw **after** the ban lifts and no active markets remain | + +## Notes +- Bans are stored as plain status fields and survive snapshots (`pm_oracle_object`, `pm_creator_ban_object` + are both serialized). A re-registration cannot wipe a ban — it is keyed by account. +- Combine with reputation counters (`bans_received`, `disputes_lost`) that the API plugin folds into the + on-read reliability score. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| oracle `banned_until` set | ✔ | `pm_dispute_resolve` (`ban_oracle`) / `pm_dispute_finalize` | +| oracle ban enforced on create/accept | ✔ | `pm_create_market_evaluator` (`"Oracle is banned"`) | +| creator ban via `pm_creator_ban_object` | ✔ | `pm_dispute_resolve` upsert; checked in `pm_create_market` (`"Creator is banned"`) | +| both objects serialized in snapshot | ✔ | `plugins/snapshot` export/import | +| no token movement from the ban itself | ✔ | status-only | + +**Observe via plugin:** `get_oracle` (`banned_until`, `bans_received`) for an oracle ban; +**`get_creator_ban(account)`** (`banned_until`, `ban_count`) for a creator ban. diff --git a/.qoder/docs/pm-workflow/oracle-dispute-loser/oracle-dispute-loser.md b/.qoder/docs/pm-workflow/oracle-dispute-loser/oracle-dispute-loser.md new file mode 100644 index 0000000000..942c3c33af --- /dev/null +++ b/.qoder/docs/pm-workflow/oracle-dispute-loser/oracle-dispute-loser.md @@ -0,0 +1,56 @@ +# Oracle — overturned + slashed (dispute loser) + +Part of [PM Workflow](../README.md). The oracle **orac** resolved **A**; the dispute **overturns to B** +and the oracle's insurance is **slashed**. The oracle still collects the tiny frozen market fee (the +fee and the punishment are separate money), but loses a large slice of its bond and reputation. + +## Interaction diagram + +```mermaid +flowchart LR + orac -->|pm_resolve_market A| M[(resolved A)] + disp -->|pm_dispute_create proposed=B| D[(dispute)] + D ==>|overturn to B| FIN[[pm_dispute_finalize / resolve]] + FIN -->|slash 5000| INS[oracle.insurance ↓] + INS --> SPLIT[bonus 2000 → disputer
3000 → forfeit_pool → B] + FIN --> AUTO[[pm_auto_payout settles B]] + AUTO -->|oracle_take 30| orac +``` + +## Operations it sends (signed) +- `pm_resolve_market` (the disputed call). No further oracle action; the committee/resolver decides. + +## Virtual operations that touch it +- `pm_dispute_finalize` / `pm_dispute_resolve` (overturn) — slashes insurance, sets `disputes_lost++`, + optionally sets `banned_until` (→ [oracle-banned](../oracle-banned/oracle-banned.md)). +- `pm_auto_payout` — settles on the corrected outcome B. + +## Tokens — disputed resolve, overturned (the "loss") +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance −**5000 slashed** | oracle_take 30 | **−4970** | + +`slash = insurance 5000 × dispute_penalty_percent (100%) × consensus_strength (100%) = 5000`. It is +**redistributed**, not burned: `bonus 2000 →` disputer, `3000 → forfeit_pool →` the new winners (B). +The oracle keeping the 30 market fee is deliberate — punishment lives in the bond, not the fee. + +## Tokens — normal resolve (no dispute) +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance 5000 (locked) | oracle_take 30 | **+30** | + +## Notes +- Slash scales with **consensus strength** (`winning_rshares / max_rshares`) — a split verdict slashes less. +- `dispute_penalty_percent < 0` (good-faith) → **no** slash; the oracle even keeps a fee bonus. +- A slashed oracle is often **banned** too (`ban_oracle` / `banned_until`), blocking new markets until expiry. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| overturn → insurance slash `× pp × consensus_strength`, `disputes_lost++` | ✔ | `pm_dispute_finalize` / `pm_dispute_resolve` (overturn) | +| slash split: disputer bonus + `forfeit_pool` | ✔ | same | +| market fee still paid from frozen config | ✔ | `settle_market` uses `mkt.oracle_fee_percent` | +| optional `banned_until` set | ✔ | `ban_oracle` (account) / scaled ban (committee) | + +**Observe via plugin:** `get_oracle` (`insurance` dropped, `disputes_lost++`, `banned_until`, +`total_insurance_slashed`), `get_dispute` (status → oracle-wrong). diff --git a/.qoder/docs/pm-workflow/oracle-dispute-winner/oracle-dispute-winner.md b/.qoder/docs/pm-workflow/oracle-dispute-winner/oracle-dispute-winner.md new file mode 100644 index 0000000000..2a0abf5f99 --- /dev/null +++ b/.qoder/docs/pm-workflow/oracle-dispute-winner/oracle-dispute-winner.md @@ -0,0 +1,52 @@ +# Oracle — upheld in a dispute (dispute winner) + +Part of [PM Workflow](../README.md). The oracle **orac** resolved **A**; a disputer challenged it, but +the verdict **upholds** A. The oracle keeps its normal market fee **and** collects the forfeited +`dispute_fee` as compensation; its insurance is untouched and `disputes_won` increments. + +## Interaction diagram + +```mermaid +flowchart LR + orac -->|pm_resolve_market A| M[(market resolved A)] + disp -->|pm_dispute_create| D[(dispute)] + D ==>|uphold A| FIN[[pm_dispute_finalize / resolve]] + FIN -->|dispute_fee 1000| orac + FIN --> AUTO[[pm_auto_payout settles A]] + AUTO -->|oracle_take 30| orac +``` + +## Operations it sends (signed) +- `pm_resolve_market` (already done). The oracle does not act again during the dispute (committee/account decides). + +## Virtual operations that touch it +- `pm_dispute_finalize` / `pm_dispute_resolve` (uphold) — credits the `dispute_fee`, sets `disputes_won++`. +- `pm_auto_payout` — credits the normal `oracle_take`. + +## Tokens — disputed resolve, upheld (the "win") +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance 5000 (locked, **not** slashed) | oracle_take 30 + **dispute_fee 1000** | **+1030** | + +The challenge backfires on the disputer and **pays the oracle**. Compare the slashed case in +[oracle-dispute-loser](../oracle-dispute-loser/oracle-dispute-loser.md). + +## Tokens — normal resolve (no dispute) +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance 5000 (locked) | oracle_take 30 | **+30** | + +## Notes +- Being upheld also improves the oracle's on-read reliability score (more `disputes_won`, no slash). +- A *good-faith* market (`dispute_penalty_percent < 0`) can hand the oracle a fee bonus even when the + outcome is changed — recognising an honest mistake rather than punishing it. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| uphold → `dispute_fee` credited to oracle, `disputes_won++` | ✔ | `pm_dispute_finalize` / `pm_dispute_resolve` (uphold) | +| insurance untouched | ✔ | no slash on uphold | +| settlement on original outcome | ✔ | `settle_market` + `pm_auto_payout` | + +**Observe via plugin:** `get_oracle` (`insurance` unchanged, `disputes_won`, higher reliability score), +`get_dispute` (status → oracle-right). diff --git a/.qoder/docs/pm-workflow/oracle/oracle.md b/.qoder/docs/pm-workflow/oracle/oracle.md new file mode 100644 index 0000000000..77acfa61b0 --- /dev/null +++ b/.qoder/docs/pm-workflow/oracle/oracle.md @@ -0,0 +1,63 @@ +# Oracle (register → accept/quote → resolve) + +Part of [PM Workflow](../README.md) — external oracle **orac** on market **M**. The oracle bonds +insurance, **quotes** its fee at accept (≤ the maker's offer and ≤ `pm_max_oracle_fee_percent`), and +resolves the outcome. Its market fee is paid from the losers' pool at settlement; its bond is at risk +only on a missed deadline or a lost dispute. + +## Interaction diagram + +```mermaid +flowchart LR + orac -->|pm_oracle_register
insurance 5000| O[(pm_oracle_object)] + orac -. reg-fee 10 .-> DAO[(committee_fund)] + orac -->|pm_oracle_accept_market
quote fee 10% + fixed 10| M[(M status=1)] + M -. VIRTUAL .-> VA[[pm_market_accepted]] + orac -->|pm_resolve_market A| M3[(M status=3)] + M3 ==>|grace passes| VP[[pm_auto_payout]] + VP -->|oracle_take 30| orac +``` + +## Operations it sends (signed) +- `pm_oracle_register` — locks `insurance` 5000, pays `pm_oracle_registration_fee` 10 → DAO. Sets the + **advisory** standing `fee_percent` / `fixed_fee` (its public price list). +- `pm_oracle_accept_market` — **quotes** `oracle_fee_percent` (10%) + `oracle_fixed_fee` (10), each + `≤` the creator's offer on the market and `≤ pm_max_oracle_fee_percent`. Freezes them onto M; market goes live. +- `pm_resolve_market` — sets `winning_outcome = A`, opens the dispute grace window. +- `pm_oracle_update` / `pm_no_contest` — optional (top-up/withdraw bond; void a market). + +## Virtual operations that touch it +- `pm_market_accepted` — announces the launch + the oracle's frozen terms. +- `pm_auto_payout` — credits the `oracle_take` (`oracle_fee + oracle_fixed_paid`) at settlement. +- `pm_oracle_missed_penalty` — if the oracle never resolves, slashes `pm_oracle_penalty_percent` (5% = 250) of insurance → DAO and refunds all bets. + +## Tokens — normal resolve (A wins) +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance 5000 (locked, refundable) + reg-fee 10 (→DAO) | **oracle_take 30** = oracle_fee 20 + fixed 10 | **+30** | + +## Tokens — disputed resolve (overturned to B) +| sends | receives | net (market) | +|-------|----------|--------------| +| insurance −**5000 slashed** | oracle_take 30 | **−4970** | + +Even when overturned the oracle keeps the small **market fee** (frozen config); the penalty is the +**insurance slash** (`5000 × dispute_penalty_percent × consensus_strength`). The slash is split into the +disputer's reward and the winners' `forfeit_pool`. See [oracle-dispute-loser](../oracle-dispute-loser/oracle-dispute-loser.md). + +## Notes +- Quoting **below** the offer is allowed (price = reputation, not a bribe); quoting **above** is rejected. +- A self-oracle (`oracle==creator`) auto-accepts at creation and additionally earns the `creator_take`. + +## Code verification & how to observe +| Element | In code | Where | +|---------|---------|-------| +| `pm_oracle_register` (insurance lock, reg-fee→DAO, fee≤cap) | ✔ | `pm_oracle_register_evaluator` | +| `pm_oracle_accept_market` quote (≤ offer, ≤ median cap) + freeze | ✔ | `pm_oracle_accept_market_evaluator` | +| `pm_resolve_market`, `pm_no_contest`, `pm_oracle_update` | ✔ | their evaluators | +| vop `pm_market_accepted` | ✔ | emitted on accept | +| vop `pm_oracle_missed_penalty` | ✔ | `process_pm_markets` missed-deadline scan | +| `oracle_take` credit at settle | ✔ | inside `settle_market` (`adjust_balance`), summarised by `pm_auto_payout` | + +**Observe via plugin:** `get_oracle` (insurance, the 14 counters + computed reliability score), +`list_oracles` (sorted), `get_market` (frozen `oracle_fee_percent`/`oracle_fixed_fee`). diff --git a/.qoder/docs/pm-workflow/prototype-frontend-design-document.md b/.qoder/docs/pm-workflow/prototype-frontend-design-document.md new file mode 100644 index 0000000000..9107dbad86 --- /dev/null +++ b/.qoder/docs/pm-workflow/prototype-frontend-design-document.md @@ -0,0 +1,2583 @@ +# Forecaster / Onix — Frontend Design Document + +**Purpose of this document.** This is a complete, user-facing specification of the *current working prototype* of the Forecaster prediction market (protocol name **Onix**). It is written for the developers who will build a **new thin client on the VIZ blockchain** (using the `viz-js` library — see the [VIZ node docs](https://github.com/VIZ-Blockchain/viz-cpp-node/tree/master/docs)). Use it as a coverage checklist: every screen, capability, API call and transaction the current app performs is enumerated here so you can confirm the thin client reproduces all of them and maps each server-side API call to the correct on-chain operation / query. + +**How to read it.** +- The prototype is a single-page app (`app.js`, cash/jQuery-style `$`) served by a thin PHP router. It talks to the backend exclusively through `POST`/`GET` calls to `/api//` returning JSON. Every one of those 58 endpoints is documented, with the exact request fields the client sends and the exact response fields the UI consumes. +- Each functional area has its own section (2–9). Section 10 is the **master coverage matrix** — one row per endpoint — that node developers should tick off against their transaction map. +- "VIZ thin-client note" call-outs are *suggestions* for how an action might map to a blockchain operation/query. They are hints for the node team, not a description of the current server internals (which are already ported). +- Internal server mechanics (LMSR/parimutuel pricing, fixed-point math, DB schema, chain settlement) are **out of scope** — this document only covers the client contract and UX. + +> Terminology note: the codebase uses **Onix Binary** for two-outcome (Yes/No) markets and **Onix Multi** for categorical markets with 2–10 outcomes. Binary now settles parimutuel, same as Multi. + +--- + +## Contents + +1. [Scope, roles, navigation & glossary](#1-scope-roles-navigation--glossary) +2. [App shell, authentication, session, profile & role registration](#2-app-shell-authentication-session-profile--role-registration) +3. [Browsing markets: list, catalog & filtering](#3-browsing-markets-list-catalog--filtering) +4. [Market detail & binary betting (bet, commit-reveal, cancel, transfer)](#4-market-detail--binary-betting-bet-commit-reveal-cancel-transfer) +5. [Multi-outcome markets (create, bet, cancel, liquidity, resolve)](#5-multi-outcome-markets-create-bet-cancel-liquidity-resolve) +6. [Creating a market (creator role)](#6-creating-a-market-creator-role) +7. [Oracle role: acceptance, insurance, pending queue & resolution](#7-oracle-role-acceptance-insurance-pending-queue--resolution) +8. [Disputes & DAO / committee governance](#8-disputes--dao--committee-governance) +9. [Wallet, balance, history, liquidity pools & leverage](#9-wallet-balance-history-liquidity-pools--leverage) +10. [Master API / transaction coverage matrix](#10-master-api--transaction-coverage-matrix) + +> **⚠ Contract gaps found during this audit** (see §5 for detail) — the node team should treat these as known bugs to fix rather than reproduce: (1) the "Add liquidity" button always calls the binary `add-liquidity` even on multi markets — `add-liquidity-multi` / `withdraw-liquidity-multi` have **no client caller**; (2) multi resolution sends field `outcome` but `resolve-market-multi` reads `winning_outcome`; (3) the multi-cancel success toast reads `data.returned` while the endpoint returns `return_amount`. + +--- + +## 1. Scope, roles, navigation & glossary + +### 1.1 Actors / roles + +The app recognises the following roles (About screen, `about.role_*`). A single account may hold several roles at once; role capability gates specific screens and API calls. + +| Role | In-app name | What they do | Gated by | +|------|-------------|--------------|----------| +| **Player / Bettor** | Player (`about.role_player`) | Buys outcome tokens (bets), cancels, transfers positions, claims payouts | Any authenticated user | +| **Market Creator** | Market creator (`about.role_creator`) | Creates markets, sets rules/oracle/committee/fees | `user.create_market == 1` (must **register as creator**) | +| **Oracle** | Oracle (`about.role_oracle`) | Accepts/rejects assigned markets, resolves outcome, no-contest, funds insurance | Registered oracle (`register-oracle`) | +| **Liquidity Provider (LP)** | Liquidity provider (`about.role_lp`) | Adds/withdraws liquidity, lazy-pool deposits, leverage/boost | Any authenticated user | +| **Disputer** | (a Player who disputes) | Opens a dispute against an oracle's resolution | Any bettor on the market, within grace window | +| **Oracle-as-respondent** | Oracle | Responds to a dispute raised against them | The market's oracle | +| **Arbitrator / Committee / DAO resolver** | Arbitrator "Judge" (`about.role_judge`) | Votes/decides disputes (oracle right/wrong + justification), can slash, unban | Committee member / DAO voter / private resolver | + +### 1.2 Authentication + +Two entry paths (see §2): +1. **Telegram Mini App** — `window.Telegram.WebApp.initData` is POSTed to `check-session` → returns a session token + user. +2. **Website / browser** — `auth-code` returns a one-time code the user pastes into the Telegram bot to bind a session. + +Session token is persisted in a cookie and `localStorage`; on every load `load-session` re-hydrates the user object. + +### 1.3 Navigation map + +The shell has a **4-item bottom tab bar** (`template/index.tpl`). Visibility is role-gated by CSS classes `v1`/`v3`/`v4` set in `update_user()`: + +- **v1** — not logged in: only *Markets* + *Profile* (login). +- **v3** — logged in, not a creator: *Markets*, *Balance*, *Profile*. +- **v4** — logged in creator: *Markets*, *Create*, *Balance*, *Profile*. + +Routing is hash-based: `#tab//` (`select_tab()` / `check_hash()` / `hashchange`). Full screen map: + +| Hash | Tab | Screen | Section | +|------|-----|--------|---------| +| `#tab/market` | Markets | Market **list** — status filter (all/active/resolved/closed) + "show risky" + category/subcategory/tags/sort filters | §3 | +| `#tab/market/about` | Markets | **About / landing** page (default when no hash) | §1.5 | +| `#tab/market/` | Markets | **Market detail** (odds, bet form, my bets, resolution, dispute blocks) | §4, §5, §7, §8 | +| `#tab/market/oracle/` | Markets | **Oracle public profile** (reliability, stats) | §7 | +| `#tab/create` | Create | **Create market** form (creator only) | §6 | +| `#tab/history` | Balance | **Balance / wallet**: top-up, withdraw, transaction history | §9 | +| `#tab/profile` | Profile | **Profile**: identity, balances, role registration, preferences, oracle settings | §2 | +| `#s/` | — | Session-injection deep link → redirects to Profile | §2 | + +### 1.4 System settings the UI reads + +`load-settings` returns a config object; the client reads these keys via `get_setting()`: + +| Key | Used for | +|-----|----------| +| `dispute_grace_hours` | Grace window (countdown) before a resolution is final / disputable | +| `min_risk_score_listing` | Threshold below which a market is "risky" (hidden unless *show risky*) | +| `min_risk_score_betting` | Threshold below which betting is risk-blocked (mandatory risk confirm) | +| `max_time_penalty` | Cap on time-penalty display in market detail | +| `commit_no_reveal_penalty_permille` | Penalty (‰) applied to committed-but-unrevealed hidden bets | +| `lmsr_min_outcomes` / `lmsr_max_outcomes` | Min/max outcomes for Onix Multi creation | + +### 1.5 About / landing screen + +Default landing (`render_about_page`, shown when no hash). Static, no API calls. Content (`about.*`): title + lead, the 5 **platform roles** (Player, Creator, Oracle, LP, Arbitrator/Judge), a "how it works in 5 steps" block, **market types** (Onix Binary = Yes/No; Onix Multi = 2–10 outcomes), FAQ, copyright and Onix protocol credit. Purely informational — the thin client can render it statically. + +### 1.6 Glossary of on-screen money/label conventions + +- Balances are integers in base units; the UI divides by `price_precision_ratio` (VIZ precision = 3) and renders with `render_number()`. Currency glyph is **Ƶ** (VIZ). +- Profile shows up to four sub-balances: **available** (`balance`), **bets** (`bets_balance`), **liquidity** (`liquidity_balance`), **oracle** (`oracle_balance`). +- **Reliability / Trust index** — an oracle score 0–100 rendered as a coloured badge with class buckets: excellent / good / average / new / poor / unreliable (`reliability_score_class` / `_label`, `oracle.score_*`). +## 2. App shell, authentication, session, profile & role registration + +This section documents the application shell (HTML container, bottom tab bar, hash routing, message area), the two identity flows (Telegram WebApp vs. website one-time code), session persistence, the Profile screen, and the Oracle / Creator / preferences registration forms. All field names below are taken verbatim from `template/index.tpl`, `app.js`, `module/api.php`, and the English labels from `i18n/en.json`. + +> **Note on transport.** The client always POSTs/GETs JSON to `/api//` (trailing slash). The generic helper `api_post(endpoint, body)` (app.js ~2044) does `fetch('/api/'+endpoint+'/', {method:'POST', body: JSON.stringify(body)})`, parses JSON, and **throws `new Error(data.error || 'Error')` on any non-2xx** — so every `api_post` caller shows the server's `error` string. Some shell calls use raw `fetch` with GET; those are noted per endpoint. Session is NOT sent in the JSON body for `api_post` calls — the server resolves the user from the `forecaster_session` cookie (except `load-session`, which sends `{session}` explicitly). + +--- + +### 2.1 Application shell (`template/index.tpl`) + +The whole app is a single HTML page. Server-side placeholders (`{title}`, `{head_addon}`, `{meta_image}`, `{content}`, `*_change_time`) are filled by PHP at render time; the SPA logic is entirely in `app.js` + `market_math.js`, using `cash.min.js` (the `$` shim) and Telegram's `telegram-web-app.js`. + +Structure the thin client must reproduce: + +- **`.container`** — white card holding all screens. +- **`.messages`** (hidden by default) — the toast/alert area. `show_message(type, text, timeout)` appends `
` where `type` ∈ `success | danger | warning`. Default timeout 5000 ms; pass `false` to make a message sticky (used for the browser-login code and the risk confirmation). `check_messages()` toggles the container's visibility based on child count. +- **Four tab-content panels**, each `id` mapped to a bottom-nav tab: + | Panel `id` | Bottom-nav `data-tab` | Label (en.json) | + |---|---|---| + | `market` | `market` | `nav.markets` = "Markets" | + | `create` | `create` | `nav.create` = "Create" | + | `history` | `history` | `nav.balance` = "Balance" (shows live balance `` + `Ƶ`) | + | `profile` | `profile` | `nav.profile` = "Profile" | + + Note the **`history` panel/tab is the "Balance" screen** — internal id is `history`, user-facing label is "Balance". +- **`.tabs`** — fixed bottom bar. `update_nav_labels()` (app.js 74) rewrites the four tab captions from i18n after locale load; the Balance tab is special-cased to preserve the inner `.balance` span and re-append `" Ƶ"`. + +**Tab width classes `v1`/`v2`/`v3`/`v4`** (index.tpl CSS 364–379) set each tab to 100 % / 50 % / 33 % / 25 % width. `update_user()` (app.js 236) sets exactly one class based on auth/role so only visible tabs occupy the bar: +- Not authenticated → `.tabs.v1`; only **Markets** is shown (Create/Balance/Profile hidden via `display:none`). +- Authenticated **without** `create_market` → `.tabs.v3`; Markets + Balance + Profile visible; Create hidden. +- Authenticated **with** `create_market == 1` → `.tabs.v4`; all four tabs visible. + +(`v2` exists in CSS but is not assigned by `update_user()`.) + +> **VIZ thin-client note (suggestion):** the tab-gating is purely a UI concern driven by two account flags (`create_market`, and implicitly auth). On VIZ these map to on-chain/account state the thin client can read once at boot (e.g., "is this account a registered creator?") and cache; there is no per-render server round-trip needed just to decide tab visibility. + +--- + +### 2.2 Hash routing scheme `#tab//` + +Routing is driven by `location.hash`. Three functions cooperate: +- `check_hash()` (app.js 216) — runs on boot and after `update_user()`. +- `hashchange` handler (app.js 442) — runs on every hash change. +- `select_tab(name)` (app.js 814) — the router body that renders a tab's content based on the remaining hash segments. + +Hash grammar and enumerated subpaths (from `select_tab`, splitting `hash` on `/`): + +| Hash | Screen rendered | Notes | +|---|---|---| +| *(empty)* | redirects to `#tab/market/about` | default landing = About page | +| `#s/` | sets `session=<…>`, replaces to `#tab/profile`, selects Profile | **login-by-link**: session token passed in the URL fragment | +| `#tab/market` | Markets list | status filter tabs, category filter, `load_markets_list(-1,0)` | +| `#tab/market/about` | About page (`render_about_page()`) | also reached by clicking the logo | +| `#tab/market/oracle/` | Oracle public profile | `load_oracle_profile(id)` → `load-oracle-profile` | +| `#tab/market/` | Single market detail | `load_market_detail(id)` (any numeric 3rd segment that isn't `about`/`oracle`) | +| `#tab/create` | Create-market form | role-gated on `create_market`; loads oracles + committees | +| `#tab/history` | Balance screen (deposit/withdraw + history table) | `load-history` | +| `#tab/profile` | Profile screen | rendered by `update_user()` + `render_profile_extra()` | + +`navigate(hash)` pushes a new history entry (no-op if unchanged); `navigate_replace(hash)` replaces the current entry (used to normalize `#s/…` and to strip subpaths after rendering Create/Balance). Tabs are also clickable directly: `app_mouse` (app.js 3018) intercepts clicks on `.tab` and calls `navigate('#tab/'+data-tab)`; clicking `.logo-clickable` navigates to `#tab/market/about`. + +--- + +### 2.3 Boot sequence & the two login flows + +On `$(function(){…})` (app.js 456) the client: +1. `load_system_settings()` (see 2.7) and `init_i18n()` → then `update_nav_labels()` + `load_categories()`. +2. Reads the session token from the **`forecaster_session` cookie**, falling back to **`localStorage['forecaster_session']`**, else from a `#s/…` hash. +3. If a token exists → `try_load_session()`. On error, clears the token and falls through to `try_check_session()`. On success, sets `auth=true`, `user=result`, adopts `user.lang` if present, and calls `update_user()`. +4. If no token → `try_check_session()`. + +**Flow A — Telegram WebApp (automatic).** `try_check_session()` (app.js 373) checks `window.Telegram.WebApp.initData`. If non-empty it POSTs it to `check-session`. On success it calls `save_session(result, update_user)` which stores `session`, sets the cookie to expire at `result.expiration`, mirrors to localStorage, sets `auth=true`, `user=result.user`. + +**Flow B — Website one-time auth code.** If there is no Telegram initData, `try_check_session()` GETs `auth-code`, then shows a **sticky warning** `auth.browser_login` = *"Sign in via browser through Telegram bot"* with a deep link `https://t.me/viz_forecaster_bot?start=`. The user opens the bot, which (out of band) issues a session; the bot hands the session back to the site via a `#s/` link, closing the loop into Flow A's persistence. + +**Session persistence** (`save_session` app.js 352, `save_session_simple` app.js 339): the token lives in the `forecaster_session` cookie (path `/`, 30-day expiry for the simple path, or server `expiration` for the full path) **and** mirrored to `localStorage`. Logout (`logout-action` in `app_mouse` 3124) sets `session=false; auth=false; user={}` then `save_session_simple()` which deletes both cookie and localStorage entry and re-renders as logged-out. + +> **VIZ thin-client note (suggestion):** On VIZ the identity is a keypair, not a server session cookie. The thin client would replace both flows with local key management: derive/import the VIZ account key, and instead of a server-issued `session` token, sign each state-changing request (a broadcast) with the account's active key. The Telegram-initData verification and the one-time-code bridge are server-auth artifacts that disappear; what must be preserved is the *account identity* and the *balance/role fields* the UI reads from `user`. + +#### `load-session` +- **Method:** POST · **When:** boot, whenever a stored session token exists; also after any role/pref change via `update_user_data()`. · **Auth:** the token itself. +- **Request** + | Field | Type | Meaning | + |---|---|---| + | `session` | string | session hash from cookie/localStorage/hash | +- **Response:** the full `users` row (with `data` JSON-decoded). UI consumes (see Profile 2.5): `data.username`, `data.first_name`, `data.last_name`, `data.photo_url`, `create_market`, `balance`, `bets_balance`, `bets_count`, `liquidity_balance`, `oracle_balance`, `oracle`, `oracle_insurance`, `oracle_rules`, `oracle_fee`, `oracle_fixed_fee`, `account`, `jurisdiction`, `lang`, `memo`. +- **Errors:** throws `Unknown session` / `Unknown user from session` (non-2xx) → boot shows `auth.session_error` and falls back to `try_check_session`. + +#### `check-session` +- **Method:** POST · **When:** running inside Telegram WebApp with non-empty `initData`. · **Auth:** Telegram HMAC signature (validated server-side against `bot_key`). +- **Request** + | Field | Type | Meaning | + |---|---|---| + | `data` | string | raw `window.Telegram.WebApp.initData` querystring | +- **Response** + | Field | Type | UI use | + |---|---|---| + | `session` | string | stored as the session token; cookie/localStorage | + | `expiration` | int (unix s) | cookie expiry | + | `user` | object | full users row → becomes `user`, drives Profile & tabs | +- **Errors:** `Unknown tg init data`, `Time range from tg init data exceed 10 min range`, `Hash from tg init data is different` → boot renders logged-out. + +#### `auth-code` +- **Method:** GET · **When:** website context (no Telegram initData). · **Auth:** none. +- **Request:** none. +- **Response** + | Field | Type | UI use | + |---|---|---| + | `code` | string (sha256) | inserted into the `auth.browser_login` deep-link `?start=` shown as a sticky warning | +- **Errors:** on failure the client throws and renders logged-out. + +--- + +### 2.4 The Markets / Create / Balance tabs (routing entry points) + +Only the routing side is in scope here (screen bodies are documented in later sections). From `select_tab`: +- **`create`** — if `!auth`, shows `market.not_logged_in`; if `auth` but `create_market != 1`, shows `market.cannot_create` = *"You cannot create markets."*; if `create_market == 1`, renders the full create form and eagerly GETs `load-oracles` and `load-committees` to populate the Oracle/Committee `` (`en`/`ru`, prefilled from `current_lang`) + jurisdiction `` prefilled from `user.jurisdiction`, and **Save preferences** button (`profile.save_preferences`). +- **Oracle section** — branches on `user.oracle`: + - If `oracle == 1`: shows insurance fund (`profile.oracle_insurance_display` with `user.oracle_insurance`), a deposit/withdraw insurance form (Deposit/Withdraw → `oracle-deposit-insurance` / `oracle-withdraw-insurance`, out of scope here), and an **Oracle settings** edit form (name / rules / fee ‰ / fixed fee) that re-submits `register-oracle`. Also renders pending markets awaiting the oracle's accept/reject (`load-pending-markets`). + - Else: the **"Become an oracle"** registration form (2.6). +- **Creator section** — if `create_market != 1`: the **"Become a market creator"** form (2.6). +- **My positions** (`load-positions`) and **My payouts** (`load-payouts`) tables, and a boost-positions container (documented in later sections). + +> **VIZ thin-client note (suggestion):** Everything on this screen is read-model state of a single account (balances split into available/bets/liquidity/oracle, counts, role flags, oracle metadata). On VIZ these become derived balances/asset holdings plus custom account metadata; the thin client can render this profile from account state + indexed positions rather than one monolithic `user` object. + +--- + +### 2.6 Role registration & preferences endpoints + +#### `register-oracle` +- **Method:** POST (`api_post`) · **When:** clicking **Register** in "Become an oracle" (`register_oracle_action`, app.js 2917) OR **Save settings** in the Oracle-settings edit form (`oracle_update_action`, app.js 2932). · **Auth:** required (server: "Auth required"). +- **Required role:** any authenticated user may become an oracle (charged a registration fee); existing oracles use the same endpoint to update settings free of charge. +- **Request** (registration path sends the first three; settings-edit path adds `oracle_fixed_fee`): + | Field | Type | Meaning | + |---|---|---| + | `account` | string | oracle display name (required; server throws "Oracle name is required" if empty) | + | `oracle_rules` | string | operating rules / statement of intent | + | `oracle_fee` | int | fee in ‰ (per-mille) taken from the market | + | `oracle_fixed_fee` | float (VIZ) | fixed reward; server multiplies ×1000; ≤0 defaults to 5000 (=5 VIZ). *(sent only from the settings-edit form)* | +- **Response** + | Field | Type | UI use | + |---|---|---| + | `status` | bool | success flag | + | `updated` | bool | present when editing an existing oracle (no fee charged) | +- **UI states:** loading text `bet.sending` = "Sending…" / `profile.saving` = "Saving…"; success → `profile.registered_success` / `profile.settings_saved` (green); on throw shows `common.error_prefix` + message (red). On success calls `update_user_data()` (re-runs `load-session`, re-renders Profile). +- **Errors surfaced:** "Insufficient balance for registration fee" (new registration when `balance < oracle_registration_fee`), "Registration failed", "Profile update failed". + +#### `register-creator` +- **Method:** POST (`api_post`) · **When:** clicking **Register** in "Become a market creator" (`register_creator_action`, app.js 2948). · **Auth:** required. +- **Request** + | Field | Type | Meaning | + |---|---|---| + | `creator_rules` | string | creator's operating rules | +- **Response** + | Field | Type | UI use | + |---|---|---| + | `status` | bool | success flag | +- **Server side-effects:** sets `create_market=1` **and** `add_liquidity=1`, stores `creator_rules`, deducts `creator_registration_fee` from balance. On success the UI calls `update_user_data()`, which flips the account to `.tabs.v4` and reveals the **Create** tab. +- **UI states:** loading `bet.sending`; success `profile.registered_success` (green); error `common.error_prefix` + message (red). +- **Errors surfaced:** "Already registered as market creator", "Insufficient balance for registration fee", "Registration failed", "Auth required". + +#### `update-user-preferences` +- **Method:** POST (`api_post`) · **When:** clicking **Save preferences** (`save_preferences_action`, app.js 2974). · **Auth:** required. +- **Request** + | Field | Type | Meaning | + |---|---|---| + | `lang` | string | UI language; server whitelists to `en`/`ru` (anything else → `en`) | + | `jurisdiction` | string | country code; server uppercases, strips non-A–Z, truncates to 5 chars | +- **Response** + | Field | Type | UI use | + |---|---|---| + | `status` | bool | success flag | + | `lang` | string | normalized value echoed back | + | `jurisdiction` | string | normalized value echoed back | +- **Client after success:** if `lang` changed, sets `current_lang`, persists `localStorage['user_lang']`, `await load_i18n(new_lang)`, `update_nav_labels()`; updates `user.jurisdiction` / `user.lang` in memory. The jurisdiction value later gates market visibility (`is_market_jurisdiction_banned`, app.js 2997) and triggers the `jurisdiction.warning` modal. +- **UI states:** loading `bet.sending`; success `common.success` = "Success!" (green); error `common.error_prefix` + message (red). +- **Errors surfaced:** `{status:false, error:'Not authenticated'}` when unauthenticated. + +#### `load-oracle-profile` +- **Method:** POST (`api_post` via `load_oracle_profile`) · **When:** navigating to `#tab/market/oracle/`, or clicking an `.oracle-profile-link`. · **Auth:** none required (public read). +- **Request** + | Field | Type | Meaning | + |---|---|---| + | `oracle_id` | int | target oracle's user id | +- **Response** (consumed by the Oracle public-profile screen; labels under the `oracle.*` i18n namespace): + | Field | Type | UI use | + |---|---|---| + | `id` | int | oracle id | + | `name` | string | display name (`oracle.profile_title`) | + | `rules` | string | shown under `oracle.rules_label` | + | `fee` | int | ‰ fee (`oracle.fee_label`) | + | `fixed_fee` | int | fixed reward (`oracle.fee_fixed`) | + | `insurance` | int | insurance fund (`oracle.insurance_fund_display`) | + | `banned` | int/bool | shows `oracle.banned_label` | + | `ban_until` | int | `oracle.banned_until` / `oracle.banned_forever` | + | `reliability_score` | int (0-100) | behavior score → reliability badge classes | + | `is_new` | 0/1 | shows `oracle.hint_new` / "New oracle" | + | `risk_score` | float | coverage = insurance ÷ active bets (`oracle.coverage`) | + | `trust_score` | int (0-100) | composite Trust Index (`oracle.trust_index`) | + | `metrics{}` | object | detailed stat table: `markets_accepted`, `markets_resolved`, `markets_no_contest`, `markets_missed`, `disputes_received`, `disputes_lost`, `disputes_won`, `disputes_auto_closed`, `dispute_responses_missed`, `total_volume_resolved`, `total_insurance_slashed`, `avg_resolution_time`, `bans_received`, `active_since`, `last_active_time` → the `oracle.stat_*` rows | + | `derived{}` | object | `resolution_rate`, `dispute_loss_rate`, `no_contest_rate`, `deadline_miss_rate`, `dispute_response_rate` → the `oracle.rate_*` indicator rows | +- **Errors:** server throws `Oracle not found` → the detail area shows `oracle.load_error`. + +> **VIZ thin-client note (suggestion):** Oracle registration, creator registration and preference updates are all account-metadata + fee mutations. On VIZ these become signed operations (e.g., an account custom-metadata/JSON op plus a token transfer for the registration fee) broadcast by the account key; the reliability/trust metrics in `load-oracle-profile` are indexer-computed aggregates the thin client would read from an off-chain indexer rather than the chain itself. + +--- + +### 2.7 System settings the UI reads (`load-settings`) + +#### `load-settings` +- **Method:** GET (raw `fetch` in `load_system_settings`, app.js 142) · **When:** first thing at boot. · **Auth:** none. +- **Request:** none. +- **Response:** a flat JSON object `{ key: value, … }` (every row of the server `settings` table). Stored in `system_settings`; read via `get_setting(key, default)` (app.js 149). + +Keys the client actually reads (all `get_setting(...)` call sites in app.js): +| Setting key | Read at (app.js) | What the UI does with it | +|---|---|---| +| `lmsr_max_outcomes` | 726, 744 | max number of outcomes allowed when creating an Onix Multi market (`market.max_outcomes`) | +| `lmsr_min_outcomes` | 733, 745 | min number of outcomes for Onix Multi (`market.min_outcomes`) | +| `dispute_grace_hours` | 1225 | grace-period countdown before payout / dispute window (`status.grace_countdown`) | +| `min_risk_score_listing` | 1236 | threshold below which a market is treated as "risky" and hidden unless "Show risky" is on | +| `min_risk_score_betting` | 1237, 1713 | threshold below which betting shows the risk warning / requires explicit confirmation (`bet.risk_detail`) | +| `max_time_penalty` | 1686 | caps the displayed late-bet penalty max % | +| `commit_no_reveal_penalty_permille` | 2131 | penalty (‰) shown for commit-reveal bets that are never revealed | + +> **VIZ thin-client note (suggestion):** these are global protocol parameters, not per-user data. On VIZ they would either be chain-consensus constants or values published by the protocol's governing account; the thin client should fetch them once at boot (as here) and cache. None of them require auth. + +--- + +### 2.8 Session/identity summary for the porting team + +- **Session token name:** `forecaster_session` (cookie **and** `localStorage`). +- **The `user` object** returned by `load-session` / `check-session.user` is the single source of truth for: identity (`data.username/first_name/last_name/photo_url`), balances (`balance`, `bets_balance`, `liquidity_balance`, `oracle_balance`), counts (`bets_count`), role flags (`create_market`, `oracle`, `add_liquidity`), oracle metadata (`account`, `oracle_rules`, `oracle_fee`, `oracle_fixed_fee`, `oracle_insurance`), preferences (`lang`, `jurisdiction`), and the deposit `memo`. +- **Role gates in the UI:** Create tab ⇔ `create_market == 1`; oracle settings/insurance UI ⇔ `oracle == 1`; the "Become …" forms appear only when the corresponding flag is unset. +## 3. Browsing markets: list, catalog & filtering + +This section documents the **Markets** browsing screen (the default landing tab, `nav.markets` = "Markets"), reached at hash `#tab/market` with no trailing id. It covers the full filter/sort/status/risk/category UI, the two list-fetch endpoints, jurisdiction hiding, pagination ("load more"), and every field of the market **card** rendered by `render_market_card`. + +All list state lives in module-level JS variables (app.js 1166–1171): + +| Variable | Default | Meaning | Sent as request field | +|---|---|---|---| +| `current_market_filter` | `-1` | Status tab (-1 all / 1 active / 3 resolved / 2 closed) | `status` | +| `current_show_risky` | `0` | "Show risky markets" checkbox state (0/1) | `show_risky` | +| `current_category` | `''` | Selected category chip id | `category` (only if non-empty) | +| `current_subcategory` | `''` | Subcategory dropdown value | `subcategory` (only if non-empty) | +| `current_tag` | `''` | Active hot-tag chip | `tag` (only if non-empty) | +| `current_sort` | `'newest'` | Sort selector value | `sort` (only if not `newest`) | + +Note: the user's **country** is `user.jurisdiction` (set via profile preferences, uppercased ISO code). It is applied **client-side** in `load_markets_list` — the list endpoints do NOT receive jurisdiction as a param; the client hides banned markets after fetch (see below). + +--- + +### Screen layout (`select_tab('market')`, app.js 1017–1051) + +When `#tab/market` has no `path[2]` (no market id / not `about` / not `oracle`), the list view is built: + +1. **Logo header** (clickable, returns to markets), then `
`. +2. **Status filter tabs** (`.market-filters` row): four buttons and one checkbox: + - `filter.all` "All" — `data-status="-1"` (starts `active`) + - `filter.active` "Active" — `data-status="1"` + - `filter.resolved` "Resolved" — `data-status="3"` + - `filter.closed` "Closed" — `data-status="2"` + - Checkbox `.show-risky-toggle` with label `filter.show_risky` "Show risky markets". +3. **Category filter container** (`.category-filter-container`) = output of `render_category_filter()` (see below). +4. **Markets list** (`.markets-list`) — initially shows `filter.loading` "Loading…", then filled by `load_markets_list(-1, 0)` on first render. + +#### Category filter block (`render_category_filter`, app.js 94–139) + +Rendered from `categories_cache` (populated by `load_categories()` on startup → `load-categories` endpoint). Contents: + +- **Category chips** (`.category-chips`): an "All categories" chip (`filter.all_categories`, `data-cat=""`) plus one chip per category showing `icon + localized label + (count)`. Active chip gets class `active` when `current_category` matches. +- **Subcategory dropdown** (`.subcategory-select`): only shown when a category is selected AND it has subcategories. First option `filter.all_subcategories` "All subcategories"; each option shows localized subcategory label + `(count)`. +- **Hot tags** (`.tag-chips`): label `filter.hot_tags` "Tags:" followed by one `.tag-chip` per hot tag showing `tag (count)`. Active tag toggled. +- **Sort selector** (`.sort-select`): label `filter.sort_label` "Sort:" with three options — `filter.sort_newest` "Newest" (value `newest`), `filter.sort_volume` "Volume" (value `volume`), `filter.sort_expiration` "Ending soon" (value `expiration`). + +**Filter interactions** (delegated handlers): + +| User action | Handler location | Effect | +|---|---|---| +| Click category chip | app.js 3204–3213 (`app_mouse`) | sets `current_category`, clears `current_subcategory` + `current_tag`, re-renders filter block, reloads list from offset 0 | +| Change subcategory dropdown | app.js 566–570 | sets `current_subcategory`, reloads list | +| Click hot-tag chip | app.js 3215–3222 | toggles `current_tag` (click again clears), re-renders filter, reloads | +| Change sort dropdown | app.js 571–575 | sets `current_sort`, reloads | +| Click status tab | app.js 3224–3230 | moves `active` class, reloads with new `status` | +| Toggle "show risky" | app.js 3232–3236 | reloads with current filter (checkbox re-read inside `load_markets_list`) | +| Click "Load more" | app.js 3238–3242 | reloads passing the last card's id as `last_market_id` (append mode) | +| Click a card (`.clickable-market`) | app.js 3192–3196 | navigates to `#tab/market/` (market detail) | + +--- + +### The market list fetch (`load_markets_list`, app.js 1173–1217) + +Builds the request body, POSTs to **`load-markets-catalog`** (NOT `load-markets` — see note under that endpoint), then for each returned item: +- Skips it if `is_market_jurisdiction_banned(market.tags)` is true (client-side jurisdiction ban). +- Otherwise appends `render_market_card(market)`. + +If `last_market_id==0` (fresh load): replaces `.markets-list` html; if empty result, shows `filter.no_markets` "No markets found". If `last_market_id>0` (load more): removes the old load-more button and **appends** the new cards. + +**Pagination:** `limit` is hard-coded to **10**. If the returned array length ≥ limit, a `.load-more-btn` (`filter.load_more` "Load more") is appended carrying `data-last` = id of the last returned market. On error: `.markets-list` shows `filter.load_error` "Error loading markets" (non-ok) or `common.error_prefix + err.message` (exception). + +#### Jurisdiction handling (`is_market_jurisdiction_banned`, app.js 2997–3004) + +Returns false if the user has no `jurisdiction` or the market has no `tags`. Otherwise it builds the ban tag `jurisdiction-ban:` (user's jurisdiction uppercased) and returns true if that exact tag is present in `market.tags`. Banned markets are silently omitted from the list. (On the market **detail** screen, app.js 1563, a banned market instead triggers `show_jurisdiction_warning` — a modal with `jurisdiction.warning` text, `jurisdiction.proceed` "I understand, proceed" and `jurisdiction.go_back` "Go back".) The market-creation form collects `jurisdiction_relevant` and `jurisdiction_banned` free-text inputs (app.js 873–874) which become these tags server-side. + +--- + +#### `load-markets-catalog` + +- **Method:** POST — `/api/load-markets-catalog/` +- **Fires when:** every markets-list load/refresh — initial render, any filter/sort/status/risky change, and "Load more". This is the endpoint the browsing screen actually calls. +- **Role/auth:** none (public). + +**Request fields** (api.php 213–243): + +| Field | Type | Meaning | +|---|---|---| +| `last_market_id` | int | Pagination cursor; adds `AND id < last_market_id`. `0`/absent = first page. | +| `limit` | int | Page size, clamped 1–100 (client sends 10). | +| `status` | int | `-1` all (default → status ≥ 1, excludes pending); `0` pending, `1` active, `2` closed, `3` resolved. | +| `category` | string | Exact category slug filter (omitted when empty). | +| `subcategory` | string | Exact subcategory slug filter. | +| `tag` | string | Tag filter via INNER JOIN on `market_tags`. | +| `sort` | string | `newest` (default, `id DESC`), `volume` (`bets_sum DESC`), `expiration` (`betting_expiration ASC`). | + +Note: `show_risky` IS put in the body by the client but this lightweight endpoint does NOT read/apply it (risk filtering only exists in `load-markets`). Multi-outcome markets are fully priced here. + +**Response:** a JSON **array** of market rows. Fields consumed by `render_market_card`: + +| Field | Type | Card usage | +|---|---|---| +| `id` | int | Card `rel` (click → detail); last id used as `data-last` for load-more. | +| `status` | int | Status badge: 1 active / 2 closed / 3 resolved / else failed; label from `status.market_` ("Active"/"Closed"/"Resolved"/"Awaiting oracle"). | +| `q` | string | Market question, shown as `.title` (html-escaped). | +| `a`, `b` | string | Binary outcome A/B labels (answer lines). | +| `category`, `subcategory` | string | (returned; not directly drawn on card). | +| `metadata` | string/json | Multi-language title/labels source (not parsed by card). | +| `reserve_a`, `reserve_b`, `k` | int | Binary AMM reserves → `calculate_bet` + `implied_probability` for price/odds/probability lines. | +| `liquidity_sum` | int | Binary "Liquidity: X Ƶ" line (`market.liquidity_label`, divided by `price_precision_ratio`). | +| `a_bets_sum`, `b_bets_sum` | int | Binary bet-distribution bar percentages (`.bets-fill-a`/`-b`). | +| `bets_sum` | int | (volume sort key server-side). | +| `betting_expiration` | unixtime | "Betting until:" datetime (`market_detail.betting_until`). | +| `result_expiration` | unixtime | "Result deadline:" datetime (`market_detail.result_deadline`). | +| `market_type` | int | 0 = binary, 1 = multi (Onix Multi badge + multi bar). | +| `outcome_count` | int | Multi badge text `market.type_multi_badge` "Onix Multi (N outcomes)". | +| `lmsr_b`, `lmsr_subsidy` | int | Multi depth line `market.depth_line` "Depth (b): … | Subsidy: … Ƶ". | +| `outcomes[]` | array | Multi only: each `{outcome_index,label,q,bets_sum,bets_count,price}`. `price` (0–10000) → `%` shown per outcome, colored left border. | +| `payout_status` | int | With status 3, `1` = awaiting payout → grace-period countdown badge. | +| `update` | unixtime | Grace-period countdown base (`update + dispute_grace_hours`). | +| `time` | unixtime | Creation time (returned; not drawn). | +| `tags[]` | array | Used by `is_market_jurisdiction_banned` to hide market. | + +**UI states/errors:** empty array → "No markets found"; array length ≥ limit → "Load more" button; grace countdown badge (`market.payout_in` "Payout in …") for resolved-awaiting-payout markets. + +> **VIZ thin-client note (suggestion):** The catalog is a paginated, filterable query over on-chain market objects. A thin client would query the node's market index by `status`, `category`, `subcategory`, `tag`, ordered by newest/volume/expiration, page by `id` cursor. Risk/oracle enrichment is not needed for the list; the client should carry `tags` so it can locally hide `jurisdiction-ban:` markets for the logged-in user. + +--- + +#### `load-markets` + +- **Method:** POST — `/api/load-markets/` +- **Fires when:** *(legacy/enriched variant — the current browsing screen calls `load-markets-catalog` instead; documented here because it is the risk-and-oracle-enriched list contract and shares the card fields.)* +- **Role/auth:** none (public). + +**Request fields** (api.php 124–157): + +| Field | Type | Meaning | +|---|---|---| +| `last_market_id` | int | Pagination cursor (`AND id < last_market_id`). | +| `limit` | int | Page size, clamped to max 100. | +| `status` | int | Same semantics as catalog (-1 all→≥1; 0/1/2/3 exact). | +| `category` | string | Sanitized `[a-z0-9_-]`, lowercased. | +| `subcategory` | string | Sanitized `[a-z0-9_-]`, lowercased. | +| `tag` | string | Sanitized `[a-z0-9_:-]`; INNER JOIN on `market_tags`. | +| `show_risky` | int | 0/1. When 0, active markets whose `risk_score < min_risk_score_listing` (default 2.5) are dropped from the list. | + +**Response:** array of enriched market rows. In addition to all catalog fields above, this endpoint adds the fields the card uses for creator/oracle meta and risk badges: + +| Field | Type | Card usage | +|---|---|---| +| `creator_data` | object | `{username,first_name,…}` → creator name in `market.meta_creator_oracle` "Creator: … · Oracle: …". | +| `account` | string | Oracle account name (fallback `#`). | +| `oracle` | int | Oracle id (fallback name). | +| `oracle_reliability_score` | int (0–100) | Feeds `render_reliability_badge`. | +| `oracle_is_new` | int (0/1) | "New" badge variant. | +| `risk_score` | float | If present: `< min_risk_score_betting` (1.5) → red `market.risk_high` "⚠ High risk (S)"; `< min_risk_score_listing` (2.5) → amber `market.risk_warning` "⚠ Risk (S)". | +| `risk_score_low` | int (0/1) | Server flag driving the `show_risky` filter. | + +**UI states/errors:** same list-level states as catalog; risky active markets suppressed unless `show_risky=1`. + +> **VIZ thin-client note (suggestion):** This is the catalog plus per-oracle reliability + coverage (risk) enrichment. A thin client would additionally read each market's oracle account, its reliability metrics and insurance-vs-open-bets coverage from the node, compute the badge locally, and gate "risky" markets behind the user's `show_risky` preference. + +--- + +#### `load-categories` + +- **Method:** POST — `/api/load-categories/` +- **Fires when:** app startup (`load_categories()`, app.js 88–92); result cached in `categories_cache` and used by `render_category_filter()` and the create-market category dropdown. +- **Role/auth:** none. +- **Request:** empty body `{}`. + +**Response** (object, api.php 387–413): + +| Field | Type | UI usage | +|---|---|---| +| `categories[]` | array | Each `{id, icon, i18n, count, subcategories[], tags[]}`. `id`→chip `data-cat`; `icon`+localized `i18n`→chip label; `count`→`(n)` on chip. | +| `categories[].subcategories[]` | array | Each `{id, i18n, count}` → subcategory dropdown options. | +| `hot_tags[]` | array | Each `{tag, count}` (top 20 active tags, excluding `jurisdiction%`) → hot-tag chips. | + +> **VIZ thin-client note (suggestion):** Categories are a fixed taxonomy (`get_market_categories`) plus live counts. A thin client can hold the taxonomy statically and derive per-category/subcategory/tag counts by aggregating active markets from the node. + +--- + +#### `load-oracles` + +- **Method:** GET — `/api/load-oracles/` +- **Fires when:** opening the **create-market** form (`select_tab('create')`, app.js 897–918) to populate the oracle ``. +- **Role/auth:** none to fetch. +- **Request:** none. + +**Response** (array, api.php 500–515): each `{id, name}` (name resolved from account, else first/last name, else `@username`, else `User #id`). Rendered as options `name [#id]`, first option `market.committee_not_selected`. + +> **VIZ thin-client note (suggestion):** List accounts flagged as committees for optional dispute-resolution assignment at market creation. + +--- + +### Reliability badge (`render_reliability_badge` / `reliability_score_class` / `reliability_score_label`, app.js 595–614) + +Used inline on the card's creator/oracle meta line and on the market-detail oracle line. Given a numeric `score` (0–100) and `is_new` flag it renders `score — `: + +| Condition | CSS class | Label (i18n) | +|---|---|---| +| `is_new` | `reliability-new` | `oracle.score_new` "New" | +| `score ≥ 80` | `reliability-excellent` | `oracle.score_excellent` "Excellent" | +| `score ≥ 60` | `reliability-good` | `oracle.score_good` "Good" | +| `score ≥ 40` | `reliability-average` | `oracle.score_average` "Average" | +| `score ≥ 20` | `reliability-poor` | `oracle.score_poor` "Poor" | +| else | `reliability-unreliable` | `oracle.score_unreliable` "Unreliable" | + +--- + +### Boost auto-close banner (card, app.js 1246–1248) + +If `boost_active_loaded` and the market id is in `boost_active_market_ids` (populated from a cached leverage-info call), the card shows a `.boost-banner` with `boost.banner_text` "⚠️ Your boost auto-closes in %%TIME%%" and a live countdown. This is per-user leveraged-position state, not part of the list endpoints. + +> **VIZ thin-client note (suggestion):** Auto-close timers derive from the user's open leveraged positions vs. each market's betting expiration; the client can compute the banner locally from position + market data queried per user. +## 4. Market detail & binary betting (bet, commit-reveal, cancel, transfer) + +This section documents the single-market detail screen and the full binary-betting lifecycle (place, batch-queue, hidden commit-reveal, cancel, transfer). Everything is rendered client-side by `load_market_detail(market_id, jurisdiction_confirmed)` (app.js 1555–2041) from one enriched fetch (`load-market-enriched`). Amounts are shown/entered in VIZ (`Ƶ`) but the wire format is **milli-VIZ integers** (VIZ × 1000). Token/weight values, fee per-mille, and reserves are also integers. Fee/penalty "precision-6" values are divided by `price_precision_ratio` (10000) or `/10000` for display. + +> Note: this screen also renders Boost/leverage, add-liquidity, dispute, oracle-resolve and public liquidity/payout tables. Those are covered in their own sections; here we describe only the market header, the bet form, the "My bets" table, and the bet/cancel/transfer endpoints. + +--- + +### Screen: Market detail + +Route: hash `#/market/` (the code re-derives `market_id` from `document.location.hash.split('/')[2]` after actions). On entry, if the user is authenticated the client first calls `pm_process_pending_reveals()` to re-send any commit-reveal reveals left pending in `localStorage` (resilience — see commit-reveal below), then fetches `load-market-enriched`. + +**Jurisdiction gate.** After the fetch, if `!jurisdiction_confirmed && is_market_jurisdiction_banned(m.tags)` the client aborts the render and calls `show_jurisdiction_warning(market_id)` instead, showing `jurisdiction.warning` ("This market is restricted in your jurisdiction …") with buttons `jurisdiction.proceed` ("I understand, proceed" → re-enters `load_market_detail` with `jurisdiction_confirmed=true`) and `jurisdiction.go_back` ("Go back"). The banned check is driven by `m.tags` (array of tag strings from the enriched response). + +The detail body (built into `.market-detail`) is composed top-to-bottom of: + +1. **Question & status** — `m.q` as title; a status badge `status.market_` (0 Awaiting oracle / 1 Active / 2 Closed / 3 Resolved; a 4th "failed" style is used for anything else). If `m.payout_status>0` a second badge `status.payouts_label` + `payout_status_labels[m.payout_status]`. +2. **Grace countdown** — only when `status==3 && payout_status==1 && m.grace_period_end` and the end is in the future: badge `status.grace_countdown` ("Payout in