diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b238a0f..4fe0e31a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,18 @@ on: jobs: check: - runs-on: ubuntu-large + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - uses: DeterminateSystems/nix-installer-action@main + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + cmd-format: "nix develop .#default --command {0}" + workspaces: ./src-tauri -> target + - name: Install dependencies run: nix develop --command just install @@ -29,8 +35,8 @@ jobs: - name: Rust clippy run: nix develop --command just cargo-clippy - # - name: Rust tests - # run: nix develop --command just cargo-test + - name: Rust tests + run: nix develop --command just cargo-test build: if: github.event_name == 'push' && github.ref == 'refs/heads/master' @@ -39,13 +45,10 @@ jobs: fail-fast: false matrix: include: - - platform: macos-26 + - platform: blacksmith-6vcpu-macos-latest args: --target aarch64-apple-darwin arch: aarch64 - - platform: macos-26 - args: --target x86_64-apple-darwin - arch: x86_64 - - platform: ubuntu-22.04 + - platform: blacksmith-8vcpu-ubuntu-2404 args: "" arch: x86_64-linux @@ -54,7 +57,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Linux dependencies - if: matrix.platform == 'ubuntu-22.04' + if: contains(matrix.platform, 'ubuntu') run: | sudo apt-get update sudo apt-get install -y \ @@ -76,8 +79,6 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ startsWith(matrix.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Rust cache uses: swatinem/rust-cache@v2 @@ -101,14 +102,14 @@ jobs: args: ${{ matrix.args }} - name: Upload DMG artifact - if: startsWith(matrix.platform, 'macos') + if: contains(matrix.platform, 'macos') uses: actions/upload-artifact@v4 with: name: dmg-${{ matrix.arch }} path: src-tauri/target/${{ matrix.arch }}-apple-darwin/release/bundle/dmg/*.dmg - name: Upload Linux artifacts - if: matrix.platform == 'ubuntu-22.04' + if: contains(matrix.platform, 'ubuntu') uses: actions/upload-artifact@v4 with: name: linux-${{ matrix.arch }} @@ -120,7 +121,7 @@ jobs: release: if: github.event_name == 'push' needs: [build] - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: write steps: diff --git a/docs/architecture/deadcat-core-design.md b/docs/architecture/deadcat-core-design.md index faa3e50e..f5f1ecc3 100644 --- a/docs/architecture/deadcat-core-design.md +++ b/docs/architecture/deadcat-core-design.md @@ -2,11 +2,15 @@ ## Purpose -`deadcat-core` is a pure computation library for interacting with Deadcat prediction market covenants on Liquid/Elements. It enables any wallet or application to create, track, interpret, and transact with prediction markets, LMSR pools, and limit orders — without prescribing how chain data is fetched, how state is persisted, or how keys are managed. +`deadcat-core` is a pure computation library for interacting with Deadcat prediction market covenants on Liquid/Elements. It enables any wallet or application to create, track, interpret, and transact with prediction markets (binary and multi-outcome), LMSR pools, and limit orders — without prescribing how chain data is fetched, how state is persisted, or how keys are managed. The primary motivating use case: integrating Deadcat functionality into existing wallets like Aqua, which already have their own wallet backend, chain connection, signer, and state management. These wallets need the covenant logic without an opinionated runtime. -**Implementation prerequisite**: This document specifies the planned end state — after several pending `.simf` covenant refactors (collateral-per-pair rename, oracle BIP-340 tagged hash, cosigner removal, script-cancel removal, pool close path addition, pool param constants). These refactors should be applied before implementing `deadcat-core`. See [contract-specification.md § Pending Refactors](../contracts/contract-specification.md#pending-refactors) for the complete list and status. +**Contract scope**: `deadcat-core` supports two market contract types (binary and multi-outcome) plus one pool contract type (binary LMSR pool, used both directly for binary markets and via Option C composition for multi-outcome markets — see [amm-scoring-rule-tradeoffs.md](../contracts/multi-outcome/amm-scoring-rule-tradeoffs.md) and [multi-outcome-market-contract.md](../contracts/multi-outcome/multi-outcome-market-contract.md)) plus the maker order contract. The unified API (see [Option E decision in Design Decisions Log](#multi-outcome-market-support-option-e-unified-api-enum-dispatched-internals)) exposes per-market operations via the `Market` view type, with multi-outcome-specific operations accessible via `Market::as_multi_outcome()` type-level specialization. + +**Implementation target**: This document is the implementation target for `deadcat-core`. Some legacy `deadcat-sdk` covenant/source files still need to be brought into line with this spec (collateral-per-pair rename, oracle BIP-340 tagged hash, cosigner removal, script-cancel removal, pool close path addition, pool param constants, plus the new multi-outcome market contract). See [contract-specification.md § Legacy Source Alignment Checklist](../contracts/contract-specification.md#legacy-source-alignment-checklist) for that migration checklist; it does not indicate unresolved protocol behavior in this document. + +**Scope**: this document describes the unified API for both binary and multi-outcome markets. The type system uses umbrella enums (`MarketParams`, `MarketState`, `MarketTransition`) over Binary/MultiOutcome-paired inner types, a `MarketResolution` discriminated union for oracle APIs, view types (`Market<'a, S>`, `Pool<'a, S>`, `Order<'a, S>`) that cache state and enforce freshness via lifetimes, and a transition-classification model where each on-chain transaction maps to exactly one covenant spend path. Multi-outcome markets use a single generic solvency-preservation spend path for all Unresolved-phase operations (see [`multi-outcome-market-contract.md § Operations`](../contracts/multi-outcome/multi-outcome-market-contract.md#operations)); the engine pattern-matches observed tx deltas into named `MultiOutcomeMarketTransition` variants (including `CrossOutcomeSwap` as a single-tx primitive) or falls back to `Composite` for arbitrary delta shapes. ## Architecture Overview @@ -53,6 +57,73 @@ deadcat-node Full batteries-included runtime. Wraps SDK with Nostr discovery Note: `deadcat-core` defines the `ContractStore` and `ContractHistory` traits. `deadcat-node` provides concrete SQLite implementations of both. The distinction: core defines the interfaces, node provides storage implementations. A consumer like Aqua would implement these traits against their own database. +## System Invariants + +Load-bearing guarantees that the `deadcat-core` library exposes and depends on. Each is stated as a property of the system, with the enforcement mechanism cross-referenced. These are the checklist against which both the library and every `.simf` contract must be audited — if an implementation conflicts with an invariant, the implementation is wrong. + +### Script uniqueness per live contract + +Every live maker order UTXO has a unique covenant script; every live LMSR pool reserve UTXO has a unique covenant script (per reserve role); every market has unique covenant scripts across its slot layout. Orders and pools achieve uniqueness via per-index derivation of `maker_pubkey` / `admin_pubkey` and per-index HMAC-derived nonces — the wallet increments `order_index` / `pool_index` per contract. Markets achieve it via 2N issuance-entropy-derived asset IDs, unique by Elements consensus. CMR collision across live contracts is structurally prevented. See [chain-only-recovery.md § Key Derivation](../protocol/chain-only-recovery.md#key-derivation), [§ Order Nonce Derivation](../protocol/chain-only-recovery.md#order-nonce-derivation), and [transaction-composability-model.md § Script Uniqueness Guarantee](transaction-composability-model.md#script-uniqueness-guarantee). + +### Covenants self-enforce + +Covenants verify their own correctness against arbitrary transactions. Any constraint that protects funds, preserves solvency, or prevents griefing is enforced by the Simplicity program, not by builder conventions. Builder-layer constraints are confined to recovery decodability (bucket 2 of the self-enforcement classification) and never fund safety (bucket 3 is forbidden). See [market-contract-principles.md § Covenant self-enforcement](../contracts/market-contract-principles.md#covenant-self-enforcement). + +### Sibling group atomicity + +Every market transition in active phases (Unresolved, Dormant) spends all covenant UTXOs in its sibling group atomically. The covenant rejects partial spends that would leave orphaned RTs or orphaned collateral. Partial-cancel / partial-burn transitions are no exception — they still co-spend the full sibling set to maintain the `prev_txid`-equality invariant across the contract's lifetime. See [market-contract-principles.md § Principle 13](../contracts/market-contract-principles.md#13-sibling-utxo-check-on-co-spent-covenant-inputs) and [enforcement-layers.md](enforcement-layers.md). + +### One covenant spend path per on-chain transaction + +Each on-chain transaction that touches a Deadcat covenant exercises exactly one covenant spend path. Transition classification is deterministic: the engine pattern-matches the tx's observable effects (RT issuance, burn outputs, collateral delta) to a single named variant — binary primitives for the binary market, or one of the generic-path delta-shape classifications (`IssuedPair`, `SplitYes`, `CrossOutcomeSwap`, `Composite`, etc.) for the multi-outcome market. This enables unambiguous indexing, replay, and interpretation. + +### Deterministic reconstructibility — owner-level + +For any contract the user created (market, pool, order), mnemonic + authoritative chain data suffice to reconstruct all covenant parameters and private material (keys, nonces, masked indices) required to recover custody, sign cancellations, or exercise admin paths. No off-chain backup is required beyond the mnemonic. See [chain-only-recovery.md](../protocol/chain-only-recovery.md). + +### Deterministic reconstructibility — non-owner-level + +For any Deadcat contract on-chain, regardless of creator, the creation transaction plus its OP_RETURN hint suffice for any node to parameterize the contract, verify script pubkey authenticity via re-derivation, and construct transactions that interact with it (trade, redeem, observe). Non-owners cannot reconstruct the owner's private material but do not need it for interaction. This property enables permissionless discovery and participation. + +### OP_RETURN authenticity is verifiable + +Every covenant creation hint is reverse-verifiable: a parameter set parsed from an OP_RETURN, re-compiled to a covenant script, must match the UTXO's on-chain script pubkey. Spoofed or stale hints produce a compile-then-compare failure and are rejectable at the recovery layer before any downstream state is trusted. + +### RT deterministic blinding + +Reissuance token continuation outputs use covenant-enforced deterministic blinding factors (ABF derived from tagged hash of the defining outpoint; CBF passed through unchanged; VBF computed as `CBF - ABF`). This removes the traditional Elements-layer RT-secrecy safeguard deliberately — enabling permissionless recovery and transaction construction — and makes the covenant's enforcement the sole defense against blinding-griefing. See [market-contract-principles.md § Principle 11](../contracts/market-contract-principles.md#11-deterministic-rt-blinding) and [deterministic-rt-blinding.md](../protocol/deterministic-rt-blinding.md). + +### View freshness via lifetimes + +Public view types (`Market<'a, S>`, `Pool<'a, S>`, `Order<'a, S>`, `MultiOutcomeMarket<'a, S>`) carry the store's lifetime. The borrow checker prevents a caller from holding a view across a mutation of the underlying store, eliminating the stale-view class of bugs without runtime checks. No freshness flags, no revalidation API — the type system enforces it. + +## Design Principles + +Policies that shape the `deadcat-core` public API. Invariants (above) are correctness constraints the system must uphold; principles are discretion constraints — they govern what the API chooses to offer vs. what it intentionally omits. + +### Engine gates covenant-invalidity and impossibility, not unfavorability + +`deadcat-core` provides operations that are **covenant-valid, possible, and not strictly dominated for the caller's role in that invocation**. An operation is "strictly dominated" when there is always a better way to achieve the same goal — the engine's router, for example, picks the best-price path rather than offering suboptimal alternatives. + +When a helper exists only to choose a default among multiple covenant-valid ways to reach substantially the same outcome, core may return a canonical recommendation. But it does **not** collapse distinct target states into one "approved" path. For LMSR pools, for example, `estimate_bootstrap` can recommend a lean default reserve vector while `build_lmsr_bootstrap_pset` still accepts explicit caller-chosen reserves. + +Operations may be unfavorable for counterparties. An informed trader dumping post-resolution tokens harms the pool operator; a taker filling a maker's order may not be what the maker wants post-resolution. That's inherent to adversarial markets. The engine's job is to serve each caller's role in their invocation cleanly; counterparties protect themselves through their own actions (pool operators close pools after resolution; makers cancel stale orders) or through covenant-level invariants. + +`CoreError` variants reflect this boundary: +- **Structural caller mismatch**: `InvalidParams` (wrong API-shape input such as a binary/multi-outcome resolution mismatch) +- **Canonicality / recovery boundary**: `ConventionViolation` (outside the canonical v1 recovery conventions), `ParentMarketNotTracked` (referenced market absent from the tracked set), `InvalidCreationTx` (on-chain tx doesn't match the claimed params) +- **Cryptographic / covenant validity**: `OracleSignatureInvalid`, `InvalidContractState`, `CovenantInvariantViolation` +- **Impossibility**: `NoLiquidity` (can't fill any positive amount), `InsufficientFunds` (wallet can't cover) +- **Information integrity**: `StaleQuote` (cached state no longer accurate), `ContractAlreadyTracked` (caller bug) + +No variant exists for "this is valid and possible but we refuse because it's inadvisable." Such a refusal would provide false safety against adversarial actors (who fork or bypass core) while adding friction for legitimate edge cases. + +### Multi-role patterns deferred to future versions + +v1 focuses on single-role operations — one caller, one role, one intent per invocation. Compositions that span multiple roles within a single atomic transaction (trader + LP self-routing, take-and-post-only remainder, market maker rebalancing across orders and pools, cross-outcome arbitrage, atomic market + pool creation) are recognized as valuable but not covered by v1's APIs. + +Until those compositions get first-class support, callers whose actual intent spans multiple roles construct PSETs directly against the covenant spec; the single-role APIs provide the ingredients (params, state inspection, contract compilation, LMSR math) but not the atomic composition. Specific named patterns deferred in the [implementation plan](deadcat-core-implementation-plan.md#deferred--out-of-scope-items) include cross-outcome arb and atomic issuance + pool bootstrap; other multi-role patterns are unnamed future work and will be added to the plan as concrete user needs emerge. + ## ContractEngine `ContractEngine` is the central type in `deadcat-core`. It owns the store, manages contract state, processes transactions, and provides interpretation and asset identification. @@ -72,15 +143,17 @@ The engine takes exclusive ownership of the store. The caller creates a `Contrac ### API Overview +`ContractEngine` owns three responsibility clusters: **write operations** (ingestion, chain sync), **discovery** (listing, lookup, asset identification, interpretation), and **operations that don't have a tracked contract yet** (creation builders, trade routing). Per-contract operations live on **view types** (`Market`, `Pool`, `Order`, `MultiOutcomeMarket`) returned by engine accessors. See [View Types](#view-types) for the per-contract API surface. + ```rust impl ContractEngine { - // Construction + // ---- Construction ---- pub fn new(store: S, network: Network) -> Self; - // Contract ingestion (per-type — see Contract Ingestion section) + // ---- Ingestion (writes — &mut self) ---- pub fn ingest_market( &mut self, - params: &PredictionMarketParams, + params: &MarketParams, // Binary(..) or MultiOutcome(..) creation_tx: &ChainTransaction, ) -> Result>; @@ -90,33 +163,82 @@ impl ContractEngine { snapshot: PoolSnapshot, ) -> Result>; - pub fn ingest_order( + /// Ingest for ownership monitoring: full history, persistent storage, + /// no auto-cleanup on terminal state. Requires the creation tx. + /// Sets `OrderState.tracking = OrderTracking::Persistent`. + pub fn ingest_persistent_order( + &mut self, + params: &MakerOrderParams, + creation_tx: &ChainTransaction, + ) -> Result>; + + /// Ingest for routing/discovery: no history, auto-untracks past finality + /// when terminal. Accepts either a `Creation` snapshot (accurate + /// `offered_amount`) or a `Current` snapshot (baseline-at-discovery + /// `offered_amount`). Sets `OrderState.tracking` to `EphemeralFresh` or + /// `EphemeralMidLife` respectively. + pub fn ingest_ephemeral_order( &mut self, params: &MakerOrderParams, snapshot: OrderSnapshot, ) -> Result>; - // Contract removal pub fn untrack_contract(&mut self, contract_id: &ContractId) -> Result<(), CoreError>; - // Contract queries (reads — &self) - pub fn contract(&self, contract_id: &ContractId) -> Result, CoreError>; + // ---- Chain sync (writes — &mut self) ---- + pub fn step(&mut self, chain: &mut C) -> Result>; + pub fn rollback_to_height(&mut self, height: u32) -> Result<(), CoreError>; + pub fn prune_finalized(&mut self, current_height: u32, finality_depth: u32) -> Result<(), CoreError>; - // Per-type listing (reads — &self) + // ---- Discovery (reads — &self) ---- + pub fn contract(&self, contract_id: &ContractId) -> Result, CoreError>; pub fn list_markets(&self, filter: StateFilter, page: Pagination) -> Result, CoreError>; pub fn list_pools(&self, filter: StateFilter, page: Pagination) -> Result, CoreError>; pub fn list_orders(&self, filter: StateFilter, page: Pagination) -> Result, CoreError>; + pub fn identify_asset(&self, asset_id: &AssetId) -> Result, CoreError>; + pub fn interpret_transaction(&self, tx: &Transaction) -> Result>; - // Relationship queries (reads — &self) - pub fn pools_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, CoreError>; - pub fn orders_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, CoreError>; + // ---- View accessors (reads — &self) ---- + // Views cache (contract_id, params, state) at construction. Returns None if not tracked. + pub fn market(&self, id: &ContractId) -> Result>, CoreError>; + pub fn pool(&self, id: &ContractId) -> Result>, CoreError>; + pub fn order(&self, id: &ContractId) -> Result>, CoreError>; - // Chain sync (writes — &mut self) - pub fn step(&mut self, chain: &mut C) -> Result>; - pub fn rollback_to_height(&mut self, height: u32) -> Result<(), CoreError>; - pub fn prune_finalized(&mut self, current_height: u32, finality_depth: u32) -> Result<(), CoreError>; + // ---- Creation builders (reads — &self; contract doesn't exist yet) ---- + pub fn build_binary_market_creation_pset( + &self, + params: &BinaryMarketCreationParams, + funding: &WalletFunding, + ) -> Result<(PreBlindedPset, BinaryMarketParams), CoreError>; + + pub fn build_multi_outcome_market_creation_pset( + &self, + params: &MultiOutcomeMarketCreationParams, + funding: &WalletFunding, + ) -> Result<(PreBlindedPset, MultiOutcomeMarketParams), CoreError>; + + pub fn build_lmsr_bootstrap_pset( + &self, + params: &LmsrPoolParams, + initial_s_index: u16, + initial_reserves: PoolReserves, + masked_index: u16, + funding: &WalletFunding, + ) -> Result>; - // Trade quoting (reads — &self) + pub fn build_create_order_pset( + &self, + params: &MakerOrderParams, + offered_amount: u64, + masked_index: u16, + funding: &WalletFunding, + ) -> Result>; + + // ---- Trade routing (reads — &self) ---- + // Routes across all pools and maker orders for a given (market, outcome, side). + // For multi-outcome markets, targets a single outcome's binary LMSR pool (and matching LOB orders). + // Basket trades (cross-outcome splits/merges) are handled via MultiOutcomeMarket view; + // cross-outcome arb (market + N pools atomic) is deferred to v2. pub fn quote_trade( &self, market_id: &ContractId, @@ -124,82 +246,91 @@ impl ContractEngine { fee_rate: FeeRate, ) -> Result>; - // PSET builders (reads — &self) - // All builders take a &WalletFunding for coin selection, fee computation, and change. - // Creation builders take concrete param types (compile on the fly). - // Post-ingestion builders take contract_id (recompile from stored params). - // Builders that involve reissuance token outputs return UnblindedPset (see Confidential Transaction Blinding). - // All other builders return PartiallySignedTransaction directly. - - // Prediction market builders (RT-involving → UnblindedPset) - pub fn build_creation_pset(&self, params: &MarketCreationParams, funding: &WalletFunding) -> Result<(UnblindedPset, PredictionMarketParams), CoreError>; - pub fn build_issuance_pset(&self, contract_id: &ContractId, pairs: u64, yes_dest: &Script, no_dest: &Script, funding: &WalletFunding) -> Result>; - pub fn build_cancellation_pset(&self, contract_id: &ContractId, pairs_to_burn: Option, funding: &WalletFunding) -> Result>; - pub fn build_oracle_resolve_pset(&self, contract_id: &ContractId, oracle_attestation: &schnorr::Signature, funding: &WalletFunding) -> Result>; - pub fn build_expire_transition_pset(&self, contract_id: &ContractId, funding: &WalletFunding) -> Result>; - // Prediction market builder (no RT involvement → PartiallySignedTransaction) - pub fn build_redemption_pset(&self, contract_id: &ContractId, side: Side, tokens_to_redeem: u64, funding: &WalletFunding) -> Result>; - // LMSR pool builders - pub fn build_lmsr_bootstrap_pset(&self, params: &LmsrPoolParams, starting_price_bps: u16, masked_index: u16, funding: &WalletFunding) -> Result>; - pub fn build_lmsr_adjust_pset(&self, contract_id: &ContractId, pair_delta: i64, collateral_delta: i64, funding: &WalletFunding) -> Result>; - pub fn build_lmsr_close_pset(&self, contract_id: &ContractId, funding: &WalletFunding) -> Result>; - // Maker order builders (maker lifecycle only — taker fills go through build_trade_pset) - pub fn build_create_order_pset(&self, params: &MakerOrderParams, offered_amount: u64, masked_index: u16, funding: &WalletFunding) -> Result>; - pub fn build_cancel_order_pset(&self, contract_id: &ContractId, funding: &WalletFunding) -> Result>; - // Trade builder (uses TradeQuote from quote_trade — handles all taker operations including order fills) - pub fn build_trade_pset(&self, quote: &TradeQuote, funding: &WalletFunding) -> Result>; - - // Transaction interpretation (reads — &self) - pub fn interpret_transaction(&self, tx: &Transaction) -> Result>; - pub fn identify_asset(&self, asset_id: &AssetId) -> Result, CoreError>; - - // Oracle attestation - pub fn oracle_attestation_spec(&self, contract_id: &ContractId, outcome_yes: bool) -> Result>; + pub fn build_trade_pset( + &self, + quote: &TradeQuote, + funding: &WalletFunding, + ) -> Result>; } -// Standalone pure functions (no engine needed) -pub fn contract_cmr(params: &ContractParams, network: Network) -> Cmr; // requires Simplicity compilation -pub fn oracle_attestation_message(yes_asset_id: &AssetId, no_asset_id: &AssetId, outcome_yes: bool) -> [u8; 32]; -pub fn estimate_bootstrap(max_loss_sats: u64, half_payout_sats: u64, starting_price_bps: u16) -> BootstrapEstimate; -pub fn derive_pool_params(deadcat_xprv: &Xpriv, market_params: &PredictionMarketParams, pool_index: u16, max_loss_sats: u64, half_payout_sats: u64, fee_bps: u16, starting_price_bps: u16) -> Result<(LmsrPoolParams, u16 /* masked_index */), ConventionError>; -pub fn derive_order_params(deadcat_xprv: &Xpriv, market_params: &PredictionMarketParams, order_index: u16, side: Side, direction: OrderDirection, price: u64, min_fill_lots: u8, min_remainder_lots: u8) -> Result<(MakerOrderParams, u16 /* masked_index */), ConventionError>; +// ---- Standalone pure functions (no engine needed) ---- -// History methods — only available when the store implements ContractHistory -impl ContractEngine { - pub fn market_history( - &self, - contract_id: &ContractId, - after: Option, - limit: u32, - ) -> Result, CoreError>; +/// CMR from contract params + network. Requires Simplicity compilation. +pub fn contract_cmr(params: &ContractParams, network: Network) -> Cmr; - pub fn pool_history( - &self, - contract_id: &ContractId, - after: Option, - limit: u32, - ) -> Result, CoreError>; +/// Compute the market_id (32-byte tagged-hash input) from market params. +pub fn compute_market_id(params: &MarketParams) -> MarketId; - pub fn order_history( - &self, - contract_id: &ContractId, - after: Option, - limit: u32, - ) -> Result, CoreError>; -} +/// Compute the BIP-340 tagged-hash message the oracle needs to sign. +/// Usable by oracle services without a ContractEngine. +pub fn oracle_attestation_message(market_id: MarketId, resolution: MarketResolution) -> [u8; 32]; + +pub fn estimate_bootstrap( + max_loss_sats: u64, + half_payout_sats: u64, + starting_price_bps: u16, +) -> Result; + +pub fn derive_pool_params( + deadcat_xprv: &Xpriv, + market_params: &MarketParams, // accepts binary or multi-outcome + outcome: OutcomeIndex, // which outcome's YES/NO pair the pool serves + pool_index: u16, + max_loss_sats: u64, + half_payout_sats: u64, + fee_bps: u16, + initial_s_index: u16, // from estimate_bootstrap (creation) or hint (recovery) +) -> Result<(LmsrPoolParams, u16 /* masked_index */), ConventionError>; + +pub fn derive_order_params( + deadcat_xprv: &Xpriv, + market_params: &MarketParams, // accepts binary or multi-outcome + outcome: OutcomeIndex, // which outcome's YES/NO pair the order offers + order_index: u16, + side: Side, + direction: OrderDirection, + price: u64, + min_fill_lots: u8, + min_remainder_lots: u8, +) -> Result<(MakerOrderParams, u16 /* masked_index */), ConventionError>; ``` -Write methods take `&mut self`. Read methods (including all PSET builders) take `&self`. Rust's borrow rules enforce at compile time that only one writer OR multiple readers can access the engine at any given time — analogous to `RwLock` semantics without runtime overhead. This means store implementors only need to worry about atomic application of state updates, not concurrent access or out-of-order writes. +Write methods take `&mut self`. Read methods take `&self`. Rust's borrow rules enforce at compile time that only one writer OR multiple readers can access the engine at any given time — analogous to `RwLock` semantics without runtime overhead. While any view type (`Market`, `Pool`, `Order`) is alive, the engine holds an immutable borrow; mutations are blocked until the view is dropped. -**PSET builders are engine methods**: All PSET builders live on the engine because they need Simplicity compilation for witness encoding (see [Simplicity Contracts](#simplicity-contracts-internal)). Simplicity contract compilation, CMR derivation, taproot tree construction, and script pubkey generation are all internal to the engine — consumers never interact with these concepts. Consumers provide contract params (plain data) and a `WalletFunding` struct, and receive PSETs back. See [PSET Construction](#pset-construction) for details. +**Per-contract operations live on views, not on the engine.** See [View Types](#view-types) for detailed per-view documentation: +- `Market<'a, S>` exposes issuance, cancellation, resolution, redemption, expiry builders plus oracle helpers, related-contract queries, and multi-outcome specialization. +- `MultiOutcomeMarket<'a, S>` exposes cross-outcome primitives (split-YES, merge-YES, split-NO, merge-NO). Obtained via `Market::as_multi_outcome()`; returns `None` for binary markets. Cross-outcome arb (market + N pools atomic) is deferred to v2. +- `Pool<'a, S>` exposes adjust and close builders plus parent-market navigation. +- `Order<'a, S>` exposes cancel builder plus parent-market navigation. -**Creation builders** (`build_creation_pset`, `build_lmsr_bootstrap_pset`, `build_create_order_pset`) take concrete param types instead of a `ContractId` because the contract hasn't been ingested yet. `build_creation_pset` takes `&MarketCreationParams` (only the 4 non-derivable fields — oracle key, collateral asset, collateral per pair, expiry time) and returns the full `PredictionMarketParams` alongside the PSET (the 4 derivable token/RT asset IDs are computed internally from the selected defining inputs). `build_lmsr_bootstrap_pset` and `build_create_order_pset` take `&LmsrPoolParams` and `&MakerOrderParams` respectively (fully formed params). The engine compiles the Simplicity contract on the fly at PSET build time. Post-ingestion builders also recompile from stored params on each call — see [Simplicity Contracts](#simplicity-contracts-internal) for the compilation cost model and rationale. +**Creation builders stay on the engine** because the contract doesn't exist yet — there's no view to operate on. They take concrete param types and, for markets, return the derived full-params alongside the PSET (the 4 / 4N token and RT asset IDs are derived from selected defining inputs). `build_lmsr_bootstrap_pset` takes `LmsrPoolParams` fully formed plus an explicit starting state (`initial_s_index`) and explicit starting reserves (`initial_reserves`). `estimate_bootstrap` is just the canonical default-policy helper for choosing those reserves; it is not the only valid bootstrap shape. `build_create_order_pset` takes `MakerOrderParams` similarly. -**No per-builder args structs**: PSET builders take operation-specific arguments as direct parameters alongside a shared `WalletFunding` struct (available UTXOs, fee rate, return script). This avoids a zoo of single-use parameter types — the function signature IS the documentation. See [WalletFunding](#walletfunding) and [PSET Construction](#pset-construction). +**Trade routing stays on the engine** because quoting inspects *multiple* contracts at once (the pool(s) and resting maker orders for a given market outcome). Putting `quote_trade` on the `Market` view would require the view to see other tracked contracts too, defeating the encapsulation — simpler to keep routing at the engine level where access to all tracked contracts is natural. `build_trade_pset` stays on the engine for the same reason. + +**History methods — only available when the store implements `ContractHistory`. Exposed on view types**: + +```rust +impl<'a, S: ContractHistory> Market<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; +} + +impl<'a, S: ContractHistory> Pool<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; +} + +impl<'a, S: ContractHistory> Order<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; +} +``` + +**Maker order lifecycle vs taker trades**: the maker side of limit orders is directly exposed (`engine.build_create_order_pset`, `order.build_cancel_pset`). The taker side — filling orders — is handled through the trade system (`engine.quote_trade` + `engine.build_trade_pset`), which routes across pools and orders for best execution. There is intentionally no `build_fill_order_pset` — direct order targeting adds API complexity without improving execution, since the router always finds the best available fill. If explicit order targeting becomes a requested feature, a direct fill builder can be added later as a non-breaking change. -**Merged builders**: `build_issuance_pset` handles both initial and subsequent issuance — the engine determines which from the contract's current state (zero outstanding pairs vs non-zero). `build_redemption_pset` handles both post-resolution and post-expiry redemption — the engine determines which from the current state. The `side` parameter on `build_redemption_pset` specifies which token to burn: for resolved markets, the engine validates it matches the winning side; for expired markets, either side is valid. +**Merged builders**: `Market::build_issuance_pset` handles both initial (Dormant → Unresolved) and subsequent (Unresolved → Unresolved) issuance — the view determines which from the cached state. `Market::build_redemption_pset` handles both post-resolution and post-expiry redemption. For binary markets, the `side` parameter specifies which token to burn; for resolved markets, the engine validates it matches the winning side; for expired markets, either side is valid. Multi-outcome redemption also takes `outcome: OutcomeIndex` to specify which outcome's token pair. -**Maker order lifecycle vs taker trades**: The maker side of limit orders is directly exposed (`build_create_order_pset`, `build_cancel_order_pset`). The taker side — filling orders — is handled through the trade system (`quote_trade` + `build_trade_pset`), which routes across pools and orders for best execution. There is intentionally no `build_fill_order_pset` — direct order targeting adds API complexity without improving execution, since the router always finds the best available fill. If explicit order targeting becomes a requested feature, a direct fill builder can be added later as a non-breaking change (new method on the engine, no store or type changes required). +**No per-builder args structs**: PSET builders take operation-specific arguments as direct parameters alongside a shared `WalletFunding` struct (available UTXOs, fee rate, return script). This avoids a zoo of single-use parameter types — the function signature IS the documentation. See [WalletFunding](#walletfunding) and [PSET Construction](#pset-construction). Note: The history `impl` block uses `S: ContractHistory` rather than `S: ContractStore + ContractHistory` because `ContractHistory` is a supertrait of `ContractStore` — the `ContractStore` bound is implied. See [ContractHistory](#optional-contracthistory). @@ -235,12 +366,12 @@ Markets are always ingested from their creation transaction: ```rust pub fn ingest_market( &mut self, - params: &PredictionMarketParams, + params: &MarketParams, // Binary(..) or MultiOutcome(..) creation_tx: &ChainTransaction, ) -> Result>; ``` -The engine compiles the Simplicity contract from the parameters, derives deterministic blinding factors for creation verification (see [Deterministic RT Blinding](../protocol/deterministic-rt-blinding.md)), verifies the creation transaction contains the expected covenant scripts, derives the initial outpoints, indexes asset IDs and scripts, and begins tracking. Returns the `ContractId` (CMR + creation txid). +The engine compiles the Simplicity contract from the parameters (for multi-outcome, selects the N-specific generated `.simf`), derives deterministic blinding factors for creation verification (see [Deterministic RT Blinding](../protocol/deterministic-rt-blinding.md)), verifies the creation transaction contains the expected covenant scripts and all expected token issuances (2 for binary, 2N for multi-outcome), derives the initial outpoints, indexes asset IDs and scripts, and begins tracking. Returns the `ContractId` (CMR + creation txid). **No anchor required**: Prediction market creation transactions include blinded reissuance token outputs. The blinding factors for these outputs are derived deterministically from public on-chain data (the defining outpoints), so no out-of-band anchor data is needed. @@ -273,14 +404,27 @@ pub enum PoolSnapshot { } ``` -With `PoolSnapshot::Creation`, the engine processes the creation transaction to derive initial state — same as market ingestion. The engine also verifies the pool's curve is well-formed: it derives `b` from `params.max_loss_sats`, recomputes `q_step_lots`, regenerates the full F-value table, and checks the Merkle root matches `params.lmsr_table_root`. A mismatch indicates the pool was created with a non-canonical table generation algorithm — the engine returns `CoreError::InvalidParams`. This verification costs ~80ms (table generation) and runs once at ingestion. With `PoolSnapshot::Current`, the engine starts tracking from the provided state without verifying history back to creation. The trade-off: `Current` = fast start (no history replay needed), but no prior transition history is recoverable. `Creation` = full history available via forward-sync from creation. Note: `Current` also sidesteps s_index derivation entirely (the caller provides `s_index` directly), making it useful for ingesting untrusted pools from unknown operators where the creation transaction's OP_RETURN may not be available or trustworthy. +With `PoolSnapshot::Creation`, the engine processes the creation transaction to derive initial state — same as market ingestion. The engine also verifies the pool's curve is well-formed: it derives `b` from `params.max_loss_sats`, recomputes `q_step_lots`, regenerates the full F-value table, and checks the Merkle root matches `params.lmsr_table_root`. A mismatch indicates the pool was created with a non-canonical table generation algorithm — the engine returns `CoreError::ConventionViolation { detail }`. This verification is a cold-cache table-generation step: the first use of a given `(max_loss_sats, half_payout_sats)` combo incurs the full bignum table cost (~5-10s), while later ingestions reuse the in-memory cache. With `PoolSnapshot::Current`, the engine starts tracking from the provided state without verifying history back to creation. The trade-off: `Current` = fast start (no history replay needed), but no prior transition history is recoverable. `Creation` = full history available via forward-sync from creation. Note: `Current` also sidesteps s_index derivation entirely (the caller provides `s_index` directly), making it useful for fast-start ingestion when the creation transaction is unavailable or the caller intentionally skips historical proof; the engine still enforces canonical supplied params and canonical-parent-market membership. -#### ingest_order +#### ingest_persistent_order and ingest_ephemeral_order -Orders support both creation-tx and non-initial ingestion: +Orders have two ingestion methods corresponding to the two tracking modes. The method signals the caller's intent at call sites (is this an order I own and want to audit, or one I'm tracking for routing?): ```rust -pub fn ingest_order( +/// Maker monitoring their own order: full history, persistent storage. +/// Requires the creation tx — accurate starting state is a prerequisite +/// for meaningful history. Sets tracking = Persistent. +pub fn ingest_persistent_order( + &mut self, + params: &MakerOrderParams, + creation_tx: &ChainTransaction, +) -> Result>; + +/// Taker / discoverer: no history, auto-untracks past finality when terminal. +/// Accepts either a Creation snapshot (accurate offered_amount, tracking +/// set to EphemeralFresh) or a Current snapshot (baseline-at-discovery +/// offered_amount, tracking set to EphemeralMidLife). +pub fn ingest_ephemeral_order( &mut self, params: &MakerOrderParams, snapshot: OrderSnapshot, @@ -297,7 +441,13 @@ pub enum OrderSnapshot { } ``` -Same trade-off as pools: `Creation` gives full fill history; `Current` gives fast start with no history. Takers typically use `Current` (they only care about the current state for filling). Makers who want fill history use `Creation`. +**Who calls which**: +- Makers ingest their own orders via `ingest_persistent_order`. +- Takers discovering orders for routing use `ingest_ephemeral_order` — typically with a `Current` snapshot (they don't have the creation tx), but `Creation` is accepted too when the discovery channel carries the creation tx (enabling honest fill-progress displays without the history storage cost). + +**Tracking mode is immutable after ingestion**: to change tracking mode, call `untrack_contract` first, then re-ingest via the other method. This is slow for the `Ephemeral → Persistent` direction because the re-ingestion via `ingest_persistent_order` forward-syncs all fills from creation to rebuild history. Acceptable for the rare promotion scenario; not optimized further. + +**Duplicate ingestion uniformly errors**: calling either method on an already-tracked `ContractId` returns `CoreError::ContractAlreadyTracked { contract_id }`, regardless of which method was used first. `ContractId` is derived from `CMR + creation_txid`, so the same underlying order produces the same `ContractId` through either method. #### Common ingestion behavior @@ -311,9 +461,11 @@ let contract_id = match engine.ingest_market(¶ms, &creation_tx) { Err(CoreError::ContractAlreadyTracked { contract_id }) => contract_id, Err(e) => return Err(e), }; +// `params` is `MarketParams` (Binary(..) or MultiOutcome(..)); passed by reference consistent with +// ingest_pool / ingest_persistent_order / ingest_ephemeral_order. ``` -**Parent market required**: `ingest_pool` and `ingest_order` validate that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, the engine returns `CoreError::InvalidParams`. `ingest_market` has no parent requirement. +**Parent market required**: `ingest_pool`, `ingest_persistent_order`, and `ingest_ephemeral_order` validate that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, the engine returns `CoreError::ParentMarketNotTracked { detail }`. `ingest_market` has no parent requirement. ### untrack_contract @@ -394,7 +546,7 @@ pub fn list_markets( ) -> Result, CoreError>; ``` -The store's listing methods return typed results (e.g., `Page` rather than `Page<(ContractId, Contract)>`). `MarketEntry` is a type alias for `ContractEntry` — see [ContractEntry](#contractentry). This enforces the type invariant at compile time — a `list_markets` implementation cannot accidentally return a pool or order. If the store's own data is corrupted (a "market" row deserializes to a different type), the error surfaces at the store layer via `Self::Error`, where data corruption errors belong. +The store's listing methods return typed results (e.g., `Page` rather than `Page<(ContractId, Contract)>`). `MarketEntry` is a type alias for `ContractEntry` — see [ContractEntry](#contractentry). Using the umbrella types means `list_markets` returns both binary and multi-outcome markets in a single call. This enforces the type invariant at compile time — a `list_markets` implementation cannot accidentally return a pool or order. If the store's own data is corrupted (a "market" row deserializes to a different type), the error surfaces at the store layer via `Self::Error`, where data corruption errors belong. **Pagination**: All listing methods use cursor-based pagination. See [Pagination Types](#pagination-types). @@ -402,40 +554,45 @@ The store's listing methods return typed results (e.g., `Page` rath ### Relationship Queries -```rust -pub fn pools_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, CoreError>; -pub fn orders_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, CoreError>; -``` +Relationship queries live on view types, not on the engine. See [View Types § Market](#market): `market.pools(filter, page)` and `market.orders(filter, page)` return pools and orders associated with the market. These accept `StateFilter` to avoid paging through terminal contracts at scale — a popular market could accumulate thousands of consumed/cancelled orders. + +The relationship is encoded in pool/order params (they reference the market's token asset IDs — binary markets: `yes_token_asset_id` / `no_token_asset_id`; multi-outcome markets: `yes_token_asset_ids[k]` / `no_token_asset_ids[k]` for a specific outcome k). The store maintains a secondary index on market_id for efficient lookups, built during ingestion by resolving the pool/order's token asset IDs via the store's own `find_by_asset_id` index. -Return pools or orders associated with a specific market. Accept `StateFilter` to avoid paging through terminal contracts at scale — a popular market could accumulate thousands of consumed/cancelled orders. The relationship is encoded in pool/order params (they reference the market's token asset IDs). The store maintains a secondary index on market_id for efficient lookups, built during ingestion by resolving the pool/order's token asset IDs via the store's own `find_by_asset_id` index. +**Ingestion ordering constraint**: Pools and orders require their parent market to be ingested first. During `ingest_pool` or either order-ingestion method, the engine validates that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, the engine returns `CoreError::ParentMarketNotTracked { detail }`. This is a natural constraint — you shouldn't track a pool for a market you don't know about, and discovery naturally produces markets before their pools/orders. -**Ingestion ordering constraint**: Pools and orders require their parent market to be ingested first. During `ingest_pool` or `ingest_order`, the engine validates that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, the engine returns `CoreError::InvalidParams`. This is a natural constraint — you shouldn't track a pool for a market you don't know about, and discovery naturally produces markets before their pools/orders. +Pools and orders are split into separate accessors (`market.pools()` vs `market.orders()`) because they scale differently — a market typically has a handful of pools but potentially thousands of orders at Polymarket scale. Both are paginated. -Pools and orders are split into separate methods because they scale differently — a market typically has a handful of pools but potentially thousands of orders at Polymarket scale. Both are paginated. +**Reverse navigation**: `Pool::parent_market()` and `Order::parent_market()` return the parent market's `Market` view (if tracked). Implemented via the `asset_id → (contract_id, token_role)` index that the engine maintains for asset identification — looking up the pool's `yes_asset_id` yields the parent market's contract_id. ### History Methods -The three typed history methods (`market_history`, `pool_history`, `order_history`) are only available when the store implements `ContractHistory`. They delegate to the store's unified `transition_history` method internally, then unwrap the `TransitionDetails` enum to return typed results: +The three typed history methods (`Market::history`, `Pool::history`, `Order::history`) are exposed on the view types and are only available when the store implements `ContractHistory`. They delegate to the store's unified `transition_history` method internally, then unwrap the `TransitionDetails` enum to return typed results: ```rust -// Engine calls store's unified method, then unwraps per-contract type -pub fn market_history(&self, contract_id: &ContractId, after: Option, limit: u32) -> Result, ...> { - let raw: Vec = self.store.transition_history(contract_id, after, limit)?; - raw.into_iter().map(|u| { - let TransitionDetails::Market(details) = u.details else { - debug_assert!(false, "store returned non-market transition for market contract"); - // filter out mismatched entries - }; - MarketHistoryEntry { contract_id: u.contract_id, txid: u.txid, /* ... */ details } - }).collect() +// Conceptually (each view type's impl block bound by ContractHistory): +impl<'a, S: ContractHistory> Market<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError> + { + let raw: Vec = self.engine.store().transition_history(&self.contract_id, after, limit)?; + raw.into_iter().map(|u| { + let TransitionDetails::Market(details) = u.details else { + debug_assert!(false, "store returned non-market transition for market contract"); + // filter out mismatched entries + }; + MarketHistoryEntry { contract_id: u.contract_id, txid: u.txid, /* ... */ details } + }).collect() + } } ``` +`Pool::history` and `Order::history` follow the same pattern but unwrap to `PoolHistoryEntry` / `OrderHistoryEntry` respectively. + **Ordering**: History is returned in ascending chain order (oldest first). This aligns with the primary use cases: price chart construction, audit trails, and catch-up from a checkpoint. The `after` parameter provides a precise cursor using `ChainPosition` (block height + tx index), which handles multiple transitions within the same block correctly. The caller paginates by passing the `position` from the last returned item as `after` in the next call. -**Why typed convenience methods**: The caller always knows the contract type when querying history. The unified `StateUpdate` with `TransitionDetails` enum forces an unnecessary match on a variant the caller already knows. The typed methods eliminate this ergonomic cost. The store trait stays simple (one `transition_history` method); the engine does the trivial unwrapping. +**Why typed convenience methods on views**: the caller always knows the contract type when querying history (they're already holding a typed view). The unified `StateUpdate` with `TransitionDetails` enum would force an unnecessary match on a variant the caller already knows. The typed view-level `history` methods eliminate this ergonomic cost. The store trait stays simple (one `transition_history` method); the view does the trivial unwrapping. -**Invariant**: All transitions for a given `contract_id` always have the same `TransitionDetails` variant (a market contract only produces `Market` transitions). A mismatch indicates a bug in the store implementation — the engine asserts in debug and filters in release. +**Invariant**: All transitions for a given `contract_id` always have the same `TransitionDetails` variant (a market contract only produces `Market` transitions). A mismatch indicates a bug in the store implementation — the view asserts in debug and filters in release. ## Core Types @@ -482,40 +639,64 @@ The parameters that define a contract, from which core derives script pubkeys, c ```rust pub enum ContractParams { - PredictionMarket(PredictionMarketParams), + Market(MarketParams), LmsrPool(LmsrPoolParams), MakerOrder(MakerOrderParams), } + +/// Umbrella over the two market contract types. Binary and multi-outcome markets +/// share many properties (oracle, collateral asset, expiry) but have distinct +/// covenant layouts (8 vs 5N+2 slots) and distinct token models (2 vs 2N tokens). +pub enum MarketParams { + Binary(BinaryMarketParams), + MultiOutcome(MultiOutcomeMarketParams), +} ``` `ContractParams` is purely definitional — it contains only the data needed to derive the contract's identity (CMR) and addresses (covenant script pubkeys). No creation-time secrets or blinding factors. Given `ContractParams` + network + the Simplicity source code (built into `deadcat-core`), all covenant addresses for all states can be derived deterministically. -### MarketCreationParams +`MarketParams` is the umbrella consumers typically interact with via the `Market` view type (see [Market View Type](#market-view-type)). Common accessors (`outcome_count`, `oracle_public_key`, `collateral_asset_id`, `expiry_time`) are exposed on `Market` without requiring consumers to destructure the enum. Consumers can still match on the enum directly when they need type-specific fields. -The input to `build_creation_pset` — only the 4 non-derivable fields needed to create a prediction market: +**Naming**: `BinaryMarketParams` was previously called `PredictionMarketParams`. The rename aligns with the paired `BinaryMarketState` / `MultiOutcomeMarketState`, `BinaryMarketTransition` / `MultiOutcomeMarketTransition`, and `BinaryMarketCreationParams` / `MultiOutcomeMarketCreationParams` conventions introduced when the multi-outcome contract was added. + +### Market Creation Params + +Creation params differ meaningfully between binary and multi-outcome markets (multi-outcome adds `outcome_count`), and the creation PSET builders are correspondingly split. Each creation builder takes only the non-derivable fields; the remaining fields (token and reissuance token asset IDs) are derived from the creation transaction's issuance entropy, which depends on the UTXOs selected as defining inputs during coin selection. ```rust -pub struct MarketCreationParams { +pub struct BinaryMarketCreationParams { + pub oracle_public_key: XOnlyPublicKey, + pub collateral_asset_id: AssetId, + pub base_payout: u64, // primary denomination; cp = base_payout × 2 (pair cost) is derived + pub expiry_time: u32, +} + +pub struct MultiOutcomeMarketCreationParams { pub oracle_public_key: XOnlyPublicKey, pub collateral_asset_id: AssetId, - pub collateral_per_pair: u64, + pub base_payout: u64, // primary denomination; cp = base_payout × outcome_count is derived pub expiry_time: u32, + pub outcome_count: u8, // N, validated in range per supported multi-outcome .simf files } ``` -The remaining 4 fields of `PredictionMarketParams` (YES/NO token and reissuance token asset IDs) are derived from the creation transaction's issuance entropy, which depends on the UTXOs selected as defining inputs during coin selection. Since coin selection happens inside the builder, these fields cannot be known by the caller beforehand. `build_creation_pset` selects the defining inputs, derives the asset IDs, compiles the covenant, builds the PSET, and returns the full `PredictionMarketParams` alongside the `UnblindedPset`. The caller uses the returned params for subsequent `ingest_market` after the transaction confirms. +The primary denomination field is `base_payout` — the per-outcome YES-expiry payout unit, drawn from the 1-2-5 table. Binary markets derive `cp = base_payout × 2`. Multi-outcome markets derive `cp = base_payout × outcome_count`. Formulas throughout this document use `collateral_per_pair` (or `cp`) as a derivation shorthand; implementations may expose it as an accessor method on the params struct. The unified-denomination rationale (divisibility becomes structural; no covenant `mod N` check needed; every denomination-table index is usable for every supported N) is specified in [multi-outcome-market-contract.md § Denomination model](../contracts/multi-outcome/multi-outcome-market-contract.md#denomination-model). + +The binary creation builder selects 2 defining inputs, derives the 2 token and 2 reissuance-token asset IDs, compiles the covenant, builds the PSET, and returns the full `BinaryMarketParams`. The multi-outcome creation builder selects 2N defining inputs in canonical leg order `YES_0, NO_0, YES_1, NO_1, ..., YES_{N-1}, NO_{N-1}`, derives the 2N token and 2N reissuance-token asset IDs, compiles the N-specific `.simf` covenant, and returns the full `MultiOutcomeMarketParams`. In both cases the caller uses the returned params for subsequent `ingest_market` after the transaction confirms. + +`BinaryMarketParams` and `MultiOutcomeMarketParams` define each market's covenant parameters (oracle key, expiry, asset IDs, etc.). `LmsrPoolParams` defines the pool's parameters (token asset IDs referencing the parent market, liquidity parameters, and `max_loss_sats` for off-chain LMSR math). `MakerOrderParams` defines the order's parameters (base/quote asset IDs, price, direction). These types map 1:1 to Simplicity covenant parameters, with one exception: `LmsrPoolParams.max_loss_sats` is not a covenant parameter but is included because all off-chain LMSR computation (cached-table quoting, table generation, spot price) requires the liquidity parameter `b = max_loss_sats / ln(2)`, and `b` is not recoverable from the covenant params alone (the `max_loss_sats → q_step_lots` derivation uses `ceil()`, which is lossy). The derivable fields `q_step_lots` and `lmsr_table_root` are retained alongside `max_loss_sats` as compilation caches — re-deriving `lmsr_table_root` is the cold-cache table-generation path. See [contract-specification.md](../contracts/contract-specification.md) for the planned field definitions per contract type, covenant structure, spend paths, and witness data. -`PredictionMarketParams` defines the market's covenant parameters (oracle key, expiry, etc.). `LmsrPoolParams` defines the pool's parameters (token asset IDs referencing the parent market, liquidity parameters, and `max_loss_sats` for off-chain LMSR math). `MakerOrderParams` defines the order's parameters (base/quote asset IDs, price, direction). These types map 1:1 to Simplicity covenant parameters, with one exception: `LmsrPoolParams.max_loss_sats` is not a covenant parameter but is included because all off-chain LMSR computation (point evaluation, table generation, spot price) requires the liquidity parameter `b = max_loss_sats / ln(2)`, and `b` is not recoverable from the covenant params alone (the `max_loss_sats → q_step_lots` derivation uses `ceil()`, which is lossy). The derivable fields `q_step_lots` and `lmsr_table_root` are retained alongside `max_loss_sats` as compilation caches — recomputing `lmsr_table_root` requires ~80ms of table generation. See [contract-specification.md](../contracts/contract-specification.md) for the planned field definitions (post-refactor), per-contract covenant structure, spend paths, and witness data. The current SDK implementations (`src-tauri/crates/deadcat-sdk/src/{prediction_market,lmsr_pool,maker_order}/params.rs`) differ from the planned state — see [contract-specification.md § Pending Refactors](../contracts/contract-specification.md#pending-refactors). +**Pool layer composition**: a single `LmsrPoolParams` always refers to one outcome's YES/NO pair. For binary markets, the pool's YES/NO tokens come directly from the binary market's `yes_token_asset_id` / `no_token_asset_id`. For multi-outcome markets, the pool's YES/NO tokens are `yes_token_asset_ids[k]` / `no_token_asset_ids[k]` for a specific outcome k — multi-outcome markets compose their AMM liquidity from N independent binary LMSR pools (Option C composition). The pool contract doesn't know or care which market contract type underlies its tokens. See [amm-scoring-rule-tradeoffs.md](../contracts/multi-outcome/amm-scoring-rule-tradeoffs.md) for the pool design decision. ### Contract -The three covenant types core tracks internally. This is an **internal type** managed by the engine and store — callers do not construct `Contract` values directly. Instead, they provide params + creation transaction (or snapshot) to the per-type ingestion methods, and the engine derives the initial contract state. +The three contract kinds core tracks internally. This is an **internal type** managed by the engine and store — callers do not construct `Contract` values directly. Instead, they provide params + creation transaction (or snapshot) to the per-kind ingestion methods, and the engine derives the initial contract state. ```rust pub enum Contract { - PredictionMarket { - params: PredictionMarketParams, - state: MarketState, + Market { + params: MarketParams, // Binary(BinaryMarketParams) or MultiOutcome(MultiOutcomeMarketParams) + state: MarketState, // Binary(BinaryMarketState) or MultiOutcome(MultiOutcomeMarketState) }, LmsrPool { params: LmsrPoolParams, @@ -530,42 +711,116 @@ pub enum Contract { Each variant's mutable state (reserves, fill amounts) lives inside the state enum, not alongside it. This prevents stale field values when a contract reaches a terminal state. See [Contract State Enums](#contract-state-enums) below. +The `Market` variant is unified across binary and multi-outcome — both are markets from the engine's perspective, differing only in their inner params/state enum variants. Consumers interact with markets through the [Market view type](#market-view-type) (Stage 2), which exposes common accessors (`outcome_count`, `oracle_public_key`, etc.) without forcing callers to destructure the umbrella enum for every query. `Market::as_multi_outcome()` returns `Option` for type-level access to multi-outcome-only operations. + ### Contract State Enums -Each contract type has a state enum that represents its current tip state — the latest snapshot, not a history log. This is stored durably via `ContractStore` (required) and updated each time `process_transaction` advances the contract. The tip state carries enough information for basic wallet UX without requiring `ContractHistory`. +Each contract kind has a state enum representing its current tip state — the latest snapshot, not a history log. This is stored durably via `ContractStore` (required) and updated each time `process_transaction` advances the contract. The tip state carries enough information for basic wallet UX without requiring `ContractHistory`. #### MarketState +Umbrella over binary and multi-outcome market states. Kept as separate inner enums because binary and multi-outcome resolution semantics genuinely differ — binary resolves via `Side` (YES or NO won the single event), multi-outcome resolves via `OutcomeIndex` (which of N mutually exclusive outcomes won). + ```rust pub enum MarketState { + Binary(BinaryMarketState), + MultiOutcome(MultiOutcomeMarketState), +} + +pub enum BinaryMarketState { + Trading { outstanding_pairs: u64 }, + ResolvedYes { outstanding_pairs: u64 }, + ResolvedNo { outstanding_pairs: u64 }, + Expired { outstanding_pairs: u64 }, +} + +pub enum MultiOutcomeMarketState { Trading { - outstanding_pairs: u64, + supplies: Vec, // length == outcome_count }, - ResolvedYes { - outstanding_pairs: u64, - }, - ResolvedNo { - outstanding_pairs: u64, + Resolved { + winning_outcome: OutcomeIndex, + collateral_unredeemed: u64, // when 0, terminal }, Expired { - outstanding_pairs: u64, + collateral_unredeemed: u64, // when 0, terminal }, } + +pub struct PairSupply { + pub yes: u64, + pub no: u64, +} ``` -`Trading` covers both the Dormant (zero outstanding pairs) and Unresolved (non-zero outstanding pairs) covenant phases. The distinction between these two phases is a covenant implementation detail — from the user's perspective, a market with 0 pairs is simply "a market where no one has issued yet" or "a fully cancelled market." `outstanding_pairs` is derived from collateral value: `collateral / collateral_per_pair`. Collateral amount is derivable in reverse: `outstanding_pairs * collateral_per_pair`. See [collateral-per-pair-refactor.md](../contracts/prediction-market/collateral-per-pair-refactor.md) for the covenant parameter rename. +`BinaryMarketState` is the state enum previously known simply as `MarketState`. It has been renamed to fit alongside `MultiOutcomeMarketState` under the umbrella. The enum's variants and field semantics are unchanged. + +For both inner enums: +- **Trading** covers both Dormant (zero outstanding) and Unresolved (non-zero outstanding) covenant phases. The distinction is a covenant implementation detail. +- `outstanding_pairs` (binary) or the `supplies` vector (multi-outcome) is derived from on-chain state — from the collateral UTXO amount and the market params. +- **Terminal state** is any post-resolution/expiry variant with zero remaining unredeemed value. For binary: `ResolvedYes`/`ResolvedNo`/`Expired` with `outstanding_pairs == 0`. For multi-outcome: `Resolved`/`Expired` with `collateral_unredeemed == 0`. `Trading` with zero outstanding is NOT terminal (can still receive issuance, resolution, or expiry transitions). +- Resolution and expiry always produce the corresponding `Resolved*`/`Expired` variant, regardless of whether the market had outstanding supply. If there was nothing outstanding (dormant terminal path), the resulting state is immediately terminal. -**Terminal state**: `ResolvedYes`, `ResolvedNo`, or `Expired` with `outstanding_pairs == 0`. This represents a market where all covenant UTXOs have been consumed — no collateral left to redeem. The outcome is implicit in the variant name (no separate `MarketOutcome` type needed). A wallet answers "did this market resolve YES or NO?" directly from the variant. `Trading { outstanding_pairs: 0 }` is NOT terminal — it's dormant (can still receive issuance, resolution, or expiry). Resolution and expiry always produce the corresponding `ResolvedYes`/`ResolvedNo`/`Expired` variant, regardless of whether the market had outstanding pairs. If `outstanding_pairs == 0` (dormant terminal path), the resulting state is immediately terminal. If `outstanding_pairs > 0`, collateral remains locked until redeemed (at which point `outstanding_pairs` reaches 0). +**Multi-outcome specifics**: +- `Trading.supplies` is a `Vec` of length `outcome_count`, indexed by outcome (`supplies[k]` is outcome k's YES/NO supply). `Vec` rather than `[PairSupply; N]` because `N` is a runtime value (per-pool), not a type parameter. +- `Resolved.collateral_unredeemed` tracks unredeemed collateral across all winning tokens (YES_k for winning outcome k plus NO_j for all j ≠ k). Detailed per-outcome redemption history is available via `ContractHistory` for consumers who need it. +- `Expired.collateral_unredeemed` tracks unredeemed collateral across all tokens redeemable at the expiry rate (all YES_k and NO_k). -Outpoints are not exposed in the public state — they are internal to the engine. +Outpoints are not exposed in the public state — they are internal to the engine. Consumers use the [Market view type](#market-view-type) to access state; common accessors (`is_active()`, `is_resolved()`, `resolved_outcome()`, `outcome_count()`) work uniformly across market types without requiring consumers to destructure the umbrella enum. -#### SlotType and CovenantPhase (Internal) +See [collateral-per-pair-refactor.md](../contracts/prediction-market/collateral-per-pair-refactor.md) for the binary covenant parameter rename. -`SlotType` and `CovenantPhase` are internal types (`pub(crate)`) used for script matching and PSET routing. They are not exposed in the public API but are described here as an implementation spec. +#### SlotIdentity and CovenantPhase + +`SlotIdentity` is the public type that labels each tracked outpoint with its slot role — used at the engine↔store boundary for labeled outpoints (see [ContractMatch](#contractmatch), [InitialContractState](#initialcontractstate), [StateUpdate](#stateupdate), and [`ContractStore::contract_outpoints`](#required-contractstore)). `CovenantPhase` is internal (`pub(crate)`) and used for script matching and PSET routing. ```rust -// pub(crate) — internal to the engine +pub enum SlotIdentity { + BinaryMarket(BinaryMarketSlot), + MultiOutcomeMarket(MultiOutcomeMarketSlot), + Pool(PoolSlot), + Order(OrderSlot), +} + +pub enum BinaryMarketSlot { + DormantYesRt, // Dormant phase + DormantNoRt, // Dormant phase + UnresolvedYesRt, // Unresolved phase + UnresolvedNoRt, // Unresolved phase + UnresolvedCollateral, // Unresolved phase + ResolvedYesCollateral, // ResolvedYes phase + ResolvedNoCollateral, // ResolvedNo phase + ExpiredCollateral, // Expired phase +} + +pub enum MultiOutcomeMarketSlot { + DormantYesRt(OutcomeIndex), + DormantNoRt(OutcomeIndex), + UnresolvedYesRt(OutcomeIndex), + UnresolvedNoRt(OutcomeIndex), + UnresolvedCollateral, + ResolvedCollateral, // single slot in the Resolved(k) phase + ExpiredCollateral, +} + +pub enum PoolSlot { + YesReserve, + NoReserve, + CollateralReserve, +} + +pub enum OrderSlot { + Utxo, +} + +// pub(crate) — internal to the engine. Market-only; pool and order phase/lifecycle +// tracking lives in their respective state enums (LmsrPoolState, OrderState). pub(crate) enum CovenantPhase { + Binary(BinaryCovenantPhase), + MultiOutcome(MultiOutcomeCovenantPhase), +} + +pub(crate) enum BinaryCovenantPhase { Dormant, Unresolved, ResolvedYes, @@ -573,22 +828,21 @@ pub(crate) enum CovenantPhase { Expired, } -// pub(crate) — internal to the engine -pub(crate) enum SlotType { - DormantYesRt, // slot 0 - DormantNoRt, // slot 1 - UnresolvedYesRt, // slot 2 - UnresolvedNoRt, // slot 3 - UnresolvedCollateral, // slot 4 - ResolvedYesCollateral, // slot 5 - ResolvedNoCollateral, // slot 6 - ExpiredCollateral, // slot 7 +pub(crate) enum MultiOutcomeCovenantPhase { + Dormant, + Unresolved, + Resolved(OutcomeIndex), // which outcome won + Expired, } ``` -Each `CovenantPhase` maps to a specific subset of slots. This mapping is the bridge between the state machine and the UTXO-following model — when a market transitions between phases, the engine knows which slots to expect in the new outputs: +`SlotIdentity` uniquely identifies a tracked UTXO's role. Within a contract, each `SlotIdentity` value appears at most once in the outpoint set (a hard invariant verified by compliance tests). Pairing each outpoint with its label at the store boundary replaces the earlier positional-ordering convention — it moves slot identity from "implicit in `Vec` index" to "explicit in the data," which eliminates a class of silent-mis-ordering bugs and handles variable-N multi-outcome markets cleanly. + +Each `CovenantPhase` maps to a specific subset of slots. This mapping is the bridge between the state machine and the UTXO-following model — when a market transitions between phases, the engine knows which slots to expect in the new outputs. + +**Binary** (8 slots, 1:1 with the existing covenant): -| CovenantPhase | Live Slots | +| BinaryCovenantPhase | Live Slots | | ------------- | ---------- | | Dormant | DormantYesRt (0), DormantNoRt (1) | | Unresolved | UnresolvedYesRt (2), UnresolvedNoRt (3), UnresolvedCollateral (4) | @@ -596,7 +850,22 @@ Each `CovenantPhase` maps to a specific subset of slots. This mapping is the bri | ResolvedNo | ResolvedNoCollateral (6) | | Expired | ExpiredCollateral (7) | -The engine maps between the public `MarketState` and internal `CovenantPhase` as follows: `Trading` with `outstanding_pairs == 0` corresponds to `Dormant`; `Trading` with `outstanding_pairs > 0` corresponds to `Unresolved`; `ResolvedYes`/`ResolvedNo`/`Expired` map directly. +**Multi-outcome** (5N+2 slots, parameterized by `outcome_count`): + +| MultiOutcomeCovenantPhase | Live Slots | +| --- | --- | +| Dormant | DormantYesRt × N, DormantNoRt × N | +| Unresolved | UnresolvedYesRt × N, UnresolvedNoRt × N, UnresolvedCollateral | +| Resolved(k) | ResolvedCollateral(k) | +| Expired | ExpiredCollateral | + +The engine maps between the public `MarketState` and internal `CovenantPhase` as follows: +- Binary `Trading` with `outstanding_pairs == 0` → `BinaryCovenantPhase::Dormant`; with `outstanding_pairs > 0` → `BinaryCovenantPhase::Unresolved`. +- Multi-outcome `Trading` with all supplies zero → `MultiOutcomeCovenantPhase::Dormant`; with any non-zero supply → `MultiOutcomeCovenantPhase::Unresolved`. +- Binary `ResolvedYes`/`ResolvedNo`/`Expired` map directly to `BinaryCovenantPhase` variants. +- Multi-outcome `Resolved { winning_outcome }`/`Expired` map directly to `MultiOutcomeCovenantPhase` variants. + +Internal dispatch (e.g., script derivation, output matching) uses a `pub(crate) trait MarketBehavior` implemented for `BinaryMarketParams` and `MultiOutcomeMarketParams` to avoid scattering `match` statements across the engine. This trait is internal and has no effect on the public API. #### LmsrPoolState @@ -627,24 +896,62 @@ Active pools track their reserves and s_index. Outpoints are internal to the eng ```rust pub enum OrderState { Active { + tracking: OrderTracking, + active_txid: Txid, offered_amount: u64, total_filled: u64, }, Consumed { + tracking: OrderTracking, final_txid: Txid, offered_amount: u64, }, Cancelled { + tracking: OrderTracking, cancel_txid: Txid, offered_amount: u64, total_filled: u64, }, } + +/// How the engine tracks an order. Controls both persistence behavior +/// and the accuracy of `offered_amount`. +pub enum OrderTracking { + /// Ingested from the order's creation transaction, with full fill history + /// and permanent storage. The engine persists every transition to + /// `ContractHistory` (if implemented) and never auto-untracks the order + /// upon reaching terminal state. `offered_amount` is the true original + /// offered value. + /// + /// Use for orders you've created and want to audit (fill-by-fill history, + /// cancellation detection, recovery). + Persistent, + + /// Ingested from the creation transaction but tracked ephemerally. + /// `offered_amount` is the true original. No history is persisted. + /// The engine auto-untracks the order past finality when it reaches + /// a terminal state (Consumed or Cancelled). + /// + /// Use for freshly-discovered orders (e.g. via Nostr announcement that + /// includes the creation tx) where accurate fill-progress display matters + /// but storing the full audit trail does not. + EphemeralFresh, + + /// Ingested from a mid-lifecycle snapshot (no creation tx available). + /// `offered_amount` reflects the value at discovery time, not the true + /// original — any fills that happened before ingestion are unobservable. + /// No history is persisted. Auto-untracks past finality when terminal. + /// + /// Use for discovered orders encountered mid-life (order books, routing). + EphemeralMidLife, +} ``` -`offered_amount` is the total value the maker locked when the order was created. For `Creation` ingestion, this is the initial UTXO value (the true original). For `Current` ingestion, this is `locked_value` from the snapshot (the remaining at discovery time — the best available without history). `total_filled` is cumulative fills since ingestion. Both are denominated in the order's locked asset — the asset the maker offered (BASE for sell-base orders, QUOTE for sell-quote orders, per `MakerOrderParams.direction`). Remaining liquidity is `offered_amount - total_filled`. +`offered_amount` is the order's baseline value for fill-progress calculations. Under `Persistent` and `EphemeralFresh` tracking, it equals the true original offered amount (the creation tx's locked output value). Under `EphemeralMidLife`, it's the locked value at discovery — fills that occurred before ingestion aren't captured. `total_filled` is cumulative fills since ingestion. Both are denominated in the order's locked asset — the asset the maker offered (BASE for sell-base orders, QUOTE for sell-quote orders, per `MakerOrderParams.direction`). Remaining liquidity is always `offered_amount - total_filled` regardless of tracking mode. + +Engine behavior forks on `tracking`: `ContractHistory` writes are skipped for the two `Ephemeral*` variants, and `prune_finalized` auto-untracks terminal `Ephemeral*` orders past finality. The `Persistent` variant behaves like other persistent contracts (markets, pools) — history written if the store supports it, terminal state preserved until explicitly untracked. -`Active` enables "5,000 of 10,000 sats filled" display. `Consumed` stores `offered_amount` (which equals `total_filled` by definition — only one is needed). `Cancelled` enables "5,000 of 10,000 filled, then cancelled" display (if `total_filled == 0`, it was a clean cancellation). Outpoints are internal to the engine and not exposed in the public state. +UX forks on the `offered_amount` accuracy implied by `tracking`: "X of Y filled" displays are honest under `Persistent` and `EphemeralFresh`; under `EphemeralMidLife`, the denominator is "baseline at discovery" rather than "true original," which UIs should clarify. View-layer helpers (`Order::has_full_origin()`, etc.) expose this distinction for callers. Outpoints are internal to the engine and not exposed in the public state. ### Pagination Types @@ -688,7 +995,31 @@ pub enum StateFilter { pub enum Side { Yes, No } ``` -Used throughout to identify which outcome token is referenced — in `OutputRole`, `TradeSpec`, `AssetInfo`, etc. +Identifies which side of an outcome a token represents. For a binary market, the single outcome has YES (pays if the event happens) and NO (pays if it doesn't). For a multi-outcome market, each outcome k has YES_k (pays if outcome k wins) and NO_k (pays if outcome k doesn't win). Used in `OutputRole`, `TradeSpec`, `AssetInfo`, `BinaryMarketTransition`, `MultiOutcomeMarketTransition`. + +### OutcomeIndex + +```rust +pub struct OutcomeIndex(u8); + +impl OutcomeIndex { + /// The sole outcome of a binary market. Used wherever a unified signature + /// requires an outcome parameter — for binary markets, pass `OutcomeIndex::BINARY`. + pub const BINARY: OutcomeIndex = OutcomeIndex(0); + + pub const fn new(index: u8) -> Self { Self(index) } + pub const fn as_u8(self) -> u8 { self.0 } +} +``` + +Identifies a specific outcome within a market. + +- **Binary markets** have exactly one outcome (the single event being predicted). The sole valid index is `OutcomeIndex::BINARY`. APIs that take `OutcomeIndex` accept only this value for binary markets — other values return `CoreError::InvalidParams`. +- **Multi-outcome markets** have `outcome_count` outcomes (the N mutually exclusive events). Valid indices are `0..outcome_count`. + +Used alongside `Side` to identify a specific token: `(OutcomeIndex, Side)` pinpoints YES_k or NO_k for outcome k. For binary markets, the `OutcomeIndex` is always `BINARY` and the `Side` distinguishes YES from NO. + +The newtype prevents accidental conflation with other `u8` indices (output positions, byte values, etc.) and enables compile-time documentation at call sites. ### FeeRate @@ -771,14 +1102,14 @@ pub struct ContractEntry { pub synced_to: u32, } -pub type MarketEntry = ContractEntry; +pub type MarketEntry = ContractEntry; pub type PoolEntry = ContractEntry; pub type OrderEntry = ContractEntry; ``` `synced_to` indicates the block height through which this contract has been checked for chain activity. It advances during `step` even when no transitions are found for the contract. See [Chain Sync](#chain-sync). -Generic entry type used by all listing and relationship query methods. The type aliases provide ergonomic names (`Page` vs `Page>`). +Generic entry type used by all listing and relationship query methods. The type aliases provide ergonomic names (`Page` vs `Page>`). `MarketEntry` uses the umbrella `MarketParams`/`MarketState`, so a single `list_markets` call returns both binary and multi-outcome markets; callers destructure the umbrella enum (or use the `Market` view type introduced in Stage 2) when they need type-specific fields. ### DerivedContractData @@ -792,7 +1123,8 @@ pub struct DerivedContractData { Permanent data derived from Simplicity compilation, passed to the store during contract tracking so it can build indexes without knowing about Simplicity. These fields never change after ingestion — they are static properties of the contract program. `asset_ids` maps each asset (YES/NO tokens, YES/NO reissuance tokens) to its `AssetInfo`. `covenant_scripts` is the set of covenant script pubkeys used for chain sync (catch-up scanning and steady-state subscription registration). Only prediction markets produce asset IDs; pools and orders have empty `asset_ids`. **`covenant_scripts` per contract type**: -- **Markets**: All 8 scripts across all covenant phases and slot types. Static — these scripts cover the market's entire lifecycle. +- **Binary markets**: 8 scripts across all covenant phases and slot types. Static — these scripts cover the market's entire lifecycle. +- **Multi-outcome markets**: `5N + 2` scripts, where `N = outcome_count`. All static — these scripts cover the market's entire lifecycle, with per-outcome variants for the 4N RT slots and the N resolved-collateral slots. - **Orders**: The single covenant script. Static — the script does not change across partial fills. - **Pools**: Empty. Pool scripts encode the s_index, which changes on every swap (unbounded), so pre-storing all possible scripts is impractical. Pool sync uses outpoint-based forward-chaining and structural output identification instead. See [Chain Sync](#chain-sync). @@ -800,21 +1132,22 @@ Permanent data derived from Simplicity compilation, passed to the store during c ```rust pub struct InitialContractState { - pub outpoints: Vec, + pub outpoints: Vec<(SlotIdentity, OutPoint)>, pub position: ChainPosition, } ``` -The mutable initial state of a contract at ingestion time, passed to the store via `track_contract`. Groups the two fields that describe "where the contract is right now": `outpoints` are the contract's current tracked UTXOs (derived from the creation transaction or provided via a `Current` snapshot), and `position` is the chain position at which those outpoints were confirmed. +The mutable initial state of a contract at ingestion time, passed to the store via `track_contract`. Groups the two fields that describe "where the contract is right now": `outpoints` are the contract's current tracked UTXOs (derived from the creation transaction or provided via a `Current` snapshot), each labeled with its [`SlotIdentity`](#slotidentity-and-covenantphase); `position` is the chain position at which those outpoints were confirmed. This is intentionally separate from `DerivedContractData`, which contains permanent data derived from Simplicity compilation (scripts, asset IDs). `InitialContractState` contains mutable state — `outpoints` change with every transition (via `apply_transitions`), and `position` sets the initial `synced_to` height. The two structs represent different categories of data the engine pre-computes for the store. -**Outpoints per contract type** (positional ordering — index = slot identity): -- **Markets**: 2 outpoints `[DormantYesRt, DormantNoRt]` for initial Dormant state. In Unresolved: `[UnresolvedYesRt, UnresolvedNoRt, UnresolvedCollateral]`. In ResolvedYes/No/Expired: `[collateral_slot]`. -- **Pools**: 3 outpoints `[YES reserve, NO reserve, Collateral reserve]`. -- **Orders**: 1 outpoint `[order UTXO]`. +**Outpoints per contract type** (each outpoint labeled with its [`SlotIdentity`](#slotidentity-and-covenantphase)): +- **Binary markets**: Dormant phase has 2 outpoints labeled `DormantYesRt` and `DormantNoRt`. Unresolved has 3 labeled `UnresolvedYesRt`, `UnresolvedNoRt`, `UnresolvedCollateral`. Terminal phases (ResolvedYes/ResolvedNo/Expired) have 1 labeled with the corresponding `*Collateral` slot. +- **Multi-outcome markets** (with `N = outcome_count`): Dormant has 2N outpoints labeled `DormantYesRt(k)` and `DormantNoRt(k)` for k ∈ [0, N). Unresolved has 2N+1 (the 2N per-outcome RTs plus `UnresolvedCollateral`). Terminal phases have 1 labeled `ResolvedCollateral` (with the winning outcome implicit from market state) or `ExpiredCollateral`. +- **Pools**: 3 outpoints labeled `YesReserve`, `NoReserve`, `CollateralReserve`. +- **Orders**: 1 outpoint labeled `Utxo`. -**Positional ordering is a hard invariant**: The engine, store, and PSET builders all depend on `Vec` index positions matching slot identity. The engine produces outpoints in this canonical order during ingestion (`InitialContractState`) and transitions (`StateUpdate.new_outpoints`). The store must preserve insertion order. `contract_outpoints` must return outpoints in the same positional order they were stored. PSET builders use the index to place the correct outpoint at the correct transaction input position for Simplicity witness encoding. +**Slot identity is explicit, not positional**: the engine, store, and PSET builders work with `Vec<(SlotIdentity, OutPoint)>` — each outpoint carries its own label rather than deriving identity from its position in a `Vec`. The engine produces labels deterministically from the current covenant phase during ingestion (`InitialContractState`) and transitions (`StateUpdate.new_outpoints`). The store persists the `(SlotIdentity, OutPoint)` pairs. `contract_outpoints` returns them. PSET builders look up the needed slot by `SlotIdentity` — no positional convention to maintain or accidentally violate. Within a single contract, each `SlotIdentity` value appears at most once (engine-enforced on write; compliance-tested at the store boundary). ### Oracle Attestation @@ -825,19 +1158,86 @@ message = tagged_hash("deadcat/oracle_attestation", market_id || outcome_byte) = SHA256(SHA256("deadcat/oracle_attestation") || SHA256("deadcat/oracle_attestation") || market_id || outcome_byte) ``` -Where `market_id = SHA256(yes_token_asset_id || no_token_asset_id)` and `outcome_byte` is `0x01` for YES or `0x00` for NO. `market_id` is a covenant-internal identifier derived from the market's token asset IDs — it is NOT the same as `ContractId`. See [oracle-bip340-tagged-hash.md](../protocol/oracle-bip340-tagged-hash.md) for the full specification and `.simf` changes. +The message structure is unified across binary and multi-outcome markets — `market_id` and `outcome_byte` differ based on market kind: + +- **Binary**: `market_id = SHA256(yes_token_asset_id || no_token_asset_id)`. `outcome_byte` is `0x01` for YES (the event happened) or `0x00` for NO. Binary resolution picks a `Side`, not an outcome index — the single event has two sides, and the oracle attests which side won. +- **Multi-outcome**: `market_id = SHA256(yes_token_asset_ids[0] || no_token_asset_ids[0] || ... || yes_token_asset_ids[N-1] || no_token_asset_ids[N-1])`. `outcome_byte` is the u8 `outcome_index` of the winning outcome, in range `[0, N-1]`. Multi-outcome resolution picks an `OutcomeIndex` — N events compete and exactly one wins. -The engine extracts the outcome by trial verification against both possible messages using the oracle's public key (from market params). If the signature doesn't verify against either outcome message, the engine returns `CoreError::InvalidParams { detail: "oracle attestation does not verify against either outcome" }`. +`market_id` is a covenant-internal identifier derived from the market's token asset IDs — it is NOT the same as `ContractId`. Domain separation across binary and multi-outcome markets is achieved via the different `market_id` derivations. See [oracle-bip340-tagged-hash.md](../protocol/oracle-bip340-tagged-hash.md) for the full specification. -The standalone function `oracle_attestation_message(yes_asset_id, no_asset_id, outcome_yes)` computes and returns the 32-byte message to sign — usable by oracle services without a `ContractEngine`. The engine convenience method `oracle_attestation_spec(contract_id, outcome_yes)` looks up the market's params from the store and returns both the message and the expected oracle public key via `OracleAttestationSpec`. Returns `CoreError::InvalidParams` for non-market contracts (oracle attestations are a prediction market concept). +**`MarketResolution` type**: because binary and multi-outcome resolutions are semantically different (binary attests a `Side`, multi-outcome attests an `OutcomeIndex`), the oracle API uses a discriminated union rather than conflating them under a single `OutcomeIndex` parameter: + +```rust +pub enum MarketResolution { + /// Binary market resolution: which side of the single event won. + /// Encodes as outcome_byte = 0x01 for Yes, 0x00 for No. + Binary(Side), + + /// Multi-outcome market resolution: which outcome event won. + /// Encodes as outcome_byte = OutcomeIndex::as_u8(). + MultiOutcome(OutcomeIndex), +} +``` ```rust +pub struct MarketId([u8; 32]); + +impl MarketId { + pub fn as_bytes(&self) -> &[u8; 32]; + pub fn from_bytes(bytes: [u8; 32]) -> Self; +} + pub struct OracleAttestationSpec { - pub message: [u8; 32], - pub oracle_pubkey: XOnlyPublicKey, // elements::secp256k1_zkp::XOnlyPublicKey + pub market_id: MarketId, + pub resolution: MarketResolution, // what the oracle is attesting to + pub message: [u8; 32], // tagged_hash, ready to sign + pub oracle_pubkey: XOnlyPublicKey, // elements::secp256k1_zkp::XOnlyPublicKey } ``` +Public API surface for oracle message construction and verification: + +```rust +// Standalone pure functions (no engine needed): + +/// Compute the market_id from market params (handles both binary and multi-outcome). +pub fn compute_market_id(params: &MarketParams) -> MarketId; + +/// Compute the BIP-340 tagged hash message the oracle needs to sign. +/// Usable by oracle services without a ContractEngine. +/// +/// The caller is responsible for pairing the correct MarketResolution variant +/// with the correct market_id (binary vs multi-outcome). A binary MarketResolution +/// paired with a multi-outcome market_id produces a valid-but-meaningless message. +pub fn oracle_attestation_message(market_id: MarketId, resolution: MarketResolution) -> [u8; 32]; + +// Engine methods (use contract_id; handle market_id lookup and resolution-variant validation internally): + +impl ContractEngine { + /// Returns { market_id, resolution, message, oracle_pubkey } for an oracle to sign. + /// Validates that the MarketResolution variant matches the contract's kind: + /// - Binary market requires MarketResolution::Binary(_); returns CoreError::InvalidParams otherwise. + /// - Multi-outcome market requires MarketResolution::MultiOutcome(_) with outcome in 0..outcome_count. + pub fn oracle_attestation_spec( + &self, + contract_id: &ContractId, + resolution: MarketResolution, + ) -> Result>; + + /// Verifies an oracle attestation against a specific (contract, resolution) pair. + /// Useful for oracles to dry-run signatures before publishing, and for clients + /// verifying attestations published out-of-band. + pub fn verify_oracle_attestation( + &self, + contract_id: &ContractId, + resolution: MarketResolution, + signature: &schnorr::Signature, + ) -> Result>; +} +``` + +The engine-level resolve builder accepts a raw signature and identifies the resolution by trial verification — for binary markets, verifies against both `MarketResolution::Binary(Side::Yes)` and `MarketResolution::Binary(Side::No)`; for multi-outcome markets, verifies against `MarketResolution::MultiOutcome(OutcomeIndex::new(k))` for each `k` in `0..outcome_count`. If the signature doesn't verify against any valid resolution, the engine returns `CoreError::OracleSignatureInvalid`. + ### RedemptionKind ```rust @@ -856,7 +1256,6 @@ Note: `IssuanceKind` (Initial vs Subsequent) is an internal type used by the eng ```rust pub enum TradeDirection { Buy, Sell } -// TODO: add ExactOutput(u64) variant when routing math supports it pub enum TradeAmount { /// Taker specifies the exact amount they send. /// Buy: exact collateral to spend. Sell: exact tokens to sell. @@ -864,19 +1263,25 @@ pub enum TradeAmount { } pub struct TradeSpec { + pub outcome: OutcomeIndex, // BINARY for binary markets; 0..N-1 for multi-outcome pub side: Side, pub direction: TradeDirection, pub amount: TradeAmount, } ``` -`TradeSpec` is the input to `quote_trade`. The three axes are orthogonal — any combination of side, direction, and amount mode is valid. +`TradeSpec` is the input to `quote_trade`. The four axes are orthogonal — any combination of outcome, side, direction, and amount mode is valid. For binary markets, `outcome` is always `OutcomeIndex::BINARY` (the single outcome); `side` picks YES or NO. For multi-outcome markets, `outcome` identifies which outcome's pool the trade targets, and `side` picks YES_k or NO_k within that outcome's pool. + +**V1 is exact-input-only.** `TradeAmount` intentionally exposes only `ExactInput(u64)` in v1. An `ExactOutput` mode is deferred until the router's fill math, slippage semantics, and stale-quote checks are specified for exact-output routing; it is not part of the current public API. + +**Basket trades are NOT part of `TradeSpec`.** Cross-outcome splits/merges (`MultiOutcomeMarket::build_split_yes_pset` etc.) are exposed as dedicated builders rather than routed through the trade quote system. Each `TradeQuote` corresponds to a single-outcome trade; multi-outcome traders issuing a basket construct a composition of single-outcome trades plus market-contract-native primitives. Cross-outcome arb (single-tx composition of a market split/merge with N pool swaps) is deferred to v2; see [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2). ### TradeQuote and Related Types ```rust pub struct TradeQuote { // Public — for display to the user: + pub outcome: OutcomeIndex, // which outcome was traded (BINARY for binary markets) pub side: Side, pub direction: TradeDirection, pub requested_amount: u64, @@ -897,11 +1302,17 @@ pub struct RouteLeg { pub output_amount: u64, } +pub enum MarketAssist { + IssuePairs { pairs: u64 }, + CancelPairs { pairs: u64 }, +} + pub enum LiquiditySource { LmsrPool { pool_id: ContractId, old_s_index: u64, new_s_index: u64, + market_assist: Option, }, LimitOrder { order_id: ContractId, @@ -915,38 +1326,55 @@ pub enum LiquiditySource { The `pub(crate)` field `route` makes `TradeQuote` non-constructable by external consumers — they can only receive one from the engine and pass it to `build_trade_pset`. See [Trade PSET Builder](#trade-pset-builder). -`TradeRoute` is a crate-internal type capturing the route plan (contract IDs, leg amounts, outpoint snapshots) needed by `build_trade_pset`. External consumers cannot inspect or construct it. +`TradeRoute` is a crate-internal type capturing the route plan (contract IDs, leg amounts, outpoint snapshots) needed by `build_trade_pset`. External consumers cannot inspect or construct it. For an assisted pool leg, `TradeRoute` carries the full parent-market continuation and burn/issuance bookkeeping; the public `market_assist` field is intentionally just a display summary. + +`RouteLeg` breaks down how the trade is routed across liquidity sources. `LiquiditySource::LmsrPool` includes s-index movement for "pool moved from 50 to 55" display and an optional `market_assist` summary. `IssuePairs` means the route co-spends the parent market's issuance path for this same `(market, outcome)` and mints `pairs` YES+NO directly into the pool's reserves. `CancelPairs` means the route co-spends the parent market's cancellation path, burns `pairs` YES+NO out of the pool reserves, and releases the corresponding market collateral. `LiquiditySource::LimitOrder` includes the matched price and base fill amount. -`RouteLeg` breaks down how the trade is routed across liquidity sources. `LiquiditySource::LmsrPool` includes s-index movement for "pool moved from 50 to 55" display. `LiquiditySource::LimitOrder` includes the matched price and base fill amount. +For multi-outcome markets, the assist always refers to the same `outcome` as the pool leg — no cross-outcome behavior is implied by `TradeQuote`. In v1, at most one `LmsrPool` leg in a route may carry `market_assist: Some(_)`; if an assisted and non-assisted route tie on taker outcome, the non-assisted route wins. Degenerate fixed-`s_index` public pair rebalances remain covenant-valid but are not intentionally emitted by `quote_trade`. ### BootstrapEstimate -Result of `estimate_bootstrap` — tells the operator how much capital they need before creating a pool: +Result of `estimate_bootstrap` — the canonical default bootstrap plan for a pool creation flow: ```rust pub struct BootstrapEstimate { pub initial_yes_reserve: u64, pub initial_no_reserve: u64, pub initial_collateral_reserve: u64, - pub initial_s_index: u64, + pub initial_s_index: u16, +} + +pub enum BootstrapError { + InvalidStartingPriceBps { starting_price_bps: u16 }, + ArithmeticOverflow, } ``` The three reserves are in different assets (YES tokens, NO tokens, collateral). The operator uses these to plan capital acquisition — e.g., issuing `max(yes, no)` token pairs (which costs `max * collateral_per_pair` collateral from the parent market) plus providing `initial_collateral_reserve` directly. Total capital outlay depends on the market's `collateral_per_pair` and what the operator does with leftover tokens, which are wallet-layer concerns outside this function's scope. -`starting_price_bps` must be in the range (0, 10000) exclusive — 0 and 10000 are rejected (`CoreError::InvalidParams`) because they represent 0% and 100% probabilities with infinite reserve ratios. Values that cause integer overflow in the LMSR computation are also rejected. No further range restriction — the purpose of `estimate_bootstrap` is to let the caller evaluate capital requirements and decide for themselves whether the reserves are practical. +`estimate_bootstrap` returns the **canonical default** reserve vector for a given curve and starting price; it does **not** define the only valid reserve vector for the pool. The helper: + +1. Snaps `starting_price_bps` to the nearest valid `initial_s_index`. +2. Computes the inward-snapped "useful band" bounds: the lowest and highest table indices whose fee-free YES spot prices remain within `[10, 9990]` bps (0.1%-99.9%). +3. Returns the smallest reserve vector that lets the pool move from `initial_s_index` to those useful-band bounds while preserving `MIN_POOL_RESERVE` on all three reserves. + +This inward snap matters because `q_step_lots` is ceil-rounded: the literal table edges can lie outside the useful 0.1%-99.9% band, so funding the full table by default would pre-load dead tail liquidity. The recommended flow is `estimate_bootstrap` → let the operator accept or override the reserves → pass the chosen reserves into `build_lmsr_bootstrap_pset`. Explicit over-funded or under-funded starting inventories remain covenant-valid as long as they satisfy the covenant minimums and the transaction is fundable. + +`estimate_bootstrap` is a standalone pure helper, so it returns `BootstrapError`, not `CoreError`. `starting_price_bps` must be in the range `(0, 10000)` exclusive — 0 and 10000 return `BootstrapError::InvalidStartingPriceBps` because they represent 0% and 100% probabilities with infinite reserve ratios. Values that overflow the reserve computation return `BootstrapError::ArithmeticOverflow`. ### ContractMatch -Returned by `ContractStore::find_by_outpoints`. Used internally by the engine to identify which tracked contracts are affected by a transaction and which specific outpoints matched. Callers of the engine never see this type — it exists at the engine-store boundary only. +Returned by `ContractStore::find_by_outpoints`. Used internally by the engine to identify which tracked contracts are affected by a transaction and which specific outpoints (and their slot roles) matched. Callers of the engine never see this type — it exists at the engine-store boundary only. ```rust pub struct ContractMatch { pub contract_id: ContractId, - pub matched_outpoints: Vec, + pub matched_outpoints: Vec<(SlotIdentity, OutPoint)>, } ``` +`matched_outpoints` carries slot labels so the engine can dispatch per-slot logic (e.g., "the UnresolvedCollateral slot was spent, this is a cancellation or resolution") without cross-referencing against `contract_outpoints`. The store indexes outpoints alongside their `SlotIdentity` labels; lookup returns both. + ### Transaction-Level Types Transaction interpretation and processing results are grouped at the **transaction level**, not the per-contract level. A single transaction can affect multiple contracts (e.g., a trade routing through a pool and filling an order), and its non-covenant outputs are a property of the transaction, not of any individual contract's transition. @@ -977,21 +1405,105 @@ Outpoints are intentionally omitted — they are internal to the engine's UTXO-f **Why `external_outputs` is transaction-level, not per-transition**: A key invariant of the Deadcat protocol is that while a transaction can compose multiple contracts (co-spending pool reserves and order UTXOs), each non-covenant output is associated with at most one contract. A `MakerReceive` output belongs to a specific order (positional at `current_index()`). A `TradeReceive` output is the taker's consolidated receive. A `Fee` output is transaction-global. No output serves dual roles for two different contracts. This means output classification never conflicts across contracts — an output is either `Unknown` from a contract's perspective or has exactly one role, and when multiple contracts can classify the same output they always agree (e.g., both the pool and order classify the taker receive as `TradeReceive`). The engine exploits this by computing a single merged classification at the transaction level, eliminating per-contract duplication and the merge boilerplate every wallet integrator would otherwise need. When a wallet needs to attribute a `MakerReceive` output to a specific order (e.g., two orders filled in the same trade produce two `MakerReceive` outputs), it matches the output's `script_pubkey` against the filled orders' `maker_receive_spk_hash` from their params. +#### Multi-Contract Transaction Patterns + +Single-contract transitions (one market, one pool, one order) are fully described by their respective `TransitionDetails` variant. Some transactions legitimately span multiple contracts atomically — a trade (pool + maybe maker orders), an atomic issuance + pool bootstrap (market + new pool creation). For these, the raw per-contract transitions are always preserved in `InterpretedTransaction.transitions`; additional helper methods on `InterpretedTransaction` classify recognized multi-contract patterns without collapsing the raw data: + +```rust +impl InterpretedTransaction { + /// Returns trade details if this transaction realizes a single-outcome + /// taker trade: one pool swap AND/OR one-or-more resting LOB order fills, + /// all targeting the same `(market_id, outcome, side)`. + /// + /// Returns `None` for transactions that don't match this shape: + /// - Pool state change with no taker (admin adjustment, close) + /// - Market-only transitions (issuance, resolution, redemption) + /// - Cross-outcome arb patterns (market split/merge + pool swaps); + /// use `as_cross_outcome_arb()` in v2 + /// - Multi-outcome bundled trades (pool swaps on different outcomes + /// in one tx without a market leg) + /// - Multi-pool same-outcome (unusual; not produced by `build_trade_pset`) + /// + /// Raw per-contract transitions are always available via `self.transitions` + /// regardless of whether this helper matches. See [`TradeRealized`](#traderealized) + /// for invariants on the returned value. + pub fn as_trade(&self) -> Option; + + /// Net change in token and collateral balances attributable to a specific contract + /// from this transaction. Rolls up per-contract transitions and external outputs. + /// Returns None if the contract had no involvement in this transaction. + /// + /// `as_trade()` is pattern-classification (returns structured aggregate); + /// `net_effect_for()` is per-contract rollup (returns wallet-relevant deltas). + /// They answer different questions. A caller asking "what did this trade do + /// to my pool balance?" uses `net_effect_for(pool_id)`; a caller asking + /// "was this a trade and what were its legs?" uses `as_trade()`. + pub fn net_effect_for(&self, contract_id: &ContractId) -> Option; +} + +// Cross-outcome arb classification (`as_cross_outcome_arb`, `CrossOutcomeArb`) is +// deferred to v2 alongside the arb PSET builder. See "Future: Cross-Outcome Arb API" +// for the deferred surface. + +/// Aggregated details of a single-outcome taker trade. +/// +/// ## Structure +/// - `market_id`, `outcome`, `side`: all legs target this triple. +/// - `pool_leg`: at most one pool swap (the router targets one pool per +/// outcome; multi-pool-same-outcome patterns don't classify as trades). +/// - `order_legs`: zero or more LOB fills on resting orders at this +/// `(outcome, side)`, in fill order. +/// - `total_input` / `total_output`: aggregated across all legs. +/// +/// ## Invariants (engine-enforced at construction) +/// - At least one leg present (`pool_leg.is_some()` OR `!order_legs.is_empty()`). +/// - All `OrderLegRealized.market_id == market_id` and `.outcome == outcome`. +/// - `PoolLegRealized.market_id == market_id` if present. +/// +/// ## Future (v2) +/// Multi-market and cross-outcome arb patterns will NOT extend this type; +/// they get their own aggregation helpers (e.g. `CrossOutcomeArb`). +/// `TradeRealized` remains strictly single-outcome. +pub struct TradeRealized { + pub market_id: ContractId, + pub outcome: OutcomeIndex, // BINARY for binary markets + pub side: Side, + pub direction: TradeDirection, + pub total_input: u64, + pub total_output: u64, + pub pool_leg: Option, // Some if pool was hit + pub order_legs: Vec, // One per LOB order filled +} + +pub struct ContractNetEffect { + pub token_deltas: Vec<(AssetId, i64)>, // asset → signed delta in user's tokens + pub collateral_delta: i64, // signed change in user's collateral +} +``` + +**Single-contract transactions** still have `as_*` helpers return `None` — they return `Some` only when the tx exactly matches the multi-contract pattern. A tx that moved a pool's s_index but did nothing else (no market co-spend) produces a single `PoolTransition::Swapped` and `as_trade()` returns `None` (no taker was involved). A tx that combines a market split/merge with N pool swaps (what would be an arb in v2) still ingests cleanly — the raw `MarketTransition::SplitYes` + N `PoolTransition::Swapped` are preserved in `self.transitions`, just without an aggregate arb classification until v2. + +**Transactions are interpreted independently.** The engine does not pattern-match across transactions to recognize user-level behaviors that span multiple txs. For example, a user-level "cross-outcome swap" (a SplitNo in tx 1 followed by a CancelledPair in tx 2 on the same market) produces two independent single-primitive interpretations. Higher-level tools that want to aggregate across tx history can do so externally; the core engine's job is single-tx classification. + ### StateUpdate -The write-path type passed to the store via `apply_transitions`. Contains outpoints needed for state advancement and rollback, but NOT used on the read path (history queries return `HistoryEntry` which omits outpoints). Does not include the computed output classification (`external_outputs`) since those are derived from the transaction at query time and do not need to be persisted: +The write-path type passed to the store via `apply_transitions`. Contains the full state-advancement delta: old + new contract state, labeled old + new outpoints, and the transition details. Not used on the read path (history queries return `HistoryEntry` which omits outpoints). Does not include the computed output classification (`external_outputs`) since those are derived from the transaction at query time and do not need to be persisted: ```rust pub struct StateUpdate { pub contract_id: ContractId, pub txid: Txid, pub position: ChainPosition, - pub old_outpoints: Vec, - pub new_outpoints: Vec, + pub old_state: Contract, + pub new_state: Contract, + pub old_outpoints: Vec<(SlotIdentity, OutPoint)>, + pub new_outpoints: Vec<(SlotIdentity, OutPoint)>, pub details: TransitionDetails, } ``` +**Why `old_state` and `new_state`**: rollback needs the pre-transition state to restore (several transitions aren't reversible from `TransitionDetails` alone — e.g., `PoolTransition::Closed` doesn't carry the old `s_index`). The engine computes both and passes them along; the store persists whatever it needs for its durability and rollback requirements. Having the engine compute `new_state` (rather than the store deriving it from `(old_state, details)`) keeps all domain logic in the engine — the store is a plain persistence layer without Simplicity or contract-math knowledge. + **Why two types**: `ProcessedTransaction` (and its inner `InterpretedTransaction`) is the caller-facing view (full data, including ephemeral computed fields like output roles). `StateUpdate` is the storage-facing view (only what needs to be persisted). The engine converts between them internally. This prevents store implementors from accidentally persisting wallet-specific data (output roles, classifications) alongside contract state, while ensuring callers always get the full picture. ### TypedStateUpdate @@ -1016,7 +1528,7 @@ pub type PoolHistoryEntry = TypedStateUpdate; pub type OrderHistoryEntry = TypedStateUpdate; ``` -`HistoryEntry` is used by the store's `transition_history` method. The typed aliases are used by the engine's convenience methods (`market_history`, `pool_history`, `order_history`). +`HistoryEntry` is used by the store's `transition_history` method. The typed aliases are used by the view types' `history()` methods (`Market::history`, `Pool::history`, `Order::history`), which return these typed entries after unwrapping the store's `TransitionDetails` enum. ### TransitionDetails @@ -1030,10 +1542,50 @@ pub enum TransitionDetails { } pub enum MarketTransition { + Binary(BinaryMarketTransition), + MultiOutcome(MultiOutcomeMarketTransition), +} + +pub enum BinaryMarketTransition { Issued { pairs: u64, collateral_locked: u64 }, + Cancelled { pairs_burned: u64, collateral_returned: u64 }, Resolved { outcome: Side }, Redeemed { kind: RedemptionKind, side: Side, tokens_burned: u64, payout_sats: u64 }, - Cancelled { pairs_burned: u64, collateral_returned: u64 }, + Expired, +} + +pub enum MultiOutcomeMarketTransition { + // Classified delta shapes (engine pattern-matches tx deltas against these common + // shapes for display convenience). All pass through the same generic covenant path. + IssuedPair { outcome: OutcomeIndex, pairs: u64, collateral_locked: u64 }, + CancelledPair { outcome: OutcomeIndex, pairs_burned: u64, collateral_returned: u64 }, + SplitYes { sets: u64, collateral_locked: u64 }, + MergeYes { sets: u64, collateral_returned: u64 }, + SplitNo { sets: u64, collateral_locked: u64 }, + MergeNo { sets: u64, collateral_returned: u64 }, + + /// Cross-outcome swap: 1 YES_i in, 1 NO_j out for each j ≠ i, paying + /// (N − 2) × collateral_per_pair. Possible as a single transaction under the + /// generic spend path; the engine pattern-matches this canonical shape. + CrossOutcomeSwap { + from_outcome: OutcomeIndex, + sets: u64, + collateral_cost: u64, + }, + + /// Arbitrary solvency-preserving delta composition that doesn't match any named + /// classification above. Raw deltas are preserved for consumers that want + /// granular detail; helper methods on the transition can classify common + /// sub-patterns. + Composite { + delta_yes: Vec, // length outcome_count + delta_no: Vec, // length outcome_count + delta_collateral: i64, // signed + }, + + // Resolution / expiry / redemption (unchanged): + Resolved { outcome: OutcomeIndex }, + Redeemed { kind: RedemptionKind, outcome: OutcomeIndex, side: Side, tokens_burned: u64, payout_sats: u64 }, Expired, } @@ -1049,9 +1601,107 @@ pub enum OrderTransition { } ``` -`MarketTransition::Issued` carries `pairs` and `collateral_locked` without an `IssuanceKind` discriminant. The engine still knows internally whether it was initial or subsequent issuance (for PSET routing), but this distinction is hidden from callers — it is a covenant implementation detail. +`BinaryMarketTransition::Issued` (and `MultiOutcomeMarketTransition::IssuedPair` / `SplitYes` / `SplitNo`) carry only the user-facing amounts (`pairs`/`sets` and `collateral_locked`) without an `IssuanceKind` discriminant. The engine still knows internally whether it was initial or subsequent issuance (for PSET routing), but this distinction is hidden from callers — it is a covenant implementation detail. + +**Each market transaction is exactly one covenant spend path.** The multi-outcome market covenant uses a single generic spend path for all Unresolved-phase transitions (see [`multi-outcome-market-contract.md § Operations`](../contracts/multi-outcome/multi-outcome-market-contract.md#operations)). That one spend path accepts any `(Δy, Δn, Δc)` preserving the solvency invariant — so a single on-chain transaction may represent a pure named primitive (IssuedPair, SplitYes, etc.), a classified cross-outcome swap, or an arbitrary composition of delta shapes. + +The engine classifies the tx's delta shape into a `MultiOutcomeMarketTransition` variant. Each named variant corresponds to a canonical delta shape defined by the covenant (see [`multi-outcome-market-contract.md § Operations`](../contracts/multi-outcome/multi-outcome-market-contract.md#operations) for the covenant-level coefficients). The variants' shapes are pairwise disjoint by construction, so matching order is a formality — but the engine uses a consistent order for implementation clarity. -`PoolTransition::Swapped` corresponds to the LMSR covenant's swap path — someone traded through the pool, moving the s-index. `PoolTransition::Adjusted` corresponds to the admin path — the pool operator (with admin key signature) adjusted liquidity without changing the s-index. The covenant enforces that YES and NO token deltas are equal on the admin path; collateral can change independently. `PoolTransition::Closed` indicates the pool admin reclaimed all reserve UTXOs via the close script path. See [lmsr-pool-close-path.md](../contracts/lmsr-pool/lmsr-pool-close-path.md). +**Canonical shape table** (all entries expressed in Δ-per-outcome for YES/NO token supplies and Δ for collateral; `cp := base_payout × N`, `cp_yes_basket := cp`, `cp_no_basket := (N - 1) × cp`, `cp_cross_swap := (N - 2) × cp`, matching [contract-specification.md § Spend Paths](../contracts/contract-specification.md#spend-paths-summary)): + +| Variant | Δy shape | Δn shape | Δc shape | +|---|---|---|---| +| `IssuedPair { outcome: i, pairs: p }` | Δy[i] = +p; all others 0 | Δn[i] = +p; all others 0 | +p × cp | +| `CancelledPair { outcome: i, pairs_burned: p }` | Δy[i] = −p; all others 0 | Δn[i] = −p; all others 0 | −p × cp | +| `SplitYes { sets: s }` | Δy[k] = +s for all k | all Δn = 0 | +s × cp | +| `MergeYes { sets: s }` | Δy[k] = −s for all k | all Δn = 0 | −s × cp | +| `SplitNo { sets: s }` | all Δy = 0 | Δn[k] = +s for all k | +s × ((N - 1) × cp) | +| `MergeNo { sets: s }` | all Δy = 0 | Δn[k] = −s for all k | −s × ((N - 1) × cp) | +| `CrossOutcomeSwap { from_outcome: i, sets: s }` | Δy[i] = −s; all others 0 | Δn[j] = +s for j ≠ i; Δn[i] = 0 | +s × ((N - 2) × cp) | + +In the typed Rust surface, `params.cp_yes_basket()`, `params.cp_no_basket()`, and `params.cp_cross_swap()` are just accessors for those exact formulas; no alternate coefficient definitions exist. + +**Classification algorithm**: + +```rust +fn classify_multi_outcome_transition( + delta_yes: &[i64], // length N + delta_no: &[i64], // length N + delta_collateral: i64, + params: &MultiOutcomeMarketParams, +) -> MultiOutcomeMarketTransition { + let n = params.outcome_count as usize; + let yes_nz: Vec<(usize, i64)> = delta_yes.iter().enumerate() + .filter(|(_, &v)| v != 0).map(|(i, &v)| (i, v)).collect(); + let no_nz: Vec<(usize, i64)> = delta_no.iter().enumerate() + .filter(|(_, &v)| v != 0).map(|(i, &v)| (i, v)).collect(); + + // 1. Pair (issue or cancel) — exactly one YES and one NO nonzero, same index, same value + if yes_nz.len() == 1 && no_nz.len() == 1 && yes_nz[0] == no_nz[0] { + let (i, d) = yes_nz[0]; + let expected_dc = d * params.cp() as i64; + if delta_collateral == expected_dc { + return if d > 0 { IssuedPair { outcome: i.into(), pairs: d as u64, collateral_locked: expected_dc as u64 } } + else { CancelledPair { outcome: i.into(), pairs_burned: (-d) as u64, collateral_returned: (-expected_dc) as u64 } }; + } + } + + // 2. SplitYes / MergeYes — all N YES move together by the same amount, no NO change + if no_nz.is_empty() && yes_nz.len() == n { + let first = yes_nz[0].1; + if yes_nz.iter().all(|&(_, v)| v == first) && delta_collateral == first * params.cp_yes_basket() as i64 { + return if first > 0 { SplitYes { sets: first as u64, collateral_locked: delta_collateral as u64 } } + else { MergeYes { sets: (-first) as u64, collateral_returned: (-delta_collateral) as u64 } }; + } + } + + // 3. SplitNo / MergeNo — symmetric (all N NO move together, no YES change) + if yes_nz.is_empty() && no_nz.len() == n { + let first = no_nz[0].1; + if no_nz.iter().all(|&(_, v)| v == first) && delta_collateral == first * params.cp_no_basket() as i64 { + return if first > 0 { SplitNo { sets: first as u64, collateral_locked: delta_collateral as u64 } } + else { MergeNo { sets: (-first) as u64, collateral_returned: (-delta_collateral) as u64 } }; + } + } + + // 4. CrossOutcomeSwap — Δy[i] = -s (unique); Δn[j] = +s for each j ≠ i + if yes_nz.len() == 1 && no_nz.len() == n - 1 { + let (i, dy_i) = yes_nz[0]; + let s = -dy_i; + if s > 0 + && no_nz.iter().all(|&(j, v)| j != i && v == s) + && delta_collateral == s * params.cp_cross_swap() as i64 { + return CrossOutcomeSwap { + from_outcome: i.into(), + sets: s as u64, + collateral_cost: delta_collateral as u64, + }; + } + } + + // 5. Composite fallback — shape didn't match any named primitive. Preserves raw deltas. + Composite { + delta_yes: delta_yes.to_vec(), + delta_no: delta_no.to_vec(), + delta_collateral, + } +} +``` + +**Matching precedence** (pairwise disjoint shapes mean order doesn't affect correctness, but the engine pins this order for consistency): Pair → SplitYes/MergeYes → SplitNo/MergeNo → CrossOutcomeSwap → Composite. + +**Shape match with wrong coefficient falls through to Composite.** If deltas satisfy a named variant's YES/NO shape but `delta_collateral` doesn't equal the expected covenant-computed amount, the algorithm falls through rather than emitting a misclassified named variant with incorrect numbers. For consensus-valid txs this shouldn't occur (covenant enforces coefficients); Composite is the safe default if a covenant bug ever let a mismatch through. + +**Edge cases**: +- All-zero deltas: consensus-valid txs always change something, so this shouldn't occur. If it does, falls through to `Composite { all zeros }` — non-harmful. +- Single-outcome Δy or Δn with zero Δc: doesn't match any shape → Composite. +- Deltas with the shape of a named variant but numerically out-of-bounds (e.g., cancelling more than outstanding): covenant rejects at spend time; this code only sees consensus-valid txs. + +**Cross-outcome swap is now a single-transaction operation.** Under the generic spend path, a user (or wallet builder) constructs one transaction with the cross-outcome-swap delta shape and the covenant accepts it atomically. This is a change from an earlier design iteration where cross-outcome swap was necessarily a two-transaction composition (split-NO + pair-cancel); that was tied to the enumerated-primitives covenant design, which has since been replaced with the generic-path design. + +**Multi-contract patterns in a single transaction** (e.g., trades that combine a pool swap with maker order fills) are detected at the `InterpretedTransaction` level via helper methods. Each participating contract still emits one primitive transition; the tx-level helpers recognize recurring multi-contract patterns without collapsing the per-contract transitions. See [Multi-Contract Transaction Patterns](#multi-contract-transaction-patterns) below. Cross-outcome arb (market + N pools atomic) is deferred to v2 — see [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2). + +`PoolTransition::Swapped` corresponds to the pool covenant's **public** path with `old_s_index != new_s_index` — someone traded through the pool, possibly with a paired reserve assist, moving the s-index. `PoolTransition::Adjusted` covers both the admin path and the degenerate public path with `old_s_index == new_s_index`. The public API intentionally does not preserve which authorization path produced an `Adjusted` transition; it just records the reserve change. `PoolTransition::Closed` indicates the pool admin reclaimed all reserve UTXOs via the close script path. See [lmsr-pool-close-path.md](../contracts/lmsr-pool/lmsr-pool-close-path.md). **Why nested by contract type**: When processing a market transition, the caller wants to match on market-specific variants without wading through pool and order cases. A flat enum mixing all contract types would force exhaustive matching across unrelated variants. @@ -1063,7 +1713,24 @@ Non-covenant outputs in a transaction. Each output carries shared fields (index, pub struct ExternalOutput { pub index: u32, pub script_pubkey: Script, + + /// Semantic purpose of this output (CollateralReturn, MakerReceive, Burn, etc.). + /// + /// **Role identifies *which* output serves a purpose; it does not encode + /// precise amounts.** When a covenant return and wallet change share a + /// script (both going to `WalletFunding::return_script`), they are + /// consolidated into a single output — the `role` reflects the primary + /// covenant purpose, but `explicit.value` (if present) is the *aggregate* + /// of all consolidated amounts. For per-role semantic amounts (collateral + /// released, payout received, tokens burned, etc.), consult + /// `TransitionDetails` — it is authoritative. pub role: OutputRole, + + /// Explicit asset and value when the output is unblinded, else `None`. + /// + /// **Consolidation caveat**: `value` may aggregate multiple semantic roles + /// (most commonly: covenant return + wallet L-BTC change both going to + /// `return_script`). For per-role semantic amounts, use `TransitionDetails`. pub explicit: Option, } @@ -1096,89 +1763,568 @@ pub enum OutputRole { } ``` -`OutputRole` is purely semantic — it labels what the output represents in the transaction, not its asset or value. The asset and value are already available via `ExplicitValues` when the output is explicit, so the role does not duplicate them. No role variant carries asset or value data — the wallet uses `identify_asset` when it needs to distinguish assets (e.g., YES vs NO tokens) within a role. +`OutputRole` is purely semantic — it labels what the output represents in the transaction, not its asset or value. The asset and value are already available via `ExplicitValues` when the output is explicit, so the role does not duplicate them. No role variant carries asset or value data — the wallet uses `identify_asset` when it needs to distinguish assets (e.g., YES_2 vs NO_5 within a multi-outcome market's issued tokens) within a role. | Role | Meaning | Appears in | | ---- | ------- | ---------- | -| `IssuedTokens` | Newly minted YES or NO tokens | Issuance | -| `CollateralReturn` | Collateral released from covenant to user | Redemption, cancellation | +| `IssuedTokens` | Newly minted outcome tokens (YES or NO, any outcome) | Pair issuance, split-YES, split-NO | +| `CollateralReturn` | Collateral released from covenant to user | Redemption, pair cancellation, merge-YES, merge-NO | | `TradeReceive` | Tokens or L-BTC received by the taker | Trade, fill order | | `MakerReceive` | Payment sent to the maker | Fill order, trade | | `OrderReturn` | Order's locked asset returned to maker | Cancel order | | `PoolReturn` | Pool reserves returned to operator | Pool closure | -| `Burn` | Tokens or RTs destroyed (unspendable OP_RETURN script) | Cancellation, resolve, expire | +| `Burn` | Tokens or RTs destroyed (unspendable OP_RETURN script) | Pair cancellation, merge-YES, merge-NO, resolution, expiry | | `Fee` | Transaction fee | All | | `Unknown` | Core can see asset/value but can't classify | Any (wallet labels via key ownership) | +**Why `IssuedTokens` is semantic rather than outcome-indexed**: the asset ID already carries outcome identity (YES_3 has a different asset ID from YES_0 or NO_3). A wallet that needs to know "which outcome was issued" calls `identify_asset(asset_id)` and inspects the returned `AssetInfo::OutcomeToken { outcome, side, ... }`. The role is about transaction-level purpose, not asset-level identity. + **Burn outputs** use bare OP_RETURN (`0x6a`) — an unspendable script by consensus rule. The engine recognizes the burn script (a known constant) and assigns `OutputRole::Burn` regardless of whether the output is explicit or confidential. For explicit burns (YES/NO tokens during cancellation), the engine provides full `ExplicitValues`. For blinded burns (RT destruction during resolution/expiry), the output is confidential (`explicit: None`) but the engine still assigns `Burn` from the script match. See [enforcement-layers.md](enforcement-layers.md) for the rationale behind OP_RETURN over P2WSH for burns. -**Output consolidation**: PSET builders consolidate outputs that share the same script and asset into a single output for efficiency and privacy (see [Output Consolidation](#output-consolidation)). A `CollateralReturn` output may therefore include fee change. When exact amounts matter, use `TransitionDetails` — it is authoritative for semantic amounts (payout, tokens burned, collateral locked, etc.). `OutputRole` identifies *which* output serves a purpose; `TransitionDetails` provides *the precise numbers*. +**Output consolidation**: PSET builders consolidate outputs that share the same script and asset into a single output for efficiency and privacy (see [Output Consolidation](#output-consolidation)). A `CollateralReturn` output may therefore include wallet L-BTC change when both land at `WalletFunding::return_script`. **This means `ExternalOutput.explicit.value` may aggregate multiple semantic roles.** When exact amounts matter — accounting, per-outcome breakdowns, cost-basis tracking — use `TransitionDetails`; it is authoritative for per-role semantic amounts (payout, tokens burned, collateral locked, etc.) regardless of consolidation or blinding. `OutputRole` identifies *which* output serves a purpose; `TransitionDetails` provides *the precise numbers*. `Fee` outputs on Elements are structurally separate (explicit fee outputs with no script) and are never consolidated with any other role. ### AssetInfo -Result of asset identification: +Result of asset identification. Unified across binary and multi-outcome markets: `OutcomeToken` and `ReissuanceToken` each carry `outcome: OutcomeIndex` (which is `BINARY` for binary markets) and `side: Side` (YES or NO). Callers who care about the market kind can destructure the embedded `params` enum. ```rust pub enum AssetInfo { - YesToken { market_id: ContractId, params: PredictionMarketParams }, - NoToken { market_id: ContractId, params: PredictionMarketParams }, - YesReissuanceToken { market_id: ContractId }, - NoReissuanceToken { market_id: ContractId }, + /// An outcome token (YES_k or NO_k). For binary markets, `outcome` is always `OutcomeIndex::BINARY`. + OutcomeToken { + market_id: ContractId, + outcome: OutcomeIndex, + side: Side, + params: MarketParams, // umbrella: Binary(BinaryMarketParams) or MultiOutcome(MultiOutcomeMarketParams) + }, + /// A reissuance token associated with one specific outcome token. + ReissuanceToken { + market_id: ContractId, + outcome: OutcomeIndex, + side: Side, + }, + /// The collateral asset used by the market (e.g., L-BTC, USDt). Shared across all outcomes. + Collateral { + market_id: ContractId, + asset_id: AssetId, + }, } ``` +Under the hood, the engine maintains an `asset_id → (contract_id, token_role)` index where `token_role ∈ { OutcomeToken(outcome, side), ReissuanceToken(outcome, side), Collateral }`. Lookups are O(1). For multi-outcome markets, the index holds 4N + 1 entries per market (2N token assets + 2N reissuance-token assets + 1 collateral asset); for binary markets, it holds 5 entries. The index supports both `identify_asset` (asset_id → AssetInfo) and internal reverse lookup during transaction interpretation (which tracked contract owns this asset?). + ### CoreError The error type for all engine operations. Generic over the store's error type, which piggybacks on the engine's existing `S: ContractStore` generic — no additional type parameter burden for consumers. -```rust -pub struct Shortfall { - pub asset_id: AssetId, - pub available: u64, - pub required: u64, +```rust +pub struct Shortfall { + pub asset_id: AssetId, + pub available: u64, + pub required: u64, +} + +pub enum CoreError { + Store(E), + ChainSource(Box), + InvalidCreationTx { reason: String }, + InvalidParams { detail: String }, + ConventionViolation { detail: String }, + ParentMarketNotTracked { detail: String }, + OracleSignatureInvalid, + InvalidContractState { contract_id: ContractId, kind: InvalidStateKind }, + ContractNotFound { contract_id: ContractId }, + ContractAlreadyTracked { contract_id: ContractId }, + InsufficientFunds { shortfalls: Vec }, + NoLiquidity { + market_id: ContractId, + outcome: OutcomeIndex, + side: Side, + direction: TradeDirection, + }, + StaleQuote { reason: StaleQuoteReason }, + CovenantInvariantViolation { + contract_id: ContractId, + kind: InvariantViolationKind, + }, +} + +/// Structured reason a builder rejected a contract's state for the +/// requested operation. Distinguishes "wrong variant" from "right variant +/// but unmet condition." See [State Machine Summary](#state-machine-summary) +/// for the full valid-transition matrix. +pub enum InvalidStateKind { + /// The contract's state variant is incompatible with the requested + /// operation (e.g., calling `build_issuance_pset` on a Resolved market). + WrongVariant { + expected: &'static [&'static str], + actual: &'static str, + }, + /// The state variant is compatible but a runtime precondition failed + /// (e.g., `build_merge_yes_pset` with insufficient basket supply; + /// `build_expire_transition_pset` called before the timelock height). + ConditionFailed { + condition: &'static str, + detail: String, + }, +} + +/// Why `build_trade_pset` rejected a quote as stale. Callers should +/// re-quote and re-confirm with the user before retrying (prices may +/// have moved). +pub enum StaleQuoteReason { + /// A referenced contract advanced (pool swap, pool admin adjust, + /// order fill, order cancel) between quote and build, producing + /// new outpoints the snapshot doesn't match. + OutpointsChanged { contract_id: ContractId }, + /// A referenced contract was untracked between quote and build. + ContractUntracked { contract_id: ContractId }, + /// A referenced contract was removed by `rollback_to_height` + /// between quote and build (e.g., its creation tx was reorged out). + ContractRemoved { contract_id: ContractId }, +} + +/// A consensus-valid transaction violated a covenant-enforced invariant +/// that should have made the transaction impossible. Indicates either a +/// covenant bug, chain-data corruption, or a version mismatch between the +/// running `deadcat-core` and the on-chain covenant. Should not occur in +/// correct operation; callers should treat this as a signal to investigate +/// (and re-ingest from scratch if necessary) rather than retry. +/// +/// Kept as an explicit variant even post-covenant-proof as defense-in-depth +/// against bugs outside the proof's scope (interpretation layer, chain +/// backend, version mismatch). Can be removed once an engine-level proof +/// covers all pathways. +pub enum InvariantViolationKind { + /// A pool transition was observed but the expected covenant-enforced + /// output window (3 consecutive outputs with `[YES, NO, Collateral]` + /// asset IDs sharing a script pubkey) was absent or malformed. + PoolWindowMalformed { detail: String }, + /// A binary or multi-outcome market transition produced outputs that + /// don't match any recognized covenant phase layout. + MarketOutputLayoutInvalid { detail: String }, + /// A maker order transition produced outputs that don't match the + /// covenant's expected fill or cancellation shape. + OrderOutputLayoutInvalid { detail: String }, +} +``` + +`ChainSource` wraps errors from the `ChainSource` trait implementation during `step`. The chain error type is boxed rather than generic to keep the engine at a single generic parameter (`S`) — `step` introduces `C: ChainSource` only at the call site. The `ChainSource::Error` bound includes `Send + Sync + 'static` to enable boxing into `Box`. Integrators can display/debug the error or downcast if they need the concrete type. `InvalidParams` covers structural caller mistakes at the API boundary (e.g., invalid outcome index for the target market kind, calling `oracle_attestation_spec` with a `MarketResolution` variant that does not match the market kind). `ConventionViolation` covers the strict-canonical policy boundary — parameters or externally-supplied contract data that fall outside the canonical v1 recovery conventions even if the underlying covenant could be consensus-valid. `ParentMarketNotTracked` is returned by pool/order ingestion when the referenced YES/NO assets do not resolve to any tracked market. `OracleSignatureInvalid` is returned by resolve-building paths when a raw oracle signature does not verify against any valid resolution for the target market. `InvalidContractState` is returned by PSET builders when the contract is in the wrong state for the requested operation; `InvalidStateKind` distinguishes `WrongVariant` (state machine rejection) from `ConditionFailed` (runtime precondition unmet, e.g., insufficient basket supply). `InsufficientFunds` is returned by PSET builders when the caller's available UTXOs don't cover the required amounts — the `shortfalls` vec reports all insufficient assets at once (e.g., "need 50 more YES tokens AND 3,000 more sats"), enabling wallet UX that shows all missing resources rather than one at a time. `NoLiquidity` is returned by `quote_trade` only when the router can't fill any positive amount (all pools exhausted, all orders dust, or no tracked sources); it carries the trade's target tuple so UIs can show "No liquidity to BUY YES on outcome 2 of market X." Partial fills do not return `NoLiquidity` — any `filled_amount > 0` returns `Ok(TradeQuote)` and the caller decides. `StaleQuote` is returned by `build_trade_pset` when the quote's snapshot is no longer current; `StaleQuoteReason` identifies the specific cause for both UI messaging and diagnostics. `CovenantInvariantViolation` indicates a should-be-impossible consensus-valid transaction violated a covenant-enforced property; this is bug-adjacent and callers should not loop-retry. Internal construction errors (e.g., Pedersen commitment math failure) indicate bugs in core and panic rather than returning an error — every `CoreError` variant represents a condition the caller can meaningfully respond to. + +**Why generic over the store error**: The engine is already generic over `S: ContractStore`, so `CoreError` adds no new generic parameters. Store error types are preserved — consumers can match on `CoreError::Store(e)` and handle their specific store error without downcasting. Store implementors define their own error type independently via an associated type on the trait. + +### PreBlindedPset and PreparedPset + +See [Confidential Transaction Blinding](#confidential-transaction-blinding) for the full design and rationale. Summarized here for type reference: + +```rust +/// Returned by builders whose output commitments may require Deadcat-managed +/// finalization before signing: RT-capable market builders and routed trades. +/// Private fields prevent extracting the PSET before `prepare` or `finalize`. +pub struct PreBlindedPset { /* private: pset, rt_blinding_plan, input_secrets, output_classification */ } + +impl PreBlindedPset { + pub fn prepare(self, wallet_blinding_pubkey: &PublicKey) -> Result; + pub fn finalize(self) -> Result; +} + +/// Returned by PreBlindedPset::prepare(). The caller must call +/// pset.blind_last(rng, secp, &input_secrets) before signing. +pub struct PreparedPset { + pub pset: PartiallySignedTransaction, + pub input_secrets: HashMap, +} +``` + +`BlindingError` is a simple error type for cryptographic failures during blinding (proof generation, commitment construction). It is separate from `CoreError` — blinding is a post-builder step decoupled from the engine's store generic. Builders that return `PreBlindedPset` return `Result>`; the blinding methods return `Result<_, BlindingError>`. + +## View Types + +`ContractEngine` exposes per-contract operations via **view types**: lightweight handles returned by `engine.market(id)`, `engine.pool(id)`, `engine.order(id)`, and `Market::as_multi_outcome()`. Each view caches the contract's `(params, state)` at construction (a single store read per view creation) and bundles per-contract operations as methods on the view. + +**Why view types**: operations on a market, pool, or order naturally cluster by the object they operate on. Putting them on the engine would mean ~25 methods at the top level, mostly varying by which `contract_id` they take. View types group them and improve discoverability — IDE autocomplete on a `Market` shows exactly what you can do with a market, without wading through unrelated pool/order methods. The view-type specialization (`Market::as_multi_outcome()`) also provides type-level dispatch: operations that exist only for multi-outcome markets live on `MultiOutcomeMarket` and are unreachable from binary markets without going through `as_multi_outcome()` (which returns `None` for binary). + +**Borrow semantics and staleness**: a view type holds `&'a ContractEngine` (immutable borrow). While any view is alive, the engine cannot be `&mut self`-borrowed — so `step`, `rollback_to_height`, `prune_finalized`, and any `ingest_*` call are blocked by the borrow checker until all views are dropped. Because contract params are immutable over the contract's lifetime and state transitions only happen through `&mut self` engine methods, the cached `(params, state)` within a view are **provably fresh** for the view's entire lifetime. No runtime staleness checks needed. + +**View lifetimes**: the `'a` lifetime ties the view to the engine's borrow. Views aren't meant to be long-lived — typical usage is `engine.market(&id)?.build_issuance_pset(...)` in one expression. Views CAN be held across multiple method calls (for composability) as long as nothing tries to mutate the engine during that time. + +### Market + +The unified view for both binary and multi-outcome markets. Common accessors work uniformly; type-specific behavior goes through `as_multi_outcome()` or direct matching on `params()` / `state()`. + +```rust +pub struct Market<'a, S: ContractStore> { + // Private. Holds engine reference + contract_id + cached (params, state). +} + +impl<'a, S: ContractStore> Market<'a, S> { + // ---- Identity and state accessors ---- + pub fn contract_id(&self) -> &ContractId; + pub fn params(&self) -> &MarketParams; + pub fn state(&self) -> &MarketState; + + // ---- Common property accessors (work uniformly for binary + multi-outcome) ---- + pub fn outcome_count(&self) -> u8; // 1 for binary, N for multi-outcome + pub fn oracle_public_key(&self) -> XOnlyPublicKey; + pub fn collateral_asset_id(&self) -> AssetId; + pub fn collateral_per_pair(&self) -> u64; + pub fn expiry_time(&self) -> u32; + + // ---- Phase predicates ---- + pub fn is_active(&self) -> bool; // Trading variant (either kind) + pub fn is_resolved(&self) -> bool; + pub fn is_expired(&self) -> bool; + pub fn is_terminal(&self) -> bool; // Resolved/Expired with zero unredeemed + pub fn resolution(&self) -> Option; // Some if resolved, None otherwise + + // ---- Unified PSET builders (work for both market kinds) ---- + // All builders take `outcome: OutcomeIndex` to identify which outcome's YES/NO pair + // is being operated on. For binary markets, `outcome` must be `OutcomeIndex::BINARY`; + // other values return CoreError::InvalidParams. + + /// Mint `pairs` new YES + NO tokens for the given outcome. Locks `pairs × collateral_per_pair`. + pub fn build_issuance_pset( + &self, + outcome: OutcomeIndex, + pairs: u64, + yes_dest: &Script, + no_dest: &Script, + funding: &WalletFunding, + ) -> Result>; + + /// Burn `pairs_to_burn` YES + NO pairs for the given outcome; releases collateral. + /// `pairs_to_burn: None` means "burn all outstanding pairs for this outcome" (full cancellation). + pub fn build_cancellation_pset( + &self, + outcome: OutcomeIndex, + pairs_to_burn: Option, + funding: &WalletFunding, + ) -> Result>; + + /// Resolve the market via an oracle attestation. For binary: single signature + /// over (market_id || outcome_byte) where outcome_byte ∈ {0x00, 0x01}. For multi-outcome: + /// signature over (market_id || outcome_index). The engine identifies which outcome + /// was attested to by trial verification. + pub fn build_oracle_resolve_pset( + &self, + attestation: &schnorr::Signature, + funding: &WalletFunding, + ) -> Result>; + + /// Redeem winning tokens (post-resolution) or any tokens (post-expiry) for collateral. + /// For binary post-resolution: `side` must match the winning side; for expired markets, + /// either side is valid. For multi-outcome: `outcome` identifies which pair; the winning + /// YES_k and any NO_j (j ≠ k) are redeemable at full value post-resolution; all tokens + /// at the fractional rate post-expiry. + pub fn build_redemption_pset( + &self, + outcome: OutcomeIndex, + side: Side, + tokens_to_redeem: u64, + funding: &WalletFunding, + ) -> Result>; + + /// Move the market from Unresolved/Dormant to Expired once `nLockTime >= expiry_time`. + /// Does not require any tokens; any party can invoke. + pub fn build_expire_transition_pset( + &self, + funding: &WalletFunding, + ) -> Result>; + + // ---- Oracle helpers ---- + + /// Returns the message, oracle pubkey, and related data for a given resolution. + /// Useful for oracle services: compute the message, sign it off-band, pass the + /// signature back to `build_oracle_resolve_pset`. + pub fn oracle_attestation_spec( + &self, + resolution: MarketResolution, + ) -> Result>; + + /// Verify an oracle signature against a specific resolution without broadcasting. + /// Useful for oracles to dry-run their signatures before publishing. + pub fn verify_oracle_attestation( + &self, + resolution: MarketResolution, + signature: &schnorr::Signature, + ) -> Result>; + + // ---- Probability / implied-price accessors ---- + + /// Liquidity-weighted probability that `outcome` wins, in basis points (0..=10000). + /// Weighting is by each pool's `b` parameter (LMSR depth). Returns None if no pool + /// exists for that outcome. + /// + /// Within any single LMSR pool, p_YES + p_NO = 10000 bps by construction (softmax), + /// so per-outcome probability is a single value — the YES side's price in bps; the + /// NO side's price derives as `10000 - probability_bps`. + pub fn probability_bps(&self, outcome: OutcomeIndex) + -> Result, CoreError>; + + /// Implied token cost in collateral sats at the current probability. For real trade + /// prices including fees and slippage, use `engine.quote_trade`. Derived as: + /// Side::Yes → probability_bps × collateral_per_pair / 10000 + /// Side::No → (10000 - probability_bps) × collateral_per_pair / 10000 + pub fn implied_token_cost_sats( + &self, + outcome: OutcomeIndex, + side: Side, + ) -> Result, CoreError>; + + // ---- Related contracts (replaces engine.pools_for_market / engine.orders_for_market) ---- + + /// All pools associated with this market across all outcomes. + /// + /// For binary markets (N=1), this is a single outcome-scoped store call under the hood. + /// For multi-outcome markets, the view iterates over `0..outcome_count` store calls + /// (one per outcome) and merges results in outcome-index order. Pagination is + /// supported via an opaque cursor that encodes `(outcome_index, inner_cursor)` — + /// callers don't need to manage the iteration themselves. + /// + /// Use `pools_for_outcome` when scoping to a single outcome (routing, per-outcome + /// display). Use `pools` for the "all pools for this market" display case. + pub fn pools(&self, filter: StateFilter, page: Pagination) + -> Result, CoreError>; + + /// All orders associated with this market across all outcomes. Same iteration + /// semantics as `pools` for multi-outcome markets. + pub fn orders(&self, filter: StateFilter, page: Pagination) + -> Result, CoreError>; + + /// Pools for a specific outcome. Direct delegate to `store.pools_for_market` (single + /// indexed store call, no iteration). For binary markets, pass `OutcomeIndex::BINARY`; + /// any other index returns an empty page. + pub fn pools_for_outcome(&self, outcome: OutcomeIndex, filter: StateFilter, page: Pagination) + -> Result, CoreError>; + + /// Orders for a specific outcome (both sides). Direct delegate to + /// `store.orders_for_market` (single indexed store call, no iteration). Side/direction + /// filtering can be applied by the caller on the returned data, or use + /// `engine.quote_trade` for routing-focused access to best orders. + pub fn orders_for_outcome(&self, outcome: OutcomeIndex, filter: StateFilter, page: Pagination) + -> Result, CoreError>; + + // ---- Type-level specialization ---- + /// Returns a `MultiOutcomeMarket` view for multi-outcome-specific operations + /// (cross-outcome splits/merges). Returns `None` for binary markets. + /// Cross-outcome arb builders are deferred to v2. + pub fn as_multi_outcome(&self) -> Option>; +} + +impl<'a, S: ContractHistory> Market<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; +} +``` + +### MultiOutcomeMarket + +Specialization of `Market` for multi-outcome markets. Obtained via `Market::as_multi_outcome()` — returns `Some` for multi-outcome markets, `None` for binary. Exposes cross-outcome primitives that don't exist in the binary market (since binary has only one outcome). + +```rust +pub struct MultiOutcomeMarket<'a, S: ContractStore> { + // Private. Holds engine reference + contract_id + cached typed (params, state). + // `params: &MultiOutcomeMarketParams`, `state: &MultiOutcomeMarketState` — not the umbrella. +} + +impl<'a, S: ContractStore> MultiOutcomeMarket<'a, S> { + // ---- Identity and state accessors (typed to multi-outcome) ---- + pub fn contract_id(&self) -> &ContractId; + pub fn params(&self) -> &MultiOutcomeMarketParams; + pub fn state(&self) -> &MultiOutcomeMarketState; + + // ---- Cross-outcome primitives (multi-outcome only) ---- + + /// Mint a complete YES set (1 of each outcome's YES) for `sets × collateral_per_pair` collateral. + /// `destinations` has length `outcome_count`; destinations[k] receives `sets` of YES_k tokens. + pub fn build_split_yes_pset( + &self, + sets: u64, + destinations: &[Script], + funding: &WalletFunding, + ) -> Result>; + + /// Burn a complete YES set for `sets × collateral_per_pair` collateral released. + pub fn build_merge_yes_pset( + &self, + sets: u64, + funding: &WalletFunding, + ) -> Result>; + + /// Mint a complete NO set (1 of each outcome's NO) for `sets × (N-1) × collateral_per_pair` collateral. + pub fn build_split_no_pset( + &self, + sets: u64, + destinations: &[Script], + funding: &WalletFunding, + ) -> Result>; + + /// Burn a complete NO set for `sets × (N-1) × collateral_per_pair` collateral released. + pub fn build_merge_no_pset( + &self, + sets: u64, + funding: &WalletFunding, + ) -> Result>; + + // ---- Probability aggregates ---- + + /// Length `outcome_count`. Entry k is `Some(probability_bps)` if any pool exists for + /// outcome k, else `None`. Each entry is a liquidity-weighted average across all pools + /// for that outcome (same as `Market::probability_bps`). + pub fn probabilities_bps(&self) -> Result>, CoreError>; + + /// Sum of per-outcome probabilities across all outcomes with at least one pool. + /// Equals ~10000 under perfect cross-outcome arb; deviation indicates an arb + /// opportunity. Outcomes with no pool are omitted from the sum. + pub fn sum_of_probabilities_bps(&self) -> Result>; + + // Cross-outcome arb (quote/build API) is deferred to v2. The covenant's generic + // solvency-preservation path already makes arb permissionless, so external bots + // can close coherence gaps without a built-in builder. See the "Future: + // Cross-Outcome Arb API" section below for the deferred surface and open + // design questions. + + // ---- Conversion back ---- + /// Returns the general `Market` view, for operations that apply to both market kinds. + pub fn as_market(&self) -> Market<'a, S>; +} +``` + +### Future: Cross-Outcome Arb API (v2) + +Cross-outcome arb closes coherence gaps among a multi-outcome market's N pools: when `Σ p_YES_k ≠ 1` (or symmetrically `Σ p_NO_k ≠ N−1`), an arbitrageur can profit by co-spending a market split/merge primitive with N pool swaps in one atomic transaction. Four directions exist — split-YES + sell-yes-to-pools, buy-yes-from-pools + merge-YES, and the NO analogues. + +**Scope decision**: deferred to v2. Rationale: + +- **Not safety-critical.** Coherence gaps are pricing drift, not solvency violations — the covenant's invariants hold regardless of whether arb is run. Markets stay solvent; users can still trade. +- **Permissionless by construction.** The multi-outcome market's generic solvency-preservation spend path ([see multi-outcome-market-contract.md § Operations](../contracts/multi-outcome/multi-outcome-market-contract.md#operations)) admits cross-outcome arb as one of its delta shapes. External arb bots can construct and broadcast these txs directly against the covenant spec without a `deadcat-core`-provided builder. +- **Advanced-actor-facing.** Arbitrageurs and keepers, not retail users. That audience tolerates external tooling while v1 ships. + +**v1 core builders do not compose into arb PSETs.** The market's single-contract builders (`build_split_yes_pset`, `build_merge_yes_pset`, etc.) and the engine-managed trade builder (`build_trade_pset`) each construct complete transactions for their own scope — they cannot be merged into one atomic multi-contract arb transaction. Arb requires one bespoke PSET co-spending the market's generic spend path with N pool public-path spends simultaneously; external tooling must construct that transaction directly against the covenant spec in v1. + +**What external arb tooling can leverage from `deadcat-core` v1**: + +| Available | Not available (must re-implement externally) | +|---|---| +| `engine.market(id).as_multi_outcome()` state inspection (supplies, asset IDs, params) | PSET construction for multi-contract atomic spends | +| `engine.pool(id)` state (s_index, reserves, params) | Combined fee calculation across market + N pool inputs | +| `LmsrPoolParams` + `MultiOutcomeMarketParams` full params for script derivation | Witness-stack layout for the generic solvency-preservation spend path | +| LMSR F-value runtime (`pub` in v1) for quoting and Merkle proof generation | Cross-contract tx atomicity management | +| `interpret_transaction` to verify a constructed arb tx before broadcast | Arb opportunity detection (coherence gap scanning) | + +The LMSR F-value runtime is specifically exposed as `pub` in v1 (not `pub(crate)`) so external tooling can compute identical Merkle proofs to what the covenant verifies, avoiding reimplementation of the bignum algorithm specified in [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md). Cross-implementation conformance remains anchored to the committed Merkle roots — tooling that reproduces them is provably equivalent. + +**Deferred API surface** (names reserved; signatures to be finalized before v2): + +- `MultiOutcomeMarket::quote_cross_outcome_arb(...) -> Result>, _>` — quote the best available arb direction. +- `MultiOutcomeMarket::build_cross_outcome_arb_pset(quote, funding, fee_rate) -> Result` — build the atomic PSET. +- `InterpretedTransaction::as_cross_outcome_arb() -> Option<&CrossOutcomeArb>` — classify an observed arb tx. +- Types: `ArbQuote`, `ArbDirection`, `ArbPoolLeg`, `CrossOutcomeArb` (observed-tx form). + +**Open design questions for v2** (these do not need to be settled for v1): + +1. Scope of directions in v2 — just the four split/merge directions, or also cross-outcome swap as an arb primitive? +2. Quote API shape — caller specifies `ArbDirection` explicitly, or engine auto-detects the most profitable? +3. Sizing model — engine picks max-profit sets vs caller-specified `sets` vs break-even sets? +4. `ArbQuote` fields — staleness via lifetime binding to `MultiOutcomeMarket` reference? Pool state snapshot granularity? +5. Classification rule — what exact delta-shape + pool-swap pattern counts as "arb" vs falling through to generic `Composite`? + +**v1 behavior on observed arb-shaped txs**: an arb tx broadcast by an external bot will ingest normally. Its per-contract transitions (market `SplitYes` / `MergeYes` / etc. + N pool `Swapped`) remain available in `InterpretedTransaction.transitions`. The aggregate multi-contract classification is deferred — such txs fall through to the generic classification until v2 lands the `as_cross_outcome_arb` helper. + +### Pool + +Per-pool view. All pools in deadcat are binary LMSR pools (see [amm-scoring-rule-tradeoffs.md](../contracts/multi-outcome/amm-scoring-rule-tradeoffs.md)); `Pool` is the single view type. For multi-outcome markets, the parent market has N pools under Option C composition — one per outcome's YES/NO pair; each is a standalone `Pool`. + +```rust +pub struct Pool<'a, S: ContractStore> { + // Private. Holds engine reference + contract_id + cached (params, state). +} + +impl<'a, S: ContractStore> Pool<'a, S> { + pub fn contract_id(&self) -> &ContractId; + pub fn params(&self) -> &LmsrPoolParams; + pub fn state(&self) -> &LmsrPoolState; + + pub fn is_active(&self) -> bool; + pub fn is_closed(&self) -> bool; + + /// Adjust the pool's reserves (admin operation — requires admin signature). + /// `pair_delta` and `collateral_delta` are signed: positive = injection, negative = withdrawal. + /// The covenant enforces that YES and NO deltas are equal (pair_delta applies to both). + pub fn build_adjust_pset( + &self, + pair_delta: i64, + collateral_delta: i64, + funding: &WalletFunding, + ) -> Result>; + + /// Close the pool (admin operation — requires admin signature). Consumes all 3 reserve UTXOs + /// atomically, releasing all assets to the operator. + pub fn build_close_pset( + &self, + funding: &WalletFunding, + ) -> Result>; + + /// Returns the parent market's view, if tracked. A pool's params reference specific asset + /// IDs (yes_asset_id, no_asset_id, collateral_asset_id) that belong to some market; this + /// navigates back to that market. + pub fn parent_market(&self) -> Result>, CoreError>; } -pub enum CoreError { - Store(E), - ChainSource(Box), - InvalidCreationTx { reason: String }, - InvalidParams { detail: String }, - InvalidContractState { contract_id: ContractId, detail: String }, - ContractNotFound { contract_id: ContractId }, - ContractAlreadyTracked { contract_id: ContractId }, - InsufficientFunds { shortfalls: Vec }, - NoLiquidity { market_id: ContractId }, - StaleQuote { detail: String }, +impl<'a, S: ContractHistory> Pool<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; } ``` -`ChainSource` wraps errors from the `ChainSource` trait implementation during `step`. The chain error type is boxed rather than generic to keep the engine at a single generic parameter (`S`) — `step` introduces `C: ChainSource` only at the call site. The `ChainSource::Error` bound includes `Send + Sync + 'static` to enable boxing into `Box`. Integrators can display/debug the error or downcast if they need the concrete type. `InvalidParams` covers caller-provided inputs that violate covenant constraints (e.g., issuance amount exceeds limits, invalid collateral asset, pool/order referencing an unknown parent market, calling `oracle_attestation_spec` on a non-market contract). `InvalidContractState` is returned by PSET builders when the contract is in the wrong state for the requested operation (e.g., `build_issuance_pset` on a settled market, `build_redemption_pset` on a trading market). `InsufficientFunds` is returned by PSET builders when the caller's available UTXOs don't cover the required amounts — the `shortfalls` vec reports all insufficient assets at once (e.g., "need 50 more YES tokens AND 3,000 more sats"), enabling wallet UX that shows all missing resources rather than one at a time. `StaleQuote` is returned by `build_trade_pset` when the quote's snapshotted outpoints are no longer current (a `step` call consumed them between quoting and building) — the caller should re-quote. Internal construction errors (e.g., Pedersen commitment math failure) indicate bugs in core and panic rather than returning an error — every `CoreError` variant represents a condition the caller can meaningfully respond to. +### Order -**Why generic over the store error**: The engine is already generic over `S: ContractStore`, so `CoreError` adds no new generic parameters. Store error types are preserved — consumers can match on `CoreError::Store(e)` and handle their specific store error without downcasting. Store implementors define their own error type independently via an associated type on the trait. +Per-maker-order view. Only the maker-lifecycle operation (cancellation) lives on this view; filling orders is a taker operation routed through `engine.quote_trade` + `engine.build_trade_pset`. + +```rust +pub struct Order<'a, S: ContractStore> { + // Private. Holds engine reference + contract_id + cached (params, state). +} -### UnblindedPset and PreparedPset +impl<'a, S: ContractStore> Order<'a, S> { + pub fn contract_id(&self) -> &ContractId; + pub fn params(&self) -> &MakerOrderParams; + pub fn state(&self) -> &OrderState; -See [Confidential Transaction Blinding](#confidential-transaction-blinding) for the full design and rationale. Summarized here for type reference: + pub fn is_active(&self) -> bool; + pub fn is_consumed(&self) -> bool; + pub fn is_cancelled(&self) -> bool; + pub fn remaining_liquidity(&self) -> u64; // offered_amount - total_filled; 0 if terminal -```rust -/// Returned by the 5 market builders that involve reissuance token outputs. -/// Private fields prevent extracting the PSET without going through a blinding method. -pub struct UnblindedPset { /* private: pset, rt_blinding_factors, input_secrets, output_classification */ } + /// Cancel the order (maker operation — requires maker signature via taproot key-spend). + pub fn build_cancel_pset( + &self, + funding: &WalletFunding, + ) -> Result>; -impl UnblindedPset { - pub fn prepare(self, wallet_blinding_pubkey: &PublicKey) -> Result; - pub fn finalize(self) -> Result; + /// Returns the parent market's view, if tracked. + pub fn parent_market(&self) -> Result>, CoreError>; } -/// Returned by UnblindedPset::prepare(). The caller must call -/// pset.blind_last(rng, secp, &input_secrets) before signing. -pub struct PreparedPset { - pub pset: PartiallySignedTransaction, - pub input_secrets: HashMap, +impl<'a, S: ContractHistory> Order<'a, S> { + pub fn history(&self, after: Option, limit: u32) + -> Result, CoreError>; } ``` -`BlindingError` is a simple error type for cryptographic failures during blinding (proof generation, commitment construction). It is separate from `CoreError` — blinding is a post-builder step decoupled from the engine's store generic. The builder returns `Result>`; the blinding methods return `Result<_, BlindingError>`. +### Builder naming convention on views + +Within each view type, builder methods drop the contract-type prefix that was previously needed to disambiguate when all builders lived on the engine: +- Engine: `build_lmsr_adjust_pset` → Pool view: `build_adjust_pset` +- Engine: `build_lmsr_close_pset` → Pool view: `build_close_pset` +- Engine: `build_cancel_order_pset` → Order view: `build_cancel_pset` + +Market and multi-outcome market builder names are unchanged (`build_issuance_pset`, `build_split_yes_pset`, etc.) because the "market" aspect isn't in the name — the action is. They're already scoped by which view they live on. + +Creation builders stay on the engine (no view exists yet pre-creation) and retain their descriptive names: +- `engine.build_binary_market_creation_pset` +- `engine.build_multi_outcome_market_creation_pset` +- `engine.build_lmsr_bootstrap_pset` (kept — "bootstrap" captures LMSR's initial-price-setting semantics) +- `engine.build_create_order_pset` ## Core Design: UTXO-Following State Machine @@ -1219,7 +2365,7 @@ When `process_transaction` is called (internally by `step`): 2. Check which tracked contracts own any of those outpoints (via `ContractMatch`) 3. For each affected contract, identify new outputs using a per-type strategy: - **Markets/orders**: match outputs against expected covenant scripts from the store's persisted script index - - **Pools**: identify the contiguous 3-slot reserve output window by asset ID, derive the new s_index from explicit reserve values via the LMSR table (see [LMSR Pools](#lmsr-pools) below) + - **Pools**: use witness-based path and s_index extraction via `RedeemNode::decode`, then identify the contiguous 3-slot reserve output window by asset ID and read reserve values from the explicit outputs (see [LMSR Pools](#lmsr-pools) below) 4. Derive transition details from the current state, new state, and output values 5. Compute external output roles for non-covenant outputs 6. Durably persist the state updates @@ -1247,20 +2393,33 @@ Core requires the caller to feed transactions in chain order. As long as this gu Core determines transitions primarily through the current contract state, script pubkey matching (against the store's persisted script index), and explicit output values. This works because the covenant design encodes state into the script pubkey — different states produce different addresses — so the new state is usually identifiable from the transaction's outputs alone. -Two specific transitions produce no new covenant outputs, making the spend path indistinguishable from outputs alone. For these cases, the engine uses lightweight Simplicity witness path detection — see [Detection Strategy and Robustness](#detection-strategy-and-robustness). +Two specific transition classes produce no new covenant continuation outputs, making the spend path indistinguishable from continuation outputs alone. For these cases, the engine uses lightweight Simplicity witness path detection — see [Detection Strategy and Robustness](#detection-strategy-and-robustness). #### Prediction Markets -The internal `CovenantPhase` maps to a unique set of slot script pubkeys (see [SlotType and CovenantPhase](#slottype-and-covenantphase-internal)). The transition type is determined by which slot scripts the new outputs match: +The internal `CovenantPhase` maps to a unique set of slot script pubkeys (see [SlotIdentity and CovenantPhase](#slotidentity-and-covenantphase)). The transition type is determined by which slot scripts the new outputs match, combined with witness-based path detection where output matching is ambiguous. -- **Issuance** (Trading with 0 pairs to Trading with >0 pairs, or Trading to Trading with more pairs): Old outputs match Dormant or Unresolved slots; new outputs match Unresolved slots. `pairs` = new collateral value / `collateral_per_pair`. This division is always exact — the covenant enforces that collateral is a multiple of the pair cost. Implementations should assert exactness rather than silently truncating. `collateral_locked` = new collateral value - old collateral value. For initial issuance from Dormant, old collateral value is zero (no prior collateral output exists), so `collateral_locked` equals the full new collateral value. `IssuanceKind` is determined internally (`Initial` if old phase was Dormant, `Subsequent` if Unresolved) but not exposed in the public `MarketTransition::Issued`. +**Binary markets** (8 slots): + +- **Issuance** (Trading with 0 pairs to Trading with >0 pairs, or Trading to Trading with more pairs): Old outputs match Dormant or Unresolved slots; new outputs match Unresolved slots. `pairs` = new collateral value / `collateral_per_pair`. This division is always exact — the covenant enforces that collateral is a multiple of the pair cost. Implementations should assert exactness rather than silently truncating. `collateral_locked` = new collateral value - old collateral value. For initial issuance from Dormant, old collateral value is zero, so `collateral_locked` equals the full new collateral value. `IssuanceKind` is determined internally (`Initial` if old phase was Dormant, `Subsequent` if Unresolved) but not exposed. - **Resolution** (Trading with >0 pairs → ResolvedYes/ResolvedNo): New output matches either `ResolvedYesCollateral` or `ResolvedNoCollateral` script. Which one determines the `outcome`. -- **Redemption** (ResolvedYes/ResolvedNo/Expired with outstanding_pairs decremented, terminal when reaching 0): No new covenant outputs. `payout_sats` is derived from the old collateral value. `side` from which token burn outputs are present. `RedemptionKind` is `PostResolution` if old state was ResolvedYes/ResolvedNo, `Expiry` if Expired. -- **Cancellation** (Trading → Trading with fewer pairs): New outputs match Unresolved or Dormant slots. `pairs_burned` = (old collateral - new collateral) / `collateral_per_pair`. `collateral_returned` = old collateral - new collateral. If new outputs match Dormant slots (all collateral returned), it's a full cancellation back to zero outstanding pairs. +- **Redemption** (ResolvedYes/ResolvedNo/Expired with outstanding_pairs decremented, terminal when reaching 0): No new covenant outputs. `payout_sats` is derived from the old collateral value. `side` from which token burn outputs are present. `RedemptionKind` is `PostResolution` if old state was Resolved*, `Expiry` if Expired. +- **Cancellation** (Trading → Trading with fewer pairs): New outputs match Unresolved or Dormant slots. `pairs_burned` and `collateral_returned` from the value differences. - **Expiry** (Trading with >0 pairs → Expired): New output matches `ExpiredCollateral` script. -- **Dormant terminal paths** (Trading with 0 pairs → ResolvedYes/ResolvedNo/Expired with 0 pairs): Both RT outpoints consumed, no new covenant outputs. The engine cannot distinguish dormant resolution (YES or NO) from dormant expiry using outputs alone — all three paths produce identical observable results (both DormantRT inputs spent, zero covenant outputs). The engine uses **witness-based path detection**: it extracts the Simplicity program bytes and witness bytes from the spending transaction's witness stack and calls `RedeemNode::decode` to identify which covenant spend path was taken. This determines the resulting variant (`ResolvedYes`, `ResolvedNo`, or `Expired`) — all with `outstanding_pairs: 0` (immediately terminal). See [Detection Strategy and Robustness](#detection-strategy-and-robustness) and [market-dormant-terminal-paths.md](../contracts/prediction-market/market-dormant-terminal-paths.md). +- **Dormant terminal paths** (Trading with 0 pairs → ResolvedYes/ResolvedNo/Expired with 0 pairs): Both RT outpoints consumed, RT burn outputs produced, and no new covenant continuation outputs. Output-only detection can't disambiguate — all three paths produce identical observable burn/continuation shape. Engine uses witness-based path detection via `RedeemNode::decode`. -**Detection strategy summary:** +**Multi-outcome markets** (5N+2 slots, detection rules analogous but scaled): + +- **Per-outcome pair issuance / cancellation**: detected by slot script match + witness path selector. Because the covenant always co-spends all 2N+1 Unresolved UTXOs and outputs always include all 2N+1 Unresolved slot continuations, the output-only signature isn't enough to distinguish "issue pair for outcome i" from "issue pair for outcome j". The engine examines the witness (`RedeemNode::decode`) to identify which outcome's spend path was selected. Supply deltas computed from RT-issuance amounts and/or token burn outputs. +- **Split-YES / Merge-YES / Split-NO / Merge-NO**: also witness-identified (all four produce similar output layouts — all 2N+1 covenant continuations). The collateral delta distinguishes (split-YES: +collateral_per_pair; merge-YES: -collateral_per_pair; split-NO: +(N-1)·collateral_per_pair; merge-NO: -(N-1)·collateral_per_pair). The engine can cross-check witness path against collateral delta for defensive verification. +- **Resolution** (Trading → Resolved(k)): new output matches one of the N `ResolvedCollateral(k)` scripts (one per possible winning outcome). The matched slot identifies `winning_outcome`. +- **Redemption**: no new covenant outputs; token burn outputs identify which `(outcome, side)` was redeemed; payout derived from the old resolved-collateral value. +- **Expiry**: new output matches `ExpiredCollateral` script (single script, not outcome-indexed since expiry is pre-resolution and doesn't pick an outcome). +- **Dormant terminal paths** (all 2N Dormant RTs → Resolved(k) or Expired, RT burn outputs and no continuation): analogous to binary. Witness-based path detection identifies which of the `N + 1` terminal paths was taken. + +**Scaling implications for multi-outcome detection**: the engine makes **one `RedeemNode::decode` call per transaction per multi-outcome market transition** (same as binary — just with a richer set of possible paths). Cost remains negligible (~<1ms). The N outcome-pair slot scripts per phase are pre-stored in the script index during ingestion, so slot-match lookups remain O(1). + +**Detection strategy summary (binary)**: | Transition | Detection method | Airtight? | |---|---|---| @@ -1271,33 +2430,44 @@ The internal `CovenantPhase` maps to a unique set of slot script pubkeys (see [S | Partial cancellation | Unresolved inputs → Unresolved outputs, collateral decreased | Yes — value direction distinguishes from issuance | | Full cancellation | Unresolved inputs → Dormant output scripts | Yes — unique scripts | | Expiry (non-dormant) | Unresolved inputs → ExpiredCollateral output script | Yes — unique script for slot 7 | -| Dormant terminal | Dormant RT inputs → no covenant outputs, old state was Trading(0 pairs), witness path detection for three-way ambiguity | Yes — witness is ground truth (determines ResolvedYes/ResolvedNo/Expired, all with outstanding_pairs: 0) | +| Dormant terminal | Dormant RT inputs → RT burn outputs + no covenant continuation outputs, witness path detection | Yes — witness is ground truth | + +**Detection strategy summary (multi-outcome)**: + +| Transition | Detection method | Airtight? | +|---|---|---| +| Issue / cancel pair (outcome k) | Script match (Unresolved continuation) + witness path (identifies outcome index k) + collateral delta | Yes — witness is ground truth | +| Split-YES / Merge-YES / Split-NO / Merge-NO | Script match (Unresolved continuation) + witness path (identifies primitive) + collateral delta (cross-checked against primitive) | Yes — witness is ground truth; collateral delta is defensive cross-check | +| Resolution (non-dormant, outcome k) | Unresolved inputs → ResolvedCollateral(k) script (1 of N possible) | Yes — unique scripts | +| Redemption | Resolved(k)/Expired inputs → no covenant outputs, token burn outputs identify (outcome, side) | Yes — old state + burn outputs disambiguate | +| Expiry (non-dormant) | Unresolved inputs → ExpiredCollateral script | Yes — unique script | +| Dormant terminal | All 2N Dormant RT inputs → RT burn outputs + no covenant continuation outputs, witness path detection (N+1 possible paths: Resolved for each outcome + Expired) | Yes — witness is ground truth | #### LMSR Pools Different `s_index` values produce different covenant addresses (the s_index is a parameter in the script derivation). Unlike markets and orders, pools cannot use pre-stored scripts for output matching because the unbounded s_index makes full script enumeration impractical. The pool's taproot tree has constant Simplicity program leaves (same CMR regardless of `s_index`) and a variable `tapdata_leaf = TaggedHash("TapData", s_index.to_be_bytes())` — only the tapdata leaf changes when `s_index` changes, but computing the full script pubkey still requires an EC scalar multiplication per candidate (the taproot tweak), making brute-force script enumeration prohibitively slow (~3-7 seconds for all 65K values). Pool transition detection uses **witness-based path and s_index extraction** for all transitions, combined with output scanning for reserve values. -**Why witness-based for all pool transitions**: The engine needs the new `s_index` on every pool transition (it's stored in `LmsrPoolState::Active`). Deriving s_index from reserve values (reverse LMSR table lookup) is fragile — admin adjustments change reserves without moving along the LMSR curve, so the reserves no longer correspond to a single point on the curve. The witness contains the exact `old_s_index` and `new_s_index` used in the covenant verification — this is ground truth, not a derived estimate. Additionally, output-only detection cannot reliably distinguish close from swap/admin (wallet outputs can mimic the covenant window pattern). Witness parsing resolves all ambiguities definitively for a negligible cost (~<1ms per `RedeemNode::decode` call, at most once per pool per block). +**Why witness-based for all pool transitions**: The engine needs the new `s_index` on every pool transition (it's stored in `LmsrPoolState::Active`). Deriving s_index from reserve values (reverse LMSR table lookup) is fragile — admin adjustments change reserves without moving along the LMSR curve, so the reserves no longer correspond to a single point on the curve. The witness contains the exact `old_s_index` and `new_s_index` used in the covenant verification — this is ground truth, not a derived estimate. Additionally, output-only detection cannot reliably distinguish close from public/admin (wallet outputs can mimic the covenant window pattern). Witness parsing resolves all ambiguities definitively for a negligible cost (~<1ms per `RedeemNode::decode` call, at most once per pool per block). **Pool transition detection algorithm**: -1. **Parse witness**: Extract the Simplicity program bytes and witness bytes from the spending transaction's witness stack for the input that spent a tracked pool outpoint. Call `RedeemNode::decode` to identify the spend path (swap, admin, or close) and extract `old_s_index` and `new_s_index`. +1. **Parse witness**: Extract the Simplicity program bytes and witness bytes from the spending transaction's witness stack for the input that spent a tracked pool outpoint. Call `RedeemNode::decode` to identify the spend path (public, admin, or close) and extract `old_s_index` and `new_s_index`. 2. **Switch on spend path**: - - **Swap or Admin**: Find the covenant output window — three consecutive explicit outputs (as enforced by the covenant) where index N has the pool's YES asset ID, N+1 has the NO asset ID, N+2 has the Collateral asset ID, and all three share the same script pubkey (co-membership). The window must exist (covenant-enforced for swap/admin paths). Read reserve values from the explicit outputs. Classify: `new_s_index != old_s_index` → `Swapped`, `new_s_index == old_s_index` → `Adjusted`. + - **Public or Admin**: Find the covenant output window — three consecutive explicit outputs (as enforced by the covenant) where index N has the pool's YES asset ID, N+1 has the NO asset ID, N+2 has the Collateral asset ID, and all three share the same script pubkey (co-membership). The window must exist (covenant-enforced for public/admin paths). Read reserve values from the explicit outputs. Classify: public path with `new_s_index != old_s_index` → `Swapped`; public path with `new_s_index == old_s_index` or admin path → `Adjusted`. - **Close**: No covenant output window expected. The pool transitions to `Closed`. `final_reserves` from the stored state at time of closure. Transition details: -- **Swap**: `old_s_index` and `new_s_index` from the witness. `old_reserves` from stored state. `new_reserves` from explicit output values. -- **Adjustment**: `old_s_index == new_s_index` confirmed by the witness (s_index frozen on admin path). `old_reserves` and `new_reserves` from stored state and output values. +- **Swap**: public spend path with `old_s_index != new_s_index`. `old_s_index` and `new_s_index` from the witness. `old_reserves` from stored state. `new_reserves` from explicit output values. +- **Adjustment**: either the admin path, or the degenerate public path with `old_s_index == new_s_index`. `old_reserves` and `new_reserves` from stored state and output values. The public API does not preserve the authorization source in `PoolTransition`; callers that care inspect the interpreted witness data directly. - **Closure**: Spend path confirmed as close by the witness. All pool outpoints consumed, no new covenant outputs. `final_reserves` from the stored state at time of closure. **Detection strategy summary:** | Transition | Detection method | Airtight? | |---|---|---| -| Swap | Witness: spend path + s_index extraction. Outputs: reserve values from covenant window. | Yes — witness is ground truth | -| Admin adjust | Witness: spend path + s_index unchanged. Outputs: reserve values from covenant window. | Yes — witness is ground truth | +| Public path | Witness: public spend path + s_index extraction. Outputs: reserve values from covenant window. `old_s != new_s` => `Swapped`; `old_s == new_s` => `Adjusted`. | Yes — witness is ground truth | +| Admin adjust | Witness: admin spend path. Outputs: reserve values from covenant window. | Yes — witness is ground truth | | Close | Witness: close spend path confirmed | Yes — witness is ground truth | #### Maker Orders @@ -1323,11 +2493,11 @@ Each contract type uses the detection method best suited to its structural chara | Contract type | Key characteristic | Detection method | Why this is the right tool | |---|---|---|---| | Markets (non-dormant) | 8 bounded, pre-storable scripts | Script pubkey matching | Each phase has unique scripts — byte comparison is O(1) and trivially airtight | -| Markets (dormant terminal) | No covenant outputs produced | Witness path detection | No scripts to match against — spend path only exists in the witness | +| Markets (dormant terminal) | No covenant continuation outputs produced | Witness path detection | No continuation scripts to match against — spend path only exists in the witness | | Orders | Two spend types at taproot level | Taproot structural check | Witness element count (1 vs 3) is the simplest possible distinguisher | | Pools (all transitions) | Unbounded s_index, need s_index value on every transition | Witness-based | s_index only in witness; scripts can't be pre-stored; reserve-based derivation is fragile after admin adjustments | -**The underlying principle**: Simplicity covenants encode state into the script pubkey — each unique state produces a unique script. For markets and orders, this enables output-based detection (script matching, structural checks). For pools, the unbounded s_index makes script enumeration impractical, and the engine needs the s_index value on every transition, so witness-based extraction is the natural fit. For dormant market terminals, no covenant outputs are produced, leaving no scripts to match — the witness is the only source of truth. +**The underlying principle**: Simplicity covenants encode state into the script pubkey — each unique state produces a unique script. For markets and orders, this enables output-based detection (script matching, structural checks). For pools, the unbounded s_index makes script enumeration impractical, and the engine needs the s_index value on every transition, so witness-based extraction is the natural fit. For dormant market terminals, no covenant continuation outputs are produced, leaving no continuation scripts to match — the witness is the only source of truth. **Witness-based detection uses `RedeemNode::decode`** from the `simplicity_lang` crate. Key properties: @@ -1360,18 +2530,18 @@ Core doesn't know or care about the discovery layer (Nostr, manual import, QR co The three ingestion methods handle the different needs of each contract type: -**Markets** (`ingest_market`): Always ingested from the creation transaction. Markets have few transitions (bounded by the number of covenant phases) and fast catch-up, so there is no benefit to non-initial ingestion. +**Markets** (`ingest_market`): Always ingested from the creation transaction. Markets have few transitions (bounded by the number of covenant phases) and fast catch-up, so there is no benefit to non-initial ingestion. Ingestion handles both binary (2 token issuances, 2 RT issuances, 3-output covenant window) and multi-outcome (2N token issuances, 2N RT issuances, (2N+1)-output covenant window) markets via the `MarketParams` enum. Verification cost scales linearly with N — each token and RT asset ID is reconstructed and matched against the creation tx's issuance metadata. **Pools** (`ingest_pool`): Support both creation-tx and non-initial ingestion via `PoolSnapshot`. Pools can accumulate thousands of state transitions (one per swap), making forward-sync from creation expensive. Non-initial ingestion via `PoolSnapshot::Current` allows a trader to start using a pool immediately from its current state without replaying history. -**Orders** (`ingest_order`): Support both creation-tx and non-initial ingestion via `OrderSnapshot`. Takers need only the current state for filling — order history is irrelevant. Makers who need fill history (monitoring, recovery) use `OrderSnapshot::Creation`. +**Orders** (`ingest_persistent_order` / `ingest_ephemeral_order`): Makers monitoring their own orders use `ingest_persistent_order` (creation tx required, full history). Takers discovering orders for routing use `ingest_ephemeral_order` — accepts either a Creation snapshot (accurate `offered_amount`, no history, auto-cleanup past finality) or a Current snapshot (mid-life discovery, baseline-at-ingestion `offered_amount`, same auto-cleanup). Tracking mode is immutable post-ingestion; untrack + re-ingest to switch. ### What each snapshot variant provides | Snapshot | History | Verification | Use case | | -------- | ------- | ------------ | -------- | -| `Creation(ChainTransaction)` | Full — forward-sync from creation recovers all transitions | Creation tx verified against params | Makers, pool operators, anyone needing price history | -| `Current { ... }` | None — no prior transitions recoverable | No verification back to creation | Takers, traders who only need current state | +| `Creation(ChainTransaction)` | Full — forward-sync from creation recovers all transitions | Creation tx verified against params and, for tracked contracts, against the canonical recovery conventions | Makers, pool operators, anyone needing price history | +| `Current { ... }` | None — no prior transitions recoverable | No verification back to creation; canonical param shape still enforced on the supplied params | Takers, traders who only need current state | The trade-off is explicit in the type system: `Current` = fast start, no history; `Creation` = full history + verified. @@ -1386,10 +2556,12 @@ Each contract tracks its own `synced_to` height independently. Existing fully-sy With `step` managing all sync internally, the consumer flow is uniform across contract types: ```rust -// 1. Ingest contracts (per-type methods) -let market_id = engine.ingest_market(¶ms, &creation_tx)?; +// 1. Ingest contracts (per-kind methods). +// `market_params` is `MarketParams` (Binary(..) or MultiOutcome(..)); all three ingestion +// methods take params by reference. +let market_id = engine.ingest_market(&market_params, &creation_tx)?; let pool_id = engine.ingest_pool(&pool_params, PoolSnapshot::Creation(pool_creation_tx))?; -let order_id = engine.ingest_order(&order_params, OrderSnapshot::Current { ... })?; +let order_id = engine.ingest_ephemeral_order(&order_params, OrderSnapshot::Current { ... })?; // 2. Sync — step handles catch-up, subscription setup, and steady-state engine.step(&mut chain)?; @@ -1411,20 +2583,33 @@ The caller never manages scripts, outpoints, subscriptions, or per-contract sync **Trading** (same as before — unrelated to sync): ```rust let quote = engine.quote_trade(&market_id, spec, fee_rate)?; -let pset = engine.build_trade_pset("e, &funding)?; -let signed = signer.sign(pset)?; +let pre_blinded = engine.build_trade_pset("e, &funding)?; +let mut prepared = pre_blinded.prepare(&wallet_blinding_pubkey)?; +prepared.pset.blind_last(&mut rng, &secp, &prepared.input_secrets)?; +let signed = signer.sign(prepared.pset)?; chain.broadcast(signed)?; // interpret_transaction for pending UX; step processes on confirmation ``` **Order creation** (maker): ```rust -let (params, masked_index) = derive_order_params(&deadcat_xprv, &market_params, order_index, Side::Yes, OrderDirection::SellBase, price, 1, 1)?; +// derive_order_params takes MarketParams (umbrella) + outcome: OutcomeIndex. +// For binary markets, pass OutcomeIndex::BINARY. +let (params, masked_index) = derive_order_params( + &deadcat_xprv, + &market_params, // MarketParams umbrella + OutcomeIndex::BINARY, // or OutcomeIndex::new(k) for multi-outcome + order_index, + Side::Yes, + OrderDirection::SellBase, + price, + 1, 1, +)?; let pset = engine.build_create_order_pset(¶ms, offered_amount, masked_index, &funding)?; let signed = signer.sign(pset)?; chain.broadcast(signed)?; -// After confirmation: ingest and step catches it up -let order_id = engine.ingest_order(¶ms, OrderSnapshot::Creation(creation_tx))?; +// After confirmation: ingest as persistent (maker wants full history) and step catches it up +let order_id = engine.ingest_persistent_order(¶ms, &creation_tx)?; engine.step(&mut chain)?; ``` @@ -1450,19 +2635,24 @@ pub trait ContractStore { // Sync support — &self (used by step internally) fn stale_contracts(&self, tip_height: u32) -> Result; - fn contract_outpoints(&self, contract_id: &ContractId) -> Result, Self::Error>; + fn contract_outpoints(&self, contract_id: &ContractId) -> Result, Self::Error>; // Per-type listing — &self (typed results, not Contract enum) fn list_markets(&self, filter: StateFilter, page: Pagination) -> Result, Self::Error>; fn list_pools(&self, filter: StateFilter, page: Pagination) -> Result, Self::Error>; fn list_orders(&self, filter: StateFilter, page: Pagination) -> Result, Self::Error>; - // Relationship queries — &self (typed results) - fn pools_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, Self::Error>; - fn orders_for_market(&self, market_id: &ContractId, filter: StateFilter, page: Pagination) -> Result, Self::Error>; + // Relationship queries — &self (typed results; outcome-scoped) + // For binary markets, callers pass `OutcomeIndex::BINARY`. For multi-outcome + // markets, callers pass the specific outcome they care about. Store implementations + // should index by (market_id, outcome) for efficient lookup. To get "all pools/orders + // across all outcomes" of a multi-outcome market, the caller (or the engine's Market + // view) iterates over `0..outcome_count` and merges results. + fn pools_for_market(&self, market_id: &ContractId, outcome: OutcomeIndex, filter: StateFilter, page: Pagination) -> Result, Self::Error>; + fn orders_for_market(&self, market_id: &ContractId, outcome: OutcomeIndex, filter: StateFilter, page: Pagination) -> Result, Self::Error>; - // Trade routing support — &self (used by quote_trade internally) - fn best_orders_for_market(&self, market_id: &ContractId, side: Side, direction: OrderDirection, ascending: bool, min_remaining: u64, limit: u32) -> Result, Self::Error>; + // Trade routing support — &self (used by quote_trade internally; outcome-scoped) + fn best_orders_for_market(&self, market_id: &ContractId, outcome: OutcomeIndex, side: Side, direction: OrderDirection, ascending: bool, min_remaining: u64, limit: u32) -> Result, Self::Error>; // Writes — &mut self fn track_contract(&mut self, contract_id: ContractId, contract: Contract, derived: DerivedContractData, initial: InitialContractState) -> Result<(), Self::Error>; @@ -1486,14 +2676,18 @@ pub struct ScriptContractInfo { pub struct OutpointContractInfo { pub contract_id: ContractId, - pub outpoints: Vec, + pub outpoints: Vec<(SlotIdentity, OutPoint)>, pub synced_to: u32, } ``` Every consumer must implement this. Read methods take `&self`, write methods take `&mut self` — mirroring the engine's own borrow semantics. The engine calls read methods during interpretation (`&self` on the engine borrows the store as `&self`) and write methods during processing (`&mut self` on the engine borrows the store as `&mut self`). -`apply_transitions` must be durable when it returns — the engine depends on this for crash safety. It must also be **idempotent**: calling it twice with the same `StateUpdate` (same `contract_id` + `txid`) must be a no-op on the second call. This is required because `process_transaction` is idempotent, which flows through to `apply_transitions`. For stores implementing `ContractHistory`, idempotency means avoiding duplicate history entries — the store should check whether a transition for the given `(contract_id, txid)` already exists before inserting. +`apply_transitions` applies the full `&[StateUpdate]` slice produced from a single chain transaction. Required semantics: + +- **Per-transaction atomic**: the slice commits entirely or not at all. Typically implemented with a single database transaction around the body. See the "Atomicity requirements" paragraph later in this section for full error-handling semantics. +- **Durable on return**: the engine depends on this for crash safety — once the call returns `Ok`, the state must survive a process crash without further action. +- **Idempotent per `(contract_id, txid)`**: calling `apply_transitions` twice with a `StateUpdate` sharing the same `(contract_id, txid)` is a no-op on the second call. Required because `process_transaction` is idempotent (for crash recovery and multi-step sync); that idempotency propagates through this method. For stores implementing `ContractHistory`, idempotency extends to history writes — don't create duplicate entries. The standard implementation checks whether a transition for `(contract_id, txid)` already exists before inserting. `find_by_outpoints` is the hot-path method called on every internal `process_transaction`. It is not paginated because its input is bounded by the transaction's input count (constrained by Liquid's transaction size limits). @@ -1515,7 +2709,9 @@ Every consumer must implement this. Read methods take `&self`, write methods tak **Processing log**: The store must persist enough rollback metadata during `apply_transitions` for `rollback_to_height` to reverse transitions. At minimum: the contract ID, old outpoints, new outpoints, old contract state, and block height for each processed transition. The old contract state (the `MarketState`, `LmsrPoolState`, or `OrderState` value before the transition) is required because several transitions are not reversible from `TransitionDetails` alone — e.g., `PoolTransition::Closed` doesn't carry the old `s_index`, `OrderTransition::Cancelled` doesn't carry the old `total_filled`. Persisting the old state makes rollback mechanical (restore old state + old outpoints) regardless of transition type. `prune_finalized` removes this metadata for transitions below the finality threshold. This processing log is separate from `ContractHistory`'s transition history — it exists for rollback, not for user-facing queries. `rollback_to_height` must also clean up derived data (asset ID index, covenant scripts) for contracts removed during rollback. -**Atomicity requirements**: Contract-level atomicity is a hard requirement — a single contract's state update (old outpoints -> new outpoints + state change) must be all-or-nothing. A half-updated contract is corrupted state. Transaction-level atomicity (all contracts updated together for a multi-contract transaction) is recommended but not strictly required for correctness. A "jagged" state where one contract has processed a transaction but another hasn't is indistinguishable from staggered ingestion — which is already a normal condition when contracts are discovered at different times. The system self-heals: re-processing the transaction advances the remaining contracts while already-processed contracts are a no-op (idempotency). Transaction-level atomicity is recommended because it's typically not much extra burden on top of the already-required contract-level atomicity (e.g., a single SQLite transaction) and avoids the jagged-view window. +**Atomicity requirements**: `apply_transitions` is **per-transaction atomic** — the full `&[StateUpdate]` slice passed in a single call commits as a unit or not at all. The engine invokes `apply_transitions` once per processed chain transaction; cross-contract transactions (e.g., routed trades) produce slices with more than one `StateUpdate`. Per-transaction atomicity is required (not merely recommended) because the engine's error semantics rely on it: on `CovenantInvariantViolation` during a multi-contract transaction, the current transaction's batch must roll back as a unit so the engine's retry and rollback logic sees a consistent state. Store implementations typically achieve this with a single database transaction around the `apply_transitions` body. Within a batch, contract-level consistency follows from per-batch atomicity — a single contract's state update (old outpoints → new outpoints + state change) commits atomically by construction. + +Across multiple transactions processed in one `step` call, each transaction's batch commits independently. On error mid-step, transactions processed before the error stay committed; the erroring transaction's batch is rolled back; unprocessed transactions remain to be processed on retry. Retrying `step` picks up where it left off via `apply_transitions`'s per-`(contract_id, txid)` idempotency. ### Optional: ContractHistory @@ -1534,7 +2730,7 @@ pub trait ContractHistory: ContractStore { Only implement if the consumer wants price charts, audit trails, etc. Core never depends on history for processing — it only needs current state. History returns `HistoryEntry` (a type alias for `TypedStateUpdate`) — the caller-facing fields without internal outpoints. To get full output classification for a historical transaction, the caller can call `interpret_transaction`. -The engine exposes history through typed convenience methods (`market_history`, `pool_history`, `order_history`) that are only available when the store implements `ContractHistory`. The store trait itself has a single unified `transition_history` method — the typed unwrapping happens in the engine. See [History Methods](#history-methods). +History is exposed through typed convenience methods on the view types (`Market::history`, `Pool::history`, `Order::history`), only available when the store implements `ContractHistory`. The store trait itself has a single unified `transition_history` method — the typed unwrapping happens inside each view's `history()` method. See [History Methods](#history-methods). ### Implementor Controls Retention @@ -1550,12 +2746,74 @@ This is an implementation detail — core doesn't need per-contract configuratio The current contract state (stored via `ContractStore`) carries enough information for basic wallet UX without requiring `ContractHistory`. A minimal consumer that only implements `ContractStore` can still answer: -- "Did this market resolve YES or NO?" -> `MarketState::ResolvedYes { outstanding_pairs: 0 }` (terminal) or `MarketState::ResolvedYes { outstanding_pairs: 500 }` (awaiting redemption) +- "Did this binary market resolve YES or NO?" -> `MarketState::Binary(BinaryMarketState::ResolvedYes { outstanding_pairs: 0 })` (terminal) or `ResolvedYes { outstanding_pairs: 500 }` (awaiting redemption) +- "Which outcome won this multi-outcome market?" -> `MarketState::MultiOutcome(MultiOutcomeMarketState::Resolved { winning_outcome, collateral_unredeemed })` — `collateral_unredeemed == 0` indicates terminal - "How much of my order has been filled?" -> `OrderState::Active { total_filled }` or `OrderState::Cancelled { total_filled }` - "What are my pool's current reserves?" -> `LmsrPoolState::Active { reserves, .. }` Transition history is for richer features: price charts, fill-by-fill order breakdowns, full audit trails. +### ContractStore Compliance Test Kit + +Store correctness is enforced by a separate crate, `deadcat-core-store-testkit`, that integrators depend on as a dev-dependency. The crate exposes `pub fn run_store_compliance(store: &mut impl ContractStore) -> TestResult` (plus `pub fn run_chain_source_compliance(chain: &mut impl ChainSource) -> TestResult` for `ChainSource` implementations). Integrators call one function per trait in their own test suite and get automated conformance checking. Pattern matches `sqlx::testing`, Diesel backend compliance suites, iroh's blob store kit — a well-established approach for trait-based APIs with pluggable backends. + +The test kit enforces the following invariant categories. Each category maps to one or more concrete test cases in the kit: + +**Outpoint tracking and slot identity** +- Outpoint round-trip: after `apply_transitions` writes `(slot, outpoint)`, `find_by_outpoints(&[outpoint])` returns a `ContractMatch` with the same `(slot, outpoint)` pair. +- Outpoint uniqueness across contracts: no two tracked contracts share an outpoint. +- Slot label uniqueness within a contract: `contract_outpoints` returns at most one entry per `SlotIdentity` value. +- Slot-type containment: `PoolSlot` values only appear in pool contracts, `MarketSlot` only in market contracts, etc. + +**Contract lifecycle** +- `track_contract` on an already-tracked `ContractId` errors with `ContractAlreadyTracked`. +- `untrack_contract` removes the contract and all derived data (asset-id index entries, covenant scripts, processing log entries, history entries for `ContractHistory` implementors). +- `DerivedContractData` is immutable after `track_contract`. + +**Sync state** +- `synced_to` monotonically advances per contract; `advance_synced_heights` with a lower value is rejected or no-op. +- `stale_contracts` groups by emptiness of `DerivedContractData.covenant_scripts` (empty → `outpoint_contracts`, non-empty → `script_contracts`) and returns only contracts with `synced_to < tip_height`. + +**Write-path atomicity and idempotency** +- `apply_transitions` is per-transaction atomic: the full slice commits as a unit or not at all. +- `apply_transitions` is idempotent on `(contract_id, txid)`: the second call is a no-op, with no duplicate history entries for `ContractHistory` implementors. +- `apply_transitions` is durable on return. + +**Indexing** +- Asset-ID index consistency: `find_by_asset_id(a)` returns contract X iff X's `DerivedContractData.asset_ids` includes `a`. +- `covenant_scripts(contract_id)` returns exactly the scripts from `DerivedContractData.covenant_scripts`. + +**Rollback** +- `rollback_to_height(N)` restores each contract's state to its most recent transition at or below N. +- Contracts whose creation transaction was in blocks strictly above N are removed. +- After rollback, `synced_to = min(old_synced_to, N)` for remaining contracts. +- Rollback is idempotent; `rollback_to_height(N)` for N ≥ current tip is a no-op. + +**Pagination** +- Cursor stability under concurrent writes: opaque cursors continue to function correctly when new contracts are ingested between pages (no duplicates, no missed items for contracts ordered before the cursor position). +- Cursor scope: a cursor from one method is rejected when passed to a different method or with different filters. + +**Query-level ordering** +- `best_orders_for_market` returns orders in price order with FIFO by `ChainPosition` among ties; filtered to `Active` state with sufficient remaining liquidity. +- `transition_history` returns in ascending `ChainPosition` (oldest-first). + +**Processing log vs. history separation** +- `prune_finalized` removes rollback metadata but does NOT remove `ContractHistory` entries. +- Stores that don't implement `ContractHistory` can still roll back correctly (processing log is independent). + +**Tracking mode behavior (order-specific)** +- `OrderTracking::Persistent` orders accumulate history if the store implements `ContractHistory`. +- `OrderTracking::EphemeralFresh` and `OrderTracking::EphemeralMidLife` orders produce no `ContractHistory` entries regardless of whether the store implements the trait. +- `prune_finalized` auto-untracks terminal Ephemeral orders past the finality depth. + +**ChainSource invariants** (for `run_chain_source_compliance`) +- `register_*` is idempotent with set semantics: re-registering same scripts/outpoints collapses to one active watch; `from_height` re-registration uses `min(existing, new)` to widen coverage. +- `unregister_*` of never-registered items is a no-op, not an error. +- Notifications delivered per registered item, not per call (no duplicate notifications from duplicate registrations). +- `transactions_by_scripts` returns results in chain order with complete-block guarantees. + +This is the source of truth for compliance. The categories above are the user-facing overview; the crate's source code enumerates the specific test cases, fixtures, and edge cases. As new invariants surface during implementation (Phases 3-6), they land in the kit first and in this list second. + ## Separation of Concerns: Wallet vs Contract Layer A key design principle: the contract layer and wallet layer have complementary, non-overlapping views of the same transaction. @@ -1572,10 +2830,12 @@ The `Transition.external_outputs` bridges the gap — core identifies which outp ## PSET Construction -All PSET builders are engine methods — no wallet access, no chain queries, no signing. The caller provides operation-specific arguments and a `WalletFunding` struct. The engine handles Simplicity contract compilation, script derivation, taproot tree construction, coin selection, and fee computation internally. Builders that involve reissuance token (RT) outputs return `UnblindedPset` — a newtype that enforces covenant blinding before the caller can sign (see [Confidential Transaction Blinding](#confidential-transaction-blinding)). All other builders return `PartiallySignedTransaction` directly. +All PSET builders are engine methods — no wallet access, no chain queries, no signing. The caller provides operation-specific arguments and a `WalletFunding` struct. The engine handles Simplicity contract compilation, script derivation, taproot tree construction, coin selection, and fee computation internally. Builders that can require Deadcat-managed output blinding return `PreBlindedPset` — a newtype that enforces final output-commitment setup before the caller can sign (see [Confidential Transaction Blinding](#confidential-transaction-blinding)). This includes RT-capable market builders and `build_trade_pset`, whose accepted quote may or may not use market assistance. Pure non-RT builders return `PartiallySignedTransaction` directly. **Simplicity is fully encapsulated**: Consumers never see compiled contracts (`CompiledPredictionMarket`, `CompiledLmsrPool`, `CompiledMakerOrder`), Commitment Merkle Roots (CMRs), taproot trees, or witness encoding. These are internal to the engine. Consumers provide contract params (plain data: oracle keys, asset IDs, prices, expiry times) and receive PSETs back. The word "Simplicity" need not appear in consumer code. +**State preconditions**: every transition-producing builder requires the contract to be in a specific set of states (e.g., `build_issuance_pset` requires `Trading`; `build_redemption_pset` requires a terminal variant with unredeemed supply). Violating the state precondition returns `CoreError::InvalidContractState { contract_id, kind: InvalidStateKind::WrongVariant { expected, actual } }`. Runtime preconditions beyond state (e.g., sufficient basket supply for `build_merge_yes_pset`; chain height ≥ expiry for `build_expire_transition_pset`) produce `InvalidStateKind::ConditionFailed { condition, detail }`. See [State Machine Summary](#state-machine-summary) for the full valid-transition matrix. Per-builder rustdoc states the "Valid from:" precondition concisely and references this matrix for the complete picture. + ### Coin Selection and Fee Computation PSET builders perform coin selection internally. The caller provides `available_utxos` via `WalletFunding` — their full candidate pool (or a pre-filtered subset if they want to exclude specific UTXOs). The builder selects the minimum needed. Passing all wallet UTXOs is the expected usage. @@ -1600,66 +2860,66 @@ PSET builders take operation-specific arguments as direct function parameters al ### Prediction Market Builders +Canonical builder signatures are in [View Types § Market](#market) (common to both market kinds) and [View Types § MultiOutcomeMarket](#multioutcomemarket) (cross-outcome primitives for multi-outcome only). The creation builders are on the engine (see [API Overview](#api-overview)). This section covers the per-builder transitions and semantics. + +**Common market builders (Market view, both kinds)**: + | Builder | Transaction | Covenant Transition | | ------- | ----------- | ------------------- | -| `build_creation_pset` | Market creation (defines YES/NO assets, creates RT outputs) | — (creates initial state) | -| `build_issuance_pset` | Token issuance (initial or subsequent) | Trading (0 pairs) → Trading (>0 pairs), or Trading → Trading (more pairs) | -| `build_cancellation_pset` | Cancel market (burn tokens, return collateral) | Trading → Trading (fewer pairs) or → Trading (0 pairs) | -| `build_oracle_resolve_pset` | Oracle resolution | Trading → ResolvedYes/ResolvedNo | +| `build_issuance_pset` | Mint pair for outcome k | Trading → Trading (more pairs) | +| `build_cancellation_pset` | Burn pair for outcome k | Trading → Trading (fewer pairs) or → Trading (0) | +| `build_oracle_resolve_pset` | Oracle resolution | Trading → Resolved* | | `build_expire_transition_pset` | Expire market | Trading → Expired | -| `build_redemption_pset` | Redeem tokens (post-resolution or post-expiry) | ResolvedYes/ResolvedNo/Expired → same variant with fewer pairs (terminal at 0) | - -Creation takes `&MarketCreationParams` (only the 4 non-derivable fields) and returns `(UnblindedPset, PredictionMarketParams)` — the builder selects defining inputs from `available_utxos`, derives the 4 token/RT asset IDs from the issuance entropy, compiles the Simplicity contract internally, and returns the full `PredictionMarketParams` alongside the PSET. The caller uses the returned params for `ingest_market` after the transaction confirms. All other builders take `contract_id` (recompiles from stored params). `build_issuance_pset` handles both initial and subsequent issuance — the engine determines which from the contract's current state. `build_redemption_pset` handles both post-resolution and post-expiry redemption — the engine determines which from the current state. The `side` parameter specifies which token to burn; for resolved markets, the engine validates it matches the winning side. +| `build_redemption_pset` | Redeem tokens (post-resolution or post-expiry) | Resolved*/Expired → same variant with fewer unredeemed (terminal at 0) | -`build_oracle_resolve_pset` and `build_expire_transition_pset` branch internally based on outstanding pairs — when called on a market with zero outstanding pairs (Dormant), they handle the dormant terminal paths (both RT UTXOs consumed, market reaches terminal state with outstanding_pairs: 0). No new builder methods are needed for this case. See [market-dormant-terminal-paths.md](../contracts/prediction-market/market-dormant-terminal-paths.md). +**Multi-outcome cross-outcome primitives (MultiOutcomeMarket view)**: -```rust -// Creation — takes non-derivable params, derives asset IDs internally, returns full params alongside PSET. -pub fn build_creation_pset(&self, params: &MarketCreationParams, funding: &WalletFunding) - -> Result<(UnblindedPset, PredictionMarketParams), CoreError>; +| Builder | Transaction | Covenant Path | +| ------- | ----------- | ------------- | +| `build_split_yes_pset` | Mint complete YES basket | Trading → Trading (all supplies.yes ↑ by sets) | +| `build_merge_yes_pset` | Burn complete YES basket | Trading → Trading (all supplies.yes ↓ by sets) | +| `build_split_no_pset` | Mint complete NO basket | Trading → Trading (all supplies.no ↑ by sets) | +| `build_merge_no_pset` | Burn complete NO basket | Trading → Trading (all supplies.no ↓ by sets) | -// Post-ingestion — takes contract_id, recompiles from stored params. Returns UnblindedPset (RT outputs). -pub fn build_issuance_pset(&self, contract_id: &ContractId, pairs: u64, yes_dest: &Script, no_dest: &Script, funding: &WalletFunding) - -> Result>; +`build_cross_outcome_arb_pset` (multi-contract atomic arb co-spending market + N pools) is deferred to v2. See [Future: Cross-Outcome Arb API](#future-cross-outcome-arb-api-v2). -pub fn build_cancellation_pset(&self, contract_id: &ContractId, pairs_to_burn: Option, funding: &WalletFunding) - -> Result>; +**Creation builders (engine)**: -// ... same pattern for oracle_resolve and expire_transition (UnblindedPset) +| Builder | Transaction | Params | +| ------- | ----------- | ------ | +| `build_binary_market_creation_pset` | Binary market creation (2 YES/NO assets, 2 RTs) | `BinaryMarketCreationParams` | +| `build_multi_outcome_market_creation_pset` | Multi-outcome market creation (2N assets, 2N RTs) | `MultiOutcomeMarketCreationParams` | -// Redemption has no RT outputs — returns PartiallySignedTransaction directly -pub fn build_redemption_pset(&self, contract_id: &ContractId, side: Side, tokens_to_redeem: u64, funding: &WalletFunding) - -> Result>; -``` +**Semantic notes**: -`build_cancellation_pset` takes `pairs_to_burn: Option` — if `None`, the engine computes the maximum cancellable amount from the available YES and NO tokens in `funding.available_utxos` (minimum of the two token balances). +- **Issuance**: `build_issuance_pset(outcome, pairs, yes_dest, no_dest, funding)` handles both initial (Dormant → Unresolved) and subsequent (Unresolved → Unresolved) issuance — the view determines which from the cached state. For binary markets, `outcome` must be `OutcomeIndex::BINARY`; other values return `CoreError::InvalidParams`. For multi-outcome, `outcome` selects which of the N outcome-pair RT slots to draw from. +- **Cancellation**: `build_cancellation_pset(outcome, pairs_to_burn, funding)` — if `pairs_to_burn` is `None`, the engine computes the maximum cancellable from available YES + NO tokens for the given outcome in `funding.available_utxos` (minimum of the two token balances). +- **Oracle resolution and expiry**: both `build_oracle_resolve_pset` and `build_expire_transition_pset` branch internally based on outstanding supply — when called on a market with zero outstanding pairs/sets (Dormant), they handle the dormant terminal paths (all RT UTXOs consumed atomically, market reaches terminal state). No new builder methods are needed for this case. See [market-dormant-terminal-paths.md](../contracts/prediction-market/market-dormant-terminal-paths.md). +- **Redemption**: `build_redemption_pset(outcome, side, tokens_to_redeem, funding)` handles both post-resolution and post-expiry redemption; the view determines which from the cached state. Post-resolution: for binary markets the engine validates `side` matches the winning side; for multi-outcome it validates either `(outcome == winning_outcome, side == Yes)` (winning YES_k) or `(outcome != winning_outcome, side == No)` (winning NO_j). Post-expiry: any `(outcome, side)` combination is valid at the fractional rate. +- **Split/merge YES (multi-outcome)**: atomically mints or burns one of each `YES_k` for the market's N outcomes, with collateral flow of `sets × collateral_per_pair`. The `destinations: &[Script]` slice on `build_split_yes_pset` has length `outcome_count`; `destinations[k]` receives `sets` units of `YES_k`. +- **Split/merge NO (multi-outcome)**: same pattern but for NO tokens; collateral flow is `sets × (N-1) × collateral_per_pair`. +- **Cross-outcome arb** (multi-outcome): single atomic transaction that co-spends the market contract's split-YES (or merge-YES) path with each outcome's binary LMSR pool public path (`old_s_index != new_s_index`). Closes cross-outcome price coherence gaps (`Σ p_YES_k ≠ 1`) in one tx. The v1 engine does not construct this aggregate PSET internally; external tooling can build it directly against the covenant spec, and core ingests the resulting per-contract transitions. First-class quote/build support is deferred to v2. +- **Creation builders**: return the full derived params (`BinaryMarketParams` / `MultiOutcomeMarketParams`) alongside the PSET so the caller can `ingest_market` after the creation transaction confirms. `build_binary_market_creation_pset` selects 2 defining inputs; `build_multi_outcome_market_creation_pset` selects 2N defining inputs and compiles the N-specific generated `.simf` covenant. -`build_creation_pset` includes a 37-byte zero-value OP_RETURN recovery hint (compressed encoding of non-derivable covenant params). See [Wallet Recovery](#wallet-recovery) and [chain-only-recovery.md](../protocol/chain-only-recovery.md). +**OP_RETURN recovery hints**: Both `build_binary_market_creation_pset` and `build_multi_outcome_market_creation_pset` include a **37-byte** zero-value OP_RETURN hint (69 bytes with exotic collateral). Binary and multi-outcome markets share the same layout, distinguished by the hint's type tag byte. For multi-outcome markets, `outcome_count` is **not stored** — it is derived at recovery time from the creation tx's new-issuance count (2N issuances → N outcomes), with a defensive filter on `AssetIssuance` records (both `amount` and `inflation_keys` non-null) to rule out asymmetric issuances. The covenant script is the authoritative binding between N and the tx shape; a wrong derived N produces a script mismatch at ingestion, which is a loud failure. All 4N asset IDs for multi-outcome markets are derivable from the creation transaction's issuance entropy — the hint doesn't scale with N. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) and [multi-outcome-market-contract.md](../contracts/multi-outcome/multi-outcome-market-contract.md). ### LMSR Pool Builders -| Builder | Transaction | Covenant Path | -| ------- | ----------- | ------------- | -| `build_lmsr_bootstrap_pset` | Pool creation (fund initial reserves) | — (creates initial state) | -| `build_lmsr_adjust_pset` | Admin liquidity adjustment | Admin path (s_index unchanged) | -| `build_lmsr_close_pset` | Pool closure (reclaim all reserves) | Close script path | - -```rust -pub fn build_lmsr_bootstrap_pset(&self, params: &LmsrPoolParams, starting_price_bps: u16, masked_index: u16, funding: &WalletFunding) - -> Result>; - -pub fn build_lmsr_adjust_pset(&self, contract_id: &ContractId, pair_delta: i64, collateral_delta: i64, funding: &WalletFunding) - -> Result>; +Canonical builder signatures are in [View Types § Pool](#pool). The bootstrap builder is on the engine (see [API Overview](#api-overview)). This section covers the per-builder transitions and semantics. -pub fn build_lmsr_close_pset(&self, contract_id: &ContractId, funding: &WalletFunding) - -> Result>; -``` +| Builder | Location | Transaction | Covenant Path | +| ------- | -------- | ----------- | ------------- | +| `build_lmsr_bootstrap_pset` | `engine` | Pool creation (fund initial reserves) | — (creates initial state) | +| `build_adjust_pset` | `Pool` view | Admin liquidity adjustment | Admin path (s_index unchanged) | +| `build_close_pset` | `Pool` view | Pool closure (reclaim all reserves) | Close script path | -`build_lmsr_adjust_pset` takes `pair_delta` (applied equally to both YES and NO reserves) and `collateral_delta` (applied to collateral independently). This API shape makes the covenant's paired-delta constraint (YES and NO must move equally) unrepresentable as an error — the caller cannot express asymmetric deltas. The engine validates that the resulting reserves meet the covenant's minimum reserve floor (`MIN_POOL_RESERVE` — a protocol constant, 1,000 sats per reserve, hardcoded in the covenant) and returns `CoreError::InvalidParams` if violated. If both deltas are zero, the engine returns `CoreError::InvalidParams` — a no-op adjustment would produce a valid but pointless transaction that wastes fees. The wallet can present an absolute-target UI ("set pool to 1000 YES/NO") by computing the delta from current reserves on their side. See [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md) for the full pool parameter design. +**Semantic notes**: -`build_lmsr_close_pset` atomically consumes all three reserve UTXOs via the dedicated Simplicity close script path (NUMS internal key makes key-spend unspendable). All reserve funds are returned to `funding.return_script`. See [lmsr-pool-close-path.md](../contracts/lmsr-pool/lmsr-pool-close-path.md). +- **`build_adjust_pset(pair_delta, collateral_delta, funding)`**: `pair_delta` is applied equally to both YES and NO reserves (signed: positive = injection, negative = withdrawal). `collateral_delta` is applied to the collateral reserve independently. The API shape makes the covenant's paired-delta constraint (YES and NO must move equally on the admin path) unrepresentable as an error — the caller cannot express asymmetric deltas. The engine validates that the resulting reserves meet the covenant's minimum reserve floor (`MIN_POOL_RESERVE` — a protocol constant, 1,000 sats per reserve, hardcoded in the covenant) and returns `CoreError::InvalidParams` if violated. If both deltas are zero, the engine returns `CoreError::InvalidParams` — a no-op adjustment would waste fees. The wallet can present an absolute-target UI ("set pool to 1000 YES/NO") by computing the delta from current reserves on their side. See [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md) for the full pool parameter design. +- **`build_close_pset(funding)`**: atomically consumes all three reserve UTXOs via the dedicated Simplicity close script path (NUMS internal key makes key-spend unspendable). All reserve funds are returned to `funding.return_script`. See [lmsr-pool-close-path.md](../contracts/lmsr-pool/lmsr-pool-close-path.md). +- **`build_lmsr_bootstrap_pset(params, initial_s_index, initial_reserves, masked_index, funding)`**: creates the pool at the chosen starting state with the caller-specified starting reserves. The recommended UX flow is to seed `initial_reserves` from `estimate_bootstrap`, but the builder does not silently re-derive or canonicalize reserves internally — explicit reserve vectors are part of the caller's chosen end state. The creation transaction includes a **40-byte** zero-value OP_RETURN recovery hint containing: market creation txid, `max_loss_sats` and `half_payout_sats` (4-bit 1-2-5 table indices each, shared with the market `base_payout` encoding), `fee_bps` (u12, 0.01% granularity), `initial_s_index` (u16, the starting table index for script verification during recovery), and XOR-masked pool operator derivation index. The hint does not encode reserves; recovery learns the actual starting reserves from the creation transaction outputs themselves. All other covenant params are derived via deterministic table generation. See [Wallet Recovery](#wallet-recovery), [chain-only-recovery.md](../protocol/chain-only-recovery.md), and [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md). -`build_lmsr_bootstrap_pset` includes a **41-byte** zero-value OP_RETURN recovery hint containing: market creation txid, `max_loss_sats` and `half_payout_sats` (9-bit encoded: 26-value mantissa x 10^exponent, supporting non-L-BTC assets), `fee_bps` (u12, 0.01% granularity), `initial_s_index` (u16, the starting table index for script verification during recovery), and XOR-masked pool operator derivation index. All other covenant params are derived via deterministic table generation. See [Wallet Recovery](#wallet-recovery), [chain-only-recovery.md](../protocol/chain-only-recovery.md), and [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md). +**Multi-outcome pool composition**: a pool always serves one outcome's YES/NO pair. For binary markets that's the single event's YES/NO. For multi-outcome markets under Option C composition, each outcome has its own independent binary LMSR pool (created via `derive_pool_params` with an `outcome: OutcomeIndex` parameter, then bootstrapped via `build_lmsr_bootstrap_pset`). The pool contract doesn't know or care which market kind underlies its YES/NO tokens. **Signing note**: Pool adjust and close PSETs require signing with both the wallet key (for fee inputs) and the pool's admin key (for the covenant spend authorization). Both keys are controlled by the pool operator. Pool swaps (via trade PSETs) are permissionless and require only the taker's wallet key. @@ -1667,22 +2927,19 @@ Pool swaps are not built directly — they are part of trade transactions (see [ ### Maker Order Builders -The maker's lifecycle is directly exposed. The taker side (filling orders) is handled through trade transactions — see [Trade PSET Builder](#trade-pset-builder). +Canonical builder signature for cancellation is in [View Types § Order](#order). The creation builder is on the engine (see [API Overview](#api-overview)). The maker's lifecycle is directly exposed here; the taker side (filling orders) is handled through trade transactions — see [Trade PSET Builder](#trade-pset-builder). -| Builder | Transaction | State Change | -| ------- | ----------- | ------------ | -| `build_create_order_pset` | Create limit order | — (creates initial state) | -| `build_cancel_order_pset` | Cancel order | Active → Cancelled | +| Builder | Location | Transaction | State Change | +| ------- | -------- | ----------- | ------------ | +| `build_create_order_pset` | `engine` | Create limit order | — (creates initial state) | +| `build_cancel_pset` | `Order` view | Cancel order | Active → Cancelled | -```rust -pub fn build_create_order_pset(&self, params: &MakerOrderParams, offered_amount: u64, masked_index: u16, funding: &WalletFunding) - -> Result>; +**Semantic notes**: -pub fn build_cancel_order_pset(&self, contract_id: &ContractId, funding: &WalletFunding) - -> Result>; -``` +- **`build_create_order_pset(params, offered_amount, masked_index, funding)`**: takes `MakerOrderParams` fully formed (params aren't derived from issuance entropy — they're committed directly). Includes a 40-byte zero-value OP_RETURN recovery hint (masked derivation index, market txid, compressed price/outcome/side/direction/min_fill/min_remainder). The `masked_index` parameter is computed by the caller via `derive_order_params`. See [Wallet Recovery](#wallet-recovery) and [chain-only-recovery.md](../protocol/chain-only-recovery.md). +- **`build_cancel_pset(funding)`**: the cancel path uses taproot key-spend with the maker's real public key (not NUMS). Requires the maker's signature; no script-path covenant execution. See [maker-order-remove-script-cancel.md](../contracts/maker-order/maker-order-remove-script-cancel.md). -`build_create_order_pset` includes a 40-byte zero-value OP_RETURN recovery hint (masked derivation index, market txid, compressed price/side/direction/min_fill/min_remainder). The `masked_index` parameter is computed by the caller via `derive_order_params`. See [Wallet Recovery](#wallet-recovery) and [chain-only-recovery.md](../protocol/chain-only-recovery.md). +**Multi-outcome order composition**: like pools, orders are per-outcome. `derive_order_params` takes an `outcome: OutcomeIndex` parameter that selects which YES/NO pair the order offers. Orders on different outcomes of the same multi-outcome market are independent contracts. Routing (`quote_trade`) targets orders that match the trade's `(outcome, side)`. ### Trade PSET Builder @@ -1699,7 +2956,13 @@ pub fn quote_trade( ) -> Result>; ``` -The engine computes the optimal route across all available pools and orders for the market, minimizing total cost to the taker including transaction fee overhead. The `fee_rate` parameter is required because the routing algorithm uses fee-adjusted effective prices — each liquidity source's activation cost (transaction weight) is weighted by the fee rate to determine whether including it improves the route. The routing algorithm uses pool-subset enumeration combined with fee-aware greedy order selection — see [trade-routing-algorithm.md](trade-routing-algorithm.md) for the full specification. Returns a `TradeQuote` representing the best available fill, including `estimated_fee` computed from the route's total transaction weight and the provided fee rate. Returns `Err(CoreError::NoLiquidity)` only when zero liquidity is available; any positive fill returns `Ok` (see [TradeQuote](#tradequote-and-related-types) for partial fill handling). +The engine computes the optimal route across all available pools and orders for the market, minimizing total cost to the taker including transaction fee overhead. The `fee_rate` parameter is required because the routing algorithm uses fee-adjusted effective prices — each liquidity source's activation cost (transaction weight) is weighted by the fee rate to determine whether including it improves the route. The routing algorithm uses pool-subset enumeration combined with fee-aware greedy order selection — see [trade-routing-algorithm.md](trade-routing-algorithm.md) for the full specification. Returns a `TradeQuote` representing the best available fill, including `estimated_fee` computed from the route's total transaction weight and the provided fee rate. + +For existing pools, the router may also choose a market-assisted pool leg when that improves fillability or taker price. Assisted legs still surface as `LiquiditySource::LmsrPool` in the quote; the exact parent-market co-spend stays internal in `TradeRoute`. In v1, assisted routing is limited to at most one pool leg per route, uses `IssuePairs` on buys and `CancelPairs` on sells, and is considered only while the parent market still supports the required issuance/cancellation path. + +Returns `Err(CoreError::NoLiquidity { market_id, outcome, side, direction })` only when the router cannot fill any positive amount for the target `(market, outcome, side, direction)` — all pools at minimum reserves in the trade direction, all orders dust, or no tracked sources. Any `filled_amount > 0` returns `Ok(TradeQuote)`, including heavily partial fills where the caller may want to abandon. Partial-fill decision-making is the caller's responsibility — inspect `TradeQuote.filled_amount` vs `TradeQuote.requested_amount`. See [TradeQuote](#tradequote-and-related-types) for details. + +Post-resolution trading is not gated — `quote_trade` succeeds regardless of the parent market's state as long as routable liquidity exists. See [Pool and Order Lifecycle at Market Resolution](#pool-and-order-lifecycle-at-market-resolution). **Step 2: Build** (engine method): @@ -1708,10 +2971,24 @@ pub fn build_trade_pset( &self, quote: &TradeQuote, funding: &WalletFunding, -) -> Result>; +) -> Result>; ``` -Takes the accepted quote and the caller's wallet funding. The engine validates that `funding.fee_rate` matches the fee rate used during quoting — if they differ, it returns `CoreError::InvalidParams` because the route was optimized for the quote's fee rate (a different rate could make the route suboptimal; the caller should re-quote with the current rate). The engine recompiles contracts from stored params, selects the needed UTXOs, computes the actual fee from the real transaction weight, and builds the PSET. The actual fee may differ from `TradeQuote.estimated_fee` because the quote's weight model assumes a single wallet input, while coin selection may add more — display the quote's fee as an estimate, not a guarantee. The quote captures a snapshot of all contract state needed at quote time (outpoints, route parameters). If the underlying contracts change between quoting and building (a `process_transaction` call consumed the snapshotted outpoints), the engine returns `CoreError::StaleQuote` — the caller should re-quote. If the quote is still valid at build time but the transaction later fails on-chain (spent inputs due to a block arriving between build and broadcast), the caller re-quotes. This is standard trading UX — quotes are inherently ephemeral. +Takes the accepted quote and the caller's wallet funding. The engine validates that `funding.fee_rate` matches the fee rate used during quoting — if they differ, it returns `CoreError::InvalidParams` because the route was optimized for the quote's fee rate (a different rate could make the route suboptimal; the caller should re-quote with the current rate). The engine recompiles contracts from stored params, selects the needed UTXOs, computes the actual fee from the real transaction weight, and builds the PSET. The actual fee may differ from `TradeQuote.estimated_fee` because the quote's weight model assumes a single wallet input, while coin selection may add more — display the quote's fee as an estimate, not a guarantee. + +The returned `PreBlindedPset` gives all routed trades the same wallet integration flow. Plain pool/order routes have an empty RT-blinding plan, so `prepare` / `finalize` perform no Deadcat RT work beyond the normal wallet-output handling chosen by the caller. Assisted routes may co-spend the parent market for `IssuePairs` or `CancelPairs`, producing RT continuation outputs that must be deterministically blinded before signing. The wrapper makes that route-dependent requirement impossible to skip without exposing a `TradePset` enum or separate plain/assisted builders. + +**Input and output layout**: the trade PSET arranges inputs and outputs using a deterministic contract-window ordering to satisfy each covenant's introspection rules simultaneously: witness-parameterized pool windows, an optional witness-parameterized parent-market window for one assisted pool leg, positional maker receives, and witness-specified order remainders. The full layout algorithm — including output-index assignment, witness `in_base` / `out_base` / `remainder_idx` construction, and the aliasing-prevention invariants the builder must uphold — is specified in [transaction-composability-model.md § Output Layout for Multi-Covenant Transactions](transaction-composability-model.md#output-layout-for-multi-covenant-transactions). Implementers of `build_trade_pset` should consult that doc as the authoritative layout spec. + +**Freshness check algorithm**: the quote captures outpoint snapshots at quote time for every contract the route touches (one `SlotIdentity`-labeled set per pool/order leg, plus the parent market window for an assisted pool leg). `build_trade_pset` verifies each snapshot is still current by comparing against `store.contract_outpoints(contract_id)`. If any snapshotted outpoint is no longer in that contract's current outpoint set, returns `CoreError::StaleQuote { reason }` with one of: + +- `StaleQuoteReason::OutpointsChanged { contract_id }` — another `process_transaction` advanced the contract (pool swap/adjust, order fill, order cancel). +- `StaleQuoteReason::ContractUntracked { contract_id }` — the contract was untracked between quote and build. +- `StaleQuoteReason::ContractRemoved { contract_id }` — the contract was removed by `rollback_to_height` between quote and build (e.g., reorged out). + +The check is O(L) store reads where L is the number of legs (typically 1 pool + ~5 orders) — negligible overhead. Partial freshness is not supported: if any leg is stale, the entire quote is, and the caller re-quotes. Note the subtle case of pool admin adjust — the pricing curve doesn't change (s_index stays put), but the covenant still produces new outpoints, so a quote made pre-adjust goes stale despite the LMSR math being identical. + +If the quote is still valid at build time but the transaction later fails on-chain (spent inputs due to a block arriving between build and broadcast), the caller re-quotes. This is standard trading UX — quotes are inherently ephemeral. See [Trade Types](#trade-types) and [TradeQuote](#tradequote-and-related-types) for full type definitions. @@ -1721,17 +2998,18 @@ See [Trade Types](#trade-types) and [TradeQuote](#tradequote-and-related-types) On Liquid, transaction outputs can be **explicit** (asset and value visible) or **confidential** (hidden behind Pedersen commitments with range and surjection proofs). The three Deadcat covenants require all covenant outputs (collateral, reserves, order locked value) to be **explicit** — the Simplicity programs use `unwrap_right()` on output introspection jets, which fails on confidential outputs. The one exception is reissuance token (RT) outputs, which Elements requires to be blinded for reissuance mechanics to work. -**Which builders need RT blinding**: The 5 prediction market builders that involve RT outputs (`build_creation_pset`, `build_issuance_pset`, `build_cancellation_pset`, `build_oracle_resolve_pset`, `build_expire_transition_pset`). The remaining 7 builders have no RT involvement — their covenant outputs are all explicit and require no blinding by core. +**Which builders need pre-blinding**: The prediction-market builders that always involve RT outputs (`build_binary_market_creation_pset`, `build_multi_outcome_market_creation_pset`, `build_issuance_pset`, `build_cancellation_pset`, `build_oracle_resolve_pset`, `build_expire_transition_pset`, plus the multi-outcome split/merge builders) return `PreBlindedPset`. `build_trade_pset` also returns `PreBlindedPset` because the accepted quote may be plain or market-assisted; the return type cannot vary by route. The remaining pure non-RT builders have only explicit covenant outputs and return `PartiallySignedTransaction` directly. **Deterministic RT blinding**: RT blinding factors are derived deterministically from public on-chain data (see [deterministic-rt-blinding.md](../protocol/deterministic-rt-blinding.md)), not generated randomly. This is essential for core's architecture: the engine internally manages RT outpoints and must reconstruct blinding factors when building future PSETs that spend those outpoints. With deterministic derivation, the engine recomputes the factors on demand without needing to persist blinding secrets. -**`UnblindedPset` newtype**: The 5 RT-involving builders return `UnblindedPset` — an opaque type whose private fields capture the explicit PSET, deterministic RT blinding factors, and all input secrets (both covenant inputs with zero blinding factors and wallet inputs with real blinding factors from `UnblindedUtxo`). The type enforces that the caller cannot extract a `PartiallySignedTransaction` without going through a blinding method, making "forgot to blind" unrepresentable at the type level. +**`PreBlindedPset` newtype**: RT-capable market builders and `build_trade_pset` return `PreBlindedPset` — an opaque type whose private fields capture the explicit PSET, an optional deterministic RT blinding plan, the input secrets (both covenant inputs with zero blinding factors and wallet inputs with real blinding factors from `UnblindedUtxo`), and output classification. Plain routed trades have an empty RT plan; assisted routed trades carry the required RT output plan. The type enforces that the caller cannot extract a `PartiallySignedTransaction` without going through `prepare` or `finalize`, making "forgot to run Deadcat pre-blinding" unrepresentable at the type level. ```rust -pub struct UnblindedPset { /* private */ } +pub struct PreBlindedPset { /* private */ } -impl UnblindedPset { - /// Blind covenant RT outputs, mark wallet outputs for confidential blinding. +impl PreBlindedPset { + /// Apply any required deterministic RT blinding and mark eligible wallet + /// outputs for confidential blinding. /// Returns a PreparedPset. Caller must then call /// `pset.blind_last(rng, secp, &input_secrets)` to blind wallet outputs, /// then sign. @@ -1740,8 +3018,8 @@ impl UnblindedPset { wallet_blinding_pubkey: &PublicKey, ) -> Result; - /// Blind covenant RT outputs with VBF balancing. Wallet outputs remain - /// explicit (unblinded). Returns a ready-to-sign PSET. + /// Apply any required deterministic RT blinding with VBF balancing. Wallet + /// outputs remain explicit (unblinded). Returns a ready-to-sign PSET. /// **Precondition**: All wallet inputs must be explicit (zero blinding factors). /// The CBF pass-through self-balances the RT portion, but wallet inputs with /// non-zero blinding factors would unbalance the equation. Naturally satisfied @@ -1757,7 +3035,7 @@ pub struct PreparedPset { } ``` -**`prepare` vs `finalize`**: Both methods blind the RT outputs identically using deterministic factors. The difference is a **privacy decision** about wallet outputs: +**`prepare` vs `finalize`**: Both methods apply the same deterministic RT blinding plan when one exists. For plain routed trades, that plan is empty and the methods simply follow the caller's wallet-output privacy choice. The difference is a **privacy decision** about wallet outputs: - **`prepare(pubkey)`**: For callers who want confidential wallet outputs (the common case on Liquid mainnet). Blinds RT outputs using non-last semantics (pushes VBF delta to `global.scalars` for later balancing). Marks wallet outputs with the provided blinding public key. Returns `PreparedPset` with the PSET and complete input secrets map. The caller then calls `pset.blind_last(rng, secp, &input_secrets)` which blinds the wallet outputs and balances VBFs. After `blind_last`, the PSET is ready to sign. @@ -1767,25 +3045,28 @@ pub struct PreparedPset { **Implementation**: Core implements deterministic RT blinding using public APIs from `elements` (PSET output fields: `amount_comm`, `asset_comm`, `value_rangeproof`, `asset_surjection_proof`, etc.) and `secp256k1-zkp` (Pedersen commitments, range proof generation, surjection proof generation). The `global.scalars` field (used for VBF delta tracking in the `prepare` path) is a serialized PSET field that survives cross-process serialization/deserialization. No fork of the `elements` crate is needed. -**Non-RT builders**: The 7 builders without RT involvement (`build_redemption_pset`, all pool builders, all order builders, `build_trade_pset`) return `PartiallySignedTransaction` directly with all outputs explicit. If the caller wants confidential wallet outputs, they handle blinding using their standard Elements wallet workflow — this is a general Liquid concern, not deadcat-specific. +**Pure non-RT builders**: `build_redemption_pset`, all pool builders, and all order builders return `PartiallySignedTransaction` directly with all outputs explicit. If the caller wants confidential wallet outputs, they handle blinding using their standard Elements wallet workflow — this is a general Liquid concern, not deadcat-specific. `build_trade_pset` is intentionally excluded from this group even though many quotes are plain, because assisted quotes require route-dependent RT pre-blinding. -**Caller flow — RT builders**: +**Caller flow — pre-blinded builders** (market builders and routed trades): ```rust +let market = engine.market(&id)?.expect("market tracked"); + // Confidential wallet outputs (Liquid mainnet) -let unblinded = engine.build_issuance_pset(&id, pairs, &yes, &no, &funding)?; -let prepared = unblinded.prepare(&wallet_blinding_pubkey)?; +let pre_blinded = market.build_issuance_pset(OutcomeIndex::BINARY, pairs, &yes, &no, &funding)?; +let mut prepared = pre_blinded.prepare(&wallet_blinding_pubkey)?; prepared.pset.blind_last(&mut rng, &secp, &prepared.input_secrets)?; signer.sign(&mut prepared.pset)?; // Explicit wallet outputs (regtest / testing — all wallet inputs must be explicit) -let unblinded = engine.build_issuance_pset(&id, pairs, &yes, &no, &funding)?; -let mut pset = unblinded.finalize()?; +let pre_blinded = market.build_issuance_pset(OutcomeIndex::BINARY, pairs, &yes, &no, &funding)?; +let mut pset = pre_blinded.finalize()?; signer.sign(&mut pset)?; ``` -**Caller flow — non-RT builders**: +**Caller flow — non-RT builders** (on view types, or engine for trades and creation): ```rust -let mut pset = engine.build_lmsr_close_pset(&id, &funding)?; +let pool = engine.pool(&pool_id)?.expect("pool tracked"); +let mut pset = pool.build_close_pset(&funding)?; // Optional: standard wallet blinding if desired signer.sign(&mut pset)?; ``` @@ -1826,6 +3107,15 @@ The **steady-state** methods manage a notification registration system. `registe **Gap-free handoff**: `register_scripts` takes `from_height` — the chain source guarantees delivery of all matching transactions at or above this height. The engine registers with `from_height = synced_to`, creating overlap with the catch-up scan rather than a gap. Overlap is harmless (`process_transaction` is idempotent). `register_spends` does NOT take `from_height` — instead, the chain source checks if the outpoint is already spent and includes the spending transaction in the next `drain_notifications` call if so. This binary spent/unspent check is sufficient because outpoints (unlike scripts) have a single possible event. +**Subscription semantics — idempotent set operations**: + +- `register_scripts(scripts, from_height)` adds each script to the active watch set. If a script is already registered, the effective `from_height` becomes `min(existing, new)` — re-registration only widens coverage, never narrows it. This matters for the engine's re-initialization path (after rollback or subscription-state invalidation): `step` calls `register_scripts` for all applicable contracts again; already-registered scripts are not an error. +- `register_spends(outpoints)` adds each outpoint to the active watch set. No `from_height` concern (binary spent/unspent check). Duplicate registrations collapse to one active watch. +- `unregister_scripts(scripts)` and `unregister_spends(outpoints)` remove each from the active watch set. Unregistering a script or outpoint that was never registered (or was already unregistered) is a **no-op, not an error** — makes cleanup and rollback handling forgiving. +- Notifications are delivered **per registered item**, not per registration call. Registering the same script or outpoint twice does not produce duplicate notifications. + +These semantics free the engine from bookkeeping "have I already registered this?" state. The engine calls `register_*` when it wants coverage and `unregister_*` when it wants to release coverage; the chain source handles the set-membership details. + **The trait is a read-only data source, not a service.** It makes no writes to the chain, does no broadcasting, and performs no fee estimation. The engine treats it as an immutable data accessor. The `&mut self` on registration methods reflects internal state management (tracking what's registered), not external side effects. **`drain_notifications` returns confirmed transactions only** (with `ChainPosition`), **in chain order** (ascending by `ChainPosition`). This matches the ordering guarantee of `transactions_by_scripts`. The engine processes notifications sequentially — out-of-order delivery could cause it to miss a transaction whose inputs reference outpoints created by a not-yet-processed earlier transaction. Mempool/unconfirmed transactions are out of scope — the caller handles mempool awareness separately via `interpret_transaction` if they want pending UX. @@ -1890,6 +3180,40 @@ The root cause of the split: pool scripts encode the `s_index`, which changes on The store exposes this through `synced_to` on `ContractEntry` (readable by the caller for informational purposes like "last synced: block 2000") and through `stale_contracts(tip_height)` (used by the engine to efficiently find contracts needing work). See [ContractStore](#required-contractstore). +## Pool and Order Lifecycle at Market Resolution + +The pool and order covenants are **market-state-agnostic** — they accept swaps and fills regardless of whether the parent market has resolved or expired. See [lmsr-pool-design.md § Market Resolution](../contracts/lmsr-pool/lmsr-pool-design.md#market-resolution). `deadcat-core` mirrors this at the policy layer: **trading through resolved-parent pools and orders is not gated**. + +### What remains available regardless of parent market state + +- `quote_trade` and `build_trade_pset` route through pools and orders on resolved-parent markets normally. Quotes return `Ok(TradeQuote)` if routable liquidity exists; PSETs build successfully. +- `build_adjust_pset` and `build_close_pset` on the `Pool` view remain callable. +- `build_cancel_pset` on the `Order` view remains callable. +- Market operations (`build_oracle_resolve_pset`, `build_expire_transition_pset`, `build_redemption_pset`) proceed according to the market's own state-machine transitions. + +### Pool operator responsibilities + +The operator closes the pool via `build_close_pset` when convenient after market resolution. Until they do, the pool remains tradable at stale prices (YES ≈ 1 at YES-resolved markets, half each at expiry), and an informed trader could drain reserves by buying out winning-token inventory. This is **not protected by `deadcat-core`** — the engine's policy is that pool operators manage their own liquidity carefully, including closing pools at terminal market states. + +**Why this isn't enforced at the covenant layer**: airtight protection would require the pool covenant to observe the parent market's state on every swap, and a covenant can only introspect the current transaction — so the only mechanism is to **co-spend the market covenant's UTXO as an input on every swap transaction**. That would make every swap substantially heavier (adding the market's collateral input + witness to the pool's ~1,000-vbyte footprint), and the cost would fall on every trade, not just ones near resolution. Paying a permanent per-trade tax to block the informed-drainer attack in the narrow window between resolution and operator-close isn't a trade worth making. See [lmsr-pool-design.md § Why the pool covenant can't feasibly gate post-resolution trading](../contracts/lmsr-pool/lmsr-pool-design.md#why-the-pool-covenant-cant-feasibly-gate-post-resolution-trading) for the full analysis. + +### Order maker responsibilities + +Order makers cancel unfilled orders via `build_cancel_pset` when convenient; otherwise takers may fill them post-resolution. As with pools, the engine does not gate this. + +### Ephemeral orders and rollback + +For orders tracked as `OrderTracking::EphemeralFresh` or `OrderTracking::EphemeralMidLife`, terminal states (`Consumed`, `Cancelled`) remain visible in storage during the finality window and auto-untrack at `prune_finalized` past finality (depth 2 on Liquid). Rollback interacts with this as follows: + +- **Rollback within finality**: processing log still contains the transitions. `rollback_to_height(N)` reverses them normally — a Consumed Ephemeral order returns to Active state if the terminal transition occurred in blocks above N, same as any other rollback. +- **Rollback past a finalized terminal transition**: the order was auto-untracked at `prune_finalized`; its processing log entries and stored state are gone. Rollback cannot restore it. On the new canonical chain, if the order exists again (e.g., its creation tx didn't get reorged), the caller must re-discover from Nostr and re-ingest. This matches the general "contracts above rollback height are removed; caller re-discovers" rule for Ephemeral orders specifically. + +Persistent orders never auto-untrack, so they rollback cleanly within the finality window and otherwise behave identically to markets and pools. + +### UI-layer warnings are appropriate + +Wallet UIs concerned about post-resolution trading risk can warn users before routing trades by checking `Market::state()` independently. This provides the honest-user protection without a false sense of airtight gating — adversarial actors would fork or bypass core regardless. See [Design Principles § Engine gates covenant-invalidity and impossibility, not unfavorability](#engine-gates-covenant-invalidity-and-impossibility-not-unfavorability) for the full rationale. + ## Simplicity Contracts (Internal) Core contains the `.simf` Simplicity contract source code and the compiler integration. Given contract parameters and a network type (testnet/mainnet), core internally: @@ -1901,7 +3225,7 @@ Core contains the `.simf` Simplicity contract source code and the compiler integ This is necessary for both PSET construction (building covenant outputs with correct scripts) and state advancement (matching output scripts to determine new state). -**Compilation model**: The Simplicity source templates are parsed once (process-wide `OnceLock` cache). Per-contract instantiation (binding parameters to the template) and commitment are performed on demand — there is no in-memory compiled contract cache. During ingestion, the engine compiles the contract, passes pre-computed scripts and asset IDs to the store as `DerivedContractData` for indexing, and discards the compiled result. PSET builders recompile from stored params on each call — the cost is moderate (~10-100ms, dominated by instantiation + commitment; template parsing is already cached). `process_transaction` and `interpret_transaction` do not need compiled contracts — they determine transitions from script pubkey matching (using the store's persisted script index) and output values, without witness decoding. `ContractEngine::new` is O(1) — it does not iterate existing contracts or compile anything at construction time. +**Compilation model**: The Simplicity source templates are parsed once (process-wide `OnceLock` cache). Per-contract instantiation (binding parameters to the template) and commitment are performed on demand — there is no in-memory compiled contract cache. During ingestion, the engine compiles the contract, passes pre-computed scripts and asset IDs to the store as `DerivedContractData` for indexing, and discards the compiled result. PSET builders recompile from stored params on each call — the cost is moderate (~10-100ms, dominated by instantiation + commitment; template parsing is already cached). `process_transaction` and `interpret_transaction` do not need compiled contracts: most transitions are determined from script pubkey matching (using the store's persisted script index) and output values, while pools and dormant market terminals use selective `RedeemNode::decode` over raw witness bytes for path and s_index disambiguation. `ContractEngine::new` is O(1) — it does not iterate existing contracts or compile anything at construction time. **Why no compiled contract cache**: The only operation requiring a compiled contract is PSET construction (specifically, witness encoding for spending covenant inputs). The simplicityhl library's `CompiledProgram` type is opaque with no serialization API, so compiled contracts cannot be persisted to disk. An in-memory cache would only save recompilation across multiple PSET builds for the same contract within a single engine lifetime — a rare scenario that doesn't justify the cache's complexity (eviction during rollback, interior mutability for `&self` methods). If simplicityhl adds `CompiledProgram` serialization in the future, persisting compiled contracts at ingestion time would eliminate recompilation entirely — a transparent internal optimization with no API change. See [simplicityhl-compiled-program-serialization.md](../upstream-simplicity/simplicityhl-compiled-program-serialization.md) for the upstream request. @@ -1947,9 +3271,80 @@ The caller periodically calls `prune_finalized` with the current chain tip and t **Important**: Pruning the processing log (for reorg rollback) is independent from retaining transition history (for price charts, audit trails). The `ContractHistory` trait stores historical transitions permanently — `prune_finalized` only removes the rollback metadata that's no longer needed. +## State Machine Summary + +This section consolidates the valid-transition matrices for each contract type into a single reference. Per-builder rustdoc references this section via "Valid from: [state list]" clauses, and `InvalidContractState { kind: WrongVariant { expected, actual } }` surfaces mismatches programmatically. The matrices are the source of truth; per-state-enum definitions and per-builder rustdoc comments defer to these tables. + +### Creation Builders + +Creation builders don't operate on existing state — they produce brand-new contracts. Listed separately from the transition matrices: + +| Builder | Produces | Preconditions | +|---|---|---| +| `build_binary_market_creation_pset` | `Trading { outstanding_pairs: 0 }` binary market | Convention-valid params (`base_payout` in 1-2-5 table, expiry on 60-block boundary) | +| `build_multi_outcome_market_creation_pset` | `Trading { supplies: [empty; N] }` multi-outcome market | Same plus `N ∈ {3, 4}` for v1 | +| `build_lmsr_bootstrap_pset` | `Active { ... }` LMSR pool | Parent market tracked; convention-valid params; reserves available | +| `build_create_order_pset` | `Active { tracking, offered_amount, total_filled: 0 }` order | Parent market tracked; convention-valid params | + +After the creation tx confirms on-chain, the caller ingests via the corresponding `ingest_*` method to begin tracking. + +### Binary Market transitions + +| From state | Valid builder | To state | Additional condition | +|---|---|---|---| +| `Trading` | `build_issuance_pset` | `Trading` (outstanding + Δ) | — | +| `Trading { outstanding > 0 }` | `build_cancellation_pset(Some(Δ))` | `Trading` (outstanding − Δ) | Δ < outstanding | +| `Trading { outstanding > 0 }` | `build_cancellation_pset(None)` | `Trading { outstanding: 0 }` | caller supplies all outstanding YES/NO pairs for that outcome | +| `Trading` | `build_oracle_resolve_pset` | `ResolvedYes` or `ResolvedNo` | valid oracle BIP-340 sig | +| `Trading` | `build_expire_transition_pset` | `Expired` | chain height ≥ `expiry_block_height` | +| `ResolvedYes \| ResolvedNo \| Expired` (outstanding > 0) | `build_redemption_pset` | same variant, outstanding decremented (terminal if 0) | outstanding > 0 | + +All other (builder, state) pairs return `InvalidContractState { kind: WrongVariant { ... } }`. Terminal states (`outstanding_pairs == 0` on any non-`Trading` variant) admit no further transitions. + +### Multi-Outcome Market transitions + +| From state | Valid builder | To state | Condition | +|---|---|---|---| +| `Trading` | `build_issuance_pset(outcome)` | `Trading` (supply[outcome] +p) | — | +| `Trading` | `build_split_yes_pset` \| `build_split_no_pset` | `Trading` (all supplies +s) | — | +| `Trading` | `build_merge_yes_pset` | `Trading` (all yes supplies −s) | all `supplies[k].yes ≥ s` | +| `Trading` | `build_merge_no_pset` | `Trading` (all no supplies −s) | all `supplies[k].no ≥ s` | +| `Trading` | `build_oracle_resolve_pset(k)` | `Resolved { winning_outcome: k, ... }` | valid oracle sig | +| `Trading` | `build_expire_transition_pset` | `Expired` | chain height ≥ expiry | +| `Resolved \| Expired` (unredeemed > 0) | `build_redemption_pset` | same variant, `collateral_unredeemed` decremented (terminal if 0) | collateral_unredeemed > 0 | + +Cross-outcome swap is not a builder in v1 (it's a `CrossOutcomeSwap` transition classification for observed txs, and v2 gets a dedicated arb quote/build API). See [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2). + +### LMSR Pool transitions + +| From state | Valid builder | To state | Condition | +|---|---|---|---| +| `Active` | (public pool path via `engine.build_trade_pset`; plain or market-assisted) | `Active` (new s_index, new reserves) | s_index within table, reserves ≥ MIN_POOL_RESERVE | +| `Active` | `build_adjust_pset` | `Active` (new reserves, same s_index) | admin key signature, non-zero delta | +| `Active` | `build_close_pset` | `Closed { final_txid }` | admin key signature | + +Pool operations remain valid regardless of parent market state — see [Pool and Order Lifecycle at Market Resolution](#pool-and-order-lifecycle-at-market-resolution). Market-assisted pool legs disappear once the parent market no longer supports issuance/cancellation, but plain pool trading and admin operations remain callable. Closed pools admit no further transitions. + +### Order transitions + +| From state | Valid builder | To state | Condition | +|---|---|---|---| +| `Active` | (fill via `engine.build_trade_pset`) | `Active` (partial) or `Consumed` (full) | sufficient remaining liquidity | +| `Active` | `build_cancel_pset` | `Cancelled` | maker key signature | + +`Consumed` and `Cancelled` are terminal. Under `tracking: EphemeralFresh` or `EphemeralMidLife`, the engine auto-untracks the order past finality via `prune_finalized` (see [OrderState](#orderstate) for details). + +### Error reporting for matrix violations + +Every (builder, invalid-state) pair returns `CoreError::InvalidContractState { contract_id, kind }` where: +- `InvalidStateKind::WrongVariant { expected, actual }` — the state variant itself is wrong for this builder (e.g., `build_issuance_pset` on `ResolvedYes`). +- `InvalidStateKind::ConditionFailed { condition, detail }` — state variant is fine but a runtime precondition failed (e.g., `build_cancellation_pset(Some(Δ))` with Δ > outstanding; `build_expire_transition_pset` before the timelock height). + +Callers can pattern-match on the kind to distinguish "fundamentally wrong call" from "temporarily unmet condition" for UX purposes. + ## Thread Safety -Write methods (`step`, `ingest_market`, `ingest_pool`, `ingest_order`, `untrack_contract`, `rollback_to_height`, `prune_finalized`) take `&mut self`. Read methods (`interpret_transaction`, `identify_asset`, `contract`, `list_markets`, `list_pools`, `list_orders`, `pools_for_market`, `orders_for_market`, `quote_trade`, `oracle_attestation_spec`, and all PSET builders) take `&self`. +Write methods on the engine (`step`, `ingest_market`, `ingest_pool`, `ingest_persistent_order`, `ingest_ephemeral_order`, `untrack_contract`, `rollback_to_height`, `prune_finalized`) take `&mut self`. Read methods on the engine (`interpret_transaction`, `identify_asset`, `contract`, `list_markets`, `list_pools`, `list_orders`, `market`, `pool`, `order`, `quote_trade`, and all engine-level PSET builders — creation and trade) take `&self`. View types (`Market`, `MultiOutcomeMarket`, `Pool`, `Order`) are constructed from `&self` engine methods and hold `&'a ContractEngine` internally — all their methods (state accessors, PSET builders, oracle helpers, relationship queries, history) are effectively `&self` reads as far as the engine is concerned. While any view is alive, the engine cannot be mutably borrowed. Rust's borrow rules provide compile-time `RwLock` semantics: multiple concurrent readers OR one exclusive writer, enforced without runtime overhead. For single-threaded consumers this is invisible. For multi-threaded consumers who need concurrent access, wrap the engine in `RwLock>`: @@ -1976,32 +3371,31 @@ Core does not add `Send` or `Sync` bounds on the `ContractStore` trait. If a sto Core provides pure LMSR computation functions for pricing, quoting, and table generation. See [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md) for the full pool design, parameter simplification rationale, and Merkle-committed curve approach. -The key functions (currently in `src-tauri/crates/deadcat-sdk/src/lmsr_pool/math.rs`, will move to `deadcat-core`): +The key functions live in the `deadcat-core` LMSR math module: - `fee_free_yes_spot_price_bps(manifest, params, s_index)` — implied probability at a given state - `quote_from_table(trade_kind, old_s_index, new_s_index, ...)` — deterministic quote from F-value table lookup - `quote_exact_input_from_manifest(manifest, params, trade_kind, s_index, input)` — best trade for a given input amount -- `generate_lmsr_table(b, half_payout_sats, q_step_lots)` — deterministic integer-only F-value generation (see [Deterministic Table Generation](../contracts/lmsr-pool/lmsr-pool-design.md#deterministic-table-generation)) +- `generate_lmsr_table(b, half_payout_sats, q_step_lots)` — deterministic bignum F-value generation (see [Deterministic Table Generation](../contracts/lmsr-pool/lmsr-pool-design.md#deterministic-table-generation) and the authoritative [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md)) - `lmsr_table_root(values)` — Merkle root from table values Types: `LmsrTradeKind` (BuyYes, SellYes, BuyNo, SellNo), `LmsrQuote` (full trade result with reserve deltas), `LmsrTableManifest` (in-memory table: depth + F-values vector). These have zero dependencies beyond basic math — no wallet, chain, or state. All functions that require `b` derive it internally from `LmsrPoolParams.max_loss_sats` — callers never provide `b` directly. -**Point evaluation vs full table**: The quoting hot path (`quote_trade`) does NOT need the full 65K-entry F-value table. It evaluates the cost function at specific points (~1us per evaluation, ~16us for a binary search over the table index range). The full table is only needed for Merkle proof generation (`build_trade_pset`, `build_lmsr_bootstrap_pset`) and pool ingestion verification — infrequent, user-initiated operations where ~80ms generation time is acceptable. This means `quote_trade` evaluating 5 candidate pools costs ~80us total, not ~400ms. No table caching is needed for the quoting path. +**Runtime model: cached full tables**: `quote_trade` and the build/ingest paths all consume the same deterministic table output. `deadcat-core` maintains an in-memory cache of full F-value tables keyed by `(max_loss_sats, half_payout_sats)`. The first use of a combo incurs the bignum cold-start cost (~5-10s); subsequent operations — quoting, Merkle proof generation, and ingestion verification — are O(1) lookups against the cached table. The router combines those cached curve lookups with live pool state: `s_index` determines where the pool sits on the curve, while current reserves determine how much volume is still fillable before a reserve floor is hit. -**Note for implementors**: After the move to `deadcat-core`, this section should be updated with the final type definitions and full function signatures. The `generate_lmsr_table` function must use a deterministic integer-only algorithm (no floating point) to ensure bit-identical F-values across all platforms — the specific algorithm is defined in the implementation. The SDK path above will no longer be valid post-migration. The liquidity parameter `b` is derived from `LmsrPoolParams.max_loss_sats` via `b = max_loss_sats / ln(2)` (using the deterministic integer algorithm). All LMSR functions that need `b` derive it from the stored `max_loss_sats` — it is never stored or passed separately. - -**Deterministic table specification required**: The derivation chain `max_loss_sats → b → q_step_lots → F-values → Merkle root` involves transcendental constants (`1/ln(2)`, `ln(999)`) and a cost function (`b × ln(exp(s/b) + exp(-s/b))`) that must be evaluated using integer-only arithmetic. Cross-implementation determinism requires a formal specification defining: exact rational approximations for all transcendental constants, the fixed-point algorithm for F-value computation (precision, series terms, rounding mode), the Merkle tree construction algorithm (hash function, leaf encoding, extracted from the `.simf` verification code), and test vectors. This will be a separate satellite document — see [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md) for background. +The authoritative deterministic algorithm and Merkle format are specified in [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md). The liquidity parameter `b` is derived from `LmsrPoolParams.max_loss_sats` via `b = max_loss_sats / ln(2)` at bignum precision. All LMSR functions that need `b` derive it from the stored `max_loss_sats` — it is never stored or passed separately. ## Key Derivation Convenience Functions -`derive_order_params` and `derive_pool_params` are standalone functions (not engine methods) that accept the deadcat xprv (`elements::bitcoin::bip32::Xpriv` at HD path `m/purpose'/deadcat'`) and encapsulate all key derivation, nonce computation, and index masking internally: +`derive_order_params` and `derive_pool_params` are standalone functions (not engine methods) that accept the deadcat xprv (`elements::bitcoin::bip32::Xpriv` at HD path `m/86'/1145258324'`) and encapsulate all key derivation, nonce computation, and index masking internally: ```rust pub fn derive_order_params( deadcat_xprv: &Xpriv, - market_params: &PredictionMarketParams, + market_params: &MarketParams, // umbrella: binary or multi-outcome + outcome: OutcomeIndex, // which outcome's YES/NO pair the order offers order_index: u16, side: Side, direction: OrderDirection, price: u64, min_fill_lots: u8, min_remainder_lots: u8, @@ -2009,20 +3403,21 @@ pub fn derive_order_params( pub fn derive_pool_params( deadcat_xprv: &Xpriv, - market_params: &PredictionMarketParams, + market_params: &MarketParams, // umbrella: binary or multi-outcome + outcome: OutcomeIndex, // which outcome's YES/NO pair the pool serves pool_index: u16, max_loss_sats: u64, half_payout_sats: u64, fee_bps: u16, - starting_price_bps: u16, + initial_s_index: u16, // from estimate_bootstrap (creation) or hint (recovery) ) -> Result<(LmsrPoolParams, u16 /* masked_index */), ConventionError>; ``` -Both functions validate OP_RETURN convention constraints before deriving parameters, returning `ConventionError` if the inputs cannot be losslessly encoded in the recovery hint. `derive_order_params` validates: `price <= 0xFFFFFF` (u24), `min_fill_lots >= 1`, `min_remainder_lots >= 1`. `derive_pool_params` validates: `max_loss_sats` and `half_payout_sats` in the 26-value mantissa × 10^exponent set, `fee_bps <= 4095` (u12), `starting_price_bps` in (0, 10000) exclusive. The `starting_price_bps` parameter is needed to compute `initial_s_index` for the XOR mask context (see [chain-only-recovery.md](../protocol/chain-only-recovery.md)) — the mask includes `initial_s_index`, which is derived from `starting_price_bps` via the inverse logistic function. `ConventionError` is a simple error type (separate from `CoreError`) with a descriptive message indicating which constraint was violated. +Both functions validate OP_RETURN convention constraints before deriving parameters, returning `ConventionError` if the inputs cannot be losslessly encoded in the recovery hint. `derive_order_params` validates: `price <= 0xFFFFFF` (u24), `min_fill_lots >= 1`, `min_remainder_lots >= 1`. `derive_pool_params` validates: `max_loss_sats` and `half_payout_sats` in the 16-value 1-2-5 table (shared with market `base_payout` encoding), `fee_bps <= 4095` (u12), `initial_s_index` in `[0, 65535]` with the constraint that the resulting implied YES price is in `(0, 10000)` bps exclusive (0% and 100% starting prices are rejected). `ConventionError` is a simple error type (separate from `CoreError`) with a descriptive message indicating which constraint was violated. -**`initial_s_index` coupling**: Three functions compute `initial_s_index` from `starting_price_bps`: `estimate_bootstrap` (for UI display), `derive_pool_params` (for the mask context), and `build_lmsr_bootstrap_pset` (for the covenant script and OP_RETURN). All three **must** use a single canonical internal function to guarantee bit-identical results. A divergence would cause silent recovery failure (mask mismatch between creation and recovery). The PSET builders also validate these constraints (defense in depth for manually-constructed params), but the derive functions are the natural first line — catching violations at the point where the caller is making the decision. +**`initial_s_index` sourcing**: `derive_pool_params` takes `initial_s_index` directly — it is not computed from `starting_price_bps` internally. The UI flow obtains `initial_s_index` from `estimate_bootstrap`, which takes `starting_price_bps` and returns the nearest valid `initial_s_index` (see [estimate_bootstrap](../contracts/lmsr-pool/lmsr-pool-design.md)). On recovery, `initial_s_index` is read directly from the pool OP_RETURN hint and passed through — no inverse conversion needed. The snap function (`bps → s_index`) lives in exactly one place (`estimate_bootstrap`); `build_lmsr_bootstrap_pset` and `derive_pool_params` consume the snapped index as-is. This eliminates the earlier three-way coupling surface where divergence between forward and inverse snap implementations could silently break recovery. Internally, each function derives from the xprv: -- **`deadcat_secret_key`** at `m/purpose'/deadcat'/secret'` — a single key used for all HMAC operations (nonce derivation, index masking). Different HMAC tags (`"deadcat/order_nonce"`, `"deadcat/order_mask"`, `"deadcat/pool_mask"`) provide full domain separation. -- **Per-instance public key** at `m/purpose'/deadcat'/orders'/i` or `m/purpose'/deadcat'/pools'/i` — the `maker_pubkey` or `admin_pubkey` baked into the covenant. +- **`deadcat_secret_key`** at `m/86'/1145258324'/secret'` — a single key used for all HMAC operations (nonce derivation, index masking). Different HMAC tags (`"deadcat/order_nonce"`, `"deadcat/order_mask"`, `"deadcat/pool_mask"`) provide full domain separation. +- **Per-instance public key** at `m/86'/1145258324'/orders'/i` or `m/86'/1145258324'/pools'/i` — the `maker_pubkey` or `admin_pubkey` baked into the covenant. The functions are standalone (not engine methods) and stateless — the xprv is passed in, child keys are derived, public parameters are extracted, and all private key material is dropped on return. The engine never touches private keys; only these two convenience functions do. @@ -2076,7 +3471,7 @@ struct OrderDiscoveryPayload { } ``` -Note: Market discovery payloads include `PredictionMarketParams` + `creation_txid`. Markets always use creation-tx ingestion, so no snapshot is needed. +Note: Market discovery payloads include `BinaryMarketParams` + `creation_txid`. Markets always use creation-tx ingestion, so no snapshot is needed. ### Untrack + Re-Ingest Promotion @@ -2096,11 +3491,11 @@ When a wallet is restored from a mnemonic, Deadcat positions need to be rediscov | Layer | Enforcement point | What it catches | |---|---|---| -| Derive functions | `derive_order_params`, `derive_pool_params` | Convention violations at param construction time (first line — best error UX) | -| PSET builders | All three creation builders | Convention violations for manually-constructed params (defense in depth) | -| Market ingestion | `ingest_market` | Non-conforming markets from external sources (protects all downstream users) | +| Derive functions | `derive_order_params`, `derive_pool_params` | Convention violations at param construction time (`ConventionError`; first line — best error UX) | +| PSET builders | All three creation builders | Convention violations for manually-constructed params (`CoreError::ConventionViolation`; defense in depth) | +| Ingestion | `ingest_market`, `ingest_pool`, `ingest_persistent_order`, `ingest_ephemeral_order` | Strict-canonical tracking boundary: reject non-conforming supplied params for any tracked contract; `Creation` snapshots additionally verify the creation tx against those params | -Market conventions are enforced at ingestion because non-conforming markets break ALL downstream users — token holders, order makers, and pool operators all trace back to the market's OP_RETURN for chain-only recovery. Pool and order conventions are enforced at creation time only (derive functions + builders) because they affect only the creator's own recovery. `ingest_pool` and `ingest_order` do NOT enforce pool/order-specific conventions — a non-conforming pool or order created by a custom tool is still fully functional for trading, and rejecting it at ingestion would prevent takers from using valid liquidity. Pool and order ingestion does validate the parent market relationship (transitively ensuring the parent market is conforming). See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the full recovery specification. +`deadcat-core` adopts a **strict-canonical tracking policy**: if the engine agrees to track a contract, the supplied params must conform to the published v1 recovery conventions. This avoids a mixed universe of "tracked but foreign" contracts whose recovery or downstream UX semantics differ from the canonical path. Markets remain the strongest case because every token holder traces back to the market creation tx, but the same policy is applied to pools and orders for API consistency and easier reasoning. The one remaining trust trade-off is `PoolSnapshot::Current` / `OrderSnapshot::Current`: because those variants intentionally omit the creation transaction, they cannot prove that the historical on-chain hint was present. They still reject non-conforming supplied params and require a canonical parent market, but the omitted creation-time proof remains the caller's responsibility. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the full recovery specification. **Wallet-funded prerequisite**: OP_RETURN recovery hints are found by scanning wallet-funded transactions. Token holder recovery uses `ChainSource::issuance_transaction` to trace asset IDs back to their creation transactions. Both paths are chain-only — no external services. @@ -2110,13 +3505,13 @@ Market conventions are enforced at ingestion because non-conforming markets brea Token recovery is automatic. YES and NO tokens are standard Elements confidential assets held at the wallet's own addresses. The wallet's normal mnemonic-based rescan (gap-limit scan over derived scriptpubkeys) finds them the same way it finds L-BTC UTXOs. No deadcat-specific recovery logic is needed. -**Labeling and redemption** require market ingestion. The wallet discovers it holds a UTXO with an unfamiliar asset ID, but doesn't know it's a "YES token for market X" until the market's `PredictionMarketParams` are available and the market is ingested. The recovery path: `asset_id` → `ChainSource::issuance_transaction(asset_id)` → market creation tx → read OP_RETURN → reconstruct market params → `ingest_market` → `identify_asset` for labeling, `build_redemption_pset` for redemption. One chain query per unique asset ID. This works for **all** token holders, including pure takers who only traded through existing pools and never created any contracts. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for details. +**Labeling and redemption** require market ingestion. The wallet discovers it holds a UTXO with an unfamiliar asset ID, but doesn't know it's a "YES token for market X" until the market's `BinaryMarketParams` are available and the market is ingested. The recovery path: `asset_id` → `ChainSource::issuance_transaction(asset_id)` → market creation tx → read OP_RETURN → reconstruct market params → `ingest_market` → `identify_asset` for labeling, `build_redemption_pset` for redemption. One chain query per unique asset ID. This works for **all** token holders, including pure takers who only traded through existing pools and never created any contracts. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for details. ### Prediction Market Positions -Markets have no on-chain "owner" — the taproot internal key is NUMS. However, `build_creation_pset` includes an OP_RETURN recovery hint in the market creation transaction. This serves two purposes: (1) enabling the market creator to re-discover and re-announce their market, and (2) providing the anchor for chain-only pool and order recovery — pool and order hints point to the market creation transaction by txid. It also enables token holder recovery: `issuance_transaction(asset_id)` traces any YES/NO token back to this transaction. +Markets have no on-chain "owner" — the taproot internal key is NUMS. However, the market creation builders include an OP_RETURN recovery hint in the market creation transaction. This serves two purposes: (1) enabling the market creator to re-discover and re-announce their market, and (2) providing the anchor for chain-only pool and order recovery — pool and order hints point to the market creation transaction by txid. It also enables token holder recovery: `issuance_transaction(asset_id)` traces any YES/NO token back to this transaction. -**37 bytes** (known collateral asset) / **69 bytes** (exotic collateral). Uses compressed encoding: 4-bit well-known collateral asset index (L-BTC=0, USDt=1, escape=15), 4-bit 1-2-5 denomination convention for `collateral_per_pair`, and absolute `expiry_time` as u24 (block height divided by 60, giving hour-level granularity with range from the Liquid genesis block to approximately the year 3931). The builder snaps `expiry_time` to the nearest 60-block boundary — the covenant uses the snapped value, making the encoding lossless. Only 4 of 8 `PredictionMarketParams` fields need encoding — the other 4 (token and RT asset IDs) are derivable from the creation transaction's issuance entropy. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout and per-field justification. +**37 bytes** (known collateral asset) / **69 bytes** (exotic collateral). Uses compressed encoding: 4-bit well-known collateral asset index (network policy asset = `0`, Liquid-mainnet USDt = `1`, escape = `15`), 4-bit 1-2-5 denomination convention for `base_payout`, and absolute `expiry_time` as u24 (block height divided by 60, giving hour-level granularity with range from the Liquid genesis block to approximately the year 3931). The builder accepts any future height, rounds `expiry_time` up to the next 60-block boundary, and commits that rounded value into the covenant params — making the encoding lossless. Only 4 of 8 `BinaryMarketParams` fields need encoding — the other 4 (token and RT asset IDs) are derivable from the creation transaction's issuance entropy. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout, network-specific asset mapping, and per-field justification. ### Maker Order Positions @@ -2126,13 +3521,13 @@ Maker orders are the only contract type directly "owned" by regular end users (a **40 bytes**. Includes: XOR-masked derivation index (for O(1) key recovery + observer privacy), market creation txid (chain-only market param recovery), price (u24, bounded by `collateral_per_pair`), min_fill_lots and min_remainder_lots (u8 each, range 1-255), and side + direction packed into the type tag byte. The `derive_order_params` function encapsulates all key derivation — callers pass the deadcat xprv + `order_index`, and the maker pubkey, canonical nonce, and masked index are computed internally (see [Key Derivation Convenience Functions](#key-derivation-convenience-functions)). -The builder validates: `price <= 2^24`, `min_fill_lots` and `min_remainder_lots` in range 1-255, `order_index <= 65535`, and parent market conforms to conventions. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout, per-field inclusion/compression justification, recovery flow, and XOR masking specification. +The builder validates: `price <= 0xFFFFFF` (u24 max, 16,777,215), `min_fill_lots` and `min_remainder_lots` in range 1-255, `order_index <= 65535`, and parent market conforms to conventions. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout, per-field inclusion/compression justification, recovery flow, and XOR masking specification. ### LMSR Pool Positions Like maker orders, pool reserve UTXOs are at covenant addresses — the wallet's standard rescan does not find them. The operator derives their admin key from the mnemonic, but the admin pubkey alone is insufficient to find the pool on-chain — the covenant scripts also depend on liquidity parameters and the s_index (which changes on every swap, making script enumeration impractical). -**41 bytes**. Uses compressed encoding: `max_loss_sats` and `half_payout_sats` as 9-bit 26-value mantissa x 10^exponent (supports non-L-BTC collateral assets like USDT), `fee_bps` as u12 (0.01% granularity, max 40.95%), `initial_s_index` as u16 (the starting table index — enables direct script verification during recovery without reverse-deriving from reserves), plus XOR-masked pool operator derivation index. All other covenant params are derived: `b` from `max_loss_sats`, `q_step_lots` from `b` and `half_payout_sats`, `lmsr_table_root` from deterministic F-value generation, token asset IDs from the parent market, admin pubkey from the mnemonic at `pool_index`. Protocol constants (`TABLE_DEPTH`, `S_BIAS`, `S_MAX_INDEX`, `MIN_POOL_RESERVE`) require no encoding. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout, per-field justification, and recovery flow. +**40 bytes**. Uses compressed encoding: `max_loss_sats` and `half_payout_sats` as 4-bit 1-2-5 table indices each (shared with the market `base_payout` encoding, range 100 to 10,000,000 sats), `fee_bps` as u12 (0.01% granularity, max 40.95%), `initial_s_index` as u16 (the starting table index — enables direct script verification during recovery without reverse-deriving from reserves), plus XOR-masked pool operator derivation index. All other covenant params are derived: `b` from `max_loss_sats`, `q_step_lots` from `b` and `half_payout_sats`, `lmsr_table_root` from deterministic F-value generation, token asset IDs from the parent market, admin pubkey from the mnemonic at `pool_index`. Protocol constants (`TABLE_DEPTH`, `S_BIAS`, `S_MAX_INDEX`, `MIN_POOL_RESERVE`) require no encoding. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte layout, per-field justification, and recovery flow. ### Oracle Market Discovery @@ -2140,7 +3535,7 @@ An oracle derives their key from the mnemonic and re-discovers markets referenci ### Cost Amortization -Market and pool creations are infrequent lifecycle events — the OP_RETURN cost (37-41 bytes at typical Liquid fee rates) is paid once and amortized over the entire lifetime of the contract (every trade, fill, adjustment, and redemption that follows). Maker order creation is the most frequent user-facing operation with an OP_RETURN, but the cost is negligible relative to the order value and trade fees. The OP_RETURN cost is never paid by market takers or regular traders — only by contract creators. +Market and pool creations are infrequent lifecycle events — the OP_RETURN cost (37-40 bytes at typical Liquid fee rates, or up to 69 bytes for markets with exotic collateral) is paid once and amortized over the entire lifetime of the contract (every trade, fill, adjustment, and redemption that follows). Maker order creation is the most frequent user-facing operation with an OP_RETURN, but the cost is negligible relative to the order value and trade fees. The OP_RETURN cost is never paid by market takers or regular traders — only by contract creators. ### Recovery Summary @@ -2149,18 +3544,19 @@ Market and pool creations are infrequent lifecycle events — the OP_RETURN cost | YES/NO tokens | Standard wallet rescan + `issuance_transaction` for labeling/redemption | — | Yes | | Prediction markets | OP_RETURN in creation tx | 37 bytes (known asset) / 69 bytes (exotic) | Yes | | Maker orders | OP_RETURN in creation tx → market hint chain | 40 bytes | Yes | -| LMSR pools | OP_RETURN in creation tx → market hint chain | 41 bytes | Yes | +| LMSR pools | OP_RETURN in creation tx → market hint chain | 40 bytes | Yes | All user types — market creators, order makers, pool operators, and pure token holders — achieve chain-only recovery. Discovery (Nostr) is only needed for human-readable metadata, not fund recovery. -**Core's role in recovery**: Core provides `identify_asset` for token labeling, `derive_order_params` and `derive_pool_params` for deterministic param reconstruction (both accept the deadcat xprv and encapsulate all key derivation, nonce computation, and index masking internally — see [Key Derivation Convenience Functions](#key-derivation-convenience-functions)), Simplicity compilation for contract verification, `ingest_*` for re-tracking, and convention enforcement (builder validation + ingestion rejection of non-conforming markets). The `ChainSource::issuance_transaction` method enables token holder recovery. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the complete specification. +**Core's role in recovery**: Core provides `identify_asset` for token labeling, `derive_order_params` and `derive_pool_params` for deterministic param reconstruction (both accept the deadcat xprv and encapsulate all key derivation, nonce computation, and index masking internally — see [Key Derivation Convenience Functions](#key-derivation-convenience-functions)), Simplicity compilation for contract verification, `ingest_*` for re-tracking, and convention enforcement (builder validation + strict-canonical ingestion rejection of non-conforming supplied params). The `ChainSource::issuance_transaction` method enables token holder recovery. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the complete specification. ## Example Integration: Aqua Wallet ```rust use deadcat_core::{ - ContractEngine, PredictionMarketParams, FeeRate, WalletFunding, - Network, Pagination, StateFilter, Side, TradeSpec, TradeDirection, TradeAmount, + ContractEngine, MarketParams, BinaryMarketParams, FeeRate, WalletFunding, + Network, Pagination, StateFilter, Side, OutcomeIndex, + TradeSpec, TradeDirection, TradeAmount, }; // 1. Initialize engine with a store implementation and network. @@ -2172,9 +3568,12 @@ let mut engine = ContractEngine::new(aqua_deadcat_store, Network::Liquid); let mut chain = EsploraChainSource::new("https://blockstream.info/liquid/api"); // 3. Ingest a market (discovered via Nostr, import, etc.) +// `ingest_market` takes a reference to the umbrella `MarketParams` enum +// (Binary(..) or MultiOutcome(..)). // No anchor needed — core derives blinding factors deterministically. // Core compiles the contract, verifies the creation tx, and indexes asset IDs + scripts. // Returns ContractId (CMR + creation_txid). +let market_params = MarketParams::Binary(binary_market_params); let market_id = engine.ingest_market(&market_params, &creation_tx)?; // 4. Sync — step handles catch-up and subscription setup automatically. @@ -2227,12 +3626,21 @@ for entry in &page.items { } // 10. Deduplicate during discovery (full ContractId check — discovery payloads include creation_txid) -let cmr = contract_cmr(&ContractParams::PredictionMarket(announced_params.clone()), Network::Liquid); +let cmr = contract_cmr( + &ContractParams::Market(MarketParams::Binary(announced_params.clone())), + Network::Liquid, +); let contract_id = ContractId { cmr, creation_txid: announced_creation_txid }; if engine.contract(&contract_id)?.is_some() { continue; } // 11. Trade: two-step quote + build (engine handles routing, coin selection, fee computation) -let spec = TradeSpec { side: Side::Yes, direction: TradeDirection::Buy, amount: TradeAmount::ExactInput(5000) }; +// TradeSpec identifies (outcome, side). For binary markets, outcome is OutcomeIndex::BINARY. +let spec = TradeSpec { + outcome: OutcomeIndex::BINARY, + side: Side::Yes, + direction: TradeDirection::Buy, + amount: TradeAmount::ExactInput(5000), +}; let fee_rate = FeeRate::from_sat_per_vb(aqua_chain.estimate_fee_rate()); let quote = engine.quote_trade(&market_id, spec, fee_rate)?; if user_confirms("e) { @@ -2241,19 +3649,30 @@ if user_confirms("e) { fee_rate, return_script: &aqua_wallet.next_return_script(), }; - let pset = engine.build_trade_pset("e, &funding)?; - let signed = aqua_signer.sign(pset)?; + let pre_blinded = engine.build_trade_pset("e, &funding)?; + let mut prepared = pre_blinded.prepare(&aqua_wallet.blinding_pubkey())?; + prepared.pset.blind_last(&mut rng, &secp, &prepared.input_secrets)?; + let signed = aqua_signer.sign(prepared.pset)?; aqua_chain.broadcast(signed)?; } -// 12. Build an RT-involving transaction (returns UnblindedPset — must blind before signing) +// 12. Build an RT-involving transaction through the Market view. +// engine.market(id) returns a Market view caching the contract's (params, state). +// build_issuance_pset lives on the view; it takes `outcome: OutcomeIndex` (BINARY for binary markets). let funding = WalletFunding { available_utxos: &aqua_wallet.list_utxos(), fee_rate: FeeRate::from_sat_per_vb(aqua_chain.estimate_fee_rate()), return_script: &aqua_wallet.next_return_script(), }; -let unblinded = engine.build_issuance_pset(&market_id, 100, &token_dest, &token_dest, &funding)?; -let prepared = unblinded.prepare(&aqua_wallet.blinding_pubkey())?; +let market = engine.market(&market_id)?.expect("market tracked"); +let pre_blinded = market.build_issuance_pset( + OutcomeIndex::BINARY, + 100, + &token_dest, + &token_dest, + &funding, +)?; +let mut prepared = pre_blinded.prepare(&aqua_wallet.blinding_pubkey())?; prepared.pset.blind_last(&mut rng, &secp, &prepared.input_secrets)?; let signed = aqua_signer.sign(prepared.pset)?; aqua_chain.broadcast(signed)?; @@ -2265,9 +3684,9 @@ aqua_chain.broadcast(signed)?; ### Tier 1: Pure Function Tests -**What**: Every standalone function and deterministic computation — LMSR math (point evaluation, table generation, quoting), key derivation (`derive_order_params`, `derive_pool_params`), oracle attestation messages, OP_RETURN encoding/decoding (byte layout round-trips), expiry time snapping (block height → u24 → block height), XOR index masking/unmasking, CBF derivation chain, `BootstrapEstimate` computation, `FeeRate` conversions, pagination cursor encoding. +**What**: Every standalone function and deterministic computation — LMSR math (table generation, cached-table quoting, spot-price helpers), key derivation (`derive_order_params`, `derive_pool_params`), oracle attestation messages, OP_RETURN encoding/decoding (byte layout round-trips), expiry time snapping (block height → u24 → block height), XOR index masking/unmasking, CBF derivation chain, `BootstrapEstimate` computation, `FeeRate` conversions, pagination cursor encoding. -**How**: Standard `#[test]` functions, zero dependencies beyond core itself. Property-based tests (e.g., `proptest`) for encoding invariants — "for any valid params, `decode(encode(params)) == params`" generates thousands of random inputs and provides much stronger guarantees than hand-picked examples. Particularly valuable for OP_RETURN round-trips, LMSR point evaluation vs full table consistency, and XOR masking/unmasking. +**How**: Standard `#[test]` functions, zero dependencies beyond core itself. Property-based tests (e.g., `proptest`) for encoding invariants — "for any valid params, `decode(encode(params)) == params`" generates thousands of random inputs and provides much stronger guarantees than hand-picked examples. Particularly valuable for OP_RETURN round-trips, LMSR quote/proof consistency against the cached tables, and XOR masking/unmasking. **Speed**: Instant (<1ms per test, hundreds of tests). @@ -2293,7 +3712,7 @@ aqua_chain.broadcast(signed)?; **What**: Structural correctness of built PSETs — output scripts, values, OP_RETURN encoding, coin selection, fee computation, RT blinding, error cases. -**How**: Build PSETs from mock `WalletFunding` inputs and inspect the resulting PSET structure. No broadcasting, no signing, no chain. Verify: correct covenant script pubkeys on outputs, correct collateral/reserve amounts, OP_RETURN present with decodable content, coin selection chose appropriate UTXOs, fee matches `weight × rate`, `UnblindedPset` → `prepare()`/`finalize()` produces valid PSET structure. +**How**: Build PSETs from mock `WalletFunding` inputs and inspect the resulting PSET structure. No broadcasting, no signing, no chain. Verify: correct covenant script pubkeys on outputs, correct collateral/reserve amounts, OP_RETURN present with decodable content, coin selection chose appropriate UTXOs, fee matches `weight × rate`, `PreBlindedPset` → `prepare()`/`finalize()` produces valid PSET structure. **Error case coverage**: `InsufficientFunds` (with correct `shortfalls`), `InvalidParams` (non-encodable params, out-of-range values), `InvalidContractState` (wrong state for operation), `StaleQuote` (outpoints changed), fee rate mismatch on `build_trade_pset`. @@ -2405,6 +3824,218 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) ## Design Decisions Log +### Store Trait Relationship Queries Are Outcome-Scoped; View Adds All-Outcomes Companions + +**Chosen**: the store trait's `pools_for_market`, `orders_for_market`, and `best_orders_for_market` all take `outcome: OutcomeIndex` as a required parameter. Callers must specify which outcome's YES/NO pair they want. Binary markets always pass `OutcomeIndex::BINARY`. + +For the display case of "all pools/orders across all outcomes of a multi-outcome market," the `Market` view exposes `pools(filter, page)` and `orders(filter, page)` that iterate internally over `0..outcome_count` and merge results. Outcome-scoped companions `pools_for_outcome` and `orders_for_outcome` delegate directly to the store for a single outcome. + +**Rejected**: +- **Store trait without `outcome`, engine filters in memory**: fine for pools (few per market) but wasteful for orders at scale (a 10-outcome market with thousands of orders per outcome would return tens of thousands of rows when routing wants 20-50). Store-level indexed filtering is much cheaper. +- **Two store trait variants (`pools_for_market` unscoped and `pools_for_outcome` scoped)**: doubles the trait surface. The unscoped variant is a thin wrapper around iteration; no real value in having both at the store layer. +- **`Option` parameter with `None` meaning all outcomes**: mixes two semantics in one method. Option A (required outcome) keeps each store method single-purpose; the view layer handles the all-outcomes aggregation where pagination logic naturally lives. + +**Why**: scale pressure on orders drives this. Indexed `(market_id, outcome)` lookups at the store are fundamental to sustaining Polymarket-scale multi-outcome markets. Binary markets pay a syntactic tax of one extra parameter (`OutcomeIndex::BINARY`) but get the same behavior they would have had. The view-layer aggregation pattern (iterate outcomes for the all-outcomes case) is bounded (N ≤ 10 in practice) and lives in one place — the `Market` view — rather than being duplicated across consumers. + +### `MultiOutcomeMarketTransition` Is a Classification of the Tx's Delta Shape + +**Chosen**: `MultiOutcomeMarketTransition` variants name common delta-shape patterns (`IssuedPair`, `SplitYes`, `CrossOutcomeSwap`, etc.) plus a `Composite { delta_yes, delta_no, delta_collateral }` escape hatch for arbitrary solvency-preserving delta shapes that don't match a named pattern. Each market transaction produces exactly one variant (the covenant executes one spend path per tx — the generic solvency-preserving path — and the engine classifies the observed deltas). + +**Rejected**: +- **Single-primitive-only variants** (as originally specified during Stage 3): assumed the covenant enumerated primitives (pair-issue, split-YES, merge-YES, split-NO, merge-NO) as separate spend paths, making each tx exactly one named primitive. That design has since been superseded by the generic solvency-preservation spend path (see [`multi-outcome-market-contract.md § Operations`](../contracts/multi-outcome/multi-outcome-market-contract.md#operations)), which accepts any `(Δy, Δn, Δc)` preserving the invariant. Under the generic path, a single tx can represent any composition of named primitives plus novel delta shapes, so `Composite` is required to represent compositions that don't match named classifications. +- **Bare `Composite` variant only, no named patterns**: loses display convenience. Consumers have to decode raw deltas to recognize common operations. Named variants provide ergonomic matching for the common cases; `Composite` catches the rest. + +**Why**: matches the covenant's actual structure. The generic spend path makes single-tx compositions possible, and the classification enum reflects that: common patterns get named variants (wallet UI can match on `IssuedPair { outcome, pairs, ... }` directly), while arbitrary compositions get captured as `Composite` with raw deltas preserved. Multi-contract patterns (trades) remain detected at the `InterpretedTransaction` level via helper methods; those are orthogonal to per-contract transition classification. Cross-outcome arb classification (market + N pools atomic) is deferred to v2. + +### Multi-Contract Patterns Detected at the Transaction Level + +Multi-contract patterns — a single transaction co-spending multiple contracts atomically (trade: pool + LOB orders) — are surfaced via helper methods on `InterpretedTransaction` (`as_trade`, `net_effect_for`). Raw per-contract transitions remain available in `InterpretedTransaction.transitions` for consumers that want granular detail. Cross-outcome arb (market + N pools) is deferred to v2; see [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2). + +### Single-Transaction Interpretation (No Cross-Transaction Inference) + +**Chosen**: the engine interprets each transaction independently. It does not pattern-match across a user's transaction history to recognize user-level behaviors that span multiple transactions (e.g., "these two txs together were a cross-outcome swap"). + +**Rejected**: cross-transaction inference in the engine. Higher-level tools (wallets, explorers, analytics) can aggregate across user tx history externally. + +**Why**: the UTXO-following state machine processes one tx at a time. Each tx produces a complete interpretation given the current contract state. Cross-tx inference would require maintaining stateful heuristics (which sequences count as "semantically atomic" from the user's perspective?) and would pollute the core's otherwise mechanical interpretation logic. Keep the core mechanical; let higher layers add semantic aggregation. + +### Liquidity-Weighted Probability; Single Value Per Outcome + +**Chosen**: `Market::probability_bps(outcome)` returns a single liquidity-weighted probability in basis points (0..=10000) — weighted by each pool's `b` parameter (LMSR depth). + +**Rejected**: +- Return separate YES and NO prices per outcome. Redundant: within any single LMSR pool, p_YES + p_NO = 10000 bps by construction, so per-outcome probability is a single number; the NO side's price derives as `10000 - probability_bps`. +- Return per-pool prices without aggregation. Available separately via the `Pool` view (`pool.params()` + `pool.state()`); the Market view exposes the canonical single value. +- Simple average (unweighted). Deep pools should dominate thin ones in the canonical "what does the market think?" number. +- Best-priced pool only. Biases toward outliers; a single thin pool at an extreme price would dominate despite low confidence. + +**Why**: weighting by `b` matches intuition — a pool with 10× the subsidy is "10× more confident" in its price because it can absorb 10× the volume before moving significantly. Single-value-per-outcome collapses redundancy while remaining honest about cross-outcome coherence via `sum_of_probabilities_bps()` (expected to equal 10000; deviation surfaces arb opportunity). + +### Cross-Outcome Arb: Deferred to v2 + +**Chosen**: defer the cross-outcome arb quote + build API (`quote_cross_outcome_arb`, `build_cross_outcome_arb_pset`, `ArbQuote`, `ArbDirection`, `ArbPoolLeg`, `as_cross_outcome_arb`) to v2. v1 ships multi-outcome markets (if B3 resolves that way) without a built-in arb builder; external bots can construct arb txs directly against the covenant's generic solvency-preservation spend path. + +**Rejected**: shipping the full quote + build + classification surface in v1. The API design has several unresolved questions (scope of directions, sizing model, staleness representation, classification rule) and landing it prematurely would bake in choices before the arb ecosystem exists to inform them. + +**Why**: cross-outcome arb is not safety-critical — coherence gaps are pricing drift, not solvency violations, and the covenant's invariants hold regardless of whether arb runs. The generic solvency-preservation spend path makes arb permissionless by construction, so external tooling can close gaps without a core-layer builder. The audience for arb is advanced actors (bots, keepers) who tolerate external tooling while v1 stabilizes. See [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2) for the deferred surface and open design questions. + +### Multi-Outcome `.simf` Code Generation + +**Chosen**: Rust-based generator using MiniJinja templates, emitting one hand-committed `.simf` file per supported N. The generator lives in a separate `deadcat-codegen` workspace crate (dev-only). `deadcat-core` reads the committed files via `include_bytes!` and has no dependency on the generator or its templating library — downstream consumers of the published crate receive pre-embedded `.simf` files in their dep graph. Generator invocation is explicit (`just generate-simf`); drift detection runs as part of `cargo test` by regenerating in-memory, asserting byte-exact equality against committed files, and invoking the SimplicityHL compiler to verify the output is semantically valid. v1 supports N ∈ {3, 4}. + +**Rejected**: +- **`build.rs`-driven regeneration**: cargo's conventional `OUT_DIR` target isn't committed; silently regenerating on every `cargo build` risks clobbering developer edits and hides the provenance of committed files. +- **Per-N CMR caching**: a reasonable-sounding optimization that doesn't work. CMR depends on the full param set (oracle pubkey, asset IDs, `base_payout`, `expiry_time`), so there is no stable "per-N CMR" to cache — every market instance has a distinct CMR. What we commit is `.simf` source text; drift is detected via byte-exact source match, not a CMR regression. +- **Handlebars, Tera, and plain-Rust string concatenation** as the templating approach: Handlebars lacks native range loops; Tera is functional but has a materially heavier dependency tree (regex, pest, etc.) than MiniJinja for no win in our use case; plain-Rust string building would work but loses the readability benefit of a template file where the N-dependent structure is visible. +- **Single parameterized `.simf` with N as witness**: Simplicity is a total language without general recursion, so loops over 2N inputs/outputs must be unrolled at compile time. N cannot be a runtime witness. +- **Hand-written `.simf` per N without codegen**: feasible at N=3, but each additional N added manually is new audit surface and risks divergence between files. Writing a small generator now is the cheaper long-term path. + +**Why**: the generator + committed-output + drift-test pattern gives three wins together — inspectability (auditors review committed `.simf` text directly), zero-drift guarantee (CI catches any mismatch between generator and committed output), and clean crate separation (no runtime deps leak to downstream). Adding new N values in future releases is non-breaking — each N has its own CMR-committed program, so existing markets are unaffected. Shrinking the supported range is breaking and should not be done once markets are live. See [multi-outcome-market-contract.md § Code Generation Strategy](../contracts/multi-outcome/multi-outcome-market-contract.md#code-generation-strategy) for implementation-level detail (file layout, template structure, verification test). + +### LMSR F-Value Computation: Bignum Runtime, Reference Merkle Roots as Fixtures + +**Chosen**: `deadcat-core` computes F-values at runtime using arbitrary-precision bignum (`num-bigint` + `num-rational`) directly from the closed-form expression `F(i) = max_loss_sats + floor(b × ln(cosh(s/b)))`. Per-pool F-value tables are cached in memory (and optionally on disk) after first computation. `deadcat-codegen` hosts the reference bignum generator and a committed fixture file with the canonical Merkle root and anchor F-values for each of the 256 valid `(max_loss_sats, half_payout_sats)` parameter combinations. A regression test on every `cargo test` re-runs the bignum reference and asserts all 256 committed roots reproduce byte-for-byte. Pool denomination uses the 16-value 1-2-5 table shared with market `base_payout` encoding (4 bits per param, 256 combinations total). + +**Rejected**: +- **Fixed-point Taylor series runtime**: faster (~100ms table gen, ~1μs point eval vs bignum's ~5–10s / ~100μs) but requires specifying a fixed-point representation (Q64.64 in u128), choosing a transcendental algorithm (Taylor + range reduction, CORDIC), committing precomputed irrational constants, writing a worked example, and maintaining correctness tests that assert Taylor matches bignum. Substantial spec surface area for marginal runtime-performance gains on operations (pool creation, first-ingest) that are infrequent relative to trading activity. +- **Embedding the full 65,536-entry F-value tables in the `deadcat-core` binary for all 256 param combos**: infeasible. 256 × 65,536 × 8 bytes = 128 MB uncompressed, ~20–30 MB with aggressive delta-varint compression. Rejected as library bloat. +- **Embedding just the 256 Merkle roots in `deadcat-core`**: 8 KB cost is trivial, but with bignum as the runtime algorithm the roots aren't needed at runtime — bignum always produces the correct value. Moving the roots to `deadcat-codegen` as test fixtures preserves the regression-protection and cross-implementation-conformance properties without paying any `deadcat-core` binary cost. +- **Hybrid (bignum compile-time for roots, Taylor runtime for F-values)**: reasonable long-term optimization but delays the v1 spec behind a second algorithm. Can be added as a non-breaking follow-up in a future release — the committed reference Merkle roots serve as the acceptance criterion for any alternative runtime implementation. +- **Pool denomination at 26-mantissa × 16-exponent (previous design)**: 416 values per param × 2 params = 173,056 combinations → 5.5 MB of Merkle root fixtures (if committed) or a much larger param space (if not). The 1-2-5 × 16-value table reduces the fixture set 675× while retaining adequate granularity for v1 pool sizes. Range is capped at 10^7 sats per param — expanding is non-breaking in a future release (adds new table entries without invalidating existing pools' Merkle roots). + +**Why**: bignum-only runtime eliminates an entire class of spec complexity (fixed-point precision analysis, Taylor term-count bounds, precomputed transcendental constants, worked examples) at the cost of 5–10 seconds of cold-start compute per new pool parameter combo per user per install. That cost is amortized across subsequent trading on the pool (cached) and is paid at deliberate multi-step actions (pool creation, first-time pool ingestion). Correctness is structural: bignum is the reference, committed Merkle roots are the regression guard, and any future faster implementation can be validated against the exact same fixtures. + +See [lmsr-deterministic-table-spec.md § F-Value Computation Algorithm](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md#f-value-computation-algorithm) for the full algorithm specification and [chain-only-recovery.md § Pool Denomination](../protocol/chain-only-recovery.md#pool-denomination-1-2-5-table-4-bits-each) for the encoding. + +### View Types for Per-Contract Operations + +**Chosen**: `ContractEngine` exposes per-contract operations via view types (`Market<'a, S>`, `Pool<'a, S>`, `Order<'a, S>`, `MultiOutcomeMarket<'a, S>`) returned by engine accessors (`engine.market(id)`, etc.). Each view holds `&'a ContractEngine` plus cached `(params, state)`. Per-contract PSET builders, state accessors, oracle helpers, and relationship queries live on the views; engine surface shrinks to ingestion, chain sync, discovery/listing, asset identification, transaction interpretation, creation builders, and trade routing. + +**Rejected**: +- **Flat engine surface with `contract_id` on every method**: original design. 25+ methods on the engine, mostly disambiguated by `contract_id` as first argument. IDE autocomplete is noisy, operations are mixed across contract kinds, discoverability suffers, `contract_id` repeated at every call site. +- **Free functions with `&engine` parameter**: e.g. `build_issuance_pset(&engine, &contract_id, pairs, ...)`. Avoids the engine-method bloat but loses method-call ergonomics and bundled-context benefits. +- **Trait-based dispatch (e.g. `ContractOps::build_issuance_pset`)**: adds type machinery for minimal gain over direct `impl` blocks on concrete view structs. + +**Why**: operations naturally cluster by the object they operate on. A `Market` view groups everything you can do with a market (issue, cancel, resolve, expire, redeem, query state, fetch pools, fetch orders, oracle attestation helpers, and for multi-outcome via `as_multi_outcome()`: split/merge YES/NO — with cross-outcome arb deferred to v2). This is idiomatic Rust API design (similar patterns in `std::fs::File`, `hyper::Client`, etc.) and it enables type-level dispatch for specializations (`MultiOutcomeMarket` only exists for multi-outcome markets — binary markets can't accidentally call `build_split_yes_pset`). + +### View Caching and Borrow-Checker-Enforced Freshness + +**Chosen**: view types cache `(params, state)` at construction time via a single `ContractStore` read. Methods on the view use the cached values without re-reading from the store. + +**Rejected**: re-read `(params, state)` from the store on every method call. Would guarantee freshness but adds 1 store read per method call. + +**Why**: Rust's borrow checker makes view caching provably safe. A view holds `&'a ContractEngine` (immutable borrow), which blocks any `&mut self` engine method from running while the view is alive. Contract params are immutable over the contract's lifetime. State transitions only happen through `&mut self` engine methods (`step`, `rollback_to_height`, etc.). Therefore, within the view's lifetime, no state change is possible — the cached values are guaranteed fresh. No runtime staleness check needed. Zero ongoing cost for caching; just a single initial store read. + +### Relationship Queries on the Market View + +**Chosen**: `Market::pools(filter, page)` and `Market::orders(filter, page)` replace the engine-level `engine.pools_for_market(market_id, ...)` and `engine.orders_for_market(market_id, ...)`. + +**Rejected**: keep relationship queries on the engine alongside `list_pools` / `list_orders`. + +**Why**: "pools belonging to this market" is a per-market operation, naturally scoped by the market's identity. Moving it to the `Market` view means consumers already holding a market reference don't need to pass the market's ID back to the engine. Discoverability: "what's associated with this market?" is answered by the view's methods, not by scanning the engine's namespace. The engine-level `list_pools` / `list_orders` stay for the global "list all pools regardless of market" case. + +### Trade Routing Stays on the Engine + +**Chosen**: `quote_trade(market_id, spec, fee_rate)` and `build_trade_pset(quote, funding)` stay on `ContractEngine`, not on the `Market` view. + +**Rejected**: `market.quote_trade(spec, fee_rate)` and `market.build_trade_pset(quote, funding)` on the `Market` view. + +**Why**: trade routing needs access to *multiple* contracts simultaneously — the target market's pool(s) and any maker orders on the target market's tokens. Putting `quote_trade` on the `Market` view would require the view to transitively see other tracked contracts (pools, orders), breaking the encapsulation the view-type pattern is meant to establish. Keeping routing at the engine level — which naturally has access to all tracked contracts — is both simpler and more honest about the operation's scope. The engine is the right home for operations that inspect or compose across multiple contracts. + +### Builder Naming on Views + +**Chosen**: on view types, drop the contract-type prefix from builder names where it would be redundant with the view's type: + +- `ContractEngine::build_lmsr_adjust_pset` → `Pool::build_adjust_pset` +- `ContractEngine::build_lmsr_close_pset` → `Pool::build_close_pset` +- `ContractEngine::build_cancel_order_pset` → `Order::build_cancel_pset` + +Market and multi-outcome market builder names are unchanged — the action (issuance, cancellation, split-YES, etc.) is already specific without needing a "market" prefix. + +**Why**: scope is already conveyed by the type (`Pool::build_adjust_pset` is unambiguous; the `lmsr_` prefix was needed when it was one of many `build_*` methods on the engine). Shorter names, cleaner code at call sites. + +### Creation Builders Stay on the Engine + +**Chosen**: creation builders (`build_binary_market_creation_pset`, `build_multi_outcome_market_creation_pset`, `build_lmsr_bootstrap_pset`, `build_create_order_pset`) stay on `ContractEngine`, not on any view type. + +**Rejected**: factory-pattern types like `engine.market_factory().build_binary_creation_pset(...)`. + +**Why**: creation operates on a contract that doesn't exist yet — there's no view to hold cached state. Putting these on the engine alongside ingestion (`ingest_*`) keeps the "introduce a new contract to the engine" path together. Also: creation builders need to return the newly-derived full params (for `ingest_market` after confirmation), which would be awkward on a factory type. Engine-level keeps it simple. + +### `derive_pool_params` / `derive_order_params` Take `OutcomeIndex` + +**Chosen**: the helper functions take `market_params: &MarketParams` (the umbrella) plus `outcome: OutcomeIndex` to identify which outcome's YES/NO pair the pool/order is for. Binary markets pass `OutcomeIndex::BINARY`; multi-outcome markets pass any valid index in `[0, outcome_count)`. + +**Rejected**: keep `market_params: &BinaryMarketParams` (binary-only helpers) and introduce parallel `derive_pool_params_multi_outcome` / `derive_order_params_multi_outcome` functions. + +**Why**: pools and orders for multi-outcome markets are first-class and need derivation support just like binary ones. The existing helper already takes market params; adding `outcome: OutcomeIndex` is the minimal extension. A single function for both kinds keeps the surface small; internal dispatch based on the `MarketParams` variant handles the per-kind asset-ID lookup. For binary callers, passing `OutcomeIndex::BINARY` is a minor annotation; for multi-outcome callers, the parameter is load-bearing. + +### Multi-Outcome Market Support: Option E (Unified API, Enum-Dispatched Internals) + +**Chosen**: Expose a unified public API across binary and multi-outcome markets where the operations are conceptually shared, with enum-based internal dispatch. Binary- and multi-outcome-specific types are siblings under umbrella enums (`MarketParams = { Binary(BinaryMarketParams), MultiOutcome(MultiOutcomeMarketParams) }`, same pattern for `MarketState`, `MarketTransition`). Consumers interact with markets via the `Market` view type (see Stage 2); operations that exist only for multi-outcome (split-YES, merge-YES, split-NO, merge-NO, cross-outcome swap) live on a `MultiOutcomeMarket` specialization accessible via `Market::as_multi_outcome() -> Option`. + +**Rejected**: +- **Option A — separate APIs**: `ingest_market` + `ingest_multi_outcome_market`, separate listings, separate state types at the surface. Leaks the binary/multi-outcome distinction into consumer code at every operation. Consumers iterating over all markets would need two listings and merge. +- **Option B — flat unified enum everywhere**: force-unify `MarketState` variants (`Resolved{outcome_index: 0}` for binary YES, `Resolved{outcome_index: 1}` for binary NO) to one enum. Rejected because binary and multi-outcome resolution semantics genuinely differ (binary's `Side` = which side of one event won; multi-outcome's `OutcomeIndex` = which of N events happened). Forcing them into one shape obscures the model. +- **Option C — binary as multi-outcome with N=1**: use the multi-outcome contract for everything. Rejected because the two contracts have different token layouts (2 tokens vs 4 tokens at N=2 in the 2N model), different slot counts, and different covenant source files. The existing binary contract is already implemented and deployed; collapsing binary into the multi-outcome code path would require regenerating it from the multi-outcome template and accepting the 2-tokens-per-outcome overhead. + +**Why**: Consumers think "I'm tracking markets" not "I'm tracking binary and multi-outcome markets as distinct categories." The unified API reflects that mental model. Internal dispatch (trait `MarketBehavior` implemented for each params type) keeps engine code clean without leaking dispatch choices into the public surface. The view-type pattern isolates per-market operations into a cohesive API (`Market`) while allowing type-level specialization for multi-outcome-only operations (`MultiOutcomeMarket`). + +### Binary/Multi-Outcome Naming Convention + +**Chosen**: `BinaryMarketParams` / `MultiOutcomeMarketParams`, `BinaryMarketState` / `MultiOutcomeMarketState`, `BinaryMarketTransition` / `MultiOutcomeMarketTransition`, `BinaryMarketCreationParams` / `MultiOutcomeMarketCreationParams`. Umbrella enums drop the kind prefix: `MarketParams`, `MarketState`, `MarketTransition`. + +**Rejected**: Keeping the legacy `PredictionMarketParams` / `MarketState` names for binary alongside `MultiOutcomeMarketParams` / `MultiOutcomeMarketState`. Rejected because the asymmetry is confusing — readers would ask "is `MarketState` the umbrella or the binary type?" + +**Why**: Uniform naming makes pattern-matching intuitive. The rename is a no-op for the covenant layer (same `.simf` file, same types at the wire level) and localized to `deadcat-core` type definitions. + +### OutcomeIndex Newtype + +**Chosen**: `OutcomeIndex(u8)` with `OutcomeIndex::BINARY = OutcomeIndex(0)` as a public constant. Used in APIs that take an outcome identifier (`TradeSpec`, issuance builders, redemption builders, oracle attestation). + +**Rejected**: Bare `u8`. Rejected because `u8` is ambiguous in the type system (could be a byte value, an output index, a side discriminant) and provides no compile-time documentation at call sites. + +**Why**: Type safety + self-documentation. `OutcomeIndex::BINARY` is explicit at call sites: `spec.build_issuance_pset(OutcomeIndex::BINARY, 5, ...)` for binary markets vs. `spec.build_issuance_pset(OutcomeIndex::new(2), 5, ...)` for multi-outcome. Zero runtime cost. + +### Oracle Attestation Uses `MarketResolution` Discriminated Union, Not `OutcomeIndex` Alone + +**Chosen**: The unified oracle attestation API (`oracle_attestation_message`, `oracle_attestation_spec`, `verify_oracle_attestation`) takes a `MarketResolution` discriminated union: + +```rust +pub enum MarketResolution { + Binary(Side), // encoded as outcome_byte 0x01 (Yes) or 0x00 (No) + MultiOutcome(OutcomeIndex), // encoded as outcome_byte OutcomeIndex::as_u8() +} +``` + +**Rejected**: Taking `OutcomeIndex` uniformly and treating binary markets as having `OutcomeIndex::BINARY = 0` = YES, `OutcomeIndex(1)` = NO. + +**Why**: Binary market resolution and multi-outcome resolution are semantically different. Binary markets have one event with two sides; resolution picks a `Side`. Multi-outcome markets have N competing events; resolution picks an `OutcomeIndex`. Conflating them under `OutcomeIndex` would mean `OutcomeIndex::BINARY` (the value 0) maps to outcome_byte 0x00 = binary NO, which is semantically confusing — readers would ask "why does the canonical binary outcome index mean NO?" + +The discriminated union preserves the semantic distinction at compile time: a binary market requires `MarketResolution::Binary(_)` and rejects `MarketResolution::MultiOutcome(_)` at the API boundary (and vice versa). The `outcome_byte` encoding happens inside the engine and is consistent with the covenant's expectation. + +This is the same pattern used elsewhere in the codebase: keep per-kind semantic types where the semantics differ (binary uses `Side` internally in `BinaryMarketTransition::Resolved`; multi-outcome uses `OutcomeIndex` in `MultiOutcomeMarketTransition::Resolved`). The umbrella `MarketResolution` appears only where a single API must serve both kinds. + +### Multi-Outcome State Tracks Per-Outcome Supplies in Trading, Collateral Sum at Terminal Phases + +**Chosen**: `MultiOutcomeMarketState::Trading { supplies: Vec }` tracks each outcome's YES/NO supply individually. `Resolved { winning_outcome, collateral_unredeemed }` and `Expired { collateral_unredeemed }` track only total unredeemed collateral. + +**Rejected**: +- `[PairSupply; N]` const-generic arrays in the state enum: rejected because `N` is a runtime value (per-market) and would force the entire `MarketState` type to be generic over `N`, breaking the umbrella enum. `Vec` runs at runtime with minor heap overhead but stays representable in a single enum. +- Full per-outcome supply tracking in `Resolved`/`Expired` terminal phases: rejected because granular post-resolution redemption data is better surfaced via `ContractHistory`. Tip state should stay tight; history methods serve detailed-audit use cases. + +**Why**: `Trading.supplies` is necessary for per-outcome price and volume display. Terminal-phase tracking collapses to a single `collateral_unredeemed` u64, which fully determines the terminal condition (reaches 0 when all claimable tokens have been burned). + +### Staging the Multi-Outcome Integration + +**Chosen**: the multi-outcome integration into `deadcat-core` is landed across three sequential stages. + +- **Stage 1 (complete)**: type definitions — umbrella enums, paired Binary/MultiOutcome inner types, `OutcomeIndex` newtype, `MarketResolution` discriminated union, generalized `AssetInfo` / `TradeSpec`, unified `OracleAttestationSpec`. +- **Stage 2 (complete)**: API surface — `Market<'a, S>`, `MultiOutcomeMarket<'a, S>`, `Pool<'a, S>`, `Order<'a, S>` view types with per-contract operations; engine surface shrunk to ingestion, chain sync, discovery, view accessors, creation builders, and trade routing; relationship queries moved to views; builder naming rationalized (`build_lmsr_adjust_pset` → `build_adjust_pset` on `Pool` view, etc.). +- **Stage 3 (complete)**: behavior — multi-outcome transaction interpretation (delta-shape classification into `MultiOutcomeMarketTransition` variants), multi-contract pattern detection on `InterpretedTransaction` (`as_trade`, `net_effect_for`) while preserving raw per-contract transitions, probability accessors on views (liquidity-weighted by LMSR `b`), chain sync notes for N-scaling. Cross-outcome arb quote/build and `as_cross_outcome_arb` classification were originally specified here but have since been deferred to v2 — see [Future: Cross-Outcome Arb API (v2)](#future-cross-outcome-arb-api-v2). + +**Why**: staging keeps each reviewable chunk focused. Stage 1 establishes the vocabulary; Stage 2 uses that vocabulary in API signatures and view-type design; Stage 3 elaborates behavior (and corrects a Stage 1 design error surfaced by deeper interpretation analysis). Staging avoids touch-every-section-at-once commits that are hard to review. + ### UTXO-following vs Transaction Classification **Chosen**: UTXO-following state machine. @@ -2477,11 +4108,13 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) **Rejected**: Two-step pattern where the engine returns results and the caller manually triggers persistence. **Why**: Since the engine exclusively owns the store, there's no reason for the caller to inspect results before deciding to persist — the transitions are deterministic from the transaction. Splitting compute and persist would create a window where a crash could leave the engine's in-memory state ahead of the store. The single-call pattern eliminates this by design. -### Contract-Level Atomicity Required, Transaction-Level Recommended +### Per-Transaction Atomicity Required + +**Chosen**: `apply_transitions` is per-transaction atomic — the full `&[StateUpdate]` slice passed in one call commits as a unit or not at all. Typically implemented with a single database transaction around the method body. -**Chosen**: Store must apply each contract's state update atomically. Applying all contracts from a multi-contract transaction atomically is recommended but not required. -**Rejected**: Requiring strict transaction-level atomicity. -**Why**: Contract-level atomicity is non-negotiable — a half-updated contract (outpoints changed but state not, or vice versa) is corrupted state. Transaction-level atomicity (all contracts in one tx updated together) is a "nice to have" for view consistency but not a correctness requirement. A "jagged" state where one contract has processed a tx but another hasn't is indistinguishable from staggered ingestion — which is already a normal condition when contracts are discovered at different times. Re-processing the transaction advances the remaining contracts (idempotency), and already-processed contracts are a no-op. Transaction-level atomicity is recommended because it's typically minimal extra burden (e.g., a single database transaction) and avoids the temporary jagged-view window. +**Rejected**: Contract-level atomicity with transaction-level merely "recommended" (an earlier iteration). Under that weaker contract, a `CovenantInvariantViolation` mid-multi-contract-transaction could leave one contract advanced while another rolled back, producing a torn state the engine's retry logic couldn't safely recover from. + +**Why**: Cross-contract transactions (routed trades, and in v2 cross-outcome arbs) produce multiple `StateUpdate` values that must commit together for the engine's error semantics to work. On `CovenantInvariantViolation` during a multi-contract transaction, the current transaction's batch must roll back as a unit so the engine sees a consistent state; partial commits would leave the store in a configuration the engine can't reach via normal transitions. Across multiple transactions within one `step` call, each transaction's batch commits independently — prior transactions stay committed on error, the erroring transaction rolls back, unprocessed transactions remain to be processed on retry. Per-batch atomicity is the minimum guarantee required; it's also cheap to implement (one SQLite transaction per call) and compatible with integrators who already have transactional backends. See the "Atomicity requirements" paragraph under the Store Trait section for the full error-handling semantics. ### Idempotent Transaction Processing @@ -2498,8 +4131,8 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) ### Advance Logic Uses Script Matching and Output Values **Chosen**: Use the detection method best suited to each contract type's structural characteristics. Markets: script pubkey matching (8 bounded, pre-storable scripts). Orders: taproot structural check (key-spend vs script-spend element count). Pools: witness-based path and s_index extraction via `RedeemNode::decode` for all transitions. Dormant market terminals: witness-based path detection for the three-way ambiguity. -**Rejected**: (a) Pattern-match transaction structure (input/output counts). (b) Uniform witness decoding on all transitions for all contract types. (c) Output-only detection for all transitions (no witness inspection). (d) Reserve-based s_index derivation for pool swap/admin transitions. -**Why**: Each contract type has a naturally fitting detection method. Markets have 8 bounded phase scripts — byte comparison is O(1) and trivially airtight for all non-dormant transitions. Orders have a taproot-level key-spend/script-spend split — element count is the simplest possible check. Pools have unbounded s_index (scripts can't be pre-stored), and the engine needs the s_index value on every transition — the witness is the only reliable source, since reserve-based derivation (option d) is fragile after admin adjustments (reserves change without moving along the LMSR curve). Dormant market terminals produce no covenant outputs, creating a three-way ambiguity (resolution YES/NO vs expiry) only resolvable from the witness. Uniform witness decoding (option b) was rejected because markets and orders have simpler, equally correct methods — adding `RedeemNode::decode` overhead to script matching or element counting would be strictly worse. Pure output-based detection (option c) was rejected because pool s_index derivation from reserves is unreliable and dormant terminal ambiguities produce wrong state variants (e.g., `Expired` when the market actually resolved YES). `RedeemNode::decode` takes raw bytes from the transaction — no `CompiledProgram` or compilation needed, no storage needed. See [Detection Strategy and Robustness](#detection-strategy-and-robustness) for the full analysis. +**Rejected**: (a) Pattern-match transaction structure (input/output counts). (b) Uniform witness decoding on all transitions for all contract types. (c) Output-only detection for all transitions (no witness inspection). (d) Reserve-based s_index derivation for pool public/admin transitions. +**Why**: Each contract type has a naturally fitting detection method. Markets have 8 bounded phase scripts — byte comparison is O(1) and trivially airtight for all non-dormant transitions. Orders have a taproot-level key-spend/script-spend split — element count is the simplest possible check. Pools have unbounded s_index (scripts can't be pre-stored), and the engine needs the s_index value on every transition — the witness is the only reliable source, since reserve-based derivation (option d) is fragile after admin adjustments (reserves change without moving along the LMSR curve). Dormant market terminals produce no covenant continuation outputs, creating a three-way ambiguity (resolution YES/NO vs expiry) only resolvable from the witness. Uniform witness decoding (option b) was rejected because markets and orders have simpler, equally correct methods — adding `RedeemNode::decode` overhead to script matching or element counting would be strictly worse. Pure output-based detection (option c) was rejected because pool s_index derivation from reserves is unreliable and dormant terminal ambiguities produce wrong state variants (e.g., `Expired` when the market actually resolved YES). `RedeemNode::decode` takes raw bytes from the transaction — no `CompiledProgram` or compilation needed, no storage needed. See [Detection Strategy and Robustness](#detection-strategy-and-robustness) for the full analysis. ### Output Identification via Script Pubkey Matching @@ -2573,11 +4206,11 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) **Rejected**: Random blinding factors for RT outputs, requiring an anchor (blinding factors) to be shared via Nostr. **Why**: The Elements protocol requires reissuance token outputs to be blinded (ABF != 0) for reissuance to work. Traditionally, random ABFs are used as an authorization mechanism — only someone who knows the ABF can reissue. With Simplicity covenants, authorization is enforced by the covenant itself, not by ABF secrecy. Using deterministic ABFs derived from public data (defining outpoints via tagged hash) satisfies the protocol requirement while eliminating the need for anchor distribution. This simplifies the ingestion API (no anchor parameter), the Nostr announcement format, and removes the "lost anchor" failure mode. See [Deterministic RT Blinding](../protocol/deterministic-rt-blinding.md) for the derivation spec. -### UnblindedPset Newtype for RT-Involving Builders +### PreBlindedPset for RT-Capable Builders and Trades -**Chosen**: The 5 prediction market builders that involve reissuance token outputs return `UnblindedPset` — an opaque newtype with `prepare(pubkey)` and `finalize()` methods. The 7 remaining builders return `PartiallySignedTransaction` directly. -**Rejected**: (a) All 12 builders return `UnblindedPset` (uniform but unnecessary wrapping for RT-free builders). (b) All builders return raw `PartiallySignedTransaction` (no enforcement). (c) Builders take blinding parameters and handle all blinding internally (conflates construction with wallet-level blinding, requires RNG/secp context parameters). (d) Two builder functions per RT-involving transaction type — one fully-blinded, one partially-blinded (doubles API surface, the fully-blinded variant has a hidden precondition about wallet input confidentiality). -**Why**: RT blinding is deadcat-specific, non-standard, and easy to forget — the newtype makes "forgot to blind" a compile error. Wallet output blinding for RT-free builders is standard Elements wallet behavior that every integrator already handles — wrapping it adds ceremony without preventing a novel mistake. The `prepare`/`finalize` choice is a simple privacy decision (confidential vs explicit wallet outputs), not a technical one about input types. The `UnblindedPset` captures all needed state at build time (PSET, deterministic RT factors, input secrets from `WalletFunding`), so neither method requires additional crypto parameters from the caller. Core implements deterministic blinding using public `elements`/`secp256k1-zkp` APIs — no fork needed. +**Chosen**: RT-capable market builders and `build_trade_pset` return `PreBlindedPset` — an opaque newtype with `prepare(pubkey)` and `finalize()` methods. Pure non-RT builders return `PartiallySignedTransaction` directly. +**Rejected**: (a) A `TradePset` enum with `Plain(PartiallySignedTransaction)` and `RequiresRtBlinding(PreBlindedPset)` variants (semantically precise but forces every wallet integration to branch on route internals). (b) Separate plain and assisted trade builders (leaks router policy into the public API and makes the caller choose a route category after accepting a quote). (c) All builders return `PreBlindedPset` (uniform but unnecessary wrapping for pure non-RT single-contract builders). (d) All builders return raw `PartiallySignedTransaction` (no enforcement). (e) Builders take blinding parameters and handle all blinding internally (conflates construction with wallet-level blinding, requires RNG/secp context parameters). (f) Two builder functions per RT-involving transaction type — one fully-blinded, one partially-blinded (doubles API surface, the fully-blinded variant has a hidden precondition about wallet input confidentiality). +**Why**: RT blinding is deadcat-specific, non-standard, and easy to forget — the newtype makes "forgot to run Deadcat pre-blinding" a compile error. `build_trade_pset` needs the same wrapper because a `TradeQuote` may use market-assisted issuance/cancellation, and a Rust return type cannot depend on the route selected inside the quote. Plain routed trades carry an empty RT-blinding plan, so the wrapper adds a uniform wallet flow without route-specific branching. Wallet output blinding for pure non-RT builders is standard Elements wallet behavior that every integrator already handles — wrapping those builders adds ceremony without preventing a novel mistake. The `prepare`/`finalize` choice is a privacy decision (confidential vs explicit wallet outputs), not a branch on whether RT outputs exist. `PreBlindedPset` captures all needed state at build time (PSET, optional deterministic RT plan, input secrets from `WalletFunding`, output classification), so neither method requires additional crypto parameters from the caller. Core implements deterministic blinding using public `elements`/`secp256k1-zkp` APIs — no fork needed. ### Store Returns Typed Results for Listing Methods @@ -2591,6 +4224,12 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) **Rejected**: (a) Single engine method that takes wallet UTXOs directly without showing a quote first. (b) Standalone builder that requires the caller to manually specify the route. **Why**: Trade is the only operation requiring cross-contract route optimization — the engine has the pool/order state and LMSR math needed to compute optimal routes. The two-step pattern enables the standard trading UX of "show quote, user confirms, then build." `TradeQuote` uses `pub(crate)` internal fields so external consumers cannot construct one — they can only receive quotes from the engine and pass them to the builder. All other PSET builders are single-step: the caller provides operation params and gets a PSET back immediately, no quoting needed. +### Assisted Pool Liquidity Stays Inside Trade API + +**Chosen**: Existing-pool issuance/cancellation assist is exposed only through `quote_trade` + `build_trade_pset`. Public `TradeQuote` displays it as `LiquiditySource::LmsrPool { market_assist: Option }`; the exact parent-market continuation remains internal in `TradeRoute`. v1 allows at most one assisted pool leg per route and prefers non-assisted routes on ties. +**Rejected**: (a) Dedicated public `build_issue_into_pool_trade_pset` / `build_cancel_from_pool_trade_pset` builders. (b) Hiding assisted liquidity entirely from `TradeQuote`. +**Why**: Assisted pool liquidity is still a taker trade — it belongs behind the same quote/confirm/build flow as every other routed trade. Separate builders would leak router internals into the public surface and create a second taker API for what is conceptually the same operation. Hiding assist entirely would make quotes misleading, because the on-chain transaction weight and the taker's net capital flows differ materially from a plain swap. The chosen shape exposes only the user-relevant summary and keeps the complicated covenant bookkeeping internal. + ### Non-Idempotent Contract Ingestion **Chosen**: Per-type ingestion methods return `CoreError::ContractAlreadyTracked { contract_id }` on duplicate ingestion. @@ -2641,9 +4280,9 @@ Trade transactions co-spend multiple covenant inputs (LMSR pools + maker orders) ### Creation Builders Take Concrete Param Types -**Chosen**: Creation builders take concrete param types (`&MarketCreationParams`, `&LmsrPoolParams`, `&MakerOrderParams`) instead of the `ContractParams` enum. `build_creation_pset` takes `MarketCreationParams` (only non-derivable fields) rather than full `PredictionMarketParams` because the 4 token/RT asset IDs depend on coin selection (see [MarketCreationParams](#marketcreationparams)). +**Chosen**: Creation builders take concrete param types (`&MarketCreationParams`, `&LmsrPoolParams`, `&MakerOrderParams`) instead of the `ContractParams` enum. `build_binary_market_creation_pset` takes `MarketCreationParams` (only non-derivable fields) rather than full `BinaryMarketParams` because the 4 token/RT asset IDs depend on coin selection (see [MarketCreationParams](#marketcreationparams)). **Rejected**: All creation builders take `&ContractParams`, with runtime validation of the variant. -**Why**: Passing the wrong variant (e.g., `ContractParams::LmsrPool` to `build_creation_pset`) would only be caught at runtime. Taking concrete types makes wrong-variant errors compile-time errors. The standalone `contract_cmr()` still takes `ContractParams` (the enum) since it is genuinely polymorphic. +**Why**: Passing the wrong variant (e.g., `ContractParams::LmsrPool` to `build_binary_market_creation_pset`) would only be caught at runtime. Taking concrete types makes wrong-variant errors compile-time errors. The standalone `contract_cmr()` still takes `ContractParams` (the enum) since it is genuinely polymorphic. ### Single Return Script for All Non-Covenant Outputs @@ -2675,7 +4314,7 @@ Note: Covenant scripts are used internally by `step` for catch-up scanning and s ### Per-Type Ingestion Methods -**Chosen**: `ingest_market`, `ingest_pool`, `ingest_order` with type-specific snapshot enums. +**Chosen**: `ingest_market`, `ingest_pool`, and the split `ingest_persistent_order` / `ingest_ephemeral_order` with type-specific snapshot enums. Orders have two ingestion methods because persistence behavior is distinct (maker-owned orders keep history and persist through terminal states; taker-tracked orders skip history and auto-untrack past finality). **Rejected**: Unified `ingest_contract(ContractParams, ChainTransaction)`. **Why**: Different contract types have genuinely different ingestion needs. Markets always need the creation tx (few transitions, fast catch-up). Pools and orders benefit from non-initial ingestion (pools can have thousands of transitions; order takers don't need history). Per-type methods make each contract's recommended pattern explicit in the type system. The snapshot enums (`PoolSnapshot`, `OrderSnapshot`) document the trade-off at the type level: `Creation` = full history + verified; `Current` = fast start, no prior history, no verification back to creation. @@ -2701,15 +4340,15 @@ Note: Covenant scripts are used internally by `step` for catch-up scanning and s ### Discoverability Trust Gap (OP_RETURN Deferred) An LMSR pool operator could create a pool, manipulate its price privately (no one can arbitrage because no one knows about it), then announce it on Nostr. The historical price data looks legitimate (all real on-chain transactions) but wasn't subject to market pressure during the private period. The same attack extends to markets: an undiscoverable market + discoverable pool means only the operator can issue tokens and trade. -The ideal solution: embed full contract params in an OP_RETURN output in the creation transaction, making the contract provably discoverable from the chain from the moment of creation. However, `LmsrPoolParams` is 228 bytes and `PredictionMarketParams` is 204 bytes — both exceed Liquid's default 80-byte OP_RETURN relay policy. This is a policy limit (configurable by federation, not a consensus constraint), and Bitcoin Core has recently removed it entirely. When Elements merges this change, OP_RETURN-based discoverability becomes viable. Deferred until then. +The ideal solution: embed full contract params in an OP_RETURN output in the creation transaction, making the contract provably discoverable from the chain from the moment of creation. However, `LmsrPoolParams` is 228 bytes and `BinaryMarketParams` is 204 bytes — both exceed Liquid's default 80-byte OP_RETURN relay policy. This is a policy limit (configurable by federation, not a consensus constraint), and Bitcoin Core has recently removed it entirely. When Elements merges this change, OP_RETURN-based discoverability becomes viable. Deferred until then. Note: The recovery hints described in [Wallet Recovery](#wallet-recovery) and [chain-only-recovery.md](../protocol/chain-only-recovery.md) are distinct from the full-params discoverability discussed here. Recovery hints use compressed encodings (standard denomination conventions, well-known asset indices, hybrid time encoding) and omit derivable fields — they enable fund recovery (reconstructing params when combined with a mnemonic and chain data), not public discoverability (making params available to anyone scanning the chain). Full discoverability requires embedding complete params, which exceeds the current OP_RETURN policy limit. -### Flat MarketState (Dormant/Unresolved Hidden, No Settled Variant) +### Dormant/Unresolved Hidden in Trading **Chosen**: The public `MarketState` has 4 variants (`Trading`, `ResolvedYes`, `ResolvedNo`, `Expired`). Dormant (0 pairs) and Unresolved (>0 pairs) are both `Trading`. Terminal state = `outstanding_pairs == 0` on any non-Trading variant. No `Settled` variant. **Rejected**: (a) Exposing `CovenantPhase` with Dormant/Unresolved. (b) Separate `Settled { final_txid, outcome }` terminal variant with `MarketOutcome` type. -**Why**: The Dormant/Unresolved distinction is a covenant implementation detail. The `Settled` variant was removed because it created a routing ambiguity: resolution from non-dormant markets produced intermediate `ResolvedYes/ResolvedNo` states, while resolution from dormant markets had to route directly to `Settled` — a special case an implementor could miss. Without `Settled`, resolution/expiry always produce the corresponding variant regardless of outstanding pairs, and `outstanding_pairs` naturally reaches 0 through redemption (or starts at 0 for dormant terminals). See the "Flat MarketState (No Settled Variant)" entry below for the full rationale. +**Why**: The Dormant/Unresolved distinction is a covenant implementation detail. The `Settled` variant was removed because it created a routing ambiguity: resolution from non-dormant markets produced intermediate `ResolvedYes/ResolvedNo` states, while resolution from dormant markets had to route directly to `Settled` — a special case an implementor could miss. Without `Settled`, resolution/expiry always produce the corresponding variant regardless of outstanding pairs, and `outstanding_pairs` naturally reaches 0 through redemption (or starts at 0 for dormant terminals). See the dedicated "Flat MarketState (No Settled Variant)" entry below for the `Settled` rationale. ### Pool Closure via Simplicity Script Path @@ -2735,25 +4374,31 @@ The `derive_order_params` function derives a unique nonce for each order from `d ### OP_RETURN Recovery Hints in All Contract Creation Transactions -**Chosen**: All three creation builders always include a zero-value OP_RETURN output with a compact recovery hint. Markets: 37 bytes (compressed non-derivable params using well-known asset index, 1-2-5 denomination, absolute u24 expiry). Orders: 40 bytes (XOR-masked index, market txid, u24 price, u8 min_fill/remainder, side+direction in type tag). Pools: 41 bytes (market txid, 9-bit mantissa x exponent for max_loss/half_payout, u12 fee_bps, u16 initial_s_index, XOR-masked index). No opt-out. All fit within a single 80-byte OP_RETURN. +**Chosen**: All three creation builders always include a zero-value OP_RETURN output with a compact recovery hint. Markets: 37 bytes (compressed non-derivable params using well-known asset index, 1-2-5 denomination, absolute u24 expiry). Binary and multi-outcome markets share the same 37-byte layout, distinguished by the type tag byte; `outcome_count` is derived from the creation tx's issuance count rather than stored. Orders: 40 bytes (XOR-masked index, market txid, u24 price, u8 min_fill/remainder, side+direction in type tag). Pools: 40 bytes (market txid, 4-bit 1-2-5 index each for max_loss/half_payout, u12 fee_bps, u16 initial_s_index, XOR-masked index). No opt-out. All fit within a single 80-byte OP_RETURN. **Rejected**: (a) No on-chain hints (orders require brute-force scanning, tokens require Nostr for labeling/redemption). (b) Optional hint via builder flag (risk of users opting out). (c) Uncompressed params (wastes bytes). (d) Hints for orders and pools only, not markets (breaks the recovery chain for all user types including pure token holders). -**Why**: Chain-only recovery for ALL user types — market creators, order makers, pool operators, and pure token holders (via `issuance_transaction` → market creation tx → OP_RETURN). Maker orders are the only contract type directly "owned" by regular end users, making compression especially important. The OP_RETURN encoding uses standard denomination conventions (1-2-5 for markets, 26-value mantissa for pools), well-known collateral asset indices, and XOR-masked derivation indices for privacy. Convention compliance is enforced at three layers: derive functions (first line), builders (defense in depth), and market ingestion (protects all downstream users). See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the complete encoding specification and recovery flows. +**Why**: Chain-only recovery for ALL user types — market creators, order makers, pool operators, and pure token holders (via `issuance_transaction` → market creation tx → OP_RETURN). Maker orders are the only contract type directly "owned" by regular end users, making compression especially important. The OP_RETURN encoding uses standard denomination conventions (1-2-5 tables shared between market and pool encoding), well-known collateral asset indices, and XOR-masked derivation indices for privacy. Convention compliance is enforced at three layers: derive functions (first line), builders (defense in depth), and ingestion as the strict-canonical tracking boundary. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the complete encoding specification and recovery flows. ### Taker Order Fills Via Trade Router **Chosen**: Order filling (taker side) is handled exclusively through the trade system (`quote_trade` + `build_trade_pset`). No `build_fill_order_pset`. **Rejected**: Direct `build_fill_order_pset` builder for explicit single-order fills. -**Why**: The trade router optimizes across all available pools and orders for best execution. A direct fill builder would allow suboptimal execution and create an inconsistency (pool swaps already go through the trade router — `build_lmsr_swap_pset` doesn't exist). The maker's lifecycle is directly exposed (`build_create_order_pset`, `build_cancel_order_pset`) because those are single-contract operations that don't benefit from routing. If explicit order targeting becomes a requested feature, a direct fill builder can be added as a non-breaking change (new engine method, no store or type changes). +**Why**: The trade router optimizes across all available pools and orders for best execution. A direct fill builder would allow suboptimal execution and create an inconsistency (pool trading already goes through the trade router; there is no standalone pool swap builder). The maker's lifecycle is directly exposed (`build_create_order_pset`, `build_cancel_pset`) because those are single-contract operations that don't benefit from routing. If explicit order targeting becomes a requested feature, a direct fill builder can be added as a non-breaking change (new engine method, no store or type changes). ### LMSR Adjust API Uses Deltas -**Chosen**: `build_lmsr_adjust_pset` takes `pair_delta: i64` (applied equally to YES and NO) and `collateral_delta: i64`, not `target_reserves: &PoolReserves`. +**Chosen**: `Pool::build_adjust_pset` takes `pair_delta: i64` (applied equally to YES and NO) and `collateral_delta: i64`, not `target_reserves: &PoolReserves`. **Rejected**: Absolute target reserves with runtime validation of the paired-delta constraint. **Why**: The LMSR covenant enforces that YES and NO reserve deltas are equal on the admin path. By taking a single `pair_delta` parameter, the API makes this constraint unrepresentable as an error — the caller cannot express asymmetric deltas. The only remaining validation is reserve floors (computed targets must meet minimums), which is a meaningful constraint rather than an input formatting error. Wallets can present absolute-target UIs by computing deltas from current reserves on their side. +### One Permissionless Public Pool Path + +**Chosen**: The pool covenant's permissionless path allows both ordinary swaps and equal YES/NO paired reserve deltas, including the degenerate `old_s_index == new_s_index` case. Admin adjust and close remain separate paths. +**Rejected**: Separate permissionless swap and permissionless pair-rebalance spend paths. +**Why**: One public path preserves future transaction composability for both binary and multi-outcome markets without needing to change already-created markets. The paired delta is derived from the reserve vector change itself rather than supplied as an independent witness scalar, so the extra flexibility does not weaken covenant correctness. v1 `quote_trade` intentionally emits only plain swaps and swap+market-assist routes; pure public pair rebalances remain covenant-valid for future composition without forcing a second taker API today. + ### Pool/Order Ingestion Requires Parent Market -**Chosen**: `ingest_pool` and `ingest_order` validate that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, returns `CoreError::InvalidParams`. `ingest_market` has no parent requirement. +**Chosen**: `ingest_pool`, `ingest_persistent_order`, and `ingest_ephemeral_order` validate that the referenced token asset IDs correspond to a known market. If the parent market isn't tracked, returns `CoreError::ParentMarketNotTracked { detail }`. `ingest_market` has no parent requirement. **Rejected**: (a) Allow orphaned pools/orders with later backfill. (b) Engine pre-computes parent ID and passes it to store. **Why**: The store builds its own parent-market index by resolving token asset IDs via `find_by_asset_id` during `track_contract`. This requires the parent market to already be in the asset index. Allowing orphans would require backfill machinery (scan for orphans when a market is ingested) — significant complexity for a case that shouldn't happen. Discovery naturally produces markets before their pools/orders. The simpler option (engine pre-computes parent ID and passes it via `DerivedContractData`) was rejected because it duplicates work the store can already do with its existing asset index. @@ -2781,11 +4426,11 @@ The `derive_order_params` function derives a unique nonce for each order from `d **Rejected**: (a) No sync tracking — engine re-scans from last transition height (stuck `from_height` for inactive contracts). (b) Global sync tip (blocks independent contract catch-up). **Why**: Without `synced_to`, a contract whose last transition was at height 1000 would be re-scanned from 1000 on every `step` call, even if the engine checked through height 2000 and found nothing. `synced_to` records "checked through 2000," so the next scan starts from 2000. Per-contract (not global) because contracts are ingested at different times and catch up independently. -### Collateral Per Pair +### Denomination: `base_payout` as Primary Param -**Chosen**: Covenant parameter `COLLATERAL_PER_PAIR` — the total collateral to issue one YES+NO pair. -**Rejected**: `COLLATERAL_PER_TOKEN` (original) — the collateral backing a single token, requiring `* 2` in every formula. -**Why**: The atomic unit of issuance is always a pair (1 YES + 1 NO). Every formula immediately multiplied by 2, and the naming caused a documentation bug (inconsistent formulas). `COLLATERAL_PER_PAIR` eliminates the factor of 2 everywhere: `pairs = collateral / collateral_per_pair`. See [collateral-per-pair-refactor.md](../contracts/prediction-market/collateral-per-pair-refactor.md). +**Chosen**: Covenant param is `BASE_PAYOUT` — the per-outcome YES-expiry payout unit. Binary markets derive `cp = base_payout × 2`. Multi-outcome markets derive `cp = base_payout × outcome_count`. Both contract types share the same 1-2-5 denomination table, indexed by `base_payout`. +**Rejected**: (a) `COLLATERAL_PER_PAIR` as primary (pair cost) — requires covenant-level `cp mod N == 0` assertion for multi-outcome, restricts the 1-2-5 table to N-compatible values (empty for N ∈ {3, 6, 7, 9}), creates asymmetry between binary and multi-outcome denomination. (b) Per-N denomination tables — 8 separate tables, complex decoder. (c) `COLLATERAL_PER_TOKEN` (original) — required `× 2` in every formula, caused documentation bugs. +**Why**: Parameterizing on the per-outcome unit rather than the pair cost makes expiry-redemption divisibility automatic by construction. In binary, `cp = base_payout × 2`; in multi-outcome, `cp = base_payout × outcome_count`, so the covenant performs no division at runtime and needs no divisibility assertion. The 1-2-5 table (unchanged) is usable for every supported market shape. Binary and multi-outcome markets share one denomination model. See [multi-outcome-market-contract.md § Denomination model](../contracts/multi-outcome/multi-outcome-market-contract.md#denomination-model) and [collateral-per-pair-refactor.md](../contracts/prediction-market/collateral-per-pair-refactor.md) (the latter is superseded by this decision but retained for historical context on the earlier `collateral_per_token → collateral_per_pair` step). ### Key-Spend-Only Order Cancellation @@ -2829,29 +4474,29 @@ The `derive_order_params` function derives a unique nonce for each order from `d **Rejected**: Token holder recovery only via Nostr discovery. **Why**: Token holders are the most common user type. Requiring Nostr for fund recovery (labeling + redemption) would make the most common recovery scenario depend on an external service. Elements chain backends (Esplora, Electrs) natively support asset issuance indexing. One chain query per unique asset ID — simple and sufficient. -### Convention Enforcement at Ingestion +### Strict-Canonical Contract Tracking -**Chosen**: `ingest_market` rejects markets whose parameters don't conform to the recovery conventions (denomination, expiry, collateral asset). -**Rejected**: (a) Accept all markets, reject only at child-contract creation. (b) Accept all markets, warn but don't reject. -**Why**: With `issuance_transaction` recovery, market convention conformity matters for ALL users — even pure token holders trace back to the market creation tx. "I won't trade on your market unless it's mnemonic-recoverable" creates the right ecosystem incentive. If `deadcat-core` is the primary tool, virtually all markets will be conforming. Non-conforming markets created by custom tools are their problem — they can use `deadcat-core` and get conformity for free. +**Chosen**: `deadcat-core` rejects non-conforming contracts at ingestion across all contract kinds. `ingest_market` verifies market conventions against the creation tx. `ingest_pool`, `ingest_persistent_order`, and `ingest_ephemeral_order` reject non-conforming supplied params on all snapshot variants; `Creation` snapshots additionally verify the creation tx, while `Current` snapshots intentionally keep their no-backfill trust trade-off. +**Rejected**: (a) Accept all pools/orders for routing while only markets are strict. (b) Accept all contracts and warn but do not reject. (c) Reject only at creation-builder time and let ingestion be permissive. +**Why**: A single tracked-contract class is easier to reason about than a mixed universe of canonical and foreign contracts. If `deadcat-core` tracks a contract, callers can assume it sits on the canonical v1 recovery surface rather than asking per-contract whether recovery or UX guarantees degrade. This is obviously required for markets, because even pure token holders trace back to the market creation tx, but the same policy is worthwhile for pools and orders because it keeps the routing and recovery model uniform. The remaining trust trade-off is explicit: `Current` snapshots skip verification back to creation, so they cannot prove that an omitted historical hint existed on-chain. They still enforce canonical param shape and a canonical parent market, preserving as much of the strict-canonical boundary as the fast-start snapshot model allows. ### Standard Denomination Conventions -**Chosen**: `collateral_per_pair` constrained to 16-value 1-2-5 table (4 bits). Pool `max_loss_sats` and `half_payout_sats` constrained to 26-value mantissa x 10^exponent encoding (9 bits each). Well-known collateral asset index (4 bits: L-BTC=0, USDt=1, escape=15). -**Rejected**: (a) Uncompressed u64 values in OP_RETURN (wastes bytes). (b) Single-digit mantissa (too coarse — 100K to 200K is a 100% jump). (c) Full two-digit mantissa (90 values x 16 exponents = too many combinations, limited practical benefit over the 26-value set). -**Why**: The conventions compress OP_RETURN hints (market: 77→37 bytes, pool: 51→41 bytes) while constraining parameters to "round numbers" that market creators naturally pick. The 26-value mantissa set (10-20 step 1, 25-95 step 5) balances precision and simplicity. The 4-bit exponent supports non-L-BTC assets (USDT needs exponent 8+). See [chain-only-recovery.md](../protocol/chain-only-recovery.md). +**Chosen**: `base_payout` constrained to 16-value 1-2-5 table (4 bits); binary markets derive `cp = base_payout × 2`, while multi-outcome markets derive `cp = base_payout × outcome_count`. Pool `max_loss_sats` and `half_payout_sats` are constrained to the **same** 16-value 1-2-5 table (4 bits each), sharing the encoding with market `base_payout`. Well-known collateral asset index (4 bits: network policy asset = `0`, Liquid-mainnet USDt = `1`, escape = `15`). +**Rejected**: (a) Uncompressed u64 values in OP_RETURN (wastes bytes). (b) Separate 26-value mantissa × 10^exponent encoding for pools (previous design — 9 bits each, wider range but adds encoding complexity and a second convention to learn). (c) Per-N denomination tables (complex decoder). +**Why**: The conventions compress OP_RETURN hints (market: 77→37 bytes, pool: 51→40 bytes) while constraining parameters to "round numbers" that market creators naturally pick. Using a single 16-value 1-2-5 table for both market and pool denomination reduces the number of distinct encodings in the protocol, simplifies decoders, and keeps the committed LMSR Merkle-root fixture space small (16×16 = 256 combinations). The 10^7-sat range ceiling is a pragmatic v1 constraint, not a structural one — expansion to wider ranges (e.g., for pools on USDt-denominated markets with larger subsidies) is non-breaking via table extension. See [chain-only-recovery.md](../protocol/chain-only-recovery.md). ### XOR Index Masking for Privacy -**Chosen**: Derivation indices (`order_index`, `pool_index`) are XOR-masked in the OP_RETURN using `HMAC(deadcat_secret_key, tag || context)[0..2]` where `deadcat_secret_key` is a single key derived from `m/purpose'/deadcat'/secret'`. The mask context includes all other OP_RETURN fields, serialized as raw values in standard big-endian encoding (not the compact OP_RETURN bit-packing). Different HMAC tags (`"deadcat/order_mask"`, `"deadcat/pool_mask"`) provide domain separation. +**Chosen**: Derivation indices (`order_index`, `pool_index`) are XOR-masked in the OP_RETURN using `HMAC(deadcat_secret_key, tag || context)[0..2]` where `deadcat_secret_key` is a single key derived from `m/86'/1145258324'/secret'`. The mask context includes all other OP_RETURN fields, serialized as raw values in standard big-endian encoding (not the compact OP_RETURN bit-packing). Different HMAC tags (`"deadcat/order_mask"`, `"deadcat/pool_mask"`) provide domain separation. **Rejected**: (a) Unmasked indices (reveals derivation order and contract count to observers). (b) No index in OP_RETURN (forces gap-limit scanning during recovery). (c) Mask context using OP_RETURN-encoded bytes (couples the mask to the OP_RETURN encoding format — a future V2 format change would break mask computation for V1 hints). **Why**: The mask is deterministic from the mnemonic + public OP_RETURN data, so recovery is still O(1). Observers see random-looking u16 values. The privacy cost of unmasked indices is small (only meaningful if an observer can link two transactions to the same wallet), but the masking cost is zero (one HMAC computation). Using raw values for the context (not OP_RETURN encodings) makes the mask encoding-agnostic — the context length doesn't affect on-chain size (only the 2-byte mask output appears in the OP_RETURN). Known property: identical-param orders on the same market share a mask — a negligible concern in an already-pathological scenario. See [chain-only-recovery.md](../protocol/chain-only-recovery.md) for the exact byte-level context serialization. ### Deterministic Derivation via `derive_order_params` and `derive_pool_params` -**Chosen**: Both `derive_order_params` and `derive_pool_params` take `deadcat_xprv` (the xprv at `m/purpose'/deadcat'`) + an index and derive all keys, nonces, and masks internally. Both return `Result<(Params, u16 /* masked_index */), ConventionError>` — validating OP_RETURN convention constraints before deriving. A single `deadcat_secret_key` (derived from the xprv) is used for all HMAC operations (nonce derivation, index masking) with different HMAC tags providing domain separation. `derive_pool_params` additionally takes `starting_price_bps` — needed to compute `initial_s_index` for the XOR mask context (the mask includes `initial_s_index`, which is derived from `starting_price_bps` via the inverse logistic function). This parameter does not affect `LmsrPoolParams` (the primary output), only the masked index (the secondary output). -**Rejected**: (a) Caller passes a pre-derived nonce or separate secret key + pubkey (foot-gun — non-deterministic nonces break recovery; separate keys require callers to manage multiple HD paths). (b) Separate `order_secret_key` and `pool_secret_key` at different HD paths (HMAC tags already provide full domain separation; a second secret key adds an HD path with no security benefit). (c) Omit `initial_s_index` from the pool mask context (weaker mask, and two pools with identical params but different starting prices would share the same mask). -**Why**: The nonce and mask MUST be deterministic from the mnemonic for chain-only recovery. Encapsulating all derivation in these functions eliminates foot-guns (wrong key, non-deterministic nonce, mismatched pubkey). The unified `deadcat_secret_key` simplifies the HD path table from 4 paths to 3. See [Key Derivation Convenience Functions](#key-derivation-convenience-functions) and [chain-only-recovery.md](../protocol/chain-only-recovery.md). +**Chosen**: Both `derive_order_params` and `derive_pool_params` take `deadcat_xprv` (the xprv at `m/86'/1145258324'`) + a `MarketParams` umbrella + an `OutcomeIndex` + an index and derive all keys, nonces, and masks internally. Both return `Result<(Params, u16 /* masked_index */), ConventionError>` — validating OP_RETURN convention constraints before deriving. A single `deadcat_secret_key` (derived from the xprv) is used for all HMAC operations (nonce derivation, index masking) with different HMAC tags providing domain separation. `derive_pool_params` additionally takes `initial_s_index: u16` directly (not `starting_price_bps`) — the mask context includes `initial_s_index`, and sourcing it directly from the hint at recovery time (or from `estimate_bootstrap` at creation time) eliminates the non-injective inversion that a `starting_price_bps → initial_s_index` conversion would require. +**Rejected**: (a) Caller passes a pre-derived nonce or separate secret key + pubkey (foot-gun — non-deterministic nonces break recovery; separate keys require callers to manage multiple HD paths). (b) Separate `order_secret_key` and `pool_secret_key` at different HD paths (HMAC tags already provide full domain separation; a second secret key adds an HD path with no security benefit). (c) Omit `initial_s_index` from the pool mask context (weaker mask, and two pools with identical params but different starting prices would share the same mask). (d) Take `starting_price_bps` and re-derive `initial_s_index` internally (requires a bit-identical inverse logistic implementation on recovery; the `bps → s_index` snap is non-injective, so multiple `bps` values produce the same `s_index` — silent-failure surface if forward and inverse drift apart). +**Why**: The nonce and mask MUST be deterministic from the mnemonic for chain-only recovery. Encapsulating all derivation in these functions eliminates foot-guns (wrong key, non-deterministic nonce, mismatched pubkey). Taking `initial_s_index` directly means the `bps → s_index` snap function lives in exactly one place (`estimate_bootstrap`) and the hint's stored value is passed through unchanged on recovery. Accepting the `MarketParams` umbrella + `OutcomeIndex` lets a single pair of functions serve both binary and multi-outcome markets. See [Key Derivation Convenience Functions](#key-derivation-convenience-functions) and [chain-only-recovery.md](../protocol/chain-only-recovery.md). ### Convenience Derive Functions Accept Private Key Material @@ -2891,8 +4536,8 @@ The `derive_order_params` function derives a unique nonce for each order from `d ### MarketCreationParams for Market Creation Builder -**Chosen**: `build_creation_pset` takes `&MarketCreationParams` (4 non-derivable fields) and returns `(UnblindedPset, PredictionMarketParams)`. The builder derives the 4 token/RT asset IDs from the selected defining inputs. -**Rejected**: (a) Builder takes full `&PredictionMarketParams` (caller can't fill in the 4 derivable asset ID fields because they depend on coin selection, which happens inside the builder). (b) Caller pre-selects defining inputs (breaks the "pass all UTXOs, builder selects" pattern). +**Chosen**: `build_binary_market_creation_pset` takes `&MarketCreationParams` (4 non-derivable fields) and returns `(PreBlindedPset, BinaryMarketParams)`. The builder derives the 4 token/RT asset IDs from the selected defining inputs. +**Rejected**: (a) Builder takes full `&BinaryMarketParams` (caller can't fill in the 4 derivable asset ID fields because they depend on coin selection, which happens inside the builder). (b) Caller pre-selects defining inputs (breaks the "pass all UTXOs, builder selects" pattern). **Why**: The 4 asset IDs are derived from issuance entropy = `hash(defining_outpoint || contract_hash)`. The defining outpoints are UTXOs selected by the builder during coin selection. Since coin selection happens inside the builder, the caller can't know the asset IDs beforehand. `MarketCreationParams` makes the API honest about what data flows in which direction — the caller provides what they know, the builder returns what it computed. ### LMSR Deterministic Table Specification Required @@ -2902,24 +4547,87 @@ The `derive_order_params` function derives a unique nonce for each order from `d ### Pool OP_RETURN Includes initial_s_index -**Chosen**: The pool OP_RETURN hint includes `initial_s_index` as u16 (2 bytes, pool hint grows from 39 to 41 bytes). -**Rejected**: (a) Derive s_index from reserve values via reverse LMSR lookup (fragile, requires specifying the bootstrap allocation formula, vulnerable to adversarial reserve values). (b) Brute-force script matching over all 65K s_index candidates (requires EC scalar multiplication per candidate, ~3-7 seconds worst case). +**Chosen**: The pool OP_RETURN hint includes `initial_s_index` as u16 (2 bytes; pool hint is 40 bytes total, would be 38 without this field). +**Rejected**: (a) Derive s_index from reserve values via reverse LMSR lookup (fragile: bootstrap reserves are explicit caller-chosen inputs rather than uniquely determined by starting price, and adversarial reserve values make reverse inference unreliable). (b) Brute-force script matching over all 65K s_index candidates (requires EC scalar multiplication per candidate, ~3-7 seconds worst case). **Why**: The pool's taproot tree structure means each s_index candidate requires a full taproot tweak (EC scalar multiplication) to verify — hashing alone is insufficient. Including `initial_s_index` directly in the hint eliminates all reverse-derivation complexity: compile for one s_index, verify script matches, done. The 2-byte cost is negligible relative to the hint's total size and is amortized over the pool's entire lifetime. +### HD Path Constants: BIP-86 Purpose + ASCII "DCAT" Coin Type + +**Chosen**: deadcat uses the HD path `m/86'/1145258324'/{secret'|orders'/i|pools'/i}`. The `purpose'` value `86'` follows BIP-86 (single-key taproot) — deadcat covenants are taproot-based. The `coin_type'` value `1145258324'` is `0x44434154` = ASCII `"DCAT"`, self-documenting and within the hardened-index range (`< 2^31 - 1`). +**Rejected**: (a) Claim a new BIP-43 `purpose'` value by publishing a BIP (slow, no real-world precedent for non-coin protocols). (b) Use a phone-keypad constant like `3228'` (ambiguous — "DCAT"/"DAAT"/"FCAT"/"EBBT" all map to 3228). (c) Reuse the existing `deadcat-sdk` path `m/84'/1776'/...` (BIP-84 is P2WPKH, not taproot; LBTC's coin_type `1776'` is for L-BTC wallets, not for a layered protocol). (d) Pick an arbitrary low number (small collision risk with existing or future registrations). +**Why**: The SLIP-0044-coin-type-under-standard-BIP-purpose pattern is the community-expected path for Liquid-layered protocols (RGB-on-Liquid registered coin_type `828942'` under this model). ASCII-derived constants are self-documenting — anyone can verify `0x44434154 = "DCAT"` — and SLIP-0044 has precedent for ASCII-like coin_types (e.g., `0x80616263 = "abc"`). BIP-86 is the right purpose because covenants are taproot. `deadcat-core` is pre-implementation, so migrating off the old SDK path is a clean break with no on-chain state to preserve. A SLIP-0044 registration PR is tracked as a pre-v1-ship action item in [deadcat-core-implementation-plan.md](deadcat-core-implementation-plan.md). + +### Multi-Outcome `outcome_count` Derived from Creation Tx Issuance Count + +**Chosen**: Multi-outcome market hints do NOT store `outcome_count`. Recovery derives it by counting `AssetIssuance` structures in the creation tx whose `asset_blinding_nonce` is zero AND whose `amount` and `inflation_keys` are both non-null; `outcome_count = issuance_count / 2`. Binary and multi-outcome market hints share the same 37-byte layout (69 bytes with exotic collateral), distinguished only by the type tag byte. +**Rejected**: (a) Store `outcome_count` as a u8 in byte 1 of the hint (38 bytes — redundant since the covenant script is the authoritative binding). (b) Store `outcome_count` in 4 bits of the type-tag byte (packed format — couples "which hint type" with "how many outcomes"). (c) Leave the encoding question to each supported N (new type tag per N — breaks forward extension). +**Why**: The creation tx's issuance count is already the authoritative on-chain fact that determines `outcome_count` — storing it separately in the hint creates a redundant consistency surface without new information. The covenant script is the ultimate source of truth: a wrong derived N produces a compiled script that doesn't match any creation-tx output, and ingestion fails loudly. The defensive filter on `AssetIssuance` amount/inflation_keys null-ness rules out the one Elements edge case (asymmetric half-issuances) that could confuse a naive count. Saves 1 byte per multi-outcome hint; keeps binary and multi-outcome hint layouts unified. + ### Convention Validation in Derive Functions **Chosen**: `derive_order_params` and `derive_pool_params` return `Result<_, ConventionError>` and validate that all inputs conform to OP_RETURN encoding conventions before deriving parameters. Builders also validate (defense in depth). **Rejected**: (a) Derive functions are infallible, validation only at builder time (error surfaces far from the logical mistake — caller gets valid-looking params back, wires up UI, hits wall at build time). (b) Derive functions validate but panic (convention violations are input errors, not bugs). -**Why**: The derive functions are the natural first line of defense — the caller is making the parameter decision at this point. Catching `fee_bps = 5000` at derivation time ("this value exceeds the u12 OP_RETURN encoding limit") is clearer than catching it at build time ("PSET construction failed"). Three enforcement layers total: derive functions → builders → market ingestion (see [Wallet Recovery](#wallet-recovery)). +**Why**: The derive functions are the natural first line of defense — the caller is making the parameter decision at this point. Catching `fee_bps = 5000` at derivation time ("this value exceeds the u12 OP_RETURN encoding limit") is clearer than catching it at build time ("PSET construction failed"). Three enforcement layers total: derive functions → builders → ingestion as the strict-canonical tracking boundary (see [Wallet Recovery](#wallet-recovery)). ### `max_loss_sats` in `LmsrPoolParams` **Chosen**: `LmsrPoolParams` includes `max_loss_sats: u64` alongside the covenant parameters, even though it is not itself a covenant parameter. **Rejected**: (a) Store `max_loss_sats` in a separate `PoolConfig` wrapper (cleaner separation but ripples through the entire API — store trait, `Contract` enum, ingestion methods, discovery types). (b) Pass `max_loss_sats` as a separate parameter on `ingest_pool` (ad-hoc, no natural place to store it). (c) Recover `b` from `q_step_lots + half_payout_sats` (impossible — the `ceil()` in the derivation is lossy). -**Why**: All off-chain LMSR computation — point evaluation for quoting, full table generation for Merkle proofs, spot price calculation — requires the liquidity parameter `b = max_loss_sats / ln(2)`. Without `max_loss_sats`, the engine literally cannot evaluate the LMSR cost function after ingestion. The struct already contains two derived fields (`q_step_lots`, `lmsr_table_root`) as compilation caches, so adding a third non-covenant field is consistent. Including `max_loss_sats` also enables automatic curve well-formedness verification at `Creation` ingestion: the engine derives `b`, recomputes the table, and verifies the Merkle root matches — catching misconfigured or adversarially-constructed pools. +**Why**: All off-chain LMSR computation — cached-table quoting, full table generation for Merkle proofs, spot price calculation — requires the liquidity parameter `b = max_loss_sats / ln(2)`. Without `max_loss_sats`, the engine literally cannot evaluate the LMSR cost function after ingestion. The struct already contains two derived fields (`q_step_lots`, `lmsr_table_root`) as compilation caches, so adding a third non-covenant field is consistent. Including `max_loss_sats` also enables automatic curve well-formedness verification at `Creation` ingestion: the engine derives `b`, recomputes the table, and verifies the Merkle root matches — catching misconfigured or adversarially-constructed pools. ### `estimate_bootstrap` Does Not Take `fee_bps` **Chosen**: `estimate_bootstrap` takes `max_loss_sats`, `half_payout_sats`, and `starting_price_bps` — no `fee_bps`. **Rejected**: Including `fee_bps` for API symmetry with `derive_pool_params`. -**Why**: The bootstrap reserves depend on the LMSR cost function shape (`b`, `q_step_lots`, `half_payout_sats`) and starting position (`starting_price_bps`). The fee has no effect on the cost function, initial reserves, or s_index mapping — it's a per-swap spread applied by the covenant, not a curve parameter. Accepting an unused parameter misleads callers into thinking fees affect capital requirements. +**Why**: `estimate_bootstrap` computes only the snapped starting state and the canonical default reserve vector for that curve. The fee has no effect on the cost function, the useful-band bounds, the default reserve calculation, or the `starting_price_bps → initial_s_index` mapping — it's a per-swap spread applied by the covenant, not a curve-shape parameter. Accepting an unused parameter misleads callers into thinking fees affect bootstrap capital planning. + +### Labeled Outpoints at the Engine↔Store Boundary + +**Chosen**: `Vec<(SlotIdentity, OutPoint)>` at every engine↔store boundary where outpoint sets appear (`InitialContractState.outpoints`, `ContractMatch.matched_outpoints`, `StateUpdate.old_outpoints` / `new_outpoints`, `OutpointContractInfo.outpoints`, `ContractStore::contract_outpoints` return). Slot identity lives in the data, not in the `Vec` index. + +**Rejected**: +- (a) Convention-only positional ordering — documentation-enforced invariant that the store implementor could silently violate (e.g., with a hash-backed index that scrambles insertion order). +- (b) Compliance tests alone — documentation plus testkit, but still convention at the type level. Better than (a) but still requires store implementors to opt in. +- (c) Type-level fixed shapes (`[OutPoint; 3]` for pools, typed structs like `PoolOutpoints { yes, no, collateral }` per contract type) — clean for pools and orders but forces a parallel shape for variable-N multi-outcome markets. Partial type-level enforcement is worse than picking a lane. +- (d-unrefined) Dropping ordering entirely with raw `Vec` — fails because `OutPoint` alone is `(txid, vout)` with no way to determine which slot the engine meant without re-fetching the previous output from chain. Labels are the refinement that makes (d) work. + +**Why**: (a) is fragile. (b) helps but is opt-in. (c) partially type-enforces but forces awkward variable-N handling. (d) with labels is strictly better: the store's contract becomes unambiguous ("persist these labeled pairs"), the engine/builders look up slots by `SlotIdentity` without positional conventions, and variable-N markets fit naturally via the `u8` outcome-index field in `MultiOutcomeMarketSlot` variants. Slot-label uniqueness within a contract is an engine-enforced invariant and is compliance-tested at the store boundary. See [SlotIdentity](#slotidentity-and-covenantphase) and the [ContractStore Compliance Test Kit](#contractstore-compliance-test-kit). + +### Two Order Ingestion Methods, Not One + +**Chosen**: `ingest_persistent_order(params, creation_tx)` and `ingest_ephemeral_order(params, snapshot)` — two distinct engine methods that signal caller intent at the call site. + +**Rejected**: +- Single `ingest_order(params, snapshot, tracking: OrderTracking)` with a tracking-mode parameter. Less self-documenting at call sites (readers must trace the `tracking` argument to understand whether this is a maker-monitoring or taker-discovery call). +- Single `ingest_order(params, snapshot)` inferring tracking mode from snapshot type. Loses the `EphemeralFresh` case (creation tx discovered from Nostr, but no history desired) — that combination doesn't get expressed. +- Keeping tracking mode implicit and supplying only one method — taker and maker use cases diverge too much; a single method forces callers to remember which behaviors they get. + +**Why**: The two methods make caller intent explicit at the call site — `ingest_persistent_order(params, tx)` obviously means "I own this and want full history," `ingest_ephemeral_order(params, snapshot)` obviously means "I'm tracking this for routing or display." The tracking-mode field in `OrderState` is still needed because it governs downstream engine behavior (history writes, `prune_finalized` cleanup), but callers don't pass a mode argument — the method-name-as-intent signal is cleaner. See [OrderState](#orderstate), [OrderTracking](#orderstate), and the engine's [Ingestion](#contract-ingestion) section. + +### No Atomic Order Promotion Method + +**Chosen**: Changing an order's tracking mode requires `untrack_contract` followed by re-ingestion via the other method. No dedicated `promote_to_owned` / `demote_to_discovered` engine method. + +**Rejected**: A `promote_to_owned(contract_id, creation_tx)` engine method that updates tracking mode in place (cheap, no history backfill) or triggers forward-sync from creation (slow, full history rebuild). + +**Why**: YAGNI. The promotion use case (a taker who initially tracked ephemerally decides they want to audit a specific order's history) is genuinely rare. The demotion case (a maker who owns an order decides they don't want history anymore, for storage cleanup) is even rarer — and they can just untrack the order entirely if storage cleanup is the goal. Dedicated promotion/demotion methods would add API surface and engine-level complexity for a use case nobody has asked for. If concrete demand surfaces post-v1, adding such methods is a pure non-breaking API addition. Until then, the two-step `untrack_contract` → re-ingest path is documented as the explicit migration for callers who need it. + +### Post-Resolution Trading Not Gated + +**Chosen**: `deadcat-core` does not gate trades through pools or orders whose parent market has resolved or expired. `quote_trade`, `build_trade_pset`, and all pool/order admin operations remain callable regardless of parent market state. + +**Rejected**: +- Halting `quote_trade` / `build_trade_pset` with a `MarketNotTrading` error variant when the parent market is non-`Trading`. +- Adding a safety-mode flag that callers can toggle to opt in or out of the gate. + +**Why**: The covenants are market-state-agnostic — they accept swaps and fills indefinitely, not by oversight but by architectural necessity. Covenants can only introspect the current transaction, so the only way for the pool covenant to verify the parent market's state is to **co-spend the market covenant's UTXO as an input on every swap transaction**. That would roughly double every swap's on-chain footprint (adding the market's collateral input + Simplicity witness to the pool's ~1,000 vbytes) and impose the cost on every legitimate trade — not just ones near resolution. Paying a permanent per-trade tax to block the informed-drainer attack in the narrow post-resolution / pre-operator-close window isn't a trade worth making. See [lmsr-pool-design.md § Why the pool covenant can't feasibly gate post-resolution trading](../contracts/lmsr-pool/lmsr-pool-design.md#why-the-pool-covenant-cant-feasibly-gate-post-resolution-trading) for the full analysis. + +Given that the covenant can't feasibly enforce the gate, engine-layer gating would provide only false safety (sophisticated actors fork or bypass `deadcat-core`), while adding friction for legitimate edge cases (an informed trader dumping now-worthless tokens benefits from the covenant-valid trade even though it's bad for the pool operator). The engine's responsibility is covenant-validity and impossibility, not unfavorability. See the broader principle at [Design Principles § Engine gates covenant-invalidity and impossibility, not unfavorability](#engine-gates-covenant-invalidity-and-impossibility-not-unfavorability) and the operational consequences at [Pool and Order Lifecycle at Market Resolution](#pool-and-order-lifecycle-at-market-resolution). Pool operators protect themselves by closing pools after resolution (`build_close_pset`); UI-layer warnings handle the honest-user protection case. + +### CovenantInvariantViolation Retained as Defense-in-Depth + +**Chosen**: Keep the `CoreError::CovenantInvariantViolation { contract_id, kind }` variant even after a covenant-level formal proof lands, annotated in rustdoc as "unreachable post-proof in the covenant layer but retained as defense-in-depth for layers the proof doesn't cover (interpretation, chain-source, version-mismatch)." + +**Rejected**: Removing the variant once a covenant proof is published, on the grounds that the proof renders the violation unreachable. + +**Why**: A covenant-level proof eliminates the possibility that the covenant accepts a malformed transaction — but doesn't guarantee the interpretation layer correctly identifies well-formed outputs, that `RedeemNode::decode` has no bugs, or that the `ChainSource` backend isn't returning spoofed or truncated data. Failure modes at those layers still surface as "malformed covenant window" at the engine. Removing the variant post-covenant-proof would require an engine-level end-to-end proof (much larger lift) to be fully justified. Keeping the variant as defense-in-depth costs little (dead-code-post-proof) and catches real-world bugs outside the proof's scope. When/if an engine-level proof does land, the variant can be removed at that point with genuine code-simplicity gain. diff --git a/docs/architecture/deadcat-core-implementation-plan.md b/docs/architecture/deadcat-core-implementation-plan.md new file mode 100644 index 00000000..9b715948 --- /dev/null +++ b/docs/architecture/deadcat-core-implementation-plan.md @@ -0,0 +1,339 @@ +# deadcat-core Implementation Plan + +## Purpose + +This document specifies the order of work for implementing `deadcat-core`, `deadcat-codegen`, and the accompanying SimplicityHL contracts. It assumes the design work in [deadcat-core-design.md](deadcat-core-design.md) and its satellites is settled and captures the execution sequence needed to turn those specs into code. + +## Scope and non-goals + +### In scope + +- Fresh SimplicityHL covenants for the binary market, LMSR pool, maker order, and multi-outcome market contracts — written from scratch with all accumulated design decisions baked in. +- New `deadcat-codegen` workspace crate (MiniJinja-based multi-outcome `.simf` generator + LMSR bignum reference + regression fixtures). +- New `deadcat-core` workspace crate (runtime contract engine, PSET builders, trade routing, recovery flows). +- Drift-detection and regression test infrastructure in both crates. + +### Out of scope for this plan + +- **Modifying the existing `deadcat-sdk` crate**. It stays untouched during this work as a reference artifact. Whether `deadcat-sdk` eventually gets deprecated, replaced, or maintained in parallel is a separate migration decision that can be made once `deadcat-core` lands. +- **`deadcat-node`** (SQLite-backed `ContractStore` / `ContractHistory` implementations, Nostr discovery integration). Builds on top of `deadcat-core`; tracked in its own plan when the time comes. A minimal in-crate `ContractStore` implementation for integration testing is part of this plan; the production-quality node layer is not. +- **Frontend integration** (the existing Tauri app). Migrates to `deadcat-core` (or `deadcat-node`) once those are ready; not part of this plan. +- **v2 items explicitly deferred during the pre-implementation review**: cross-outcome arb API, multi-outcome N > 4, expanded pool denomination range, cross-language ports, atomic issuance + pool bootstrap, LP-tokenized pools, audit-reproducible CMR recipe tooling. + +## Framing: from-scratch, not refactor + +The existing `deadcat-sdk` contracts are **reference implementations, not baselines to modify**. The new contracts in `deadcat-core` are written fresh, incorporating every design decision captured in the pre-implementation review. The [legacy source alignment checklist](../contracts/contract-specification.md#legacy-source-alignment-checklist) in `contract-specification.md` is therefore better read as "features of the new contracts" than "changes to apply to old ones." + +This framing shifts the risk profile: instead of "did we correctly migrate an existing property?" the question becomes "did we correctly enforce every invariant from first principles?" The [covenant self-enforcement principle](../contracts/market-contract-principles.md#covenant-self-enforcement) and [System Invariants](deadcat-core-design.md#system-invariants) are the checklist. + +## Prerequisites + +Before Phase 1 starts: + +- **SimplicityHL toolchain** (compiler + CMR utility) available in the dev environment and invokable as a Rust library (the LMSR drift test and multi-outcome drift test both rely on this). +- **Deadcat Rust workspace** reorganized to accommodate the new crates. Concretely, the workspace root grows a `crates/deadcat-core/` and `crates/deadcat-codegen/` directory. Physical layout under `deadcat-core`: + ``` + crates/deadcat-core/ + contracts/ + prediction_market.simf # binary (hand-written) + lmsr_pool.simf # hand-written + maker_order.simf # hand-written + multi_outcome/ + multi_outcome_market_n3.simf # generated by deadcat-codegen, committed + multi_outcome_market_n4.simf # generated by deadcat-codegen, committed + src/ + artifacts/ # generated by `simplex build`, committed + prediction_market.rs + lmsr_pool.rs + maker_order.rs + multi_outcome_market_n3.rs + multi_outcome_market_n4.rs + ... + ``` + Two tiers of committed codegen: `deadcat-codegen` writes `.simf` sources to `contracts/multi_outcome/`, and `simplex build` writes typed Rust modules to `src/artifacts/`. Both are committed and covered by drift tests. `deadcat-core` reads `.simf` files via `include_bytes!` where needed, and imports the `src/artifacts/` modules directly for typed `Arguments` / `Witness` usage. + +## Phases + +Phase numbering reflects execution order. Within each phase, items are listed in recommended sequence; parallel work within a phase is possible where dependencies don't bind. + +### Phase 1 — SimplicityHL contracts + +**Goal**: four production-quality `.simf` contracts (binary market, LMSR pool, maker order, multi-outcome market template) that together uphold every covenant-level invariant documented in [market-contract-principles.md](../contracts/market-contract-principles.md) and [enforcement-layers.md](enforcement-layers.md). + +**Deliverables**: + +1. **Binary market** (`crates/deadcat-core/contracts/prediction_market.simf`). All spend paths per [contract-specification.md § Binary Market](../contracts/contract-specification.md). Design-in from day one: + - `base_payout` as the primary denomination param (no intermediate `collateral_per_token` / `collateral_per_pair` rename step) + - Oracle BIP-340 tagged hash attestation + - Covenant-enforced deterministic RT blinding (ABF from tagged hash, CBF pass-through, VBF derived) + - Dormant terminal paths (resolution + expiry from zero outstanding pairs) + - Sibling UTXO check on every Unresolved-phase co-spend, including partial cancellation + - Burn outputs use bare `OP_RETURN` (not P2WSH-to-zero) + - `ensure_no_issuance` on every non-issuance spend path +2. **LMSR pool** (`crates/deadcat-core/contracts/lmsr_pool.simf`). Per [lmsr-pool-design.md](../contracts/lmsr-pool/lmsr-pool-design.md) and [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md). Design-in: + - `ADMIN_PUBKEY` naming (no cosigner, no legacy COSIGNER name) + - Close script path + - Pool params as protocol constants (`TABLE_DEPTH`, `S_BIAS`, `S_MAX_INDEX`, `MIN_POOL_RESERVE`) + - Merkle proof verification for F-value lookup (the only on-chain crypto; off-chain runtime supplies the proofs) + - Covenant accepts any u64 for `max_loss_sats` / `half_payout_sats`; builder enforces the 1-2-5 × 16 denomination convention at creation time (bucket 2) +3. **Maker order** (`crates/deadcat-core/contracts/maker_order.simf`). Per [contract-specification.md § Maker Order](../contracts/contract-specification.md) and [transaction-composability-model.md § Maker Order: Hybrid Positional + Witness](transaction-composability-model.md#maker-order-hybrid-positional--witness-proposed-change). Design-in: + - Witness-parameterized remainder index (not `current_index() + 1`) + - Key-spend-only cancellation (no script-cancel path) + - No cosigner +4. **Multi-outcome market template** (SimplicityHL source of the template, authored in a form suitable for MiniJinja substitution — delivered in Phase 2 alongside the generator). Per [multi-outcome-market-contract.md](../contracts/multi-outcome/multi-outcome-market-contract.md). Design-in: + - Generic solvency-preservation spend path (one path for all Unresolved-phase transitions) + - `base_payout` as the primary param; `cp = base_payout × N` derived at codegen time + - Check 1 (`Δy_k − Δn_k` uniform across k) and Check 2 (`Δc = (S + ΣΔn_k) × cp`) + - Sibling UTXO check across all 2N+1 covenant inputs + - Deterministic RT blinding on all 2N RT continuation outputs + - Resolution, expiry, redemption paths per spec +5. **Committed `simplex build` artifacts**: after each `.simf` contract is completed, run `simplex build` and commit the generated `src/artifacts/*.rs` files (one per `.simf` plus the generated multi-outcome variants). These typed `Arguments` / `Witness` structs are the production-level interface for PSET builders (Phase 5) and engine interpretation (Phase 4) — see [Build artifacts and typed witnesses](#build-artifacts-and-typed-witnesses) for layout details and rationale. + +**Quality gate**: for each contract, a line-by-line audit against the [covenant self-enforcement](../contracts/market-contract-principles.md#covenant-self-enforcement) three-bucket classification. Every constraint is explicitly categorized as covenant-enforced (bucket 1), builder-enforced-recovery-critical (bucket 2), or builder-enforced-fund-critical (bucket 3). **No bucket 3 constraints are acceptable**; any that surface during audit must be promoted to covenant enforcement. Exit this phase only when the audit is clean. + +**Risk**: highest-risk phase. Several new invariants (generic solvency path, partial-cancel sibling atomicity, deterministic blinding with ABF exposure) are load-bearing and subtle. Worth an explicit review cycle before exiting the phase. Use the existing `deadcat-sdk` `.simf` contracts as reference material — many of the structural patterns carry over — but treat them as examples, not constraints. + +### Phase 2 — `deadcat-codegen` crate + +**Goal**: dev-only crate that produces canonical `.simf` outputs and LMSR fixture data. + +**Deliverables**: + +1. **Crate bootstrap** with deps `num-bigint`, `num-rational`, `minijinja`, and whatever SimplicityHL compiler library exposure is available. Plus `sha2` or similar for Merkle root hashing per the existing Merkle format in [lmsr-deterministic-table-spec.md § Merkle Tree Format](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md#merkle-tree-format). +2. **LMSR bignum reference implementation** — direct evaluation of `F(i) = max_loss_sats + floor(b × ln(cosh(s/b)))` at arbitrary precision. Unit-tested against the anchor identities (`F(S_BIAS) = max_loss_sats`, symmetry around `S_BIAS`, monotonicity, etc.). +3. **LMSR fixture generator** — emits the committed fixture file mapping all 256 `(max_loss_sats, half_payout_sats)` combos to their canonical Merkle root + anchor F-values + resolved `q_step_lots`. Invoked via `just regenerate-lmsr-fixtures`. +4. **LMSR drift test** — runs on every `cargo test`. Re-executes the bignum reference, asserts each of 256 committed roots reproduces byte-for-byte. Catches any unintended change in bignum behavior. +5. **Multi-outcome `.simf` template** (`templates/multi_outcome_market.simf.j2`) — MiniJinja-parameterized SimplicityHL source, substituting N and the N-dependent unrolled sections. +6. **Multi-outcome generator** — `fn generate_multi_outcome_simf(n: usize) -> String` + a CLI binary (`just generate-simf`) that writes outputs to `crates/deadcat-core/contracts/multi_outcome/`. Generates N=3 and N=4 files. +7. **Multi-outcome drift test** — for each supported N: regenerate in-memory, assert byte-match against the committed file, then invoke the SimplicityHL compiler on the generated source with a canonical test param set and assert compilation succeeds (catches both drift and semantically invalid template output). +8. **Directory consistency checks** — assert no extraneous files exist under `contracts/multi_outcome/` and no expected files are missing. +9. **smplx artifact drift test** — runs on every `cargo test`. Re-invokes `simplex build` against each `.simf` source and asserts the regenerated `src/artifacts/*.rs` files match the committed versions byte-for-byte. Catches stale artifacts after `.simf` source changes (analogous to the LMSR fixture drift test and the multi-outcome `.simf` drift test). Regeneration via `just regenerate-artifacts` when intentional changes are being committed. +10. **Precision calibration** — one-time dev artifact. Generates all 256 Merkle roots at progressively lower bignum precision (binary-search from 512 bits down), identifying the minimum precision at which the output diverges from high-precision ground truth. Result is a pinned empirical fact in [lmsr-deterministic-table-spec.md § Precision Calibration](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md#precision-calibration) — not a per-CI regression. CLI subcommand `just calibrate-precision` re-runs the calibration on demand (for validating alternative bignum methods or auditing the precision claim). + +**Quality gate**: `cargo test` passes at the workspace root with both `deadcat-codegen` drift tests running. `just generate-simf` and `just regenerate-lmsr-fixtures` both produce outputs that match their committed counterparts when run on a clean repo. + +**Risk**: moderate. The primary risks are correctness of the LMSR bignum reference (mitigated by algebraic identity tests against the closed-form expression) and correctness of the multi-outcome template (mitigated by the in-memory SimplicityHL compile check in the drift test). + +### Phase 3 — `deadcat-core` foundations + +**Goal**: compile-ready crate with core types, trait skeletons, and covenant-loading machinery. No PSET builders yet. + +**Deliverables**: + +1. **Crate bootstrap** with minimal runtime deps: `simplicity_lang` (or whatever the Rust binding crate is named), `num-bigint`, `num-rational`, `elements` / `bitcoin` primitives, `sha2`, etc. Explicitly **no** `minijinja`, no `num-bigint` dev-dep-only cutoff — bignum is a runtime dep per the deterministic LMSR runtime decision. +2. **Core type module** per [deadcat-core-design.md § Core Types](deadcat-core-design.md#core-types) — `ContractId`, `OutcomeIndex`, `Side`, `MarketResolution`, `MarketParams` / `MarketState` / `MarketTransition` umbrella enums, `BinaryMarketParams` / `MultiOutcomeMarketParams`, `LmsrPoolParams`, `MakerOrderParams`, etc. Pure data types with no behavior yet. +3. **Store traits** — `ContractStore`, `ContractHistory` per [deadcat-core-design.md § ContractEngine](deadcat-core-design.md#contractengine) and [trade-routing-algorithm.md](trade-routing-algorithm.md). Abstract definitions only, no implementations. +4. **Contract loading** — `include_bytes!` all committed `.simf` files (binary, pool, order, multi-outcome for each supported N). Parse templates once in a process-wide cache; instantiate and commit per contract on demand. Do not maintain an in-memory compiled-contract cache in v1 — `ContractEngine::new` stays O(1), ingestion persists derived scripts/assets to the store, and PSET builders recompile from stored params when needed. +5. **`ContractEngine` skeleton** — struct with `store: S`, `network: Network`, and empty impl blocks for the methods to come. +6. **Minimal in-memory `ContractStore` implementation** — sufficient for integration testing in subsequent phases. Not production-quality; that's `deadcat-node`'s job. +7. **LMSR F-value runtime** — the bignum F-value computation, with per-pool-combo in-memory cache. Optional disk cache can wait for a later pass. +8. **`ContractId` derivation** — `contract_cmr(params, network) -> Cmr` standalone function and the engine-integrated version. +9. **Compliance test kit crate** — a separate workspace crate (`deadcat-core-store-testkit`) that integrators depend on as a dev-dependency. Exposes two top-level entry points: `run_store_compliance(&mut impl ContractStore)` and `run_chain_source_compliance(&mut impl ChainSource)`. Bootstraps here in Phase 3 with the core outpoint round-trip and rollback invariants; grows through Phases 4-6 as new invariants surface from integration tests. See [deadcat-core-design.md § ContractStore Compliance Test Kit](deadcat-core-design.md#contractstore-compliance-test-kit) for the invariant categories the kit enforces. Pattern matches sqlx, diesel, iroh — integrators call one function in their own test suite and get automated conformance checking. + +**Quality gate**: crate compiles. Core types have tests for construction and basic invariants (e.g., `OutcomeIndex::BINARY == OutcomeIndex::new(0)`). LMSR F-value runtime produces values matching `deadcat-codegen`'s committed fixtures for at least the three canonical param combos (binding proof that runtime and reference agree). The minimal in-memory `ContractStore` implementation passes `run_store_compliance`. + +### Phase 4 — `ContractEngine` mechanics + +**Goal**: contracts can be ingested, transactions can be interpreted, state can be stepped forward. The read path of the engine works end-to-end; PSET builders still stubbed. + +**Deliverables**: + +1. **Ingestion methods** — `ingest_market`, `ingest_pool`, `ingest_persistent_order`, `ingest_ephemeral_order` per [deadcat-core-design.md § Contract Ingestion](deadcat-core-design.md#contract-ingestion). Creation-based ingestion verifies creation-tx authenticity by re-deriving the covenant script pubkey from params and matching against the on-chain output; `Current` snapshots enforce canonical supplied params and parent-market linkage without verifying history back to creation. +2. **Transaction interpretation** — `interpret_transaction` + `InterpretedTransaction` with `TransitionDetails` per-contract variants. Dispatches to per-contract interpretation logic. +3. **State-step mechanics** — `step` method, `StateUpdate` write-path type, `ProcessedTransaction` result type. +4. **Chain-sync scaffolding** — `ChainSource` trait per [chain-only-recovery.md § ChainSource Addition](../protocol/chain-only-recovery.md#chainsource-addition), including `issuance_transaction` lookup. Abstract; concrete Esplora/Electrs backends live outside this crate. +5. **View accessors** — `engine.market(id)`, `engine.pool(id)`, `engine.order(id)` returning the view types (empty-bodied so far; methods land in Phase 5). +6. **Multi-contract tx pattern detection** — `InterpretedTransaction::as_trade()` / `net_effect_for()` (without `as_cross_outcome_arb` — cross-outcome arb classification is deferred to v2). + +**Quality gate**: integration test scaffolding. Create a binary market, issue pairs, cancel pairs, resolve, redeem — all through the engine's read path (ingestion + interpretation + stepping) using an in-memory test chain. Cover the multi-outcome contract with the same shape. + +**Risk**: moderate. Ingestion verification (script-pubkey match) is the load-bearing authenticity check; get it wrong and the entire recovery story breaks. Chain-sync scaffolding is structurally straightforward but has many edge cases around reorgs, unconfirmed-vs-confirmed state, etc. + +### Phase 5 — View types and PSET builders + +**Goal**: the write path works. All builders return valid PSETs that the covenants accept. + +**Deliverables** — in increasing complexity (each builder depends on Phases 3–4's read-path mechanics, which are by now stable): + +1. **View type bodies** — `Market`, `Pool`, `Order`, `MultiOutcomeMarket` per [deadcat-core-design.md § View Types](deadcat-core-design.md). State accessors, relationship queries, and oracle helpers land here alongside their builders. +2. **Binary market creation + lifecycle builders** — `build_binary_market_creation_pset` on the engine; `build_issuance_pset`, `build_cancellation_pset` (partial and full — `pairs_to_burn: Option`), `build_oracle_resolve_pset`, `build_redemption_pset`, `build_expire_transition_pset` on `Market` view. +3. **Maker order builders** — `build_create_order_pset` on the engine; `build_cancel_pset` on `Order` view. **Taker fills go through the trade router** (`engine.quote_trade` + `engine.build_trade_pset`), not a per-order builder — verified as part of the Phase 6 trade-routing deliverable. See [deadcat-core-design.md § Order](deadcat-core-design.md#order) and [Taker Order Fills Via Trade Router](deadcat-core-design.md#taker-order-fills-via-trade-router). +4. **LMSR pool builders** — `build_lmsr_bootstrap_pset` on the engine (consumes the LMSR F-value runtime from Phase 3 to generate Merkle root for covenant params); `build_adjust_pset` / `build_close_pset` on `Pool` view. Pool swaps also flow through the trade router, not a per-pool builder. +5. **Multi-outcome market creation + cross-outcome builders** — `build_multi_outcome_market_creation_pset` on the engine; `build_issuance_pset` (accepts an `OutcomeIndex`), `build_cancellation_pset`, `build_oracle_resolve_pset`, `build_redemption_pset`, `build_expire_transition_pset` on `Market` view (shared with binary via the unified view); `build_split_yes_pset` / `build_merge_yes_pset` / `build_split_no_pset` / `build_merge_no_pset` on `MultiOutcomeMarket` specialization. + +**Quality gate**: for each builder, an integration test that (a) constructs the PSET, (b) signs and broadcasts to a regtest chain, (c) observes the confirmation via the engine's read path, (d) asserts the resulting state matches expectations. End-to-end round trip per contract-type primitive. + +**Risk**: moderate-to-high. Many builders, each with its own witness construction, input/output layout, and covenant-specific quirks. The pool builders are the heaviest due to Merkle proof generation and F-value lookup. The multi-outcome generic-path builders are structurally simpler (one covenant path for many operations) but require careful delta-shape construction. Budget for iteration. + +### Phase 6 — Routing, recovery, polish + +**Goal**: public API is ready for external consumers. + +**Deliverables**: + +1. **Trade routing** — `quote_trade` on the engine + `build_trade_pset` per [trade-routing-algorithm.md](trade-routing-algorithm.md). Composes pool swaps with maker order fills in a single atomic transaction. `build_trade_pset` returns `PreBlindedPset` so plain and market-assisted routes share one wallet signing flow. +2. **Recovery flows** — per [chain-only-recovery.md § Integration Contract](../protocol/chain-only-recovery.md#integration-contract). Owner-level recovery (markets / pools / orders created by the wallet) and non-owner-level recovery (token holder ingestion via `issuance_transaction` lookup). +3. **Error type polish** — `CoreError` variants with clear doc comments, particularly `ConventionViolation`, `ParentMarketNotTracked`, `OracleSignatureInvalid`, `InvalidContractState`, `NoLiquidity`, `InvalidCreationTx`, `StaleQuote`, `ContractAlreadyTracked { contract_id }`. Integration-contract preconditions captured in rustdoc on every public method that touches chain data or key derivation. +4. **Pool lifecycle at market resolution** — v1 does NOT gate post-resolution trading. The covenant is market-state-agnostic and `deadcat-core` follows: `quote_trade` / `build_trade_pset` remain available, admin operations (close, adjust) remain callable, order fills on resolved-parent markets remain routable. Pool operators are responsible for closing pools via `build_close_pset` when convenient. Document the behavior in `deadcat-core-design.md`'s "Pool and Order Lifecycle at Market Resolution" subsection, consistent with the broader design principle that the engine gates covenant-invalid and impossible operations — not merely unfavorable ones. +5. **Rustdoc pass** on the entire public API. Every public method has preconditions (per the integration contract), return-value semantics, and error-variant documentation. +6. **Integration test suite** covering realistic user flows: create market → create pool → seed with liquidity → place orders → execute trades → resolve → redeem. Multi-outcome variants of the same flows. + +**Quality gate**: public API freeze for v1. Semver commitments can start. External integrators (e.g., Aqua) can begin integration work against a stable surface. + +**Risk**: low-to-moderate. Routing correctness has been specified; the algorithm is bounded and tractable. Recovery flows have been specified at the integration-contract level; implementation is mechanical. The main risks here are discovering gaps that should have been caught earlier — treat this phase as a "stress test" of the preceding design work. + +## Phase dependencies + +- **Phase 2** depends on **Phase 1** (multi-outcome template needs the contract design locked in). +- **Phase 3** depends on **Phase 2** (needs committed `.simf` files to `include_bytes!` and committed LMSR fixtures for test oracle). +- **Phase 4** depends on **Phase 3**. +- **Phase 5** depends on **Phase 4** (builders produce txs that the engine's read path must then interpret correctly). +- **Phase 6** depends on **Phase 5** (routing and recovery exercise the full builder surface). + +Parallelization opportunities: +- LMSR bignum reference (Phase 2) can be written alongside Phase 1 contract work; they touch disjoint areas. +- Phase 3 core type work can be drafted in parallel with late-stage Phase 1, split by covenant dependence. The three buckets: + - **Independent of Phase 1 — safe to draft now**: small value types (`OutcomeIndex`, `Side`, `FeeRate`, `Network`, `ChainPosition`), `MarketResolution`, `WalletFunding`, `UnblindedUtxo`, `ExplicitValues`, pagination types (`Page`, `Pagination`, `StateFilter`), error types (`CoreError` skeleton, `ConventionError`, `BlindingError`), `ContractStore` trait method signatures, creation-params structs (`BinaryMarketCreationParams`, `MultiOutcomeMarketCreationParams`), `MarketId`, `OracleAttestationSpec`, and the `ContractId` struct shape. + - **Must wait for Phase 1 to finalize**: per-contract `Params` / `State` / `Transition` types — they must match `simplex build` typed artifacts byte-for-byte — plus `TransitionDetails` per-contract variants, `PreBlindedPset` / `PreparedPset` (private fields depend on RT blinding implementation), `SlotIdentity` enum (depends on covenant slot layout), and anything importing from `src/artifacts/`. + - **Soft dependency — sketch early, finalize late**: `Contract` umbrella enum and `ContractEntry`, `MarketParams` umbrella enum, `ContractEngine` skeleton (method signatures land; implementations defer), and the in-memory `ContractStore` implementation (structure sketchable, outpoint-tracking details defer). +- Phase 5 builders are mostly independent of each other; can be implemented in parallel once Phase 4 lands. + +## Pre-v1-ship action items + +Tracked here so they don't fall off the radar before public release: + +- **SLIP-0044 coin_type registration PR** — deadcat uses HD path `m/86'/1145258324'/...` (coin_type `0x44434154` = ASCII "DCAT"). Submit a PR to [satoshilabs/slips](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) registering this coin_type slot before public release. See [chain-only-recovery.md § HD Paths](../protocol/chain-only-recovery.md#hd-paths). If the SLIP-0044 maintainers assign a different number, document the migration in the decisions log — since we're pre-implementation, no on-chain state is affected. + +## Deferred / out-of-scope items + +Carried forward to v2 or later (documented here so nothing falls through the cracks): + +- **Cross-outcome arb API** (`quote_cross_outcome_arb`, `build_cross_outcome_arb_pset`, `as_cross_outcome_arb`, `ArbQuote` / `ArbDirection` / `ArbPoolLeg` / `CrossOutcomeArb`) — see [deadcat-core-design.md § Future: Cross-Outcome Arb API (v2)](deadcat-core-design.md#future-cross-outcome-arb-api-v2). Externally-broadcast arb txs ingest cleanly in v1 as raw per-contract transitions. +- **Multi-outcome N > 4** — the generator supports any N but only N=3 and N=4 are committed for v1. Extension is non-breaking. +- **Expanded pool denomination range** — v1 caps at 10^7 sats per param via the 1-2-5 × 16 table. Expanding is non-breaking (new combos → new fixtures → new Merkle roots; existing pools unaffected). +- **Fixed-point Taylor LMSR runtime** — v1 ships bignum-only. Switching to hybrid (bignum compile-time, Taylor runtime) is a non-breaking performance optimization; committed Merkle roots become the acceptance criterion. +- **Cross-language ports** (JS, Swift) — the canonical Merkle roots in `deadcat-codegen` fixtures serve as the cross-implementation conformance set. Ports implementable in any language that can reproduce the roots byte-for-byte. +- **Atomic issuance + pool bootstrap** — see [future-atomic-issuance-lmsr.md](../contracts/lmsr-pool/future-atomic-issuance-lmsr.md). +- **LP-tokenized pools** — admin-operated pools only in v1. +- **Audit-reproducible CMR recipe** — tooling polish for security auditors. Not blocking; can ship alongside an audit pass. +- **Pool-lifecycle-at-market-resolution subsection** of the multi-outcome contract spec — noted as a v2 follow-up item during the market-resolution and cross-outcome-arb discussions. + +## Known implementation risks + +Enumerated so they don't surprise anyone mid-phase: + +1. **SimplicityHL covenant correctness in Phase 1** — several new invariants (generic solvency path, partial-cancel sibling atomicity, deterministic blinding with ABF exposure) are load-bearing and subtle. A flawed covenant here compounds through every subsequent phase. Mitigation: explicit audit cycle with the [covenant self-enforcement classification](../contracts/market-contract-principles.md#covenant-self-enforcement) as the rubric; no phase-2 work until Phase 1 audit is clean. +2. **LMSR bignum correctness** — the reference implementation is the source of truth for all future LMSR work. A bug here silently propagates into committed fixtures and gets baked in. Mitigation: verify against algebraic identities (`F(S_BIAS) = max_loss_sats`, symmetry, monotonicity), cross-check a small set of anchor values against an independent high-precision tool (e.g., Python `mpmath`) during development. +3. **Multi-outcome template correctness** — a template bug propagates to every generated N. Mitigation: the drift test's in-memory SimplicityHL compile check catches syntactic issues; cover each generated N with integration tests that exercise the full spend-path set. +4. **Ingestion script-pubkey verification** — the authenticity check that every other engine guarantee depends on. If this is loose (e.g., accepts spoofed params with a coincidentally-matching txid), recovery breaks. Mitigation: exhaustive positive and negative tests in Phase 4. +5. **PSET builder correctness across cross-contract composition** — atomic trades (pool + maker orders) and (in v2) arb (market + pools) layer constraints from multiple covenants into one tx. Any builder that produces txs that one covenant accepts but another rejects is a bug that only surfaces at broadcast time. Mitigation: integration tests specifically targeting multi-contract atomic transactions in Phase 5. +6. **smplx API churn (pre-1.0)** — adopting typed artifacts from Phase 1 commits us to `simplex build` output compatibility. A breaking change in smplx between our pin point and v1 release could force re-generation and test updates. Mitigation: direct collaboration with the Blockstream team building smplx — they've committed to fixing issues we surface. The Nix pin on v0.0.3 provides a reproducible baseline; upgrades are explicit and auditable via `flake.lock` diff. Worst case (collaboration breakdown), the committed `src/artifacts/*.rs` files are plain Rust source that can be frozen and manually maintained. + +## Simplicity testing with smplx + +[smplx](https://github.com/BlockstreamResearch/smplx) (Simplex) is Blockstream's development framework for Simplicity contracts. It provides a CLI (`simplex`), a test harness (`#[simplex::test]`), and a build system (`simplex build`) that compiles `.simf` source into Rust artifacts with typed argument and witness structs. It bundles `elementsd` and `electrs` binaries in its release tarball, handling regtest provisioning automatically. + +### What smplx provides + +| Capability | Replaces | Relevant phase | +|---|---|---| +| `simplex build` — compiles `.simf` → typed Rust `Arguments` / `Witness` structs | Manual `TemplateProgram::new(SOURCE)` + untyped witness construction | Phase 1, 5 | +| `#[simplex::test]` — auto-provisions funded regtest wallet + `TestContext` | Hand-rolled `Fixture` scaffolding in integration tests | Phase 5, 6 | +| `simplex regtest` — persistent local Elements + Electrs for iterative dev | Manual regtest lifecycle management | Phase 1 (exploratory), Phase 5 | +| `Program::execute()` with `DefaultTracker` — local Simplicity execution with trace output | Direct `BitMachine::exec` calls | Phase 1 (validation during authoring) | + +### What smplx does not replace + +- **Tier 1 BitMachine unit tests** — fast, mock-env tests that validate contract logic without a chain. smplx's test harness always provisions a regtest; there is no "unit test only" mode. Tier 1 tests continue to use `simplicity_lang::BitMachine` directly. +- **Tier 1.5 C evaluator tests** — Rust-vs-C serialization roundtrip checks via `c_eval.rs`. smplx does not expose the C evaluator pipeline. These remain as-is. +- **LMSR math** — pure Rust computation with no Simplicity involvement. Tested with standard `#[test]`. +- **Codegen drift tests** — `deadcat-codegen` fixture regeneration and byte-match assertions. Standard `cargo test`. + +In short: smplx targets Tier 2 (regtest integration) and contract development workflows. Tiers 1 and 1.5 are unaffected. + +### Nix flake integration + +smplx releases a single tarball per platform containing three pre-built binaries (`simplex`, `elementsd`, `electrs`). This maps directly to a Nix derivation that fetches the tarball and puts all three on `PATH`: + +```nix +simplex = pkgs.stdenv.mkDerivation { + pname = "simplex"; + version = "0.0.3"; + src = pkgs.fetchurl { + url = "https://github.com/BlockstreamResearch/smplx/releases/download/v0.0.3/simplex-v0.0.3-${ + if pkgs.stdenv.isDarwin then "darwin-arm64" + else "linux-x86_64" + }.tar.gz"; + sha256 = if pkgs.stdenv.isDarwin + then "sha256-Mcoo93RgR/SB5GkMqZi8ZbmbsieyM0XLMTKe6GNEl6c=" + else "sha256-4CxpeOSdRn/eJ345CgbjaE3YS1J95OmTC2J8+IcbaEQ="; + }; + sourceRoot = "."; + dontConfigure = true; + dontBuild = true; + installPhase = '' + mkdir -p $out/bin + cp simplex elementsd electrs $out/bin/ + chmod +x $out/bin/* + ''; +}; +``` + +Add `simplex` to the `devShells.default.packages` list. All three binaries become available inside `nix develop` — no `simplexup` installer needed, no `~/.simplex/` global state. The Nix shell provides the Rust toolchain (`cargo`, `rustc`); smplx's `simplex test` invokes `cargo test` under the hood and finds it on `PATH`. + +This replaces the current approach of vendoring platform-specific `elementsd-$TRIPLE` / `electrs-$TRIPLE` binaries under `tests/` and pointing at them via `ELEMENTSD_EXEC` / `ELECTRS_LIQUID_EXEC` env vars. + +### Justfile integration + +```just +# Regtest integration tests via smplx (Tier 2) +simplex-test: + cd crates/deadcat-core && simplex test + +# Regtest integration tests for a specific test +simplex-test-one name: + cd crates/deadcat-core && simplex test --tests "{{name}}" --nocapture + +# Unit tests (Tier 1 / 1.5 + pure Rust) — standard cargo, no smplx +unit-test: + cargo test --workspace --exclude deadcat-core-integration + +# Regenerate simplex build artifacts (src/artifacts/) after .simf source changes +regenerate-artifacts: + cd crates/deadcat-core && simplex build +``` + +### Project configuration + +A minimal `Simplex.toml` at the `crates/deadcat-core/` root: + +```toml +[build] +src_dir = "./contracts" +simf_files = ["*.simf"] +out_dir = "./src/artifacts" + +[regtest] +bitcoins = 10_000_000 +``` + +The `[build]` section tells `simplex build` where to find the `.simf` contracts and where to emit generated Rust artifact modules. The `[regtest]` section configures the auto-provisioned regtest for `simplex test`. + +### Build artifacts and typed witnesses + +`simplex build` parses each `.simf` file and generates a Rust module in `src/artifacts/` with: +- A `Program` struct wrapping the compiled Simplicity program +- A typed `Arguments` struct for compile-time parameters (maps to `param::` declarations in `.simf`) +- A typed `Witness` struct for runtime witness values (maps to `witness::` declarations in `.simf`) + +This gives compile-time type checking on covenant parameters and witness fields — a misnamed or mistyped field becomes a Rust compiler error rather than a runtime Simplicity assertion failure. **`deadcat-core` adopts the generated types as the production interface from Phase 1 onward**: PSET builders, engine interpretation, and test code all use the typed `Arguments` / `Witness` structs. Rationale: the refactor cost from manual API to typed artifacts in a later phase is avoided; typed safety compounds across the engine/builder boundary (both sides share the same generated schema). + +### Version pinning + +The Nix derivation pins a specific smplx release (currently v0.0.3). When upgrading, update the version, URLs, and hashes in the flake. The `elementsd` and `electrs` versions are coupled to the smplx release — they ship together in the tarball, so version compatibility is guaranteed by construction. + +smplx is pre-1.0 (v0.0.3 as of 2026-04-02). API churn is expected. The Nix pin provides a reproducible baseline; upgrades are explicit and auditable via flake.lock diff. + +## Status + +Pre-implementation. Design complete; Phase 1 can start. diff --git a/docs/architecture/deadcat-core-normative-map.md b/docs/architecture/deadcat-core-normative-map.md new file mode 100644 index 00000000..498acf5d --- /dev/null +++ b/docs/architecture/deadcat-core-normative-map.md @@ -0,0 +1,151 @@ +# deadcat-core Normative Map + +Status: Normative guide to the `deadcat-core` design documents. + +This document is a map of the `deadcat-core` design docs. The design docs are +the map of the implementation. + +This document does not specify `deadcat-core` directly. It specifies which +design documents are authoritative for each part of `deadcat-core`, and how to +resolve scope, status, and priority between them. If this document conflicts +with a referenced spec, fix the underlying specs rather than treating this file +as a parallel implementation spec. + +## Source Priority + +When documents disagree, use this order: + +1. [`deadcat-core-design.md`](deadcat-core-design.md) + is authoritative for public Rust API shape, engine behavior, store traits, + contract state enums, transition interpretation, PSET builder placement, + ingestion, chain sync, error semantics, implementation scope, and design + decisions for `deadcat-core`. + +2. [`market-contract-principles.md`](../contracts/market-contract-principles.md) + is authoritative for covenant-level security principles shared by market + contracts: solvency, RT burn requirements, sibling UTXO checks, oracle power, + deterministic RT blinding, permissionlessness, and terminal-path + completeness. + +3. [`contract-specification.md`](../contracts/contract-specification.md) + is authoritative for per-contract covenant behavior where it agrees with + `deadcat-core-design.md` and `market-contract-principles.md`. + +4. Focused protocol specs are authoritative for their named surfaces: + - [`chain-only-recovery.md`](../protocol/chain-only-recovery.md) + - [`deterministic-rt-blinding.md`](../protocol/deterministic-rt-blinding.md) + - [`oracle-bip340-tagged-hash.md`](../protocol/oracle-bip340-tagged-hash.md) + +5. Focused architecture specs are authoritative for their named mechanisms, + except where explicitly superseded by `deadcat-core-design.md`: + - [`transaction-composability-model.md`](transaction-composability-model.md) + - [`trade-routing-algorithm.md`](trade-routing-algorithm.md) + - [`enforcement-layers.md`](enforcement-layers.md) + +6. Contract-specific focused specs are authoritative for their named contract + or math surface, subject to the higher-priority docs above: + - [`lmsr-pool-design.md`](../contracts/lmsr-pool/lmsr-pool-design.md) + - [`lmsr-deterministic-table-spec.md`](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md) + - [`lmsr-pool-close-path.md`](../contracts/lmsr-pool/lmsr-pool-close-path.md) + - [`multi-outcome-market-contract.md`](../contracts/multi-outcome/multi-outcome-market-contract.md) + - [`market-dormant-terminal-paths.md`](../contracts/prediction-market/market-dormant-terminal-paths.md) + +7. Historical, decision-record, refactor, and future-design docs explain why + choices were made or preserve rejected paths. They are not implementation + specs unless a normative doc links to a specific section and says it is + authoritative for the current implementation. + +## Topic Ownership + +| Topic | Authoritative source | +| --- | --- | +| Public Rust API | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Core type shapes | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Engine methods and view types | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Store trait and atomicity | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Ingestion and tracking policy | [`deadcat-core-design.md`](deadcat-core-design.md), then [`chain-only-recovery.md`](../protocol/chain-only-recovery.md) | +| Chain sync and rollback | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Output classification | [`deadcat-core-design.md`](deadcat-core-design.md) | +| Market state machines | [`deadcat-core-design.md`](deadcat-core-design.md), then [`contract-specification.md`](../contracts/contract-specification.md) | +| Market covenant principles | [`market-contract-principles.md`](../contracts/market-contract-principles.md) | +| Binary market covenant behavior | [`contract-specification.md`](../contracts/contract-specification.md), subject to [`market-contract-principles.md`](../contracts/market-contract-principles.md) | +| Multi-outcome market covenant behavior | [`multi-outcome-market-contract.md`](../contracts/multi-outcome/multi-outcome-market-contract.md), subject to [`deadcat-core-design.md`](deadcat-core-design.md) and [`market-contract-principles.md`](../contracts/market-contract-principles.md) | +| LMSR pool parameters and lifecycle | [`lmsr-pool-design.md`](../contracts/lmsr-pool/lmsr-pool-design.md) | +| LMSR deterministic table generation | [`lmsr-deterministic-table-spec.md`](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md) | +| LMSR pool close path | [`lmsr-pool-close-path.md`](../contracts/lmsr-pool/lmsr-pool-close-path.md) | +| Maker order behavior | [`deadcat-core-design.md`](deadcat-core-design.md), then [`contract-specification.md`](../contracts/contract-specification.md) | +| Trade routing | [`trade-routing-algorithm.md`](trade-routing-algorithm.md), with public API from [`deadcat-core-design.md`](deadcat-core-design.md) | +| Multi-covenant transaction layout | [`transaction-composability-model.md`](transaction-composability-model.md) | +| OP_RETURN recovery | [`chain-only-recovery.md`](../protocol/chain-only-recovery.md) | +| Oracle attestation message | [`oracle-bip340-tagged-hash.md`](../protocol/oracle-bip340-tagged-hash.md) | +| RT blinding | [`deterministic-rt-blinding.md`](../protocol/deterministic-rt-blinding.md) | +| Burn script and enforcement layering | [`market-contract-principles.md`](../contracts/market-contract-principles.md), then [`enforcement-layers.md`](enforcement-layers.md) | +| Implementation phases | [`deadcat-core-implementation-plan.md`](deadcat-core-implementation-plan.md) | + +## V1 Scope + +V1 includes: + +- Binary markets. +- Multi-outcome markets for N in `{3, 4}`. +- Market issuance, cancellation, oracle resolution, expiry, and redemption. +- Multi-outcome split/merge primitives. +- LMSR pools. +- Maker orders. +- Trade routing across pools and maker orders. +- Existing-pool market-assisted routes via `build_trade_pset -> PreBlindedPset`. +- Chain-only recovery using canonical OP_RETURN hints. +- Strict-canonical tracking policy. +- Store compliance requirements sufficient for atomic multi-contract state + updates. + +V1 excludes: + +- Cross-outcome arb quote/build API. +- Cross-outcome arb aggregate transaction classification. +- LP-tokenized pools. +- Atomic market creation plus pool bootstrap. +- Exact-output trade routing. +- N greater than 4 unless explicitly added. +- Fixed-point Taylor LMSR runtime optimization. + +## Conflict Handling + +If an implementation agent finds a contradiction: + +1. Do not infer a new protocol rule. +2. Prefer the highest-priority source listed above. +3. If the conflict is only in examples or historical rationale, follow the + normative source and leave a note for docs cleanup. +4. Ask for human review if the conflict affects: + - fund safety, + - RT issuance or destruction, + - deterministic blinding, + - oracle authorization, + - chain-only recovery, + - PSET layout, + - store atomicity, + - public API shape. + +## Known Cleanup Items + +Resolve these before treating the docs as fully implementation-ready: + +- Multi-outcome public params Rust representation for runtime `outcome_count`. +- Exact LMSR admin/close signature preimages and domain strings. +- Whether `half_payout_sats` is intentionally independent from the parent + market denomination, or should be constrained by it. + +## Document Status Labels + +Related markdown files should start with one of: + +- `Status: Normative` +- `Status: Normative for ` +- `Status: Decision record, non-normative` +- `Status: Historical, non-normative` +- `Status: Future / v2, non-normative for v1` + +Implementation agents should not implement from non-normative docs unless a +normative doc links to a specific section and says it is authoritative for the +current implementation. diff --git a/docs/architecture/enforcement-layers.md b/docs/architecture/enforcement-layers.md index 70118b5d..730a3f02 100644 --- a/docs/architecture/enforcement-layers.md +++ b/docs/architecture/enforcement-layers.md @@ -59,7 +59,7 @@ The builder/application layer. Conventions enforced by `deadcat-core` during PSE **Mechanisms:** - OP_RETURN recovery hints (chain-only recovery) -- Standard denomination conventions (1-2-5 table, 26-value mantissa) +- Standard denomination conventions (shared 16-value 1-2-5 table for market `base_payout` and pool `max_loss_sats` / `half_payout_sats`) - Deterministic key and nonce derivation - Output layout conventions for multi-covenant transactions - Convention validation in derive functions and PSET builders @@ -123,11 +123,11 @@ Each row maps a security property to the layer(s) that enforce it and notes any | Property | Primary enforcement | Cross-layer dependency | What goes wrong without it | |---|---|---|---| -| **Token supply = collateral / collateral_per_pair** | L2: covenant checks collateral on issuance | L2 must also enforce RT burns (see below) and `ensure_no_issuance` on non-issuance paths, because L1 reissuance/issuance mechanisms bypass L2 | Unbacked tokens dilute legitimate holders | +| **Token supply = collateral / cp** (where `cp = base_payout × N`) | L2: covenant checks collateral on issuance | L2 must also enforce RT burns (see below) and `ensure_no_issuance` on non-issuance paths, because L1 reissuance/issuance mechanisms bypass L2 | Unbacked tokens dilute legitimate holders | | **RT destruction on terminal transitions** | L2: `ensure_blinded_reissuance_burn_output` verifies burn script + commitment | Required because L1 reissuance uses RT + ABF (public with deterministic blinding) — if RTs escape to wallet addresses, L1 reissuance bypasses L2 entirely | Attacker mints unbacked tokens via Elements reissuance | | **No parasitic issuance** | L2: `ensure_no_issuance` on every covenant input for non-issuance paths | L1 allows issuance fields on any input — without L2 checks, a builder could attach issuance to a resolution/swap/fill spend | Attacker mints tokens alongside a legitimate covenant transition | | **Oracle-only resolution** | L2: BIP-340 signature verification against `ORACLE_PUBLIC_KEY` | L1 provides the Schnorr verification primitive (secp256k1 jet) | Anyone can resolve markets, stealing from token holders | -| **Collateral conservation** | L2: covenant checks `collateral = pairs × collateral_per_pair` | None — purely L2 | Issue tokens without backing | +| **Collateral conservation** | L2: covenant checks `collateral = pairs × cp` (where `cp = base_payout × N`) | None — purely L2 | Issue tokens without backing | | **Correct redemption rates** | L2: covenant enforces half-value (expired) or full-value (resolved) | None — purely L2 | Expired-market holders redeem at full value | | **Deterministic RT blinding** | L2: covenant verifies commitments match deterministic ABF + CBF pass-through | L1 Pedersen balance constrains transaction structure (need confidential outputs for confidential inputs). L2 enforcement is the griefing defense — without it, L4 convention alone is insufficient | Malicious issuer locks the market for all participants | | **Swap pricing integrity** | L2: Merkle proofs for F(old_s) and F(new_s), conservation equation | None — purely L2 | Extract more tokens than the LMSR curve allows | @@ -225,9 +225,9 @@ When adding a new security property or modifying an existing one: ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — `ensure_blinded_reissuance_burn_output`, `ensure_no_issuance`, `verify_token_commitment` -- `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — pool covenant (swap, admin, close paths) -- `src-tauri/crates/deadcat-sdk/contract/maker_order.simf` — order covenant (fill path only) +- `crates/deadcat-core/contracts/prediction_market.simf` — `ensure_blinded_reissuance_burn_output`, `ensure_no_issuance`, `verify_token_commitment` +- `crates/deadcat-core/contracts/lmsr_pool.simf` — pool covenant (swap, admin, close paths) +- `crates/deadcat-core/contracts/maker_order.simf` — order covenant (fill path only) - `docs/protocol/deterministic-rt-blinding.md` — RT blinding scheme and covenant enforcement - `docs/contracts/contract-specification.md` — spend paths and covenant constraints - `docs/architecture/deadcat-core-design.md` — security model section, covenant-enforced properties table diff --git a/docs/architecture/trade-routing-algorithm.md b/docs/architecture/trade-routing-algorithm.md index 65f8fead..a864dbf9 100644 --- a/docs/architecture/trade-routing-algorithm.md +++ b/docs/architecture/trade-routing-algorithm.md @@ -2,20 +2,42 @@ ## Overview -The `quote_trade` engine method computes the optimal route for a trade across all available LMSR pools and limit orders for a market. The algorithm minimizes total cost to the taker, including transaction fees — which depend on how many liquidity sources are included in the route. +The `quote_trade` engine method computes the optimal route for a trade across all available LMSR pools and limit orders for a **specific `(market, outcome, side)` combination**. The algorithm minimizes total cost to the taker, including transaction fees — which depend on how many liquidity sources are included in the route. -The external interface is simple: `TradeSpec` in, `TradeQuote` out. This document specifies the internal routing algorithm. +The external interface is simple: `TradeSpec { outcome, side, direction, amount }` in, `TradeQuote` out. This document specifies the internal routing algorithm. + +**Scoping**: routing operates on a single outcome's YES/NO pair at a time. For binary markets, there is only one outcome (`OutcomeIndex::BINARY`), so the `outcome` axis is trivial. For multi-outcome markets composed via Option C (N independent binary LMSR pools per market, one per outcome's YES/NO pair — see [`amm-scoring-rule-tradeoffs.md`](../contracts/multi-outcome/amm-scoring-rule-tradeoffs.md)), each trade targets one outcome's liquidity: the pools and maker orders for that outcome's YES_k / NO_k assets. The algorithm is identical per-outcome — this document is "the algorithm for one outcome's liquidity" with no special multi-outcome semantics at the routing layer itself. + +**Multi-outcome operations that don't route through this algorithm**: basket trades (minting or burning complete YES/NO sets) are direct primitives on `MultiOutcomeMarket`. See [`deadcat-core-design.md § MultiOutcomeMarket`](deadcat-core-design.md#multioutcomemarket) for details. Cross-outcome arbitrage (closing `Σ p_YES_k ≠ 1` gaps by atomically composing the market's split-YES/merge-YES primitive with per-outcome pool swaps) will get its own quote/build flow on `MultiOutcomeMarket` in v2; v1 treats externally-broadcast arb txs as ordinary multi-contract transactions (per-contract transitions preserved, no aggregate arb classification). See [`deadcat-core-design.md § Future: Cross-Outcome Arb API (v2)`](deadcat-core-design.md#future-cross-outcome-arb-api-v2). Neither case is routed through `quote_trade`, which handles only single-outcome trades. ## Algorithm Structure -The router uses **pool-subset enumeration × fee-aware greedy order selection**: +The router uses **pool-subset enumeration × fee-aware greedy order selection**, scoped to the trade's target outcome: -1. **Pre-select candidate pools**: Rank all active pools by estimated average fill price for the requested amount (one LMSR computation per pool). Take the top N (N = 5). -2. **Enumerate pool subsets**: For each subset of the N candidate pools (including the empty set — no pools), run the fee-aware greedy order selection. +1. **Pre-select candidate pools**: Rank all active pools for the target outcome by the best currently fillable variant they can offer (plain or assisted), using both the pool's current `s_index` and its live reserves. Take the top N (N = 5). +2. **Enumerate pool subsets**: For each subset of the N candidate pools (including the empty set — no pools), run the fee-aware greedy order selection. Within a subset, each pool can still be evaluated as a plain or assisted variant. 3. **Pick the best result**: The subset that produces the lowest total cost (fill cost + transaction fee) wins. Pool subset enumeration ensures the pool inclusion decision is optimal — no greedy heuristic for whether to activate a pool. Order selection within each subset is greedy, which is near-optimal for fixed-price sources with independent per-source costs. +### Assisted Pool Variants + +For an **existing** pool, the router may evaluate up to two variants of the same pool leg: + +- **Plain**: ordinary pool swap, no parent-market co-spend. +- **Assisted**: co-spend the parent market on the same outcome to add or remove equal YES/NO pairs while executing the pool trade. + +The assisted variant rules are fixed in v1: + +- Buys may use `IssuePairs`. +- Sells may use `CancelPairs`. +- The parent market must still support the required issuance/cancellation path. +- At most one assisted pool leg may appear in a route. +- Degenerate fixed-`s_index` pair-only public rebalances are covenant-valid but are not emitted by `quote_trade`. +- If a plain and assisted route tie on taker outcome, the plain route wins. + +The assisted variant's fill cost is the taker's **net** cost/output for the combined market+pool action, not just the pool-side collateral movement. + ## Fee-Aware Greedy Order Selection For a given pool subset, the greedy fills the requested amount by iterating over candidate sources (included pools + top-K price-sorted orders) and selecting the source with the best **fee-adjusted effective price** at each step: @@ -31,6 +53,7 @@ Where: The marginal weight captures the fixed cost of activating each source: - **Pool (first use)**: ~1000 vbytes (3 reserve inputs + 3 reserve outputs + Simplicity witnesses) +- **Assisted pool leg**: pool weight plus the parent market window weight for the same outcome (binary markets add one 3-slot market window; multi-outcome markets add one 2N+1-slot market window) - **Pool (already in route)**: 0 vbytes (inputs/outputs already accounted for — additional volume is free) - **Limit order**: ~400 vbytes (1 order input + 1-2 outputs + witness) @@ -47,15 +70,15 @@ A partial fill (filled_amount < requested_amount) is returned to the caller via ## Pool Pre-Selection -With P active pools for a market, full subset enumeration costs 2^P. To bound this: +With P active pools for the trade's target outcome, full subset enumeration costs 2^P. To bound this: -1. For each pool, compute the **estimated average fill price** for the full requested amount using point evaluation of the LMSR cost function. This is one computation per pool — O(1), ~1-16μs. +1. For each pool serving the target outcome, compute the pool's best **currently fillable variant** (plain and, if eligible, assisted) and the associated **fillable amount** and **estimated average fill price** for `min(requested_amount, pool_fillable_now)`. Pools that cannot fill any positive amount under either variant are discarded up front. This is O(1) per pool assuming the combo's table is cached (see [Integer Precision and Caching](#integer-precision-and-caching) for the bignum caching strategy; first-use per combo incurs a ~5-10s table generation cost). 2. Rank pools by this estimate (lower = better). 3. Take the top N pools (N = 5 constant). Discard the rest. -Ranking by average fill price (not spot price) ensures deep pools with slightly worse spot prices are preferred over shallow pools with great spot prices for large trades. A shallow pool at 50.00 that slips to 55.00 over 1000 tokens ranks below a deep pool at 50.50 that barely moves. +Ranking by average fill price (not spot price) ensures deep pools with slightly worse spot prices are preferred over shallow pools with great spot prices for large trades. A shallow pool at 50.00 that slips to 55.00 over 1000 tokens ranks below a deep pool at 50.50 that barely moves. Current reserves matter just as much as the curve here: two pools with identical `(max_loss_sats, half_payout_sats, fee_bps, s_index)` can have different current fillability because admin adjustments or custom bootstrap reserves changed their inventories. The router follows the actual reserves, not a hard-coded "useful band" cap. -**Pool flooding defense**: An attacker creating 100 pools to slow routing only causes 100 LMSR lookups in the pre-selection step (microseconds). The top-5 filter bounds the enumeration at 2^5 = 32 regardless of total pool count. +**Pool flooding defense**: An attacker creating 100 pools for a single outcome to slow routing only causes 100 LMSR lookups in the pre-selection step (microseconds). The top-5 filter bounds the enumeration at 2^5 = 32 regardless of total pool count. For multi-outcome markets, the attacker can't flood "all outcomes at once" to amplify the attack — routing scopes to one outcome's pool set, so flooding other outcomes' pool sets has no effect on this trade's routing cost. ## Order Constraints @@ -64,7 +87,7 @@ Ranking by average fill price (not spot price) ensures deep pools with slightly Each maker order has `min_fill_lots` (minimum per fill) and `min_remainder_lots` (minimum remaining after a partial fill). The greedy computes the **actual fillable amount** before evaluating an order's fee-adjusted price: ``` -available = order.remaining_locked +available = order.offered_amount - order.total_filled desired = min(remaining_request, available) if desired < order.min_fill_lots: @@ -100,6 +123,19 @@ min_remaining = ORDER_MARGINAL_WEIGHT × fee_rate × DUST_MULTIPLIER Where `DUST_MULTIPLIER` is a constant (e.g., 2-3×) ensuring the order's depth meaningfully exceeds its activation cost. This filters dust orders at the store level, preventing an attacker from filling the top-K query slots with tiny orders that crowd out legitimate liquidity. +## Quote Staleness + +A `TradeQuote` captures snapshots of every contract the route touches at quote time: pool legs, order legs, and the parent market window for any assisted pool leg. `build_trade_pset` verifies these snapshots are still current against the store's tracked outpoints; if any have changed, it returns `CoreError::StaleQuote` and the caller re-quotes. Causes: + +- **Pool swap**: another trade advanced the pool's s_index and produced new reserve outpoints. +- **Pool admin adjust**: the operator changed reserves without changing s_index — the pricing curve is unchanged, but new outpoints are still produced, so the quote goes stale even though the LMSR math would be identical. This is a subtle case: a taker with a stale quote might expect it to still be valid because "nothing about the price changed," but the outpoint-level snapshot is what the freshness check uses. +- **Assisted parent-market transition**: if the quote uses `IssuePairs` or `CancelPairs`, the quote also snapshots the co-spent market window. Any issuance/cancellation/resolution/expiry that changes those outpoints makes the quote stale. +- **Order fill**: another taker filled the order leg, either partially (new active outpoint) or fully (outpoint consumed). +- **Order cancel**: the maker cancelled, outpoint consumed. +- **Parent market resolution on a plain quote**: this doesn't directly invalidate the quote — `deadcat-core` doesn't gate post-resolution trading. The pool/order outpoints themselves only change if the pool/order has also transitioned (e.g., an admin close following resolution). Assisted variants simply disappear on re-quote once the market no longer supports issuance/cancellation. Note that the non-gating is an architectural consequence (covenant-level enforcement would require co-spending the market on every pool swap, doubling every swap's weight), not a policy oversight. See [`deadcat-core-design.md § Pool and Order Lifecycle at Market Resolution`](deadcat-core-design.md#pool-and-order-lifecycle-at-market-resolution) and [`lmsr-pool-design.md § Why the pool covenant can't feasibly gate post-resolution trading`](../contracts/lmsr-pool/lmsr-pool-design.md#why-the-pool-covenant-cant-feasibly-gate-post-resolution-trading). + +A stale quote is not an error in the usual sense — it's an information-integrity signal that the world changed. Wallet UX is standard "re-quote and re-confirm." + ## Transaction Weight Model The router tracks cumulative transaction weight to compute fees and enforce the weight cap: @@ -108,9 +144,10 @@ The router tracks cumulative transaction weight to compute fees and enforce the |---|---| | Base (wallet input + confidential change + fee output) | ~4,500 vbytes | | Per pool (3 reserve inputs + 3 reserve outputs + witnesses) | ~1,000 vbytes | +| Per assisted market window | market-kind dependent (binary: one 3-slot window; multi-outcome: one 2N+1-slot window) | | Per limit order fill (order input + maker receive output + witness) | ~400 vbytes | -Marginal weight is the incremental weight from adding a source to an existing route. A pool's marginal weight is ~1,000 vbytes on first activation, 0 on subsequent fills (already in the transaction). An order's marginal weight is always ~400 vbytes. +Marginal weight is the incremental weight from adding a source to an existing route. A pool's marginal weight is ~1,000 vbytes on first activation, 0 on subsequent fills (already in the transaction). An assisted pool variant adds the plain pool weight plus the parent market window weight. An order's marginal weight is always ~400 vbytes. ### Weight cap @@ -118,27 +155,55 @@ A hard ceiling on transaction weight (e.g., 100,000 vbytes) prevents pathologica In practice, the fee-aware pricing limits leg count long before the weight cap — each additional leg must provide enough price improvement to justify its weight. The cap is a safety bound, not a typical constraint. -## Integer Precision +## Integer Precision and Caching + +All fill amounts and costs computed by the router must use the **exact same deterministic LMSR algorithm** that the covenant verifies on-chain — the arbitrary-precision bignum algorithm specified in [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md). If the router uses floating-point approximations or independent LMSR reimplementations, the quoted amounts may differ from what the covenant enforces, causing on-chain transaction failure. + +**Caching strategy at bignum speeds**: Individual F-value computations are ms-scale at arbitrary precision, so on-demand point evaluation during routing would be prohibitively slow when multiple candidate pools need evaluation. `deadcat-core` maintains an in-memory cache of full F-value tables keyed by `(max_loss_sats, half_payout_sats)` combos; the router serves all lookups from the cache. First-use cost per combo is ~5-10s (full table generation); subsequent operations — pool pre-selection, fee-aware greedy evaluation, Merkle proof generation at build time — are O(1) cache hits. The 256-combo v1 param space means the full cache stabilizes quickly under typical wallet usage. + +A fixed-point Taylor runtime (deferred to v2 — see [implementation plan § deferred items](deadcat-core-implementation-plan.md#deferred--out-of-scope-items)) would restore microsecond-scale point evaluation and enable truly uncached quoting. Committed Merkle roots are the cross-implementation conformance set, so that switch is non-breaking. -All fill amounts and costs computed by the router must use the **exact same deterministic integer-only LMSR algorithm** that the covenant verifies on-chain. The router uses **point evaluation** — computing `F(s_index)` at specific points using the same integer algorithm as the full table generator, without materializing all 65K entries. This produces bit-identical values to a table lookup at ~1μs per evaluation (~16μs for a binary search), compared to ~80ms to generate the full table. The full table is only needed later by `build_trade_pset` for Merkle proof construction. If the router uses floating-point approximations or independent LMSR reimplementations, the quoted amounts may differ from what the covenant enforces, causing on-chain transaction failure. +**Reserve-aware fillability**: Cached tables answer "what would the curve charge for this `s_index` movement?" They do **not** by themselves answer "is that movement currently possible?" The router must combine the cached table with the pool's live reserves. A pool fill stops at the first of: +- requested amount satisfied +- crossover against the next-best alternative +- reserve floor would be violated on the next step +- table boundary reached -This applies to: -- **Pool fill computation**: tokens received for a given input amount (point evaluation of `F(new_s) - F(old_s)`) -- **Crossover binary search**: finding the s_index where a pool's marginal price exceeds the next alternative (binary search over s_index range using point evaluation at each candidate) +For pools bootstrapped with the canonical default reserves, the reserve-floor stop will usually occur near the inward-snapped useful-band bounds. Explicit over-funding can push the fillable region further out; the routing algorithm automatically honors that because it follows actual reserves rather than a builder-policy default. + +Caching applies to: +- **Pool fill computation**: tokens received for a given input amount (lookup of `F(new_s) - F(old_s)` from cached table) +- **Crossover binary search**: finding the s_index where a pool's marginal price exceeds the next alternative (binary search over s_index range using cached lookups at each candidate) ## Full Algorithm ``` function quote_trade(market_id, spec, fee_rate): - // 1. Load candidates - all_pools = store.pools_for_market(market_id, ActiveOnly) - orders = store.best_orders_for_market(market_id, spec.side, matching_direction, ascending, min_remaining, K=50) - // orders are filtered by side + direction, sorted by (price, creation_position) — FIFO within same price - - // 2. Pre-select top-N pools by average fill price (point evaluation, ~1-16μs per pool) + // spec = TradeSpec { outcome, side, direction, amount } + // For binary markets, spec.outcome == OutcomeIndex::BINARY. + // For multi-outcome markets, spec.outcome picks which outcome's liquidity to route against. + matching_direction = if spec.direction == Buy then SellBase else SellQuote + order_ascending = (spec.direction == Buy) // buyers prefer lower price; sellers prefer higher + requested_amount = requested_amount_from(spec.amount) + + // 1. Load candidates — scoped to the trade's target outcome + all_pools = store.pools_for_market(market_id, spec.outcome, ActiveOnly, Pagination::all()) + orders = store.best_orders_for_market( + market_id, spec.outcome, spec.side, matching_direction, + order_ascending, min_remaining, K=50, + ) + // orders are filtered by (outcome, side, direction), sorted by (price, creation_position) + // — FIFO within same price + + // 2. Pre-select top-N pools by their best reserve-aware variant + viable_pools = [] for each pool in all_pools: - pool.estimated_cost = lmsr_point_eval_exact_input(pool, requested_amount) - candidate_pools = top_n_by_estimated_cost(all_pools, N=5) + best_variant = best_pool_variant_for_preselection(pool, spec, requested_amount, fee_rate) + if best_variant.fillable_amount == 0: + continue + pool.preselection_estimate = best_variant + viable_pools.push(pool) + candidate_pools = top_n_by_avg_price_then_depth(viable_pools, N=5) // 3. Enumerate pool subsets × fee-aware greedy best_result = None @@ -157,6 +222,7 @@ function quote_trade(market_id, spec, fee_rate): function fee_aware_greedy(pools, orders, requested_amount, fee_rate): remaining = requested_amount weight = BASE_TX_WEIGHT + assisted_pool_used = false legs = [] total_cost = 0 order_cursor = 0 // index into price-sorted orders @@ -167,19 +233,23 @@ function fee_aware_greedy(pools, orders, requested_amount, fee_rate): // Evaluate each pool for each pool in pools: if pool is exhausted: continue - marginal_w = if pool already in legs { 0 } else { POOL_WEIGHT } - if weight + marginal_w > MAX_TX_WEIGHT: continue - - // Use exact LMSR point evaluation: fill pool up to crossover point - // Crossover = s_index where pool marginal price exceeds next best alternative - // Binary search over s_index range using point evaluation (~16μs) - next_best_price = best_alternative_price(orders, order_cursor, fee_rate) - (fill_amt, fill_cost) = lmsr_point_eval_fill_to_crossover(pool, remaining, next_best_price) - if fill_amt == 0: continue - - eff_price = (fill_cost + marginal_w × fee_rate) / fill_amt - if eff_price < best.eff_price: - best = (pool, fill_amt, fill_cost, marginal_w) + for each variant in [plain_variant(pool), assisted_variant_if_available(pool, spec, assisted_pool_used)]: + if variant is unavailable: continue + marginal_w = variant.marginal_weight_if_activated(weight, assisted_pool_used) + if weight + marginal_w > MAX_TX_WEIGHT: continue + + // Use cached LMSR F-value lookups plus live reserves: fill this variant up to + // crossover point, reserve-floor exhaustion, or table boundary. + // Crossover compares against the best remaining alternative source. + next_best_price = best_alternative_price(pools, orders, variant, order_cursor, fee_rate) + (fill_amt, fill_cost) = fill_variant_to_crossover(variant, remaining, next_best_price) + if fill_amt == 0: continue + + // For assisted variants, fill_cost is the taker's net cost/output for the + // combined market+pool action, and marginal_w already includes the market window. + eff_price = (fill_cost + marginal_w × fee_rate) / fill_amt + if best is None or eff_price < best.eff_price or (eff_price == best.eff_price and variant.is_plain_pool()): + best = (variant, fill_amt, fill_cost, marginal_w) // Evaluate next order while order_cursor < orders.len(): @@ -193,11 +263,11 @@ function fee_aware_greedy(pools, orders, requested_amount, fee_rate): fill_cost = actual_fill × order.price eff_price = (fill_cost + ORDER_WEIGHT × fee_rate) / actual_fill - if eff_price < best.eff_price: + if best is None or eff_price < best.eff_price: best = (order, actual_fill, fill_cost, ORDER_WEIGHT) break // only evaluate the top unconsumed order - if best is None: break // no viable sources + if best is None: break // no viable sources or all remaining sources are exhausted // Execute best fill legs.append(best) @@ -205,6 +275,7 @@ function fee_aware_greedy(pools, orders, requested_amount, fee_rate): total_cost += best.fill_cost weight += best.marginal_weight if best.source is order: order_cursor += 1 + if best.source is assisted pool variant: assisted_pool_used = true if remaining == 0: break @@ -218,9 +289,9 @@ function fee_aware_greedy(pools, orders, requested_amount, fee_rate): - N = candidate pool cap (5) → 2^N = 32 subset iterations - K = candidate order limit (50) → greedy loop iterations - P = pools in subset (≤5) → per-iteration pool evaluations -- log S = LMSR point evaluation binary search depth (~16 for 16-bit s_index range) +- log S = binary-search depth over the 16-bit s_index range (~16) -For typical values: 32 × 50 × (5 + 16) ≈ 33,600 point evaluations at ~1μs each ≈ ~34ms worst case, typically sub-millisecond (most greedy iterations terminate early). +For typical values with warm caches: 32 × 50 × (5 + 16) ≈ 33,600 cache lookups ≈ sub-millisecond worst case, typically much faster (most greedy iterations terminate early). Cold-start (first use of a given `(max_loss_sats, half_payout_sats)` combo) incurs a ~5-10s full-table generation; this is a one-time cost per combo amortized across all subsequent quotes. **Space**: O(K + P) for the candidate lists. @@ -228,5 +299,7 @@ For typical values: 32 × 50 × (5 + 16) ≈ 33,600 point evaluations at ~1μs e ## Key Files -- `src-tauri/crates/deadcat-sdk/src/amm_pool/math.rs` — LMSR math functions (will move to `deadcat-core`) -- `docs/architecture/deadcat-core-design.md` — `ContractStore` trait with `best_orders_for_market`, `quote_trade` API +- `crates/deadcat-core/src/lmsr_pool/math.rs` — LMSR math functions; binary LMSR is the only scoring rule, used per-outcome for multi-outcome markets under Option C composition +- `docs/architecture/deadcat-core-design.md` — `ContractStore` trait (outcome-scoped `pools_for_market` / `best_orders_for_market`), `quote_trade` engine API, `TradeSpec` / `TradeQuote` types +- `docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md` — pool design decision (binary LMSR + Option C composition for multi-outcome) +- `docs/contracts/multi-outcome/multi-outcome-market-contract.md` — market contract providing the cross-outcome primitives (split/merge YES/NO) that basket trades and future v2 arb compose with diff --git a/docs/architecture/transaction-composability-model.md b/docs/architecture/transaction-composability-model.md index d8e70841..82dc1e79 100644 --- a/docs/architecture/transaction-composability-model.md +++ b/docs/architecture/transaction-composability-model.md @@ -2,7 +2,7 @@ ## Overview -Trade transactions on Deadcat can co-spend multiple covenant inputs — LMSR pool reserves and maker order UTXOs — in a single transaction. Each covenant input independently runs its Simplicity program, introspecting the transaction's outputs to verify its constraints. This document specifies how the three covenant types introspect outputs, how the PSET builder arranges inputs and outputs to satisfy all covenants simultaneously, and what prevents output aliasing (two covenants both claiming the same output). +Trade transactions on Deadcat can co-spend multiple covenant inputs — LMSR pool reserves, maker order UTXOs, and, for an assisted pool leg, the parent market window — in a single transaction. Each covenant input independently runs its Simplicity program, introspecting the transaction's outputs to verify its constraints. This document specifies how the three covenant types introspect outputs, how the PSET builder arranges inputs and outputs to satisfy all covenants simultaneously, and what prevents output aliasing (two covenants both claiming the same output). ## Output Aliasing: The Core Risk @@ -19,6 +19,8 @@ The pool covenant accepts `in_base` and `out_base` as witness data. It asserts t This is maximally flexible — the builder chooses where to place pool outputs. Two different pools always have different scripts (different params → different CMR → different `script_hash_for_state()` at any s_index), so aliasing between pools is impossible regardless of output placement. +The same public pool path can be used with `old_s_index != new_s_index` (ordinary swap or swap+paired-delta assist) or `old_s_index == new_s_index` (degenerate paired rebalance). Admin adjust remains a separate spend path. Witness flexibility here selects only the pool's inspection window; it does not weaken script verification or reserve checks. + ### Maker Order: Hybrid Positional + Witness (Proposed Change) **Current model:** The order covenant uses `current_index()` for both the maker receive output (at index `i`) and the remainder output (at index `i+1`). This creates rigid 2-slot windows that overlap when two partial-fill orders are at adjacent input indices. @@ -48,11 +50,23 @@ Each output uses the protection model best suited to its risk profile: - `maker_order.simf`: the fill validation functions accept `remainder_idx` from witness data instead of computing `safe_add_32(i, 1)`. Remainder output script and value checks use the witness-provided index. - `witness::REMAINDER_IDX` is added as a new witness declaration (replaces the implicit `i+1`). -### Prediction Market: Hardcoded Absolute Indices (No Change Needed) +### Prediction Market: Witness-Parameterized (Cluster 1 Decision) + +Both market contracts now follow the same composability model as the pool covenant: the witness provides `in_base` and `out_base`, the covenant asserts the current input is at `in_base + slot_offset`, and it validates a bounded contiguous input/output window rooted at those bases. This preserves covenant correctness while allowing the market to sit anywhere in a larger transaction. + +For binary markets, this is a covenant-level capability decision, not a promise that every v1 builder uses arbitrary placement. The standard creation/issuance/cancellation/resolution/expiry/redemption builders may still choose a canonical layout for simplicity, but the committed contract semantics no longer hardcode absolute transaction positions. That preserves the option to add future multi-contract builders without changing already-created markets. + +The anti-aliasing story matches the general model in [market-contract-principles.md](../contracts/market-contract-principles.md): witness flexibility only selects the contract's inspection window; it does not relax script verification, asset verification, or output-window bounds. A malicious builder can move the window, but cannot make the covenant accept another contract's outputs as its own. + +## Script Uniqueness Guarantee -The market covenant asserts `current_index() == 0` and checks outputs at fixed positions (0, 1, 2, 3, 4, 5...). This is acceptable because all market lifecycle operations (issuance, cancellation, resolution, redemption, expiry) are single-contract — there is no practical reason to co-spend two markets' covenant UTXOs in the same transaction. The rigid layout fully specifies the transaction structure for each operation. +Every live maker order UTXO has a unique covenant script, and every live LMSR pool reserve UTXO has a unique covenant script (per reserve role). This is structurally guaranteed, not a convention: -**Future consideration:** The atomic issuance + pool bootstrap enhancement ([future-atomic-issuance-lmsr.md](../contracts/lmsr-pool/future-atomic-issuance-lmsr.md)) would co-spend market RT UTXOs while creating pool reserve outputs. The market covenant's hardcoded indices may already accommodate this (the covenant doesn't constrain token output destinations, and extra outputs between the token outputs and the fee output may be tolerated). This requires verification against the exact `.simf` logic when that feature is scoped. +- **Maker orders**: `maker_pubkey` and `order_nonce` are both deterministic functions of `order_index`, which the wallet increments per order. Two orders from the same wallet have different indices → different keys and nonces → different CMRs. Two orders from different wallets have different seeds → different keys → different CMRs. See [chain-only-recovery.md § Key Derivation](../protocol/chain-only-recovery.md#key-derivation) and [§ Order Nonce Derivation](../protocol/chain-only-recovery.md#order-nonce-derivation). +- **LMSR pools**: `admin_pubkey` is derived per `pool_index`, and the pool's Merkle root varies with `max_loss_sats` / `half_payout_sats`. Two pools with matching params still differ by admin pubkey → different CMR. +- **Prediction markets**: params include 2N issuance-derived asset IDs unique per creation tx, so two markets never share a CMR. + +This guarantee is what makes the aliasing analysis below tractable: "same covenant script" attack preconditions are not reachable via `derive_order_params` / `derive_pool_params`, they would require manually-constructed params that bypass deterministic derivation. ## Aliasing Analysis @@ -80,7 +94,14 @@ The proposed hybrid model (Option B) eliminates the more likely attack vector st ### Builder Layout Algorithm -The `build_trade_pset` builder arranges inputs and outputs using the natural ordering: +The v1 builder uses two deterministic layouts: + +- **Plain routes** (no assisted pool leg): pools first, then orders, then wallet. +- **Routes with one assisted pool leg**: the co-spent market window first, then that pool window, then any remaining pools, then orders, then wallet. + +Placing the market window immediately before its assisted pool window localizes the only market co-spend in v1 and keeps witness-base assignment mechanical. + +#### Plain Routes **Inputs:** ``` @@ -98,6 +119,33 @@ The `build_trade_pset` builder arranges inputs and outputs using the natural ord Each pool's witness sets `in_base` and `out_base` to the pool's starting input index. Each order's maker receive naturally lands at the output index matching its input index. Remainders float to witness-specified indices after all positional outputs. +#### Routes With One Assisted Pool Leg + +Let `Wm` be the co-spent market window size: + +- binary market, Unresolved phase: `Wm = 3` +- multi-outcome market, Unresolved phase: `Wm = 2N + 1` + +**Inputs:** +``` +[0..Wm-1] Assisted market inputs +[Wm..Wm+2] Assisted pool reserve inputs +[Wm+3..Wm+3+3P-1] Remaining pool reserve inputs +[..] Order inputs +[..] Wallet inputs +``` + +**Outputs:** +``` +[0..Wm-1] Assisted market continuation outputs +[Wm..Wm+2] Assisted pool reserve outputs +[Wm+3..Wm+3+3P-1] Remaining pool reserve outputs +[..] Order maker receive outputs +[..] Order remainders, taker receive, fee, change, burn outputs +``` + +The market witness sets its own `in_base` / `out_base` to `0`. The assisted pool witness sets `in_base = Wm` and `out_base = Wm`. Any remaining pool windows follow after that in 3-slot groups. + ### Layout Example: 2 Pools + 2 Partial-Fill Orders ``` @@ -124,14 +172,61 @@ Order B witness: remainder_idx=9 No overlapping windows. No aliasing. Each covenant's introspection is independently satisfied. +### Layout Example: Binary Buy With `IssuePairs` + +An existing binary pool is short on depth for a YES buy, so the route co-spends the parent market's issuance path and mints `pairs` YES+NO directly into the pool while also moving the pool along the curve: + +``` +Inputs: Outputs: +[0] Market YES RT → [0] Market YES RT continuation +[1] Market NO RT → [1] Market NO RT continuation +[2] Market collateral → [2] Market collateral continuation (+ pairs × cp) +[3] Pool YES reserve → [3] Pool YES reserve (new s_index) +[4] Pool NO reserve → [4] Pool NO reserve (new s_index) +[5] Pool collateral reserve → [5] Pool collateral reserve (new s_index) +[6..] Wallet collateral inputs [6] Taker YES receive + (issuance collateral + [7] Fee + trade payment + fee) [8..] Change + +Market witness: in_base=0, out_base=0 +Pool witness: in_base=3, out_base=3 +``` + +No temporary YES/NO outputs are needed. The newly issued tokens are paid directly into the pool reserve outputs at `[3]` and `[4]`; those outputs already represent the post-trade reserve state the pool covenant is checking. + +### Layout Example: Binary Sell With `CancelPairs` + +An existing binary pool buys YES from the taker, but the route also cancels `pairs` out of the pool reserves to release market collateral: + +``` +Inputs: Outputs: +[0] Market YES RT → [0] Market YES RT continuation +[1] Market NO RT → [1] Market NO RT continuation +[2] Market collateral → [2] Market collateral continuation (- pairs × cp) +[3] Pool YES reserve → [3] Pool YES reserve (new s_index) +[4] Pool NO reserve → [4] Pool NO reserve (new s_index) +[5] Pool collateral reserve → [5] Pool collateral reserve (new s_index) +[6] Wallet YES sold by taker [6] YES burn output for cancelled pairs +[7..] Wallet fee inputs [7] NO burn output for cancelled pairs + [8] Taker collateral receive + [9] Fee + [10..] Change + +Market witness: in_base=0, out_base=0 +Pool witness: in_base=3, out_base=3 +``` + +The cancelled YES and NO tokens leave the pool via the burn outputs at `[6]` and `[7]`. The taker's collateral receive at `[8]` is the combined result of the pool-side rebate and the market-side cancellation release; `TradeQuote` presents this as one assisted pool leg even though two covenants contribute to the final amount. + ### Layout Invariants The builder must ensure: -1. Pool output windows (`[out_base, out_base+2]`) do not overlap with each other -2. Pool output windows do not overlap with order input indices (since order maker receives are positional at `current_index()`) -3. Order remainder witness indices do not collide with each other or with any positional output +1. Market and pool output windows do not overlap with each other +2. Pool output windows (`[out_base, out_base+2]`) do not overlap with each other +3. Contract output windows do not overlap with order input indices (since order maker receives are positional at `current_index()`) +4. Order remainder witness indices do not collide with each other or with any positional output -The natural ordering (pools first, then orders, then wallet) satisfies all three invariants by construction. An incorrect layout produces a transaction that fails (covenant script mismatch on a contested output index) — not an aliasing exploit. +The chosen layouts satisfy all four invariants by construction. An incorrect layout produces a transaction that fails (covenant script mismatch on a contested output index) — not an aliasing exploit. ## Recovery and Duplicate Contracts @@ -145,8 +240,8 @@ During mnemonic recovery, if the user creates new contracts before completing ch ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/maker_order.simf` — order covenant: change remainder from `current_index() + 1` to `witness::REMAINDER_IDX` -- `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — pool covenant: already uses witness-based `in_base`/`out_base` (no changes needed) -- `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — market covenant: hardcoded indices (no changes needed for v1) -- `docs/contracts/contract-specification.md` — pending refactors table: add order remainder witness-parameterization +- `crates/deadcat-core/contracts/maker_order.simf` — order covenant: change remainder from `current_index() + 1` to `witness::REMAINDER_IDX` +- `crates/deadcat-core/contracts/lmsr_pool.simf` — pool covenant: already uses witness-based `in_base`/`out_base` (no changes needed) +- `crates/deadcat-core/contracts/prediction_market.simf` — market covenant: witness-parameterized `in_base`/`out_base` for flexible transaction composition +- `docs/contracts/contract-specification.md` — legacy source alignment checklist: add order remainder witness-parameterization - `docs/architecture/deadcat-core-design.md` — `build_trade_pset` output layout algorithm diff --git a/docs/contracts/contract-specification.md b/docs/contracts/contract-specification.md index 93965913..5da8d375 100644 --- a/docs/contracts/contract-specification.md +++ b/docs/contracts/contract-specification.md @@ -1,31 +1,53 @@ # Contract Specification -This document specifies the three Deadcat covenant contracts from the perspective of a `deadcat-core` implementor: planned parameter types, covenant structure, spend paths, and witness data. It consolidates information from the `.simf` source files and multiple satellite refactor docs into a single reference. +This document specifies the Deadcat covenant contracts from the perspective of a `deadcat-core` implementor: planned parameter types, covenant structure, spend paths, and witness data. It consolidates information from the `.simf` source files and multiple satellite refactor docs into a single reference. -**This document describes the planned end state** — after all pending refactors are applied. The current SDK source may differ. See [Pending Refactors](#pending-refactors) for the delta. +**This document is the implementation target.** Some legacy `deadcat-sdk` covenant/source files still differ; the checklist below tracks that legacy-source alignment work. It does not indicate unresolved protocol behavior in this spec. -## Prediction Market +## Contract Types at a Glance + +Deadcat has **two prediction market contracts** plus two supporting contracts: + +| Contract | Purpose | Use when | +|---|---|---| +| **Prediction Market (binary)** | YES/NO on a single event | Outcomes are binary, or the set of outcomes can change over time (composed at the app layer with oracle-discipline-enforced inter-market coherence) | +| **Multi-Outcome Market** | N mutually exclusive, exhaustive outcomes with per-outcome YES/NO tokens. Provides atomic cross-outcome primitives (split-YES, split-NO, cross-outcome swap) for efficient arb, LP rotation, and basket operations. | Outcome set is fixed and known at creation (e.g., Fed rate decision: raise/flat/lower/other, sports brackets, Oscar categories) | +| **LMSR Pool** | Binary automated market maker. Serves binary markets directly and per-outcome YES/NO pairs within multi-outcome markets via **Option C composition** (N binary LMSR pools per multi-outcome market). | Providing liquidity on any market — single pool contract type for both binary and multi-outcome | +| **Maker Order** | Limit order against a binary market's token | Fills at a fixed price, cancellable by the maker | + +The two prediction market contracts **coexist by design**. Creators pick by event characteristics: + +- **Known, exhaustive outcome set** → multi-outcome market contract. Gets atomic cross-outcome primitives, stronger oracle containment, single shared collateral UTXO. +- **Dynamic or non-exhaustive outcomes** (candidates dropping out, open-ended questions) → composed binary market contracts at the app layer. + +Regardless of which market contract is used, **AMM liquidity is always provided via binary LMSR pools**. For multi-outcome markets, N binary LMSR pools are composed (one per outcome's YES/NO pair) and cross-outcome AMM coherence (`Σ p_YES_k = 1`) is arb-enforced. The multi-outcome market contract's cross-outcome primitives make that arb atomic (single-transaction closure of coherence gaps). See [multi-outcome/multi-outcome-market-contract.md](multi-outcome/multi-outcome-market-contract.md) for the full spec and [multi-outcome/amm-scoring-rule-tradeoffs.md](multi-outcome/amm-scoring-rule-tradeoffs.md) for the pool design decision. + +Both market contracts uphold the same set of covenant-enforced principles (permissionlessness within the solvency invariant, narrow oracle authority, terminal-path completeness, RT destruction, sibling UTXO check, witness-parameterized indices, etc.). These are specified once in [market-contract-principles.md](market-contract-principles.md) and implemented by each contract. The per-contract specs below describe what is specific to each — token model, spend paths, parameters — and inherit the shared principles by reference. + +## Prediction Market (Binary) ### Parameters ```rust -pub struct PredictionMarketParams { +pub struct BinaryMarketParams { pub oracle_public_key: XOnlyPublicKey, // BIP-340 Schnorr pubkey for oracle attestation pub collateral_asset_id: AssetId, // L-BTC, USDt, or other Elements asset pub yes_token_asset_id: AssetId, // derivable from creation tx issuance entropy pub no_token_asset_id: AssetId, // derivable from creation tx issuance entropy pub yes_reissuance_token_id: AssetId, // derivable from creation tx issuance entropy pub no_reissuance_token_id: AssetId, // derivable from creation tx issuance entropy - pub collateral_per_pair: u64, // total collateral for one YES+NO pair (convention: 1-2-5 table) - pub expiry_time: u32, // block height deadline (convention: snapped to 60-block boundary) + pub base_payout: u64, // primary denomination (1-2-5 table); cp = base_payout × 2 is the pair cost + pub expiry_time: u32, // block height deadline (convention: rounded up to next 60-block boundary) } ``` 4 of 8 fields are derivable from the creation transaction's issuance entropy. The remaining 4 are stored in the OP_RETURN recovery hint. See [chain-only-recovery.md](../protocol/chain-only-recovery.md). -**Unit convention**: All amounts (`collateral_per_pair`, `max_loss_sats`, `half_payout_sats`, token counts, reserve values) are denominated in the **smallest indivisible unit** of the respective asset — satoshis for L-BTC (10^-8 BTC), 10^-8 for USDt on Liquid, etc. The `_sats` suffix on some fields reflects the L-BTC-as-canonical-example convention, not an L-BTC-only restriction. Protocol constants like `MIN_POOL_RESERVE = 1,000` are in smallest units regardless of asset. +**Denomination model**: The primary covenant param is `base_payout` — the per-outcome YES-expiry payout unit. Binary markets derive `cp = base_payout × 2`. Multi-outcome markets derive `cp = base_payout × outcome_count`. This keeps the two market kinds on one denomination model without conflating the binary API's single outcome with the binary settlement multiplier of 2. See [multi-outcome-market-contract.md § Denomination model](multi-outcome/multi-outcome-market-contract.md#denomination-model) for the rationale. + +**Unit convention**: All amounts (`base_payout`, `max_loss_sats`, `half_payout_sats`, token counts, reserve values) are denominated in the **smallest indivisible unit** of the respective asset — satoshis for L-BTC (10^-8 BTC), 10^-8 for USDt on Liquid, etc. The `_sats` suffix on some fields reflects the L-BTC-as-canonical-example convention, not an L-BTC-only restriction. Protocol constants like `MIN_POOL_RESERVE = 1,000` are in smallest units regardless of asset. -Builder validates: `collateral_per_pair` in 1-2-5 table, `expiry_time` on 60-block boundary, `collateral_asset_id` in well-known set or exotic-escape-compatible. +Builder validates: `base_payout` in the 1-2-5 table, accepts any future `expiry_time`, rounds it up to the next 60-block boundary, and validates the rounded value against the recovery conventions. `collateral_asset_id` must be in the well-known set or exotic-escape-compatible. ### Covenant Structure @@ -48,8 +70,8 @@ Each slot has a unique script pubkey derived from the contract params + slot ide | Transition | From slots | To slots | Authorization | Covenant enforces | |---|---|---|---|---| -| Initial issuance | 0, 1 | 2, 3, 4 | RT spend | Collateral = pairs x collateral_per_pair | -| Subsequent issuance | 2, 3, 4 | 2, 3, 4 | RT spend | Collateral increased by pairs x collateral_per_pair; sibling UTXO check | +| Initial issuance | 0, 1 | 2, 3, 4 | RT spend | Collateral = pairs × cp, where cp = base_payout × 2 | +| Subsequent issuance | 2, 3, 4 | 2, 3, 4 | RT spend | Collateral increased by pairs × cp; sibling UTXO check | | Partial cancellation | 2, 3, 4 | 2, 3, 4 | RT spend + token burn | Collateral decreased, tokens burned; sibling UTXO check | | Full cancellation | 2, 3, 4 | 0, 1 | RT spend + token burn | All collateral returned, all tokens burned; sibling UTXO check | | Resolution (YES) | 2, 3, 4 | 5 | Oracle BIP-340 signature | Oracle signs tagged hash of market_id + outcome; RT burn outputs verified at unspendable script with correct commitment; sibling UTXO check | @@ -58,9 +80,9 @@ Each slot has a unique script pubkey derived from the contract params + slot ide | Redemption (post-NO) | 6 | none | Token burn | NO tokens burned, collateral released at full value | | Redemption (expired) | 7 | none | Token burn | Any tokens burned, collateral released at half value | | Expiry | 2, 3, 4 | 7 | Timelock >= expiry_time | No signature required; RT burn outputs verified at unspendable script with correct commitment; sibling UTXO check | -| Dormant resolution (YES) | 0, 1 | none | Oracle BIP-340 signature | Both RTs consumed, no outputs | -| Dormant resolution (NO) | 0, 1 | none | Oracle BIP-340 signature | Both RTs consumed, no outputs | -| Dormant expiry | 0, 1 | none | Timelock >= expiry_time | Both RTs consumed, no outputs | +| Dormant resolution (YES) | 0, 1 | none | Oracle BIP-340 signature | Both RTs consumed; RT burn outputs verified at unspendable script; no covenant continuation outputs | +| Dormant resolution (NO) | 0, 1 | none | Oracle BIP-340 signature | Both RTs consumed; RT burn outputs verified at unspendable script; no covenant continuation outputs | +| Dormant expiry | 0, 1 | none | Timelock >= expiry_time | Both RTs consumed; RT burn outputs verified at unspendable script; no covenant continuation outputs | **Sibling UTXO check**: All transitions that co-spend RTs and collateral verify that the three covenant inputs were created in the same transaction (`input_prev_outpoint` txid match across all three). This prevents collateral substitution — an attacker cannot create a fake collateral UTXO at the covenant script address and swap it in for the real one, because the fake UTXO's `prev_txid` won't match the RTs'. See [enforcement-layers.md](../architecture/enforcement-layers.md) for the full attack analysis. @@ -79,12 +101,103 @@ See [oracle-bip340-tagged-hash.md](../protocol/oracle-bip340-tagged-hash.md). ### Witness Data -For **dormant terminal paths** (resolution/expiry from 0 outstanding pairs): both RT inputs are spent with no covenant outputs. The three-way ambiguity (YES/NO/Expired) is resolved via `RedeemNode::decode` on the witness — the spend path identifies which transition occurred. +For **dormant terminal paths** (resolution/expiry from 0 outstanding pairs): both RT inputs are spent, RT burn outputs are produced, and no covenant continuation outputs are produced. The three-way ambiguity (YES/NO/Expired) is resolved via `RedeemNode::decode` on the witness — the spend path identifies which transition occurred. All other transitions are detectable from script pubkey matching alone (8 unique scripts). +## Multi-Outcome Market + +Generalizes the binary market to N mutually exclusive, exhaustive outcomes with **per-outcome YES/NO tokens** (2N tokens total). This contract is appropriate when the outcome set is fixed and known at creation time. Its primary value is the **atomic cross-outcome primitives** (split-YES, split-NO, cross-outcome swap), which enable efficient arb, LP rotation, and basket operations that composed binary markets cannot provide atomically. + +**AMM liquidity** for a multi-outcome market comes from **Option C composition**: N binary LMSR pools, one per outcome's YES/NO pair. Cross-outcome AMM coherence (`Σ p_YES_k = 1`) is arb-enforced, with arbitrageurs using the market contract's cross-outcome primitives to close gaps in a single atomic transaction. + +For the full spec, design rationale, and alternatives considered, see [multi-outcome/multi-outcome-market-contract.md](multi-outcome/multi-outcome-market-contract.md). + +### Parameters + +```rust +pub struct MultiOutcomeMarketParams { + pub oracle_public_key: XOnlyPublicKey, + pub collateral_asset_id: AssetId, + pub yes_token_asset_ids: [AssetId; N], // derivable from creation tx + pub no_token_asset_ids: [AssetId; N], // derivable from creation tx + pub yes_rt_asset_ids: [AssetId; N], // derivable from creation tx + pub no_rt_asset_ids: [AssetId; N], // derivable from creation tx + pub base_payout: u64, // primary denomination (1-2-5 table); cp = base_payout × N is the pair cost + pub expiry_time: u32, // 60-block boundary + pub outcome_count: u8, // N +} +``` + +Builder validates: `N` in supported range (**v1: N ∈ {3, 4}**), `base_payout` in 1-2-5 table, `expiry_time` on 60-block boundary. Expiry-redemption divisibility is structural under this model (`cp = base_payout × N` is trivially divisible by N), so no `cp mod N == 0` check is required at builder or covenant level. + +### Covenant Structure + +**5N + 2 slots**: +- 2N Dormant RTs (YES_i and NO_i, one per outcome) +- 2N Unresolved RTs (YES_i and NO_i) +- 1 Unresolved collateral +- N Resolved_k collateral slots (one per winning outcome) +- 1 Expired collateral + +| N | Slot count | +|---|---| +| 3 | 17 | +| 5 | 27 | +| 8 | 42 | +| 10 | 52 | + +### Token Model and Solvency Invariant + +For outcome k winning, `YES_k` and `NO_j` (for all j ≠ k) each redeem for `cp = base_payout × N`. Pre-resolution, the contract maintains an outcome-independent quantity `Q = y_k + sum_{j≠k} n_j` (same value for every k), with collateral `C = cp × Q`. All supported operations preserve outcome-independence of Q. + +### Spend Paths (summary) + +Formulas below use these canonical derivation shorthands: + +- `cp := base_payout × N` +- `cp_yes_basket := cp` +- `cp_no_basket := (N - 1) × cp` +- `cp_cross_swap := (N - 2) × cp` + +Per-outcome operations: +- **Issue pair (outcome i)**: mint `sets` of YES_i and sets of NO_i, lock `sets × cp`. +- **Cancel pair (outcome i)**: burn sets of YES_i and sets of NO_i, release `sets × cp`. + +Cross-outcome operations: +- **Split YES** / **Merge YES**: mint/burn `sets` of each YES_i, for `sets × cp_yes_basket`. +- **Split NO** / **Merge NO**: mint/burn `sets` of each NO_i, for `sets × cp_no_basket`. +- **Cross-outcome swap** (`YES_i → {NO_j : j ≠ i}`): burn `sets` of `YES_i`, mint `sets` of each `NO_j` for `j ≠ i`, and lock `sets × cp_cross_swap`. + +Resolution/redemption: +- **Resolution (outcome k)**: oracle signs u8 outcome_index; all 2N RTs burned; collateral moves to Resolved_k slot. +- **Redemption**: YES_k and NO_j (j ≠ k) each redeem for `cp = base_payout × N`. +- **Expiry redemption**: YES_i redeems for `base_payout`; NO_i redeems for `base_payout × (N-1)`. Both are exact integers by construction. Uniform 1/N outcome probability assumption keeps the rate solvency-preserving. + +All Unresolved-phase transitions co-spend all **2N+1 covenant inputs** (all RTs + collateral) to maintain the sibling UTXO check. + +### Oracle Attestation + +``` +message = tagged_hash("deadcat/oracle_attestation", market_id || outcome_index) +market_id = SHA256(yes_token_asset_ids[0] || no_token_asset_ids[0] || ... || yes_token_asset_ids[N-1] || no_token_asset_ids[N-1]) +outcome_index = u8 in [0, N-1] +``` + +Tag string matches the binary market. Domain separation is via `market_id`. + +### Code Generation + +Each supported N has its own `.simf` file generated from a template by `deadcat-codegen` at **dev time**. The generated `.simf` sources are **committed to the repo** under `crates/deadcat-core/contracts/multi_outcome/`, and the compiled Simplicity artifacts (`src/artifacts/*.rs`, produced by `simplex build`) are also committed. Runtime reads these via `include_bytes!` and imports the typed artifact modules directly — no template instantiation or compilation happens at `cargo build` time. Regeneration is triggered explicitly by developers via `just generate-simf` + `just regenerate-artifacts` when an intentional change is being committed; drift tests catch stale committed outputs on every `cargo test`. **v1 supports N ∈ {3, 4}**. Expansion to larger N is non-breaking (new N → new template instantiation → new committed `.simf` + artifacts → new contract; existing contracts unaffected). Above N=10, transaction weight from the 2N+1-way covenant co-spend becomes uncomfortable; large-N events should use hierarchical composition (markets-of-markets) or binary-market composition with arbitrage-based coherency. + +**The binary market stays separate from the multi-outcome template.** `prediction_market.simf` is the canonical binary contract and is not regenerated from the multi-outcome template. The template serves markets with 3 or more outcomes only in v1. See [multi-outcome-market-contract.md](multi-outcome/multi-outcome-market-contract.md). + ## LMSR Pool +**The single AMM pool contract in deadcat.** One binary LMSR pool serves both single-event binary markets and per-outcome YES/NO pairs within multi-outcome markets (via Option C composition — N pools per multi-outcome market, one per outcome). The pool contract doesn't know or care which market contract type underlies its YES/NO tokens. See [multi-outcome/amm-scoring-rule-tradeoffs.md](multi-outcome/amm-scoring-rule-tradeoffs.md) for the pool design decision. + +**Liquidity model**: admin-operated, permissionless creation. Each pool has a single operator who chooses parameters, provides subsidy, can adjust reserves and close via admin-signed spend paths, earns fees, and bears impermanent loss. Anyone can deploy a pool on any market with any parameters; multiple competing pools per market are expected. LP-tokenized pools are deferred to v2. + ### Parameters ```rust @@ -94,10 +207,10 @@ pub struct LmsrPoolParams { pub collateral_asset_id: AssetId, // from parent market pub lmsr_table_root: [u8; 32], // derived: Merkle root of F-value table pub q_step_lots: u64, // derived from b and half_payout_sats - pub half_payout_sats: u64, // creator-specified (convention: 26-value mantissa x 10^exp) - pub fee_bps: u64, // creator-specified (u64 for Simplicity; validated < 10,000; convention: <= 4,095) + pub half_payout_sats: u64, // creator-specified (convention: 16-value 1-2-5 table, shared with market base_payout encoding) + pub fee_bps: u16, // creator-specified public API type (convention: <= 4,095; widened internally for Simplicity arithmetic) pub admin_pubkey: XOnlyPublicKey, // from mnemonic at pool_index - pub max_loss_sats: u64, // NOT a covenant param — needed for off-chain LMSR math (b derivation, point evaluation, table generation) + pub max_loss_sats: u64, // NOT a covenant param — needed for off-chain LMSR math (b derivation, cached-table quoting, table generation) } ``` @@ -117,28 +230,31 @@ pub struct LmsrPoolParams { 3 reserve UTXOs (YES, NO, Collateral) sharing a script pubkey that encodes the current `s_index`. The taproot internal key is NUMS (key-spend unspendable). The taproot tree has constant Simplicity program leaves (same CMR regardless of `s_index`) and a variable `tapdata_leaf = TaggedHash("TapData", s_index.to_be_bytes())`. When `s_index` changes (swap), only the tapdata leaf changes — the Simplicity programs and their CMRs are constant for given pool params. This means computing a pool's script pubkey for a given `s_index` requires one Simplicity compilation (to get the constant program CMRs) plus lightweight hashing and an EC scalar multiplication (for the taproot tweak). -Swap and admin paths produce three consecutive reserve outputs in fixed order, as enforced by the covenant: YES (index N), NO (index N+1), Collateral (index N+2), all sharing the same script pubkey encoding the current/new `s_index`. +Public and admin-adjust paths produce three consecutive reserve outputs in fixed order, as enforced by the covenant: YES (index N), NO (index N+1), Collateral (index N+2), all sharing the same script pubkey encoding the current/new `s_index`. ### Spend Paths | Path | Authorization | s_index | Covenant enforces | |---|---|---|---| -| Swap | Permissionless | Changes | Merkle proofs for F(old_s) and F(new_s), collateral conservation with fee inequality, reserve minimums, correct trade direction | +| Public | Permissionless | Changes or Frozen | Merkle proofs for F(old_s) and F(new_s), reserve deltas decompose into one valid LMSR movement plus one equal YES/NO pair delta, collateral conservation with fee inequality for the LMSR component, reserve minimums, correct trade direction when `s_index` changes | | Admin adjust | Admin key signature | Frozen | YES and NO deltas must be equal, reserve minimums maintained | | Close | Admin key signature | N/A | All 3 reserve UTXOs consumed atomically, no new covenant outputs | See [lmsr-pool-close-path.md](lmsr-pool/lmsr-pool-close-path.md) for the close path specification. +The **public** path is the covenant-level superset used for ordinary swaps, swap+market-assist composition with the parent market, and degenerate fixed-`s_index` pair rebalances. v1 `build_trade_pset` emits only ordinary swaps and swap+market-assist routes; pure pair-only public rebalances remain covenant-valid for future composition. + ### Witness Data All pool transitions use **witness-based detection** via `RedeemNode::decode`: -- **Swap vs Admin**: Distinguished by spend path in the witness. s_index change confirmed by witness (swap: old != new, admin: old == new). +- **Public vs Admin**: Distinguished by spend path in the witness, not by whether `s_index` changed. On the public path, `old_s_index != new_s_index` is an ordinary or assisted swap-like movement; `old_s_index == new_s_index` is a degenerate paired rebalance. On the admin path, `old_s_index == new_s_index` is operator-managed reserve adjustment. - **Close**: Spend path confirmed as close by the witness. No covenant outputs. - **s_index extraction**: The witness contains the authoritative `old_s_index` and `new_s_index`. This is ground truth — reserve-based reverse lookup is fragile after admin adjustments. +- **Paired delta derivation**: Any equal YES/NO paired reserve delta is derived from the reserve vector change itself; the witness does not supply an independent `pair_delta` scalar. -### LMSR Math: Point Evaluation +### LMSR Math: Cached Tables -The quoting hot path (`quote_trade`) does NOT need the full 65K-entry F-value table. It uses direct cost function evaluation at specific points (~1us per evaluation, ~16us for a binary search). The full table is only needed for Merkle proof generation (`build_trade_pset`, `build_lmsr_bootstrap_pset`) and pool ingestion verification (~80ms, infrequent operations). See [lmsr-pool-design.md](lmsr-pool/lmsr-pool-design.md). +The v1 implementation uses the deterministic full-table output everywhere — quoting, proof generation, and ingestion verification. `deadcat-core` caches full F-value tables keyed by `(max_loss_sats, half_payout_sats)`: the first use of a combo incurs the bignum cold-start cost, and subsequent operations are O(1) lookups against the cached table. Routing still remains reserve-aware: the cached table determines curve pricing, while live reserves determine currently fillable volume. See [lmsr-pool-design.md](lmsr-pool/lmsr-pool-design.md). ## Maker Order @@ -153,9 +269,9 @@ pub enum OrderDirection { pub struct MakerOrderParams { pub base_asset_id: AssetId, // YES or NO token from parent market pub quote_asset_id: AssetId, // collateral asset from parent market - pub price: u64, // quote units per base unit (convention: <= 2^24) - pub min_fill_lots: u64, // minimum base units per fill (convention: 1-255) - pub min_remainder_lots: u64, // minimum base units remaining after partial fill (convention: 1-255) + pub price: u64, // quote units per base unit (convention: <= 0xFFFFFF = u24 max) + pub min_fill_lots: u8, // minimum base units per fill (convention: 1-255) + pub min_remainder_lots: u8, // minimum base units remaining after partial fill (convention: 1-255) pub direction: OrderDirection, pub maker_receive_spk_hash: [u8; 32], // SHA256 of maker's P2TR receive scriptPubKey pub maker_pubkey: XOnlyPublicKey, // maker's x-only pubkey (taproot internal key for cancel) @@ -199,33 +315,32 @@ Order detection uses **taproot structural checks**, not Simplicity witness decod **NUMS key**: "Nothing Up My Sleeve" — a point on the secp256k1 curve with no known discrete logarithm, derived by hashing a fixed string to a curve point. This is the standard NUMS point used across the Bitcoin/Liquid ecosystem (same value used by BIP-341 for the unspendable internal key). Key-spend with this internal key is cryptographically infeasible, forcing all spends through the script path (Simplicity covenant). Maker orders use the maker's real public key instead — key-spend is their cancellation mechanism. -## Pending Refactors +## Legacy Source Alignment Checklist -These changes are specified in satellite docs but not yet applied to the `.simf` source files: +These items track legacy covenant/source updates needed so the older `deadcat-sdk` sources match this spec: | Refactor | Satellite doc | Status | Blocks | |---|---|---|---| -| `collateral_per_token` → `collateral_per_pair` | [collateral-per-pair-refactor.md](prediction-market/collateral-per-pair-refactor.md) | Pending | Market contract, market params | +| `collateral_per_token` → `base_payout` (primary denomination; `cp = base_payout × N` derived) | [collateral-per-pair-refactor.md](prediction-market/collateral-per-pair-refactor.md) | Pending | Market contract, market params (binary and multi-outcome). Supersedes the intermediate `collateral_per_pair` rename — going directly to `base_payout` unifies binary and multi-outcome denomination and makes expiry-redemption divisibility structural. | | Oracle BIP-340 tagged hash | [oracle-bip340-tagged-hash.md](../protocol/oracle-bip340-tagged-hash.md) | Pending | Market contract, oracle attestation | | Remove cosigner from order fill path | [maker-order-remove-cosigner.md](maker-order/maker-order-remove-cosigner.md) | Pending | Order contract, order params | | Rename pool `COSIGNER_PUBKEY` → `ADMIN_PUBKEY` | [maker-order-remove-cosigner.md](maker-order/maker-order-remove-cosigner.md) | Pending | Pool contract, pool params | | Remove order script-cancel path | [maker-order-remove-script-cancel.md](maker-order/maker-order-remove-script-cancel.md) | Pending | Order contract | | Add pool close script path | [lmsr-pool-close-path.md](lmsr-pool/lmsr-pool-close-path.md) | Pending | Pool contract | | Pool params → protocol constants | [lmsr-pool-design.md](lmsr-pool/lmsr-pool-design.md) | Pending | Pool contract | -| Deterministic integer table generation | [lmsr-pool-design.md](lmsr-pool/lmsr-pool-design.md) | Pending — requires formal specification document (exact constants, algorithms, Merkle format, test vectors) | Pool math | +| Deterministic F-value computation (bignum runtime + committed reference Merkle roots) | [lmsr-deterministic-table-spec.md](lmsr-pool/lmsr-deterministic-table-spec.md) | Specified — see the deterministic LMSR runtime decision in the design docs. Runtime implementation in `deadcat-core` uses `num-bigint` + `num-rational` at arbitrary precision; reference generator and committed fixtures live in `deadcat-codegen`. | Pool math | +| Pool denomination: 26-mantissa × 16-exponent → 16-value 1-2-5 table (shared with market encoding) | [chain-only-recovery.md § Pool Denomination](../protocol/chain-only-recovery.md#pool-denomination-1-2-5-table-4-bits-each) | Specified | Pool params, OP_RETURN hint (41 → 40 bytes) | | Covenant-enforced deterministic RT blinding | [deterministic-rt-blinding.md](../protocol/deterministic-rt-blinding.md) | Pending | Market contract (ABF enforcement, CBF pass-through, `verify_token_commitment` refactor) | | Dormant terminal paths (resolution + expiry from zero pairs) | [market-dormant-terminal-paths.md](prediction-market/market-dormant-terminal-paths.md) | Pending | Market contract (DormantYesRt and DormantNoRt slot programs) | | Order remainder witness-parameterization | [transaction-composability-model.md](../architecture/transaction-composability-model.md) | Pending | Order contract (`remainder_idx` from witness instead of `current_index() + 1`) | | Sibling UTXO check + partial cancellation RT co-spend | [enforcement-layers.md](../architecture/enforcement-layers.md) | Pending | Market contract (add `prev_txid` match on all RT+collateral co-spend paths; partial cancellation must co-spend RTs to maintain sibling invariant) | | Burn script: P2WSH → OP_RETURN | [enforcement-layers.md](../architecture/enforcement-layers.md) | Pending | Market contract (`ensure_blinded_reissuance_burn_output` checks bare OP_RETURN script hash instead of P2WSH hash). Rationale: consensus-level unspendability, UTXO set pruning. Blinded OP_RETURN confirmed supported on Elements. | -**Implementation order**: The `.simf` refactors should be applied before implementing `deadcat-core`. The core implementation is specified against the planned end state. +**Implementation order**: The legacy covenant/source alignment work above should be applied before implementing `deadcat-core`. The core implementation is specified against the end state described in this document. ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — market covenant source -- `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — pool covenant source -- `src-tauri/crates/deadcat-sdk/contract/maker_order.simf` — order covenant source -- `src-tauri/crates/deadcat-sdk/src/prediction_market/params.rs` — current market params (pre-refactor) -- `src-tauri/crates/deadcat-sdk/src/lmsr_pool/params.rs` — current pool params (pre-refactor) -- `src-tauri/crates/deadcat-sdk/src/maker_order/params.rs` — current order params (pre-refactor) +- `crates/deadcat-core/contracts/prediction_market.simf` — binary market covenant implementation target +- `crates/deadcat-core/contracts/lmsr_pool.simf` — pool covenant implementation target +- `crates/deadcat-core/contracts/maker_order.simf` — order covenant implementation target +- `crates/deadcat-core/contracts/multi_outcome/` — generated multi-outcome covenant implementation targets diff --git a/docs/contracts/lmsr-pool/future-atomic-issuance-lmsr.md b/docs/contracts/lmsr-pool/future-atomic-issuance-lmsr.md index 44868805..9618159d 100644 --- a/docs/contracts/lmsr-pool/future-atomic-issuance-lmsr.md +++ b/docs/contracts/lmsr-pool/future-atomic-issuance-lmsr.md @@ -1,5 +1,11 @@ # Future Enhancement: Atomic Issuance + LMSR Pool Bootstrap +## Scope Note + +This document is about **brand-new pool bootstrap** only: creating the pool and issuing its starting YES/NO inventory in one transaction. + +It is **not** about existing-pool assisted trading. In the v1 design, `quote_trade` / `build_trade_pset` may already co-spend the parent market to issue or cancel pairs for an **existing** pool when that improves fillability. That existing-pool composition does not require bootstrap-specific covenant changes. See [transaction-composability-model.md](../../architecture/transaction-composability-model.md) and [trade-routing-algorithm.md](../../architecture/trade-routing-algorithm.md). + ## Current Limitation Pool creation requires pre-existing YES, NO, and L-BTC tokens. The user must issue token pairs via the market covenant in a separate transaction before creating an LMSR pool. This adds an extra on-chain step, increases fees, and complicates the UX. diff --git a/docs/contracts/lmsr-pool/lmsr-deterministic-table-spec.md b/docs/contracts/lmsr-pool/lmsr-deterministic-table-spec.md index baa5d383..a7223ffc 100644 --- a/docs/contracts/lmsr-pool/lmsr-deterministic-table-spec.md +++ b/docs/contracts/lmsr-pool/lmsr-deterministic-table-spec.md @@ -1,6 +1,6 @@ # LMSR Deterministic Table Specification -**Status**: Skeleton — defines what needs to be specified. The exact algorithm, constants, and test vectors are to be filled in during implementation. +**Status**: Specified — runtime algorithm is arbitrary-precision bignum computation of the closed-form F-value expression, with committed reference Merkle roots as regression fixtures in `deadcat-codegen`. No fixed-point precision tuning, no Taylor-series term-count bound, no precomputed transcendental constants are needed. ## Overview @@ -18,6 +18,27 @@ The `LMSR_TABLE_ROOT` is a compile-time parameter baked into the Simplicity prog The swap witness provides six values: `old_s_index`, `new_s_index`, `f_old`, `f_new`, `old_proof`, `new_proof`. The covenant: +#### Public-path quote definitions + +The LMSR public path uses these exact integer definitions: + +- `FEE_DENOM = 10_000` +- `fee_c = FEE_DENOM - fee_bps` +- `L = traded_lots × half_payout_sats` +- `base_notional` is the signed-safe pre-fee quote computed from `L`, `f_old`, and `f_new` + +Signed-safe `base_notional` construction: + +- If `f_new >= f_old`, let `d = f_new - f_old` + - buy path: `base_notional = L + d` + - sell path: `base_notional = L - d` +- If `f_old > f_new`, let `d = f_old - f_new` + - buy path: `base_notional = L - d` + - sell path: `base_notional = L + d` +- Any subtraction underflow (`L < d`) makes the transition invalid. + +`base_cost` is the buy-path interpretation of `base_notional`. `base_rebate` is the sell-path interpretation of `base_notional`. + 1. **Verifies two Merkle proofs** — confirms that `(old_s_index, f_old)` and `(new_s_index, f_new)` are committed leaves under `LMSR_TABLE_ROOT`: ``` leaf = SHA256(0x00 || "LMSR_TBL_V1" || be64(index) || be64(value)) @@ -25,12 +46,12 @@ The swap witness provides six values: `old_s_index`, `new_s_index`, `f_old`, `f_ ``` Each proof is a list of `(sibling_hash, is_right)` pairs, folded from leaf to root. The covenant asserts the computed root equals `LMSR_TABLE_ROOT` and the proof depth equals `TABLE_DEPTH`. -2. **Uses f_old and f_new in the conservation equation** — the swap path computes `base_notional` from `f_old`, `f_new`, `traded_lots`, and `half_payout_sats`, then enforces fee-adjusted pricing via 128-bit integer inequality: +2. **Uses f_old and f_new in the conservation equation** — the public path's LMSR movement computes `base_notional` from `f_old`, `f_new`, `traded_lots`, and `half_payout_sats`, then enforces fee-adjusted pricing via 128-bit integer inequality: ``` - buy: delta_in × fee_c ≥ base_notional × FEE_DENOM - sell: delta_out × FEE_DENOM ≤ base_notional × fee_c + buy: delta_in × fee_c ≥ base_cost × FEE_DENOM + sell: delta_out × FEE_DENOM ≤ base_rebate × fee_c ``` - where `fee_c = FEE_DENOM - FEE_BPS` and all arithmetic is integer-only. + where `base_cost` / `base_rebate` are the buy/sell interpretations of `base_notional`, `fee_c = FEE_DENOM - fee_bps`, and all arithmetic is integer-only. 3. **Verifies trade direction** — `new_s_index > old_s_index` for buys, `<` for sells. @@ -105,9 +126,7 @@ assert current == LMSR_TABLE_ROOT ## F-Value Computation Algorithm -**NOT YET SPECIFIED** — this is the section that requires implementation work. - -### The mathematical definition +### Mathematical definition ``` F(i) = floor(b × ln(exp(q_yes(i)/b) + exp(q_no(i)/b))) @@ -119,60 +138,49 @@ where: S_BIAS = 32,768 (protocol constant) ``` -Since `q_no = -q_yes`, this simplifies to: -``` -F(i) = floor(b × ln(exp(s/b) + exp(-s/b))) - = floor(b × ln(2 × cosh(s/b))) - = floor(b × (ln(2) + ln(cosh(s/b)))) - = floor(max_loss_sats + b × ln(cosh(s/b))) +Since `q_no = -q_yes`, this simplifies (via `exp(x) + exp(-x) = 2 × cosh(x)` and `b × ln(2) = max_loss_sats`) to: +``` +F(i) = max_loss_sats + floor(b × ln(cosh(s/b))) where s = q_yes(i) ``` -At `i = S_BIAS`: `s = 0`, `cosh(0) = 1`, `ln(1) = 0`, so `F(S_BIAS) = floor(max_loss_sats)` = the minimum F-value (the pool's maximum loss). +At `i = S_BIAS`: `s = 0`, `cosh(0) = 1`, `ln(1) = 0`, so `F(S_BIAS) = max_loss_sats` (the minimum F-value, representing the pool's maximum loss). -### What the algorithm must define +The per-index range of `s/b` is bounded by construction of `q_step_lots`: `|s/b| ≤ ln(999)/2 ≈ 3.45`. This bounds the working range of the transcendental evaluation. -Each of the following must be specified with exact precision, rounding mode, and intermediate representation: +### Runtime algorithm: arbitrary-precision bignum -1. **Computation of `b` from `max_loss_sats`** - - Mathematical: `b = max_loss_sats / ln(2)` - - Requires: exact rational approximation of `1/ln(2)` (or `ln(2)` for division) - - Output: `b` as a fixed-point value with defined precision - - Rounding: specified (e.g., round-to-nearest, truncate) +The runtime algorithm is direct arbitrary-precision evaluation of the closed-form expression above. Specifically: -2. **Computation of `q_step_lots` from `b` and `half_payout_sats`** - - Mathematical: `q_step_lots = max(1, ceil(ln(999) × b / (65536 × half_payout_sats)))` - - Equivalent: `q_step_lots = max(1, ceil(ln(999) × max_loss_sats / (65536 × ln(2) × half_payout_sats)))` - - Output: u64 - - Rounding: ceiling, floored at 1 - - `ln(999) ≈ 6.9078` is a **protocol-fixed derivation constant** encoding the 0.1%-99.9% price range target. The table's edge indices (`i = 0` and `i = S_MAX_INDEX`) correspond to implied YES prices of approximately 0.1% and 99.9% when `q_step_lots` equals this formula's result. The exact rational approximation of `ln(999)` is part of the deterministic integer algorithm specification. - - For most practical pool parameters, `q_step_lots = 1`. The formula only produces values > 1 for very deep pools (approximately `max_loss_sats > 6,583 × half_payout_sats`). +1. **Dependencies**: `num-bigint` for arbitrary-precision integer arithmetic and `num-rational` for exact rational arithmetic. For transcendental evaluation (`cosh`, `ln`), compute to sufficient working precision (nominally 200+ bits) via any deterministic method — Taylor series at high precision, continued fractions, or a deterministic MPFR-backed implementation are all acceptable choices. The reference implementation in `deadcat-codegen` picks one method; other implementations must match the reference's F-values byte-for-byte. -3. **Evaluation of `F(i)` for each `i` in `[0, 2^TABLE_DEPTH)`** - - Mathematical: `F(i) = floor(b × ln(exp(s/b) + exp(-s/b)))` where `s = (i - S_BIAS) × q_step_lots × half_payout_sats` - - Requires: deterministic evaluation of `exp` and `ln` (or `cosh` and `ln`) at sufficient precision - - All intermediates must use defined-precision integer/fixed-point arithmetic - - No `f64` — IEEE 754 does not guarantee deterministic `exp`/`ln` across platforms - - Output: u64 (the `floor()` of the high-precision result) +2. **Precision budget**: working precision must be sufficient that the final `floor()` to u64 is correct across the entire param space. For `max_loss_sats ≤ 10^16` and `|s/b| ≤ 3.45`, 200 bits of working precision in rational/high-precision floating intermediate yields `floor()` correctness with ~50 bits of margin. No fixed-point analysis required. -4. **Point evaluation (same algorithm, single index)** - - Used by `quote_trade` for quoting (~1μs per evaluation) - - Must produce bit-identical results to the table generation path - - Performance target: ~16 evaluations in ~16μs (binary search over s_index range) +3. **Derivation chain** (see [Derivation Chain Summary](#derivation-chain-summary) for the full pipeline): + - `b = max_loss_sats / ln(2)` — stored as a high-precision rational or arbitrary-precision float. + - `q_step_lots = max(1, ceil(ln(999) × b / (65536 × half_payout_sats)))` — output is u64; ceiling rounding. + - For each `i ∈ [0, 65536)`: compute `s`, then `F(i) = max_loss_sats + floor(b × ln(cosh(s/b)))` with final `floor()` rounding to u64. -### Algorithm design considerations +4. **No fixed-point precision tuning, no Taylor term-count tuning, no precomputed irrational constants.** The bignum precision budget is deliberately over-provisioned so that implementation details of the transcendental step do not affect output correctness. Two compliant implementations using different precision levels (as long as both exceed the budget) produce identical F-values. -**Approach options** (to be evaluated during implementation): +5. **Caching strategy**: `deadcat-core` caches generated F-value tables per unique `(max_loss_sats, half_payout_sats)` pair to amortize the bignum cost. First-use cost per pool parameter combination is on the order of 5–10 seconds (bignum is slow); subsequent lookups on the cached table are O(1). Cache is stored in memory and optionally persisted to disk by the consuming wallet layer. -- **Fixed-point Taylor series for `exp(x)`**: Express `exp(x) = 2^k × exp(r)` where `r` is small, compute `exp(r)` via truncated Taylor series with defined term count and precision. `ln` via inverse or separate series. -- **Fixed-point `cosh` directly**: `cosh(x) = (exp(x) + exp(-x))/2`. Avoids the log-sum-exp decomposition. Even Taylor terms only: `cosh(x) = 1 + x²/2! + x⁴/4! + ...` -- **CORDIC**: Iterative shift-and-add algorithm for hyperbolic functions. Deterministic by construction but potentially slower. -- **High-precision rational arithmetic**: Use a bignum library with exact rational intermediates, convert to u64 at the end. Simplest to reason about correctness but may be slow for 65K evaluations. +### Protocol constants -**Key constraint**: The algorithm must be fast enough that generating 65,536 F-values takes ≤ ~100ms. Point evaluation must be ≤ ~1μs. +- `ln(2)`, `ln(999)` — no precomputed approximation is committed at the protocol level. Implementations compute or embed these at their own chosen precision, provided the overall F-value output matches the reference. +- `S_BIAS = 32,768`, `S_MAX_INDEX = 65,535`, `TABLE_DEPTH = 16` — unchanged, integer constants. +- `ln(999)` encodes the 0.1%–99.9% price range target (the table edges correspond to implied YES prices of ~0.1% and ~99.9%). -**Precision requirement**: The `floor()` to u64 must be correct. This means the high-precision intermediate must have enough fractional bits that the error is < 1.0 at the final step. For the parameter ranges in practice (`max_loss_sats` in the 26-value set, `half_payout_sats` similarly), the F-values range from `max_loss_sats` (minimum, at `S_BIAS`) to roughly `max_loss_sats + b × ln(999)` (maximum, at the table edges). The absolute values are in the millions-to-billions range (sats), so 64-bit integer part + ~32 fractional bits should suffice. To be verified during implementation. +### Why bignum over fixed-point Taylor + +Several alternatives were considered and rejected in favor of bignum: + +- **Fixed-point Taylor + range reduction**: fast at runtime (~100ms for a full table, ~1μs per point evaluation) but requires careful precision analysis, worked examples, and a correctness proof ("Taylor matches reference for all 256 combos"). Substantial spec surface area for a marginal runtime-performance benefit. +- **CORDIC**: similar complexity concerns; no clear win over Taylor. +- **Hybrid (bignum for compile-time Merkle root, Taylor for runtime)**: can be added later as a pure optimization. The committed reference Merkle roots (see [Reference Fixtures](#reference-fixtures)) serve as the canonical acceptance criterion for any alternative implementation; switching to Taylor in a future release is non-breaking provided all reference roots reproduce byte-for-byte. + +For v1, bignum-only prioritizes correctness and spec simplicity over runtime performance. The cold-start cost (5–10 seconds per new pool combo, one-time per install) is acceptable given that pool creation and first-ingest events are infrequent compared to trading activity. ## Derivation Chain Summary @@ -180,38 +188,55 @@ Each of the following must be specified with exact precision, rounding mode, and Input: max_loss_sats (u64), half_payout_sats (u64) │ ▼ -Step 1: b = max_loss_sats / ln(2) [fixed-point, precision TBD] +Step 1: b = max_loss_sats / ln(2) [bignum rational / high-precision float] │ ▼ -Step 2: q_step_lots = max(1, ceil(ln(999) × b / (65536 × half_payout_sats))) [u64] +Step 2: q_step_lots = max(1, ceil(ln(999) × b / (65536 × half_payout_sats))) [u64, ceiling rounding] │ ▼ -Step 3: For i in 0..65536: [fixed-point, precision TBD] +Step 3: For i in 0..65536: [bignum rational / high-precision float] s = (i - 32768) × q_step_lots × half_payout_sats - F(i) = floor(b × ln(exp(s/b) + exp(-s/b))) + F(i) = max_loss_sats + floor(b × ln(cosh(s/b))) [final floor to u64] │ ▼ -Step 4: Merkle root from F-values [SHA256, already specified] +Step 4: Merkle root from F-values [SHA256, already specified — see Merkle Tree Format] ``` -Steps 1-3 need the deterministic integer algorithm. Step 4 is already implemented and matches the `.simf`. +All arithmetic operations use the same bignum precision budget. No intermediate step uses a lower precision than the rest. + +## Reference Fixtures + +Correctness validation uses the committed Merkle root approach rather than per-index test vectors: + +- **`deadcat-codegen`** (dev-only crate) contains the bignum reference implementation and a committed fixture file mapping each of the 256 `(max_loss_sats, half_payout_sats)` parameter combinations to: + - Its canonical Merkle root (32 bytes). + - Anchor F-values at key indices (`F(0)`, `F(S_BIAS)`, `F(S_MAX_INDEX)`) for human inspection and debugging. + - The resolved `q_step_lots`. +- **Regression test** runs on every `cargo test`: re-execute the bignum reference for each of the 256 combos, assert each computed Merkle root matches the committed fixture value. If the bignum implementation or its dependencies ever produce different output (e.g., `num-bigint` version bump changes rounding behavior), this test fails loudly. +- **Regeneration**: `just regenerate-lmsr-fixtures` invokes the reference generator to emit fresh fixture content. Run by developers when an intentional algorithm change (or parameter-space expansion) is being committed. +- **Cross-implementation conformance**: future alternative implementations (Taylor, CORDIC, cross-language ports) target the same committed Merkle roots. Any implementation reproducing all 256 roots byte-for-byte is provably equivalent to the bignum reference over the entire valid parameter space. +- **No per-index test vector is committed** — the Merkle root over 65,536 F-values is a stronger equivalence check than any finite sample of individual F-values. If two implementations agree on the root, they agree on every F-value. + +## Precision Calibration + +The "200+ bits working precision is sufficient" claim is empirically validated, not assumed. During `deadcat-codegen` development, a one-time calibration run establishes the minimum safe precision for the chosen bignum method: + +1. **Establish ground truth**: generate all 256 Merkle roots at a very high precision (e.g., 512 bits). Re-generate at 1024 bits and assert the results match byte-for-byte. If they don't, the starting precision is too low — go higher. Once two consecutive precision levels agree, the higher one is "trusted truth." +2. **Binary-search downward**: halve the precision repeatedly (512 → 256 → 128 → 64 → …). At each level, regenerate all 256 roots plus their underlying 16.7M F-values (65,536 per combo × 256 combos) and compare to ground truth. +3. **Record the threshold**: the first precision where any root or F-value disagrees with ground truth is the "minimum safe precision" for this bignum method. The precision budget pinned in the spec (nominally 200 bits) must exceed this threshold by a comfortable margin. -## Test Vectors +The calibration is a **one-time development artifact**, not a per-CI regression. Its output is a documented fact pinned in the satellite spec (e.g., "empirical minimum for our bignum method is X bits; we use 200 bits for Y bits of safety margin"). The ongoing regression check remains the committed Merkle roots — any implementation that reproduces them byte-for-byte is provably equivalent. -**To be generated after the algorithm is implemented.** The test vector set should cover: +The threshold is **specific to the chosen bignum method** (Taylor series at high precision, continued fractions, MPFR-backed evaluation, etc.). An alternative implementation with a different method may have a different minimum; it only needs to reproduce the committed roots, not the threshold. Implementations whose published conformance demands that they document their precision strategy (for auditability) may cite this calibration result as the justification for their chosen working precision. -1. **Minimum viable params**: smallest `max_loss_sats` and `half_payout_sats` in the 26-value convention set -2. **Typical params**: mid-range values representative of real pools -3. **Maximum params**: largest values in the convention set -4. **Edge indices**: `F(0)`, `F(S_BIAS)`, `F(S_MAX_INDEX)` — the extremes and the minimum -5. **Symmetry check**: `F(S_BIAS - k)` should equal `F(S_BIAS + k)` for all valid `k` (the cost function is symmetric around the bias point) -6. **Full Merkle root**: at least one complete set of `(max_loss_sats, half_payout_sats) → q_step_lots → all 65536 F-values → Merkle root` -7. **Point evaluation consistency**: verify that evaluating `F(i)` individually produces the same value as the table generation path for all `i` in a test case +A CLI subcommand in `deadcat-codegen` (`just calibrate-precision`) runs the calibration and emits a report. Integrators and auditors can re-run it against a fork or alternative implementation to validate the precision claim. ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — `lmsr_table_leaf_hash`, `lmsr_table_node_hash`, `merkle_proof_step_fn` (the on-chain verification code — the authoritative definition of the Merkle format) -- `src-tauri/crates/deadcat-sdk/src/lmsr_pool/table.rs` — Rust-side Merkle tree (leaf hash, node hash, root, proof generation/verification — matches `.simf` byte-for-byte) -- `src-tauri/crates/deadcat-sdk/src/lmsr_pool/math.rs` — quoting logic (`quote_from_table`, `quote_exact_input_from_manifest`, `fee_free_yes_spot_price_bps`) +- `crates/deadcat-core/contracts/lmsr_pool.simf` — `lmsr_table_leaf_hash`, `lmsr_table_node_hash`, `merkle_proof_step_fn` (the on-chain verification code — the authoritative definition of the Merkle format) +- `crates/deadcat-core/src/lmsr_pool/table.rs` — Rust-side Merkle tree (leaf hash, node hash, root, proof generation/verification — matches `.simf` byte-for-byte) +- `crates/deadcat-core/src/lmsr_pool/math.rs` — quoting logic (`quote_from_table`, `quote_exact_input_from_manifest`, `fee_free_yes_spot_price_bps`) +- `crates/deadcat-codegen/` (planned, shared with the multi-outcome `.simf` generator) — bignum reference implementation and committed fixture file with per-combo Merkle roots and anchor F-values +- `crates/deadcat-core/` — runtime F-value computation (bignum-based), per-pool-combo in-memory cache - `docs/contracts/lmsr-pool/lmsr-pool-design.md` — pool parameter design, derivation formulas, deterministic generation rationale -- `docs/architecture/deadcat-core-design.md` — LMSR Math section, point evaluation vs full table distinction +- `docs/architecture/deadcat-core-design.md` — LMSR Math section, cached-table runtime model and reserve-aware routing notes diff --git a/docs/contracts/lmsr-pool/lmsr-pool-close-path.md b/docs/contracts/lmsr-pool/lmsr-pool-close-path.md index ad9b9ceb..eff5db27 100644 --- a/docs/contracts/lmsr-pool/lmsr-pool-close-path.md +++ b/docs/contracts/lmsr-pool/lmsr-pool-close-path.md @@ -77,14 +77,13 @@ Note: Outpoints are internal to the engine and not exposed in the public state. ### New PSET Builder ```rust -pub fn build_lmsr_close_pset( +pub fn build_close_pset( &self, - contract_id: &ContractId, funding: &WalletFunding, ) -> Result>; ``` -Takes only the contract ID and wallet funding. The engine reads the current reserves from the stored state, compiles the covenant for witness encoding, and builds the PSET. All reserve outputs go to `funding.return_script`. +Takes only wallet funding. The `Pool` view already identifies the contract being closed; the engine reads the current reserves from stored state, compiles the covenant for witness encoding, and builds the PSET. All reserve outputs go to `funding.return_script`. ### State Advancement @@ -104,7 +103,9 @@ Takes only the contract ID and wallet funding. The engine reads the current rese Maker orders intentionally use a real internal key — the maker's ability to key-spend is the sole cancellation mechanism. The Simplicity program handles fills only; cancellation is exclusively via key-spend. See [maker-order-remove-script-cancel.md](../maker-order/maker-order-remove-script-cancel.md). Markets and pools use NUMS because their lifecycle is governed by covenant logic, not a single party's key. -## Key Files +## Legacy Source Touchpoints + +These are the current `deadcat-sdk` files where this legacy-source delta exists today. The `deadcat-core` implementation should realize the same behavior in its new pool contract modules. - `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — add close path to primary program - `src-tauri/crates/deadcat-sdk/src/lmsr_pool/contract.rs` — compilation (no structural change — same leaves) diff --git a/docs/contracts/lmsr-pool/lmsr-pool-design.md b/docs/contracts/lmsr-pool/lmsr-pool-design.md index e11decd4..71104cf0 100644 --- a/docs/contracts/lmsr-pool/lmsr-pool-design.md +++ b/docs/contracts/lmsr-pool/lmsr-pool-design.md @@ -33,7 +33,7 @@ The pool holds three reserves: When a trader buys YES tokens, they pay collateral and receive YES tokens from the pool. The cost is `C(s2) - C(s1)` (plus fees), where s1 → s2 is the state movement. The pool's YES reserve decreases and collateral increases. The reverse for sells. -The reserves determine the pool's **capacity** — how many trades it can absorb before hitting minimum reserve limits. The cost function determines the **pricing** — how much each trade costs. These are independent: a pool can have deep pricing (high `b`) with limited capacity (low reserves), or vice versa. +The reserves determine the pool's **capacity** — how many trades it can absorb before hitting minimum reserve limits. The cost function determines the **pricing** — how much each trade costs. These axes are orthogonal at the covenant level but coupled in practice: the curve (`b`, `q_step_lots`, `s_index`) determines marginal pricing, while the live reserves determine how far the pool can currently travel along that curve before a reserve floor is hit. A pool can therefore have deep pricing (high `b`) with limited current capacity (lean reserves), or vice versa. ### Discretization and the Merkle-Committed Curve @@ -59,9 +59,9 @@ A pool creator specifies exactly four values: | Parameter | Type | Description | |---|---|---| | `max_loss_sats` | `u64` | Maximum possible loss for the pool (worst case). Determines market depth. | -| `fee_bps` | `u16` | Swap fee in basis points (0-9999). Pool operator's revenue per trade. | +| `fee_bps` | `u16` | Swap fee in basis points (public type `u16`; v1 convention-valid range `0..=4095`). Pool operator's revenue per trade. | | `half_payout_sats` | `u64` | Denomination — sats per "lot" of outcome tokens. Determines the monetary scale. | -| `starting_price_bps` | `u16` | Starting YES price in basis points (0-10000). Where the pool begins on the curve. | +| `starting_price_bps` | `u16` | Starting YES price in basis points (`0 < price < 10000`). Where the pool begins on the curve. | Everything else is either derived or a protocol constant. @@ -71,9 +71,9 @@ Everything else is either derived or a protocol constant. |---|---|---| | `b` | `max_loss_sats` | `b = max_loss_sats / ln(2)` (deterministic integer math) | | `q_step_lots` | `b`, `half_payout_sats` | Derived to ensure the 0.1%-99.9% price range fits within the table. For most pools, `q_step_lots = 1`. See [lmsr-deterministic-table-spec.md](lmsr-deterministic-table-spec.md) for the canonical formula. | -| `s_index` (initial) | `starting_price_bps` | Nearest valid s_index for the requested price, derived from the inverse logistic function | +| `s_index` (initial) | `starting_price_bps` | Nearest valid s_index for the requested price. Computed inside `estimate_bootstrap` and returned as `initial_s_index`; downstream (`derive_pool_params`, `build_lmsr_bootstrap_pset`, the OP_RETURN hint) consumes the snapped value directly — no inverse conversion lives anywhere. | | `lmsr_table_root` | `b`, `half_payout_sats`, `q_step_lots` | Merkle root of the deterministically generated F-value table | -| Initial reserves | `b`, `starting_price_bps`, `half_payout_sats` | Balanced allocation — equal trading depth in both directions from starting price | +| Initial reserves | `b`, `starting_price_bps`, `half_payout_sats`, `MIN_POOL_RESERVE` | `estimate_bootstrap` returns the canonical default bootstrap: the smallest reserve vector that lets the pool move from the snapped starting state to the inward-snapped useful 0.1%-99.9% band while preserving minimum reserves. Callers may still choose different explicit reserves at creation time. | ### Protocol Constants @@ -84,20 +84,24 @@ Everything else is either derived or a protocol constant. | `S_MAX_INDEX` | 65,535 | Full table range. The LMSR cost function naturally makes extremes expensive — no need to artificially limit. | | `MIN_POOL_RESERVE` | 1,000 sats | Applied to all three reserves (YES, NO, Collateral). Well above Liquid's dust limit (~546 sats), negligible locked capital (3,000 sats total per pool). | +**Encoding in `.simf`**: SimplicityHL lacks `const::` declarations, so protocol constants are encoded as zero-argument functions that return the literal value (e.g., `fn min_pool_reserve() -> u64 { 1000 }`), with call sites referencing `min_pool_reserve()` instead of the raw literal. This produces CMRs identical to inline literals (SimplicityHL inlines the function at compile time) while giving auditors a single named declaration per constant — easier to review than searching for `1000` throughout the program. All four constants are hard-baked covenant structure, not params: `TABLE_DEPTH` is structurally required (Merkle verification is unrolled 16 times in the program source because SimplicityHL has no loops); `S_BIAS` and `S_MAX_INDEX` derive from `TABLE_DEPTH` and mismatching them would be semantically nonsensical; `MIN_POOL_RESERVE` has no strategic decision to make at the per-pool level (it's a dust floor). The only amount-axis parameter is per-pool (`max_loss_sats`, `half_payout_sats`). + ### Why Fixed Depth 16 The table depth determines the number of discrete price points (2^depth) and affects Merkle proof size: -| Depth | Price points | Proof size (2 per swap) | Table in memory | Generation time | +| Depth | Price points | Proof size (2 per swap) | Table in memory | Relative bignum cold-cache cost | |---|---|---|---|---| -| 12 | 4,096 | ~784 B | 32 KB | ~5ms | -| 14 | 16,384 | ~912 B | 128 KB | ~20ms | -| **16** | **65,536** | **~1,040 B** | **512 KB** | **~80ms** | -| 18 | 262,144 | ~1,168 B | 2 MB | ~300ms | -| 20 | 1,048,576 | ~1,296 B | 8 MB | ~1s | +| 12 | 4,096 | ~784 B | 32 KB | Lower | +| 14 | 16,384 | ~912 B | 128 KB | Low | +| **16** | **65,536** | **~1,040 B** | **512 KB** | **Moderate** | +| 18 | 262,144 | ~1,168 B | 2 MB | High | +| 20 | 1,048,576 | ~1,296 B | 8 MB | Very high | Depth 16 ensures `q_step_lots = 1` for pools up to ~33M sats max loss, at a negligible cost of ~26 extra sats per swap compared to depth 12. The pricing granularity near 50% is determined by `max_loss_sats`, not the table depth — when `q_step_lots = 1`, all depths produce identical pricing. The depth only matters for how large a pool can be before `q_step_lots` bumps above 1 (coarsening the minimum trade size). The 512 KB table is trivial to hold in memory. +With the v1 bignum runtime, exact wall-clock generation times depend on the implementation and hardware. What matters architecturally is that the cold-cache cost grows with table size and is amortized by per-combo caching; depth 16 remains the best trade-off between proof size, memory footprint, and headroom before `q_step_lots` coarsens. + A fixed depth means: - Single `.simf` file with no metaprogramming or template-based code generation - All pools share the same Merkle verification structure (same covenant program for the proof-checking logic) @@ -148,11 +152,13 @@ The LMSR cost function `C(s) = b × ln(exp(s/b) + exp(-s/b))` involves transcend This matters because the F-values are committed to via a Merkle root. If two implementations produce different F-values from the same parameters, they produce different Merkle roots, and one of them won't match the on-chain commitment. -### The Solution: Deterministic Integer Algorithm +### The Solution: Deterministic Bignum Reference -`deadcat-core` defines a canonical integer-only algorithm for generating F-values. The algorithm uses only operations with guaranteed deterministic results (addition, subtraction, multiplication, division, bit shifts) at sufficient precision (128-bit or higher intermediates) to produce bit-identical F-values on any platform. +`deadcat-core` generates F-values via arbitrary-precision bignum evaluation of the closed-form expression `F(i) = max_loss_sats + floor(b × ln(cosh(s/b)))`. Working precision is deliberately over-provisioned (nominally 200+ bits via `num-bigint` + `num-rational`) so that implementation details of the transcendental step do not affect output — any compliant implementation produces the identical `Vec` of F-values, and thus the identical Merkle root, for a given `(b, half_payout_sats, q_step_lots)`. -The specific algorithm (fixed-point arithmetic with defined precision, or series expansion with a fixed number of terms) requires a formal specification document. The key property is: **given the same `(b, half_payout_sats, q_step_lots)`, every implementation produces the identical `Vec` of F-values and thus the identical Merkle root.** The derivation chain involves transcendental constants (`1/ln(2)` for computing `b` from `max_loss_sats`, `ln(999) ≈ 6.9` for `q_step_lots`) and the cost function `b × ln(exp(s/b) + exp(-s/b))` — all of which must use exact rational approximations and defined-precision fixed-point arithmetic for cross-implementation determinism. The specification must also define the Merkle tree construction (hash function, leaf encoding) to match the `.simf` covenant's verification code. A separate satellite document with exact constants, algorithms, and test vectors is required before implementation. +The full specification — derivation chain, Merkle tree format, precision budget, and reference fixtures — lives in [lmsr-deterministic-table-spec.md](lmsr-deterministic-table-spec.md). Cross-implementation conformance is anchored to a committed fixture set in `deadcat-codegen`: 256 Merkle roots (one per `(max_loss_sats, half_payout_sats)` combo) that any alternative implementation must reproduce byte-for-byte. + +**v1 ships bignum-only.** A hybrid implementation (bignum at compile-time, fixed-point Taylor at runtime) is a non-breaking performance optimization for a future release — switching implementations only requires reproducing the same 256 committed Merkle roots. See the plan's [deferred items](../../architecture/deadcat-core-implementation-plan.md#deferred--out-of-scope-items). ### Implications @@ -164,9 +170,11 @@ Deterministic generation eliminates several problems: 4. **Caveat emptor resolved**: Anyone can verify a pool's curve is well-formed by regenerating the table from params and inspecting the F-values. No trust in the pool creator's off-chain claims. 5. **`interpret_transaction` works without stored manifests**: The engine regenerates the table for any pool whose params are known. -The generation cost (~80ms for depth 16) is acceptable for one-time operations at pool ingestion or PSET building. +The generation cost (~5-10 seconds per `(max_loss_sats, half_payout_sats)` combo at bignum precision per [lmsr-deterministic-table-spec.md § Reference Fixtures](lmsr-deterministic-table-spec.md#reference-fixtures)) is acceptable for one-time operations amortized by caching. `deadcat-core` maintains an in-memory cache keyed by combo; subsequent lookups are O(1). + +**Quoting via cached tables**: At bignum precision, individual F-value computations are ms-scale rather than microsecond-scale, so on-demand point evaluation during `quote_trade` would be prohibitively slow for multiple candidate pools. Instead, `deadcat-core` maintains an in-memory cache of full F-value tables keyed by `(max_loss_sats, half_payout_sats)` combos. The first quote for a given combo incurs the ~5-10s full-table generation cost; subsequent quotes (same pool or any pool sharing the combo) are O(1) lookups against the cache. With the 256-combo v1 param space and typical wallet usage, the cache amortizes well. -**Point evaluation for quoting**: The quoting hot path (`quote_trade`) does NOT need the full 65K-entry table. It evaluates the cost function at specific points using the same deterministic integer algorithm (~1us per evaluation). A binary search to find the optimal `new_s_index` for a given input amount requires ~16 evaluations = ~16us per pool. Compare: full table generation = ~80ms. This means `quote_trade` evaluating 5 candidate pools costs ~80us total, with no table caching needed. The full table is only required for Merkle proof generation (`build_trade_pset`, `build_lmsr_bootstrap_pset`) and pool ingestion verification — infrequent, user-initiated operations where ~80ms is acceptable. +A fixed-point Taylor runtime (deferred to v2 per the [implementation plan](../../architecture/deadcat-core-implementation-plan.md#deferred--out-of-scope-items)) would restore microsecond-scale point evaluation and enable uncached quoting if needed. Since the committed Merkle roots are the cross-implementation conformance set, that switch is non-breaking. ## Pool Lifecycle @@ -175,17 +183,17 @@ The generation cost (~80ms for depth 16) is acceptable for one-time operations a The pool creator: 1. Specifies `max_loss_sats`, `half_payout_sats`, `fee_bps`, `starting_price_bps` -2. Calls `estimate_bootstrap(max_loss_sats, half_payout_sats, starting_price_bps)` to see the required reserves (YES tokens, NO tokens, collateral) — lightweight, called on every slider change (note: `fee_bps` does not affect reserves) -3. Obtains the required YES and NO tokens by issuing pairs on the parent prediction market -4. Calls `derive_pool_params(deadcat_xprv, market_params, pool_index, ...)` to construct the full `LmsrPoolParams` with all derived fields (admin pubkey, table root, q_step_lots, asset IDs) and the XOR-masked pool index — heavier, called once when the user commits to creating -5. Calls `build_lmsr_bootstrap_pset(¶ms, starting_price_bps, masked_index, &funding)` to build the transaction +2. Calls `estimate_bootstrap(max_loss_sats, half_payout_sats, starting_price_bps)` to see the **canonical default** reserves (YES tokens, NO tokens, collateral) and the resulting `initial_s_index` — lightweight, called on every slider change (note: `fee_bps` does not affect the estimate). The snap function (`starting_price_bps → initial_s_index`) lives here; every downstream consumer reads `initial_s_index` from this result. +3. Accepts those default reserves or overrides them with an explicit reserve vector, then obtains the needed YES and NO tokens by issuing pairs on the parent prediction market +4. Calls `derive_pool_params(deadcat_xprv, market_params, outcome, pool_index, max_loss_sats, half_payout_sats, fee_bps, initial_s_index)` to construct the full `LmsrPoolParams` with all derived fields (admin pubkey, table root, q_step_lots, asset IDs) and the XOR-masked pool index — heavier, called once when the user commits to creating +5. Calls `build_lmsr_bootstrap_pset(¶ms, initial_s_index, initial_reserves, masked_index, &funding)` to build the transaction 6. Signs and broadcasts -`derive_pool_params` is a standalone pure function that takes the parent market's `PredictionMarketParams` (for asset IDs), the creator's four params, and the admin pubkey (from mnemonic). It derives `b`, `q_step_lots`, generates the F-value table deterministically, computes the Merkle root, and returns a fully-formed `LmsrPoolParams`. The builder then compiles the Simplicity covenant from these params and constructs the creation transaction with three reserve outputs (YES, NO, Collateral) and an OP_RETURN recovery hint. +`derive_pool_params` is a standalone pure function that takes the parent market's `MarketParams` umbrella (binary or multi-outcome), an `OutcomeIndex` selecting which outcome's YES/NO pair the pool serves (pass `OutcomeIndex::BINARY` for binary markets), the creator's four params plus `initial_s_index`, and derives the admin pubkey internally from the mnemonic. It derives `b`, `q_step_lots`, generates the F-value table deterministically, computes the Merkle root, and returns a fully-formed `LmsrPoolParams`. The builder then compiles the Simplicity covenant from these params and constructs the creation transaction with three reserve outputs (YES, NO, Collateral) and an OP_RETURN recovery hint, using the caller-specified `initial_reserves`. Note: an integrator COULD construct `LmsrPoolParams` manually (it's a plain data struct with public fields), but `derive_pool_params` is strongly recommended because it guarantees the canonical deterministic table generation algorithm is used. A different implementation would produce a different Merkle root, and the covenant would reject all swaps. -The starting `s_index` is computed from `starting_price_bps` — the engine maps the requested price to the nearest valid discrete s_index. The initial reserves are computed as a balanced allocation: equal trading depth in both directions from the starting price. +`initial_s_index` represents the nearest valid discrete s_index for the requested starting price. It is computed by `estimate_bootstrap` (the UI uses the returned value for live feedback), passed through `derive_pool_params` and `build_lmsr_bootstrap_pset` unchanged, and stored directly in the pool OP_RETURN for recovery. The default reserves returned by `estimate_bootstrap` are a policy recommendation, not a hidden covenant requirement: the creation transaction's actual reserve outputs are authoritative. ### Estimation @@ -194,59 +202,96 @@ pub fn estimate_bootstrap( max_loss_sats: u64, half_payout_sats: u64, starting_price_bps: u16, -) -> BootstrapEstimate; +) -> Result; pub struct BootstrapEstimate { pub initial_yes_reserve: u64, pub initial_no_reserve: u64, pub initial_collateral_reserve: u64, - pub initial_s_index: u64, + pub initial_s_index: u16, +} + +pub enum BootstrapError { + InvalidStartingPriceBps { starting_price_bps: u16 }, + ArithmeticOverflow, } ``` -A standalone pure function (no engine needed). The UI calls this on every slider change for live feedback — sub-millisecond, just LMSR math. The three reserves tell the operator exactly how many tokens and how much collateral to provide. `initial_s_index` corresponds to the nearest valid LMSR curve point for the requested starting price (may differ slightly due to discretization). `starting_price_bps` must be in (0, 10000) exclusive — 0% and 100% are rejected (infinite reserve ratios). +A standalone pure function (no engine needed). The UI calls this on every slider change for live feedback. It returns the **canonical default** bootstrap plan, not the only valid one. `initial_s_index` corresponds to the nearest valid LMSR curve point for the requested starting price (may differ slightly due to discretization). `starting_price_bps` must be in `(0, 10000)` exclusive — 0% and 100% return `BootstrapError::InvalidStartingPriceBps` (infinite reserve ratios). Values that overflow the reserve computation return `BootstrapError::ArithmeticOverflow`. + +The helper computes the inward-snapped "useful band" bounds first: the lowest and highest table indices whose fee-free YES spot prices remain within `[10, 9990]` bps (0.1%-99.9%). It then returns the smallest reserve vector that lets the pool move from `initial_s_index` to those bounds while preserving `MIN_POOL_RESERVE` on all three reserves. The literal table edges are intentionally **not** used for the default, because `q_step_lots` is ceil-rounded and the table edges can overshoot the useful band, which would force the operator to pre-fund dead tail liquidity. -The `initial_yes_reserve` and `initial_no_reserve` are determined by the pool's capacity in each direction from the starting price. At 50/50, they're roughly equal. At 70/30, more NO tokens are needed (more room to move toward 0%) and fewer YES tokens (less room toward 100%). The balanced allocation ensures equal trading depth in both directions. +At 50/50, the default YES and NO reserves are roughly equal. At 70/30, more NO tokens are needed (more room to move toward 0%) and fewer YES tokens (less room toward 100%). Callers remain free to over-fund or under-fund relative to this default by passing different `initial_reserves` into `build_lmsr_bootstrap_pset`. ### Param Derivation ```rust pub fn derive_pool_params( deadcat_xprv: &Xpriv, - market_params: &PredictionMarketParams, + market_params: &MarketParams, // umbrella: binary or multi-outcome + outcome: OutcomeIndex, // which outcome's YES/NO pair the pool serves pool_index: u16, max_loss_sats: u64, half_payout_sats: u64, fee_bps: u16, - starting_price_bps: u16, + initial_s_index: u16, ) -> Result<(LmsrPoolParams, u16 /* masked_index */), ConventionError>; ``` -A standalone pure function that constructs the full `LmsrPoolParams` with all derived fields. Returns `ConventionError` if inputs violate OP_RETURN encoding conventions (`max_loss_sats` and `half_payout_sats` not in the 26-value mantissa set, `fee_bps > 4095`, `starting_price_bps` outside (0, 10000) exclusive). Called once when the user commits to creating a pool — heavier than `estimate_bootstrap` because it generates the full 65K-entry F-value table and computes the Merkle root (~80ms). The `starting_price_bps` parameter is needed to compute `initial_s_index` for the XOR mask context (see [chain-only-recovery.md](../../protocol/chain-only-recovery.md)). The resulting `LmsrPoolParams` is passed directly to `build_lmsr_bootstrap_pset`. +A standalone pure function that constructs the full `LmsrPoolParams` with all derived fields. Returns `ConventionError` if inputs violate OP_RETURN encoding conventions (`max_loss_sats` and `half_payout_sats` not in the 16-value 1-2-5 table, `fee_bps > 4095`, `initial_s_index` corresponding to an implied YES price outside `(0, 10000)` bps exclusive). Called once when the user commits to creating a pool — heavier than `estimate_bootstrap` because it generates the full 65K-entry F-value table and computes the Merkle root (cold-cache bignum path on first use of a combo, cached thereafter). `initial_s_index` is sourced from `estimate_bootstrap` at creation time and directly from the pool OP_RETURN hint at recovery time — no inverse conversion from `starting_price_bps` is required. The resulting `LmsrPoolParams` is passed directly to `build_lmsr_bootstrap_pset`. + +### Permissionless Public Path + +Swaps are not built directly — they're part of trade transactions routed by the engine. See [trade-routing-algorithm.md](../../architecture/trade-routing-algorithm.md). The trade router evaluates pools alongside limit orders for best execution, factoring in both the pool's swap fee (`fee_bps`) and the transaction weight overhead. Pool quoting is reserve-aware: the cached LMSR table determines price movement, while the live reserves cap how much volume is currently fillable before a reserve floor would be violated. + +### Public-path quote definitions + +The LMSR public path uses these exact integer definitions: + +- `FEE_DENOM = 10_000` +- `fee_c = FEE_DENOM - fee_bps` +- `L = traded_lots × half_payout_sats` +- `base_notional` is the signed-safe pre-fee quote computed from `L`, `f_old`, and `f_new` -### Trading (Swaps) +Signed-safe `base_notional` construction: -Swaps are not built directly — they're part of trade transactions routed by the engine. See [trade-routing-algorithm.md](../../architecture/trade-routing-algorithm.md). The trade router evaluates pools alongside limit orders for best execution, factoring in both the pool's swap fee (`fee_bps`) and the transaction weight overhead. +- If `f_new >= f_old`, let `d = f_new - f_old` + - buy path: `base_cost = base_notional = L + d` + - sell path: `base_rebate = base_notional = L - d` +- If `f_old > f_new`, let `d = f_old - f_new` + - buy path: `base_cost = base_notional = L - d` + - sell path: `base_rebate = base_notional = L + d` +- Any subtraction underflow (`L < d`) makes the transition invalid. -The covenant's swap path enforces: -- `old_s_index != new_s_index` (state must change) -- Correct trade direction (BuyYes/SellNo must increase s_index; SellYes/BuyNo must decrease) -- Collateral conservation with fee inequality: - - Buys: `collateral_in × (FEE_DENOM - fee_bps) >= base_cost × FEE_DENOM` - - Sells: `collateral_out × FEE_DENOM <= base_rebate × (FEE_DENOM - fee_bps)` -- Reserve minimums maintained after the trade +The pool's permissionless spend path is a single generalized **public path**. It covers: + +- ordinary swaps (`old_s_index != new_s_index`, no pair assist) +- swap + paired reserve assist (`old_s_index != new_s_index`, equal YES/NO pair delta derived from the reserve outputs) +- degenerate pair-only rebalances (`old_s_index == new_s_index`) + +On this public path, the covenant enforces: + +- the reserve changes decompose into one valid LMSR movement plus one equal YES/NO paired delta +- correct trade direction when `old_s_index != new_s_index` (BuyYes/SellNo must increase s_index; SellYes/BuyNo must decrease) +- collateral conservation with fee inequality for the LMSR movement: + - Buys: `collateral_in × fee_c >= base_cost × FEE_DENOM` + - Sells: `collateral_out × FEE_DENOM <= base_rebate × fee_c` +- reserve minimums maintained after the public transition - Valid Merkle proofs for F(old_s_index) and F(new_s_index) +The equal YES/NO paired delta is derived from the reserve vector change itself; it is not supplied as an independent witness scalar. Positive paired delta corresponds to issuing pairs into the pool. Negative paired delta corresponds to cancelling pairs out of the pool. In both cases, any collateral locked or released by the parent market stays on the market side — it does not directly change the pool's collateral reserve beyond the ordinary swap equation above. + Fee rounding always favors the pool: buyers pay ceiling, sellers receive floor. +At the public API layer, `quote_trade` / `build_trade_pset` use this path for ordinary swaps and for swap+market-assist routes on **existing** pools. In v1, buys may use `IssuePairs`, sells may use `CancelPairs`, and at most one assisted pool leg appears in a route. Degenerate pair-only public rebalances remain covenant-valid but are not intentionally emitted by `quote_trade`. + ### Admin Adjustments The pool operator can adjust reserves without changing the s_index (and thus without changing the pricing curve). This is the admin path, authorized by the operator's admin key signature. ```rust -pub fn build_lmsr_adjust_pset( +pub fn build_adjust_pset( &self, - contract_id: &ContractId, pair_delta: i64, collateral_delta: i64, funding: &WalletFunding, @@ -260,16 +305,15 @@ pub fn build_lmsr_adjust_pset( - **Remove liquidity / take profits**: Negative deltas. The operator extracts fee revenue accumulated as excess collateral. - **Rebalance**: Adjust collateral without changing token reserves. -Admin adjustments change **capacity**, not **pricing**. The F-values (and thus the cost function) are fixed at creation — only the reserves change. +Admin adjustments change **capacity**, not **pricing**. The F-values (and thus the cost function) are fixed at creation — only the reserves change. This remains the operator's reserve-management tool even though the public path can also express equal YES/NO pair changes: the admin path does not require a taker trade or a matching parent-market issuance/cancellation. ### Closure The pool operator closes the pool via the dedicated close script path, atomically consuming all three reserve UTXOs. See [lmsr-pool-close-path.md](lmsr-pool-close-path.md). ```rust -pub fn build_lmsr_close_pset( +pub fn build_close_pset( &self, - contract_id: &ContractId, funding: &WalletFunding, ) -> Result>; ``` @@ -278,7 +322,25 @@ All reserve funds are returned to `funding.return_script`. The pool transitions ### Market Resolution -The pool covenant is **market-state-agnostic** — it doesn't know or care whether the parent prediction market has resolved. Swaps remain technically valid after resolution. However, no rational trader would swap after resolution (the outcome is known, so the token prices are known), so the pool naturally goes idle. The operator closes the pool when convenient, then redeems any winning tokens via the parent market's redemption path. +The pool covenant is **market-state-agnostic** — it doesn't know or care whether the parent prediction market has resolved. Plain swaps remain technically valid after resolution. However, no rational trader would swap after resolution (the outcome is known, so the token prices are known), so the pool naturally goes idle. The operator closes the pool when convenient, then redeems any winning tokens via the parent market's redemption path. + +Market-assisted public-path variants are narrower: they are only available while the parent market still supports issuance/cancellation. Once the market resolves or expires, those assisted variants disappear, but ordinary pool trading and admin operations remain covenant-valid. + +### Why the pool covenant can't feasibly gate post-resolution trading + +A gated design would require the pool covenant to verify the parent market's state on every swap. Because a covenant can only introspect the current transaction, the only way for the pool to observe market state is to **co-spend the market covenant's UTXO as an input on every swap transaction** — the pool's spend path would require the market's Unresolved-phase collateral UTXO to be present in the same tx, and would reject the swap if the market wasn't in Trading state. + +The optional assisted public-path variants do not change this conclusion. Those routes already co-spend the market because the taker is deliberately using issuance/cancellation as part of the fill. The rejected design is requiring that overhead for **every** swap, even plain ones that do not otherwise benefit from touching the market. + +This is architecturally possible but prohibitively expensive: +- **Every swap tx grows by the full market co-spend** — adding the market's collateral input plus its witness (Simplicity program + control block) to the pool's own ~1,000-vbyte swap footprint. Realistically 1.5-2× the current swap size. +- **Every swap pays this overhead** — whether or not the market is near resolution. A trader swapping on a market with years left until expiry pays the same per-trade co-spend cost as one trading right before resolution. +- **Serialization constraint** — cross-outcome arb and other multi-pool patterns would compound: an N-pool-swap arb already co-spends the market in some directions; forcing co-spend on every pool swap regardless of direction makes these even heavier. +- **No covenant-cheap alternative** — there's no way for a pool covenant to check market state without seeing the market UTXO. Merkle inclusion proofs against a market-state commitment would require the market contract to emit such commitments, which they don't (and wouldn't in v1). + +The cost falls on the 99%+ of swaps that happen during the market's active life — paying a permanent tax so that the <1% edge case (the informed-drainer attack right after resolution) is blocked. That trade is rejected: **operator-layer protection** (closing the pool via `build_close_pset` after resolution) is the appropriate tool. Operators who keep pools open post-resolution are accepting the drain risk; those who don't, don't pay. + +**`deadcat-core` mirrors this at the engine layer**: trading remains routable through `quote_trade` / `build_trade_pset` regardless of parent market state. The engine does not gate post-resolution trading — the covenant is market-state-agnostic by the above architectural choice, and engine-layer gating would provide only false safety (sophisticated actors fork `deadcat-core` or bypass it). See [`deadcat-core-design.md § Pool and Order Lifecycle at Market Resolution`](../../architecture/deadcat-core-design.md#pool-and-order-lifecycle-at-market-resolution) and [Design Principles § Engine gates covenant-invalidity and impossibility, not unfavorability](../../architecture/deadcat-core-design.md#engine-gates-covenant-invalidity-and-impossibility-not-unfavorability). ## Pool Operator Economics @@ -288,13 +350,13 @@ The pool earns fee revenue on every swap. The fee (`fee_bps`) is the spread betw ### Risk -The pool's maximum loss is `max_loss_sats` (= `b × ln(2)`). This worst case occurs when the market moves maximally in one direction from the pool's starting price. In practice, if the market moves and then returns, the pool profits from the round-trip fees. +The pool's full-curve loss parameter is `max_loss_sats` (= `b × ln(2)`). That is the theoretical worst-case LMSR loss if the pool is funded deeply enough to traverse the entire curve. A particular reserve vector may expose only a subset of that tail; the v1 canonical default bootstrap intentionally funds only the useful 0.1%-99.9% band unless the operator chooses to add more inventory. In practice, if the market moves and then returns, the pool profits from the round-trip fees. The pool's net P&L = cumulative fee revenue - trading losses from directional movement. A pool in an active, balanced market (prices moving around rather than trending in one direction) typically profits from fees exceeding losses. ### Capital Efficiency -The total capital needed (sum of `initial_yes_reserve` + `initial_no_reserve` + `initial_collateral_reserve` from `BootstrapEstimate`, converted to collateral terms via the market's `collateral_per_pair`) is larger than `max_loss_sats` because the pool must hold token inventory, not just collateral to cover losses. +The total capital needed for the canonical default bootstrap (sum of `initial_yes_reserve` + `initial_no_reserve` + `initial_collateral_reserve` from `BootstrapEstimate`, converted to collateral terms via the market's `collateral_per_pair`) is larger than `max_loss_sats` because the pool must hold token inventory, not just collateral to cover losses. Operators who explicitly over-fund the pool need correspondingly more capital; operators who under-fund accept a narrower immediately tradable band. ## On-Chain Covenant Parameters @@ -308,11 +370,11 @@ With the simplifications above, `LmsrPoolParams` contains: | `lmsr_table_root` | 32 bytes | Derived (Merkle root of F-values) | | `q_step_lots` | u64 | Derived from `b` and `half_payout_sats` | | `half_payout_sats` | u64 | Creator-specified | -| `fee_bps` | u64 | Creator-specified (u64 for Simplicity arithmetic jets; validated < 10,000) | +| `fee_bps` | u16 | Creator-specified (public API type; convention-valid range `<= 4095`, widened internally for Simplicity arithmetic) | | `admin_pubkey` | 32 bytes | From mnemonic | | `max_loss_sats` | u64 | Creator-specified — NOT a covenant param (see below) | -The first 8 fields are covenant parameters (compiled into the Simplicity program). `max_loss_sats` is not a covenant parameter — the covenant only verifies Merkle proofs, never evaluates the cost function. It is included in the struct because all off-chain LMSR computation (point evaluation for quoting, table generation for Merkle proofs, spot price calculation) requires the liquidity parameter `b = max_loss_sats / ln(2)`, and `b` is not recoverable from the covenant params alone (the `ceil()` in the `max_loss_sats → q_step_lots` derivation is lossy). `q_step_lots` and `lmsr_table_root` are retained alongside `max_loss_sats` as compilation caches — recomputing `lmsr_table_root` requires ~80ms of table generation. +The first 8 fields are covenant parameters (compiled into the Simplicity program). `max_loss_sats` is not a covenant parameter — the covenant only verifies Merkle proofs, never evaluates the cost function. It is included in the struct because all off-chain LMSR computation (cached-table quoting, table generation for Merkle proofs, spot price calculation) requires the liquidity parameter `b = max_loss_sats / ln(2)`, and `b` is not recoverable from the covenant params alone (the `ceil()` in the `max_loss_sats → q_step_lots` derivation is lossy). `q_step_lots` and `lmsr_table_root` are retained alongside `max_loss_sats` as compilation caches — recomputing `lmsr_table_root` is the cold-cache table-generation path for that combo. **Removed from params** (now constants in the `.simf`): `table_depth`, `s_bias`, `s_max_index`, `min_r_yes`, `min_r_no`, `min_r_collateral`. @@ -320,7 +382,7 @@ The first 8 fields are covenant parameters (compiled into the Simplicity program ## OP_RETURN Recovery Hint -The pool creation transaction includes a **41-byte** zero-value OP_RETURN output for mnemonic-based recovery. The hint uses compressed encoding: `max_loss_sats` and `half_payout_sats` as 9-bit values (26-value mantissa x 10^exponent, supporting non-L-BTC assets), `fee_bps` as u12 (0.01% granularity), `initial_s_index` as u16 (the starting table index, enabling direct script verification during creation-tx recovery), plus an XOR-masked pool operator derivation index. +The pool creation transaction includes a **40-byte** zero-value OP_RETURN output for mnemonic-based recovery. The hint uses compressed encoding: `max_loss_sats` and `half_payout_sats` as 4-bit 1-2-5 table indices each (shared with the market `base_payout` encoding, range 100 to 10,000,000 sats), `fee_bps` as u12 (0.01% granularity), `initial_s_index` as u16 (the starting table index, enabling direct script verification during creation-tx recovery), plus an XOR-masked pool operator derivation index. All other covenant params are derived: `b` from `max_loss_sats`, `q_step_lots` from `b` and `half_payout_sats`, `lmsr_table_root` from deterministic F-value generation, token asset IDs from the parent market, admin pubkey from the mnemonic at `pool_index`. Protocol constants require no encoding. @@ -331,5 +393,5 @@ See [chain-only-recovery.md](../../protocol/chain-only-recovery.md) for the exac - `docs/architecture/deadcat-core-design.md` — main design doc (references this satellite doc) - `docs/architecture/trade-routing-algorithm.md` — trade routing algorithm using LMSR pools + limit orders - `docs/contracts/lmsr-pool/lmsr-pool-close-path.md` — close script path covenant design -- `src-tauri/crates/deadcat-sdk/src/lmsr_pool/math.rs` — current LMSR math (will move to `deadcat-core`) -- `src-tauri/crates/deadcat-sdk/contract/lmsr_pool.simf` — pool covenant source +- `crates/deadcat-core/src/lmsr_pool/math.rs` — LMSR math implementation target +- `crates/deadcat-core/contracts/lmsr_pool.simf` — pool covenant implementation target diff --git a/docs/contracts/maker-order/maker-order-remove-cosigner.md b/docs/contracts/maker-order/maker-order-remove-cosigner.md index ce1100a0..4c4eb0d5 100644 --- a/docs/contracts/maker-order/maker-order-remove-cosigner.md +++ b/docs/contracts/maker-order/maker-order-remove-cosigner.md @@ -49,7 +49,7 @@ fn main() { // After fn main() { let i: u32 = jet::current_index(); - let i_rem: u32 = safe_add_32(i, 1); + let i_rem: u32 = witness::REMAINDER_IDX; let out_spk_hash: u256 = get_output_script_hash(i); assert!(jet::eq_256(out_spk_hash, param::MAKER_RECEIVE_SPK_HASH)); match param::DIRECTION { @@ -74,7 +74,7 @@ The following can be removed: ## Problem 2: Misleading "Cosigner" Name for Pool Admin Key The LMSR pool covenant uses `COSIGNER_PUBKEY` for the key that authorizes admin operations (adjust, close). This is misleading: -- The swap path is permissionless — no "co-signing" happens. +- The public pool path is permissionless — no "co-signing" happens. - The admin/close paths use this key as the **sole** authorization, not a co-signature. - The pool operator controls the key themselves — no second party involved. @@ -90,7 +90,9 @@ This aligns with the existing "admin path" / "admin adjust" terminology used in The pool's close path uses the same authorization model — see [lmsr-pool-close-path.md](../lmsr-pool/lmsr-pool-close-path.md). -## Key Files +## Legacy Source Touchpoints + +These are the current `deadcat-sdk` files where this legacy-source delta exists today. The `deadcat-core` implementation should realize the same behavior in its new order and pool contract modules. - `src-tauri/crates/deadcat-sdk/contract/maker_order.simf` — remove cosigner check, `COSIGNER_PUBKEY` param, `COSIGNER_SIGNATURE` witness - `src-tauri/crates/deadcat-sdk/src/maker_order/params.rs` — remove `cosigner_pubkey` field diff --git a/docs/contracts/maker-order/maker-order-remove-script-cancel.md b/docs/contracts/maker-order/maker-order-remove-script-cancel.md index 21b96a05..85725930 100644 --- a/docs/contracts/maker-order/maker-order-remove-script-cancel.md +++ b/docs/contracts/maker-order/maker-order-remove-script-cancel.md @@ -44,7 +44,7 @@ fn main() { // After (reflects both script-cancel removal and cosigner removal per maker-order-remove-cosigner.md) fn main() { let i: u32 = jet::current_index(); - let i_rem: u32 = safe_add_32(i, 1); + let i_rem: u32 = witness::REMAINDER_IDX; let out_spk_hash: u256 = get_output_script_hash(i); assert!(jet::eq_256(out_spk_hash, param::MAKER_RECEIVE_SPK_HASH)); match param::DIRECTION { @@ -59,6 +59,8 @@ The following can also be removed: - `witness::MAKER_CANCEL_SIGNATURE` witness declaration - `witness::PATH` witness declaration (no longer needed — only one path) +The fill path keeps the witness-provided `REMAINDER_IDX` introduced by the transaction-composability model; remainders are no longer forced to `current_index() + 1`. + ## Impact on deadcat-core ### Watertight Order Transition Detection @@ -71,21 +73,23 @@ With key-spend as the only cancellation mechanism, the engine can use a simple s Key-spend vs script-spend is trivially distinguishable from the witness stack structure — key-spend has a single stack element (64-byte signature), script-spend has multiple elements (witness data + script + control block). This is a Bitcoin/Elements-level structural check, not Simplicity witness decoding. It does not require compiled contracts. -### build_cancel_order_pset +### build_cancel_pset -`build_cancel_order_pset` constructs a key-spend transaction. This is simpler than the current implementation — no Simplicity witness encoding needed, just a taproot key-spend signature. The PSET builder still needs to know the taproot internal key and merkle root (to compute the tweak), but does not need the compiled Simplicity contract. +`build_cancel_pset` constructs a key-spend transaction. This is simpler than the current implementation — no Simplicity witness encoding needed, just a taproot key-spend signature. The PSET builder still needs to know the taproot internal key and merkle root (to compute the tweak), but does not need the compiled Simplicity contract. ## Consistency Across Contract Types | Contract | Internal Key | Can Key-Spend? | Script Paths | |---|---|---|---| | Prediction Market | NUMS | No | Issuance, resolution, redemption, cancellation, expiry | -| LMSR Pool | NUMS | No | Swap, admin adjust, close | +| LMSR Pool | NUMS | No | Public, admin adjust, close | | Maker Order | `maker_pubkey` | Yes (cancellation) | Fill only | Maker orders intentionally use a real internal key — the maker's ability to key-spend is the sole cancellation mechanism. Markets and pools use NUMS because their lifecycle is governed by covenant logic, not a single party's key. -## Key Files +## Legacy Source Touchpoints + +These are the current `deadcat-sdk` files where this legacy-source delta exists today. The `deadcat-core` implementation should realize the same behavior in its new order contract modules. - `src-tauri/crates/deadcat-sdk/contract/maker_order.simf` — remove cancel path, `check_cancel`, related witnesses - `src-tauri/crates/deadcat-sdk/src/maker_order/witness.rs` — remove cancel witness satisfaction diff --git a/docs/contracts/market-contract-principles.md b/docs/contracts/market-contract-principles.md new file mode 100644 index 00000000..e733effb --- /dev/null +++ b/docs/contracts/market-contract-principles.md @@ -0,0 +1,189 @@ +# Market Contract Principles + +This document enumerates the design principles that both Deadcat market contracts — the **binary prediction market** and the **multi-outcome market** — must uphold. These are covenant-enforced properties: each one describes what the contract itself guarantees regardless of who builds the transaction or what tooling they use. + +Usage conventions (1-2-5 mantissa for collateral amounts, 60-block expiry snapping, well-known collateral asset sets, OP_RETURN recovery hint format, `market_id` derivation formula, asset ordering) are not principles — they are builder-side rules enforceable only by `deadcat-core` itself. This document covers only the on-chain, covenant-level guarantees that hold for any client. + +**Framing**: this is the shared market-contract implementation target for `deadcat-core`. The current `prediction_market.simf` source in `deadcat-sdk` does not yet implement all of these principles; see the [legacy source alignment checklist](contract-specification.md#legacy-source-alignment-checklist) in [contract-specification.md](contract-specification.md) for that delta. The intent is that the rewritten contracts meet every principle here. + +## Scope + +- **In scope**: the binary prediction market contract and the multi-outcome market contract. +- **Out of scope**: the LMSR pool contract and the maker order contract. These have their own principle sets (reserves, pricing integrity, maker-only cancellation) distinct from the market-contract principles below. + +## Covenant self-enforcement + +Every principle below is covenant-enforced: the Simplicity program verifies the property from transaction-observable data alone. PSET builders are off-chain conveniences; the covenant assumes an adversary constructs the spending transaction. Any constraint that protects funds, preserves solvency, or prevents griefing must live in the covenant, not in builder code. + +This framing classifies every constraint the design relies on into one of three buckets: + +1. **Covenant-enforced** — checked in the Simplicity program; safe against arbitrary builders. +2. **Builder-enforced, recovery-critical** — violation breaks chain-only recovery decode but cannot drain funds, alter resolution, or produce unauthorized issuance. Acceptable at the builder layer when explicitly documented as such. Examples: the 1-2-5 mantissa table for `collateral_per_pair`, OP_RETURN hint format, expiry snapping to 60-block boundaries. +3. **Builder-enforced, fund-critical** — **not acceptable.** Any constraint that, if violated, permits fund loss, solvency violation, or griefing must be promoted to covenant enforcement before release. + +**Audit obligation**: every spend path in every `.simf` must be reviewed against this classification. Every constraint the covenant's correctness depends on belongs in bucket 1; none in bucket 3. Bucket-2 constraints must be named and their failure modes documented. + +## Foundational principle + +### 1. The contract's purpose is to uphold solvency across every possible resolution outcome + +The market contract exists to guarantee that, no matter which outcome resolves, every token holder of the winning side can redeem their tokens at full value from the preserved collateral. The contract permits **any** transaction that preserves this invariant — it is not a whitelist of named operations, but an invariant enforcer. + +The concrete operations the contract exposes (issue pair, cancel pair, split YES, merge YES, split NO, merge NO, cross swap, resolution, redemption, expiry) are **convenient bases** of the space of solvency-preserving transitions. They are enumerated for engineering reasons — simpler covenant logic, clearer PSET construction, lower witness overhead — but the underlying principle is the invariant, not the enumeration. + +Equivalent statement: *For every outcome k and every reachable contract state, `C ≥ payout(k)` where `payout(k)` is the total collateral owed to winners on that outcome.* The contract only accepts transitions that preserve this bound. + +## Authority and permissionlessness + +### 2. No privileged role for market operations + +Any party — including the oracle — can issue pairs, cancel pairs, split/merge sets, redeem winning tokens, and trigger expiry, with the only prerequisites being the required collateral (for mints) and the required tokens (for burns). No signature from the market creator, the oracle, or any admin is consulted by the covenant for these operations. + +The oracle has no special privilege over token supply or collateral movement. If the oracle holds tokens, it participates as a regular user. + +### 3. Oracle authority is narrow, pre-committed, and self-consistent + +The oracle's sole covenant-granted power is a single on-chain transition: moving the contract from its active phase (Unresolved or Dormant) to a Resolved_k phase. This power is exercised via one BIP-340 Schnorr signature over a tagged hash: + +``` +message = tagged_hash("deadcat/oracle_attestation", market_id || outcome_index) +``` + +The tag string is hardcoded in the covenant. The oracle public key is committed into the covenant params at market creation and immutable thereafter. No other oracle action is recognized. + +**Self-consistency guarantee (covenant-enforced within one contract)**: at most one outcome resolution lands on-chain, regardless of how many signatures the oracle produces. The first valid resolution consumes all RT UTXOs and the Unresolved collateral UTXO, leaving no spendable state for a second resolution. Both sides of a binary market cannot be simultaneously redeemable; for a multi-outcome market, at most one outcome's winning tokens become redeemable. + +**Trust boundary (outside covenant scope)**: the contract cannot verify that the oracle's signed outcome matches reality — Liquid has no access to the outside world. Nor can it enforce coherence across *composed* multi-outcome events built from multiple binary contracts (e.g., "an election built from N per-candidate binary markets"). An oracle signing YES on two composed binary markets produces two covenant-coherent-per-market resolutions that are jointly incoherent; preventing this requires oracle discipline or app-layer arbitrage, not the covenant. + +The contract's guarantee: *no matter what the oracle signs, within a single contract's scope the result is self-consistent and solvent.* Users of a single market trust the oracle on outcome truth. Users of composed events additionally trust the oracle to stay consistent across markets. + +### 4. NUMS internal key, no key-spend path + +The taproot internal key is a NUMS point ("nothing up my sleeve" — a curve point with no known discrete log). Key-spend is cryptographically infeasible. All spends go through the Simplicity script path, ensuring the covenant always runs. + +## State machine completeness + +### 5. Terminal paths are reachable from every non-terminal state + +Markets must be able to reach a terminal state (Resolved_k or Expired) regardless of outstanding token supply. In particular, the zero-liquidity case — a market that was created and never used, or fully unwound back to zero outstanding — must be resolvable by the oracle and expirable by timelock. + +Concretely: the Dormant phase (zero outstanding tokens, only RT UTXOs on-chain, no collateral locked) exposes oracle-resolution and timelock-expiry spend paths. Both consume all RT UTXOs, verify RT burn outputs, and produce no covenant continuation, immediately transitioning to Resolved_k or Expired with zero outstanding tokens (terminal). + +Without this, an abandoned market's RT UTXOs would sit on-chain indefinitely, and a market with no traders could never be cleaned up — not a security bug, but a lifecycle completeness defect. + +### 6. Resolution collapses the contract to a single collateral UTXO + +A single valid resolution transition consumes every covenant UTXO except for the new Resolved_k collateral output. Specifically: all RTs are burned (see principle 9), the Unresolved collateral UTXO is consumed, and the only covenant UTXO remaining is the collateral at the Resolved_k script. Its asset and value are preserved from the pre-resolution state. + +From this point, the contract only supports redemption spends against the Resolved_k collateral UTXO. No covenant path returns to Unresolved or crosses to a different Resolved_j. + +### 7. No double resolution + +Combined with principle 6 and principle 9 (RT destruction), a contract cannot be resolved twice. Any second resolution attempt would have to re-mint the Unresolved collateral UTXO and re-materialize the RT UTXOs, both of which are impossible: the covenant has no path back from Resolved_k, and the Elements consensus rule `nInflationKeys.IsNull() || assetBlindingNonce.IsNull()` makes additional RT creation impossible (see [enforcement-layers.md](../architecture/enforcement-layers.md), "RT Supply is Fixed at Creation"). + +## Solvency and conservation + +### 8. Collateral conservation on every transition + +Every mint or burn transition is gated by a covenant-checked relationship between the collateral delta and the token delta: + +- Minting operations increase the locked collateral by exactly the amount required to back the new tokens under the solvency invariant. +- Burning operations release exactly the amount of collateral the burned tokens were backing. + +The per-contract formulas differ (binary uses `collateral_per_pair`; multi-outcome uses `collateral_per_pair × ΔQ` where Q is the outcome-independent solvency quantity), but the principle is identical: the covenant evaluates the full transition against the invariant and rejects any transaction that would leave the contract under-collateralized for any possible outcome. + +### 9. RT destruction on terminal transitions + +Every resolution and expiry transition burns all reissuance tokens via a covenant-verified burn output (`ensure_blinded_reissuance_burn_output` or equivalent). The burn output uses bare `OP_RETURN` (consensus-level unspendability, pruned from the UTXO set) rather than a P2WSH-to-zero, and uses the correct asset ID, value, and deterministic blinding factors. + +**Why this matters**: Elements-level reissuance operates below the covenant layer. Any party holding an RT UTXO and knowing its ABF (which is public under deterministic blinding — see principle 11) can mint new tokens of the original asset. If an RT ever escapes to a wallet address after resolution, the covenant no longer runs on it and the Elements protocol will accept reissuances against it. RT destruction is the defense. See [enforcement-layers.md](../architecture/enforcement-layers.md), "Gotcha 1: Elements Reissuance Bypasses Covenants." + +### 10. No parasitic issuance + +Every non-issuance covenant spend path calls `ensure_no_issuance` on every covenant input, rejecting any transaction that attaches issuance fields (`nAmount`, `nInflationKeys`, `assetBlindingNonce`, `assetEntropy`) to an input that is not exercising the covenant's issuance path. + +**Why this matters**: Elements consensus allows any input to carry issuance fields. Without explicit opt-out, a malicious builder could attach token issuance to a resolution spend, a cancellation, or any other path, minting unbacked tokens alongside a legitimate transition. + +### 11. Deterministic RT blinding + +All RT outputs use covenant-enforced deterministic blinding factors: + +- ABF derived via tagged hash from the defining outpoint (public, recomputable). +- CBF passed through unchanged from input to output across every transition (CBF is constant over an RT's lifetime). +- VBF computed as `VBF = CBF - ABF`. + +Elements' traditional RT security relies on ABF secrecy; deterministic blinding deliberately makes ABFs public (for permissionless recovery and transaction construction). This removes the traditional Elements-layer safeguard and makes the covenant-enforced blinding scheme load-bearing against two attack classes: + +1. **Griefing**: without covenant enforcement, a malicious issuer could use random ABFs/VBFs for new RT outputs, locking the market for all other participants (no one else can compute the VBFs needed for subsequent Pedersen balance). Covenant enforcement makes this impossible. +2. **Reissuance**: unauthorized reissuance is prevented by RT burn enforcement (principle 9), not ABF secrecy. The two defenses are complementary — not redundant. + +See [deterministic-rt-blinding.md](../protocol/deterministic-rt-blinding.md). + +### 12. Correct redemption rates + +Redemption transitions release collateral at covenant-verified rates. Both market contracts parameterize on `base_payout` (the primary denomination) and derive `cp := base_payout × N` where N is the outcome count (`N = 2` for binary, `N ∈ [3, MAX_N]` for multi-outcome). All rates below are exact integers by construction: + +- **Resolved**: winning tokens redeem for `cp = base_payout × N` each. All other tokens are inert. +- **Expired**: Binary: `base_payout` per token, symmetric across YES and NO (total `2 × base_payout = cp`). Multi-outcome: `base_payout` per YES token, `base_payout × (N-1)` per NO token. + +The expired rates treat every outcome as equally probable (a uniform 1/N prior) — not because this is "correct" in any Bayesian sense, but because it is the solvency-preserving choice under the constraint that the covenant cannot run arbitrary dynamic computation. + +**Exact redemption is structural, not asserted.** Parameterizing on `base_payout` and deriving `cp = base_payout × N` makes divisibility automatic: every expiry rate is an integer multiple of `base_payout`. The covenant performs no division at runtime, asserts no divisibility predicate, and rejects no markets for having "incompatible" denominations. See [multi-outcome-market-contract.md § Denomination model](multi-outcome/multi-outcome-market-contract.md#denomination-model) for the full rationale. + +## UTXO identity and aliasing + +### 13. Sibling UTXO check on co-spent covenant inputs + +Every transition in the active phases (Unresolved, Dormant) that co-spends multiple covenant inputs requires that every covenant input in the set share the same `prev_txid` — i.e., was created by the same previous transition. This prevents collateral-substitution attacks where an attacker creates a fake UTXO at the covenant's collateral script address (the script is public and derivable from market params) and co-spends it with real RTs. + +The check is **position-independent**: it validates a property of the input set, not where those inputs sit in the transaction's input list. Principle 15 (witness-parameterized indices) leverages this independence. + +Every transition, including partial cancellation / partial burn, must co-spend all covenant inputs to maintain the sibling invariant across the market's lifecycle. See [enforcement-layers.md](../architecture/enforcement-layers.md), "Gotcha 5: Covenant Scripts Are Not Unique Per UTXO." + +### 14. Asset identity on every constrained output + +Every covenant-constrained output has its asset ID explicitly verified against the expected value committed in market params — collateral outputs against `collateral_asset_id`, token outputs against the appropriate `yes_token_asset_id` or `no_token_asset_id[k]`, RT continuation outputs against the appropriate reissuance token asset ID. + +A covenant that only checks value and script pubkey (but not asset) can be satisfied by substituting a different asset at the same value, silently draining the market. + +### 15. Witness-parameterized input and output indices + +Both market contracts accept `in_base` and `out_base` from the Simplicity witness. The covenant asserts `current_index() == in_base + (my slot offset)` and validates a contiguous block of covenant inputs starting at `in_base` and a contiguous block of expected outputs starting at `out_base`. + +This flexibility does **not** weaken covenant correctness. The safety argument is the same one already used for pool and order composition: the witness only chooses **where** the contract's input/output window sits in the transaction, not **what** the contract accepts. The covenant still verifies bounded contiguous windows, the expected script for every continuation output, and the expected asset on every constrained output. A malicious builder can move the window or overlap it with unrelated transaction structure, but cannot make the contract accept another contract's output or silently alias an output that fails the script/asset checks. + +This enables flexible multi-contract transaction composition — a market transition can be co-spent with a binary LMSR pool swap, a maker order fill, or another market's operation in a single atomic transaction, with the PSET builder choosing where each contract's inputs and outputs sit. Key cases this unlocks: + +- **Cross-outcome arb** on multi-outcome markets: the market's split-YES or merge-YES primitive co-spent with N pool swaps in one tx, closing `Σ p_YES_k = 1` coherence gaps atomically. +- **Pool + maker-order routing**: a trade that crosses both a pool and a resting maker order in one tx, taking the best aggregate price. +- **Atomic liquidity bootstrap**: pool creation co-spent with a market split operation that sources the pool's initial token reserves. + +**No correctness sacrifice.** The covenant still verifies, per-position within its block: expected script pubkey, expected asset ID, expected value, expected issuance/burn, expected blinding factors. Aliasing across contracts is blocked by script uniqueness (different contract params → different script pubkeys). Aliasing within the contract's own output block is blocked because every slot has a distinct script in the set. + +See [transaction-composability-model.md](../architecture/transaction-composability-model.md) for the general composition framework. + +## Consensus-layer integration + +### 16. Timelock-enforced expiry + +Expiry transitions require `nLockTime ≥ expiry_time` where `expiry_time` is a covenant param fixed at market creation. The covenant uses the `check_lock_height` Simplicity jet to defer enforcement to Elements consensus — Layer 1 ensures the transaction's locktime is respected; Layer 2 checks that the locktime satisfies the expiry requirement. + +### 17. Confidential-transaction compatibility + +The covenant operates correctly with confidential inputs and outputs. RT outputs are always blinded (their deterministic CBF pass-through self-balances the RT portion of the Pedersen commitment equation, meaning transactions with zero or more additional blinded wallet outputs both work). Collateral continuation outputs are explicit-value to keep covenant introspection simple. User-facing token and collateral outputs can be blinded or explicit at the builder's choice. + +## Summary: the covenant's contract with the world + +Putting the principles together: the covenant guarantees that, for every market, the only thing the oracle can do on-chain is attest one outcome (narrow authority), and the only thing other actors can do is execute transactions that preserve solvency across every possible resolution outcome (permissionless within the invariant). When an outcome is attested, the contract collapses to a single collateral UTXO that pays winning tokens at full value and cannot be re-resolved or tampered with, even by the oracle. All of this holds against the full Elements attack surface (reissuance, parasitic issuance, fake UTXOs, asset substitution, blinding griefing) because every cross-layer escape hatch is explicitly closed. + +Truthfulness of signed outcomes, and coherence across composed multi-outcome events, lie outside the covenant's enforcement scope and require oracle trust. + +## Key Files + +- [contract-specification.md](contract-specification.md) — top-level index with per-contract parameters, slot layouts, and spend paths for the binary and multi-outcome markets +- [multi-outcome/multi-outcome-market-contract.md](multi-outcome/multi-outcome-market-contract.md) — multi-outcome (2N token) market full spec +- [../architecture/enforcement-layers.md](../architecture/enforcement-layers.md) — cross-layer security framework (Layers 1-4, per-property enforcement table, cross-layer gotchas) +- [../architecture/transaction-composability-model.md](../architecture/transaction-composability-model.md) — witness-parameterized indices and multi-contract composition +- [../protocol/deterministic-rt-blinding.md](../protocol/deterministic-rt-blinding.md) — RT blinding scheme and covenant enforcement +- [../protocol/oracle-bip340-tagged-hash.md](../protocol/oracle-bip340-tagged-hash.md) — oracle attestation message format +- [../protocol/chain-only-recovery.md](../protocol/chain-only-recovery.md) — recovery flow that builds on these principles diff --git a/docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md b/docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md index 7405d905..f9178639 100644 --- a/docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md +++ b/docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md @@ -1,19 +1,43 @@ # AMM Scoring Rule Trade-offs -**Status**: Design exploration / reference. Trade-offs documented here are intended to be objective; recommendations and subjective interpretations are clearly demarcated and confined to the final section. +**Status**: Decision record. This document records the scoring rule comparison that informed deadcat's pool design decision. **See [Decision Summary](#decision-summary) for the final choice.** The comparative analysis below is retained as reference for the reasoning. + +## Decision Summary + +After working through the full scoring rule design space under deadcat's constraints (Simplicity covenants, no transcendental jets, 2N-token multi-outcome market contract, no per-trade co-spend with the market contract), the committed decision is: + +- **Scoring rule**: **LMSR** (Hanson 2003), with the existing 1D Merkle-committed F-value table. +- **Pool dimensionality**: **Binary only** (N=2). No unified multi-outcome pool contract — even the feasible N=3 2D-table variant is declined for implementation simplicity. +- **Multi-outcome market liquidity**: **Option C composition** — N independent binary LMSR pools per market, one per outcome's YES/NO pair. Cross-outcome coherence is arb-enforced (not structural at the AMM layer). +- **Liquidity model**: **Admin-operated** pools with permissionless creation. No LP-tokenized pool in v1. Each pool has a single operator who commits subsidy, earns fees, and bears impermanent loss; anyone can create a new pool with any params. + +The reasoning behind each of these decisions is distributed across the comparative analysis below. Key landing points: + +- **QMSR was seriously considered** (polynomial inline, better subsidy efficiency, any-N support) but ultimately rejected because (a) it has no production deployments in prediction markets, (b) its linear cost curve produces weaker first-mover incentives than LMSR's exponential curve — meaningful for the AMM-as-price-oracle use case, (c) LMSR's Merkle-table overhead at N=2 is a one-time implementation cost rather than an ongoing one. +- **LS-QMSR was disqualified** by the 50% price-display bias (see the trilemma section). +- **LS-LMSR was disqualified** as strictly dominated by standard LMSR at every N in deadcat's environment. +- **Unified multi-outcome LMSR at N=3 (2D table)** is technically feasible with the same total entries as binary 1D, but was declined: the 2D-variant covenant, tooling, and audit overhead don't pay back enough given N=3 is a small fraction of real markets and composed binary works via arb. +- **FPMM / constant-product** (Gnosis-style) structurally requires co-spending the market contract on every trade, serializing all pool trades across all pools of a market on the market's collateral UTXO. This was the blocker — all pool trades on a market would serialize on the market's collateral UTXO. LMSR/QMSR don't have this issue because their pricing state is independent of reserves. + +The **architectural orthogonality** is worth naming explicitly: the market contract layer (binary vs N-outcome) and the pool layer (always binary LMSR, always composed via Option C for multi-outcome markets) are independent choices. An N-outcome market contract is still used when the creator wants its cross-outcome primitives (atomic split-YES, split-NO, cross-outcome swap) for efficient arb, LP rotation, and complex trading. The pool layer doesn't know or care which market contract type is underneath. ## Purpose -This document compares four automated market maker scoring rules for use in deadcat's prediction market pools: +This document compares automated market maker scoring rules that were considered for deadcat's pool layer: -- **LMSR** — Logarithmic Market Scoring Rule (Hanson 2003) -- **QMSR** — Quadratic Market Scoring Rule (Brier-derived) -- **LS-LMSR** — Liquidity-Sensitive LMSR (Othman et al. 2013) -- **LS-QMSR** — Liquidity-Sensitive QMSR (proposed; see verification status below) +- **LMSR** — Logarithmic Market Scoring Rule (Hanson 2003) — **chosen** +- **QMSR** — Quadratic Market Scoring Rule (Brier-derived) — considered, rejected +- **LS-LMSR** — Liquidity-Sensitive LMSR (Othman et al. 2013) — considered, rejected +- **LS-QMSR** — Liquidity-Sensitive QMSR (proposed) — considered, disqualified by price bias +- **FPMM / constant product** — considered as alternative family, rejected for per-trade market co-spend requirement -The comparison covers conceptual properties (properness, depth profile, bounded loss) and practical constraints (Simplicity covenant feasibility, witness sizes, interaction with the limit order book) for two distinct market structures: single-event binary YES/NO markets, and N-outcome markets with a YES and NO token per outcome. +The comparison covers conceptual properties (properness, depth profile, bounded loss) and practical constraints (Simplicity covenant feasibility, witness sizes, interaction with the limit order book and the market contract layer). The original scope covered both single-event binary YES/NO markets and N-outcome markets with YES/NO per outcome as direct pool targets; the landed decision uses binary pools for both and composes for multi-outcome. -> **TODO: Verify LS-QMSR derivations.** The LS-QMSR construction described here (substituting `b = α·S` into the QMSR cost function and price formula) was derived during design discussion and the math has been worked through informally, but it has not been independently verified. Before adopting LS-QMSR for production, the construction should be checked against a formal proof of strict properness, the bounded-loss derivation should be verified at multiple N, and the path independence argument (currently relying on the same homogeneity trick as LS-LMSR) should be confirmed rigorously. +> **LS-QMSR verification status.** The LS-QMSR construction was independently analyzed with the following results: +> +> - **Path independence**: **Confirmed.** Follows directly from degree-1 homogeneity of `C(q) = Σ q_k · p_k(q)`. +> - **Bounded loss**: **Confirmed** as an upper bound. The formula `α(N-1)/(4N) · S` is tight for α ≤ 2 and conservative for larger α. Verified numerically at multiple (N, α) pairs. +> - **Strict properness**: **Disproven.** The LS construction causes the displayed price to diverge from the trader's true belief: at the trader's optimum, `p_k = π_k/2 + 1/(2N)` — a fixed 50% shrinkage toward uniform, independent of α. This is a fundamental consequence of the adaptive-properness-path-independence trilemma (see dedicated section below) and was the disqualifying finding for LS-QMSR. ## Background @@ -171,15 +195,16 @@ Prices are degree-0 homogeneous, so by the same construction as LS-LMSR: C(q) = Σ q_k · p_k(q) = (Σ q_k²)/(α·S) + S(α-1)/(α·N) ``` -**Properties** (subject to TODO verification): +**Properties** (verification status updated): - Prices sum to 1, homogeneous of degree 0. -- Path independent (via degree-1 homogeneity of C). +- Path independent (via degree-1 homogeneity of C). **Verified.** - **No domain restriction for α ≥ 1**: the condition `q_k ≥ S(1-α)/N` is automatically satisfied when α ≥ 1. -- Bounded loss: `α(N-1)/(4N) · S` — proportional to volume, half the standard QMSR loss for the same effective b. +- Bounded loss: `α(N-1)/(4N) · S` — proportional to volume. **Verified** as an upper bound; tight for α ≤ 2. See note below. - Adaptive depth: state-dependent. Outcomes with high volume share have low price impact (thick); outcomes with low volume share have high price impact (thin). -- Marginal cost ≠ instantaneous price (same caveat as LS-LMSR). +- Marginal cost ≠ instantaneous price (same caveat as LS-LMSR). **This has material consequences for price accuracy — see the trilemma section below.** +- **Displayed prices do not reflect true belief**: at the trader's optimum, `p_k = π_k/2 + 1/(2N)` — a fixed 50% shrinkage toward uniform, independent of α. See the trilemma section for derivation and implications. -> **TODO**: verify the bounded loss derivation `α(N-1)/(4N) · S` independently. The derivation goes through worst-case analysis of `q_k - C(q)` over all valid q vectors, finding the optimum at `r* = [α(N-1) + 2]/(2N)` (in the symmetric configuration). Result should be checked at N = 2, 3, 5, 10 numerically. +> **Bounded loss verification**: the bound `α(N-1)/(4N) · S` was confirmed via constrained optimization of `max_{r,k} [r_k - (Σr_j²)/α - (α-1)/(αN)]` subject to `Σr_k = 1, r_k ≥ 0`. The unconstrained optimum `r_1* = [1 + (N-1)α/2]/N` is feasible when α ≤ 2; for larger α, the boundary constraint binds and the true worst case is strictly less than the formula. Verified numerically at (N=2, α=1): loss/S = 1/8 ✓; (N=3, α=1): loss/S = 1/6 ✓; (N=5, α=2): loss/S = 2/5 ✓. --- @@ -187,23 +212,23 @@ C(q) = Σ q_k · p_k(q) = (Σ q_k²)/(α·S) + S(α-1)/(α·N) **Strict properness**: a market scoring rule is strictly proper if a risk-neutral trader's unique expected-profit-maximizing strategy is to move prices exactly to their true belief. -### LMSR / LS-LMSR +### LMSR -**Unconditionally strictly proper**. Expected profit for moving prices from π to r given true belief q: +**Unconditionally strictly proper** (fixed b). Expected profit for moving prices from π to r given true belief q: ``` E[profit] = b · [KL(q ‖ π) - KL(q ‖ r)] ``` -(For LMSR with fixed b. For LS-LMSR, a similar KL-based identity holds but with `b = α·S` varying along the trade path; the key conclusion — unique optimum at r = q — is preserved.) - KL divergence is non-negative and equals zero iff arguments are identical, so the expected payoff is uniquely maximized at r = q. No constraints ever bind because LMSR prices approach 0 and 1 asymptotically but never reach them. The KL identity has a strong interpretive consequence: **the LMSR market price is a sufficient statistic for the market's aggregate belief**. The cost function literally is a KL geometry over the probability simplex, and traders trading honestly is equivalent to performing Bayesian updates on the market's posterior. -### QMSR / LS-QMSR +**LS-LMSR**: subject to the trilemma (`∇C ≠ p`), so the displayed price at the trader's optimum is not exactly the true belief. However, the bias is proportional to `1/α` and small for practical α values. See the trilemma section for details. -**Strictly proper in the unconstrained case**. Expected profit for binary QMSR (moving from π to r): +### QMSR + +**Strictly proper in the unconstrained case** (fixed b). Expected profit for binary QMSR (moving from π to r): ``` E[profit] = 2b(r-π)(q-π) - b(r-π)² @@ -211,13 +236,17 @@ E[profit] = 2b(r-π)(q-π) - b(r-π)² Setting ∂/∂r = 0 yields r = q. Unique optimum at the true belief. -**Properness can be compromised when boundary constraints bind**. If the unconstrained optimum r = q lies outside the valid domain, the trader is forced to stop at the boundary. The reported price is then the closest representable belief to q, not q itself. +**Properness can be compromised when boundary constraints bind**. If the unconstrained optimum r = q lies outside the valid domain, the trader is forced to stop at the boundary. The reported price is then the closest representable belief to q, not q itself. The cross-coupling term means a trader pushing one outcome's price up automatically suppresses other prices through `-S/(bN)`, which reduces (but does not eliminate) how often the unconstrained optimum lies outside the valid domain. + +### LS-QMSR + +**Not strictly proper in the price-display sense.** Although no domain constraints bind for α ≥ 1, the LS construction introduces a separate properness failure: the trader's optimal strategy moves displayed prices to `p_k = π_k/2 + 1/(2N)`, not to the true belief π_k. This is a fixed 50% shrinkage toward uniform, independent of α. -For LS-QMSR with α ≥ 1, no domain constraints bind, so properness is unconditional. For standard QMSR, the cross-coupling term means a trader pushing one outcome's price up automatically suppresses other prices through `-S/(bN)`, which reduces (but does not eliminate) how often the unconstrained optimum lies outside the valid domain. +The underlying scoring rule is still proper in the decision-theoretic sense — the marginal cost `∂C/∂q_k` equals the true belief at the optimum. But the displayed price (which the system reports as "the market says X%") is systematically biased. See the trilemma section for the full derivation and comparison with LS-LMSR. ### Sharpness of the optimum -Both families are strictly proper, but they differ in how sharply the optimum is identified — i.e., the curvature of the expected-profit landscape at r = q. +Both fixed-b families (LMSR and QMSR) are strictly proper, but they differ in how sharply the optimum is identified — i.e., the curvature of the expected-profit landscape at r = q. For LMSR: curvature ≈ `1 / [q(1-q)]` per unit b. For QMSR (binary): curvature = `2b` (constant). @@ -240,11 +269,76 @@ Whether stronger first-mover incentives are unambiguously good is debatable — --- +## The Adaptive-Properness-Path-Independence Trilemma + +Any market scoring rule based on a cost function `C(q)` faces a three-way tradeoff among: + +1. **Adaptive liquidity** — the depth parameter `b` adjusts automatically with market volume (`b = α·S`) +2. **Price accuracy** — the displayed price equals the trader's true belief at the optimum (`∇C = p`) +3. **Path independence** — the cost of a trade depends only on start and end states (`C(q') - C(q)`) + +Standard scoring rules (LMSR, QMSR with fixed `b`) achieve (2) and (3): the cost function gradient IS the price vector, and C is well-defined. LS variants (LS-LMSR, LS-QMSR) achieve (1) and (3): the cost function is degree-1 homogeneous (path independent), and b adapts to volume. **Achieving all three is provably impossible** for QMSR-type prices: when `b` depends on the market state via any function `b(q)` with `∂b/∂q_k ≠ 0`, the price field has non-zero curl (`∂p_k/∂q_j ≠ ∂p_j/∂q_k` for `q_k ≠ q_j`), so no cost function `C` with `∇C = p` exists. The same argument applies to LMSR-type prices under any state-dependent `b`. + +### Why ∇C ≠ p in LS variants + +When `b = α·S` depends on the market state, differentiating the cost function `C(q) = Σ q_k · p_k(q)` produces extra terms: + +``` +∂C/∂q_k = p_k + Σ_j q_j · ∂p_j/∂q_k +``` + +The second term is non-zero because changing `q_k` changes `S`, which changes `b`, which changes all prices. For fixed-`b` scoring rules, `∂b/∂q_k = 0` and the extra terms vanish, giving `∇C = p`. + +A risk-neutral trader maximizes `E[profit] = Σ π_k q'_k - C(q')`, yielding the first-order condition `∂C/∂q'_k = π_k`. This means the trader moves the state to where the **marginal cost** (not the displayed price) equals their true belief. The information is encoded in `∇C`, not in `p`. + +### LS-QMSR: constant 50% shrinkage + +For LS-QMSR, the trader's first-order condition can be solved in closed form. At the optimum: + +``` +optimal share: r_k = α·π_k/2 + (2-α)/(2N) +displayed price: p_k = π_k/2 + 1/(2N) +``` + +The displayed price is a **50% shrinkage toward uniform**, independent of α. A trader with true belief π = 0.9 in a binary market moves the displayed price to 0.7, not 0.9. + +**Derivation sketch**: the FOC `∂C/∂q_k = π_k` gives `2r_k/α - (Σr_j²)/α + (α-1)/(αN) = π_k`. Summing over k yields the constraint `Σr_j² = 1/N` at the optimum. Substituting back and computing `p_k = r_k/α - 1/(αN) + 1/N` produces `p_k = π_k/2 + 1/(2N)`. + +The root cause is QMSR's **linear** price formula. The optimal `r_k` scales linearly with α (as `α·π_k/2`), causing α to cancel perfectly in `p_k = r_k/α + ...`, leaving a constant bias. This cancellation is specific to the linear structure. + +Two additional structural consequences: + +- **Prices stay biased regardless of volume.** Because prices are degree-0 homogeneous (they depend only on the ratios `r_k = q_k/S`, not on the scale `S`), additional trading at the optimal ratios adds volume without changing prices. A second trader with the same belief can profitably scale up at the same ratios — the profit function is degree-1 homogeneous, so expected profit scales linearly with trade size — but prices remain at `π/2 + 1/(2N)` throughout. The bias is a property of the ratio equilibrium, not a transient that washes out with volume. +- **Unbounded individual profit.** The degree-1 homogeneity of the profit function means a trader with non-uniform beliefs can achieve unbounded expected profit by scaling up their position. In practice this is bounded by capital, but it contrasts with standard MSRs where strict convexity of `C` ensures a unique finite optimum. The market maker's loss ratio remains bounded (the `α(N-1)/(4N)` bound holds regardless), but the absolute loss scales with volume. + +### LS-LMSR: small, α-dependent shrinkage + +For LS-LMSR, the same structural issue exists (`∇C ≠ p`), but the exponential (softmax) price formula means the optimal `r_k` scales **sub-linearly** with α. The α does not cancel from the price formula, and the bias shrinks as α grows. For practical α values, the displayed price is very close to the true belief. + +This is the fundamental advantage of nonlinear price formulas under the LS construction: the more curvature the price function has, the smaller and more α-dependent the bias becomes. + +The trilemma is mathematically strict — `∇C ≠ p` for any state-dependent `b` — but LS-LMSR makes it operationally soft. For practical α values, the price bias is negligible, giving LS-LMSR most of the benefits of all three properties simultaneously. The trilemma bites hard for LS-QMSR (constant 50% bias) but barely for LS-LMSR. This distinction is what makes the choice of base scoring rule matter so much: the exponential nonlinearity of LMSR nearly overcomes the trilemma, while the linearity of QMSR does not. It also suggests a research direction: a scoring rule with **intermediate** nonlinearity (e.g., quadratic prices from a cubic scoring rule) should produce intermediate bias under the LS construction — potentially small enough to be practical, while remaining polynomial and Simplicity-compatible. See open question 2. + +### Implications + +The trilemma has direct consequences for scoring rule selection: + +| Scoring rule | Adaptive b | ∇C = p | Path independent | Price bias at optimum | +|---|---|---|---|---| +| LMSR (fixed b) | No | Yes | Yes | None | +| QMSR (fixed b) | No | Yes | Yes | None (unconstrained domain) | +| LS-LMSR | Yes | No | Yes | Small, ∝ 1/α | +| LS-QMSR | Yes | No | Yes | 50% shrinkage toward uniform | + +For a system where the AMM's displayed price serves as the source of truth (as in deadcat), price accuracy is a first-order concern. The LS-QMSR's constant 50% bias means the price oracle systematically underreports the market's true belief — a market that "really believes" 90% displays 70%. + +--- + ## Subsidy Efficiency / Bounded Loss The operator's worst-case loss bounds how much subsidy capital must be locked at pool creation. For LS variants, the loss scales with volume; for fixed-b variants, it's a fixed amount. -| N | LMSR loss | QMSR loss | LS-LMSR loss/S | LS-QMSR loss/S (TODO: verify) | +| N | LMSR loss | QMSR loss | LS-LMSR loss/S | LS-QMSR loss/S (verified) | |---|---|---|---|---| | 2 | 0.693b | 0.250b | 0.693α | 0.125α | | 3 | 1.099b | 0.333b | 1.099α | 0.167α | @@ -255,7 +349,7 @@ The operator's worst-case loss bounds how much subsidy capital must be locked at **Comparison ratios** (efficiency advantage of QMSR-family over LMSR-family at equal subsidy): -| N | QMSR vs LMSR | LS-QMSR vs LS-LMSR (TODO: verify) | +| N | QMSR vs LMSR | LS-QMSR vs LS-LMSR (verified) | |---|---|---| | 2 | 2.77× | 5.54× | | 3 | 3.30× | 6.59× | @@ -281,11 +375,11 @@ For binary, depth at price p is `b/(1-p)` for moving the price up; `b/p` for mov ### QMSR -Constant: `∂p/∂q = (N-1)/(bN)`. Same depth at every price level. +Constant price impact: `∂p/∂q = (N-1)/(bN)`, independent of state. A unit of trading moves the price by the same amount regardless of the current price level. -For binary, depth is `2bp` (moving up) or `2b(1-p)` (moving down). +However, the cost per unit of probability shift (depth as defined above) is not constant: for binary, depth is `2bp` (moving up) or `2b(1-p)` (moving down). Directional depth varies linearly with p; the bidirectional average `b·[p + (1-p)] = b` is constant. -**Depth profile shape**: uniform across all prices. +**Depth profile shape**: constant price impact; directional depth varies linearly with p (cheap to push prices toward 0 or 1, expensive to push away). ### LS-QMSR @@ -449,7 +543,17 @@ The router solves a joint optimization: given a requested trade size, it selects - **LMSR + LOB**: strong first-mover incentives (exponential cost), thick extremes (resists tail moves), transcendental routing. Best when accurate tail-probability pricing is critical and implementation overhead is acceptable. - **QMSR + LOB**: moderate first-mover incentives (linear cost), uniform depth, linear routing. Better subsidy efficiency. Best when the active trading range dominates and operator collateral matters. - **LS-LMSR + LOB**: adaptive overall scale on top of LMSR's shape. The LOB provides some organic adaptive liquidity (makers enter when volume justifies it), so LS-LMSR's adaptive-b advantage is partially redundant. Still provides stronger first-mover incentives than QMSR-family options. -- **LS-QMSR + LOB**: adaptive per-outcome depth, no domain constraint (α ≥ 1), best subsidy efficiency. The adaptive depth is orthogonal to the LOB's organic scaling — it provides per-outcome depth tuning that the LOB alone doesn't replicate (since makers concentrate on popular outcomes). +- **LS-QMSR + LOB**: adaptive per-outcome depth, no domain constraint (α ≥ 1), best subsidy efficiency. The adaptive depth is orthogonal to the LOB's organic scaling — it provides per-outcome depth tuning that the LOB alone doesn't replicate (since makers concentrate on popular outcomes). **However**, the 50% price-display bias (see trilemma section) significantly undermines the AMM's role as source of truth for current and historical prices. + +### Adaptive liquidity without the LS construction + +The LS construction's appeal is automatic depth scaling — thin markets are thin, thick markets are thick, without operator intervention. The trilemma shows this comes at the cost of price accuracy. Several alternative mechanisms achieve adaptive liquidity while preserving `∇C = p` (strict properness): + +- **LOB-provided organic depth**: the LOB already provides adaptive liquidity — makers enter when volume and spreads justify it. This scales naturally with market interest without touching the scoring rule. The AMM provides always-available baseline liquidity; the LOB thickens it when demand warrants. +- **Operator-managed b adjustments**: the pool admin mechanism (already part of the LMSR pool design) allows the operator to increase b as the market matures. Exogenous adaptation with no properness cost. The operator observes volume growth and adjusts; the LOB provides interim depth during any lag. +- **Time-based b growth**: `b(t) = b_0 + f(t)` where b increases with market age. Since b depends on time rather than market state, `∇C = p` is fully preserved — strict properness, path independence, polynomial arithmetic. Depth grows automatically without operator intervention. The tradeoff: depth increases even for markets nobody trades, locking up more subsidy capital. Manageable with a modest growth rate and a cap. + +These mechanisms are less elegant than the LS construction but avoid the trilemma entirely. For deadcat's architecture, the combination of standard QMSR + active LOB + operator b-adjustment appears to achieve the practical goal of adaptive liquidity without sacrificing price accuracy. --- @@ -484,7 +588,7 @@ A single binary market has one YES token, one NO token, and resolves to one of t - **Implementable**: 2D Merkle table (because b changes per trade) — feasible for binary but larger/more complex than standard LMSR's 1D table - **Subsidy**: loss/volume = 0.693α (proportional to volume rather than fixed) - **Depth profile**: same logarithmic shape as LMSR; overall scale adapts to volume -- **Properness**: unconditional +- **Properness**: subject to small α-dependent bias (see trilemma section); near-exact for practical α values - **Domain**: no constraint - **First-mover incentive**: strong - **Per-swap witness**: larger than LMSR (table dimension is doubled) @@ -492,57 +596,70 @@ A single binary market has one YES token, one NO token, and resolves to one of t ### LS-QMSR -- **Implementable**: inline polynomial arithmetic, native to Simplicity (TODO: verify) -- **Subsidy**: loss/volume = α/8 (binary) (TODO: verify) +- **Implementable**: inline polynomial arithmetic, native to Simplicity +- **Subsidy**: loss/volume = α/8 (binary) (verified upper bound) - **Depth profile**: state-dependent; adaptive thickness -- **Properness**: unconditional for α ≥ 1 (no boundary binds) +- **Properness**: **biased** — displayed price = `π/2 + 1/4` (binary), a fixed 50% shrinkage toward uniform (see trilemma section) - **Domain**: no constraint for α ≥ 1 - **First-mover incentive**: moderate (similar to QMSR) - **Per-swap witness**: ~2-3 kB (similar to QMSR) -- **Notes**: requires LS-QMSR verification before adoption +- **Notes**: the 50% price-display bias is a significant concern for the AMM's role as source of truth; see trilemma section and adaptive liquidity alternatives --- -## Use Case 2: N-Outcome Markets with YES/NO per Outcome +## Use Case 2: N-Outcome Markets (Multi-Outcome) -In this market structure, each of N mutually exclusive outcomes has both a YES token and a NO token. Pair-split primitives (collateral ↔ YES_k + NO_k) and basket primitives (collateral ↔ full YES basket or full NO basket) enable structured liquidity. See `multi-outcome-market-contract.md` for the contract design. +In deadcat's multi-outcome market structure, each of N mutually exclusive outcomes has both a YES and a NO token (2N tokens total). The market contract provides permissionless solvency-preserving primitives: per-outcome pair issue/cancel, split-YES / merge-YES (full basket ↔ collateral_per_pair), split-NO / merge-NO (full NO basket ↔ (N-1)·collateral_per_pair), and cross-outcome swap (YES_i ↔ {NO_j : j≠i} + (N-2) collateral, derivable). See [`multi-outcome-market-contract.md`](multi-outcome-market-contract.md) for the contract design. -### LMSR +**The landed pool-layer decision for multi-outcome markets is Option C**: N independent binary LMSR pools per market, one per outcome's YES/NO pair. Cross-outcome AMM coherence is arb-enforced, with arbitrageurs exploiting the N-outcome market contract's native cross-outcome primitives to close coherence gaps atomically. The analysis below covers why this was chosen over unified multi-outcome pool designs. -The lack of transcendental functions in Simplicity means LMSR's Merkle table approach scales awkwardly with N: +### Option C (chosen): N binary LMSR pools per market -- **N=2**: 1D table, fully feasible -- **N=3**: 2D table at 256×256 = ~65k entries, feasible -- **N=4**: 3D table at ~16M entries, borderline (witness/proof size growth, generation cost) -- **N=5+**: dimensional explosion, impractical +- **Pool type**: the same binary LMSR pool contract used for binary markets, instantiated N times per multi-outcome market. +- **Coherence**: `Σp_YES_k = 1` across pools is **arb-enforced**, not structural. Arb paths leverage the market contract's split-YES / merge-YES primitives (single atomic transaction when the N-outcome market contract is used; multi-tx sequence if the market is instead composed from N binary market contracts). +- **Subsidy scaling**: `N × b · ln(2)` across all pools (each pool independent). Higher per-outcome depth than unified LMSR for the same total subsidy because each pool's constraint is independent of the others. +- **Parallelism**: trades on different outcomes hit different pool UTXOs, so they parallelize. Only cross-outcome arb touches multiple pools atomically. +- **LP model**: admin-operated per pool. Creators pick which outcomes to provide liquidity for; a market may have some outcomes with deep pools and others with thin pools or no pool at all. Permissionless creation. +- **Implementation**: same `.simf` as binary LMSR pool. No multi-outcome-specific pool contract type. -For N ≥ 4-5 in a unified pool, LMSR-based designs typically fall back to one of two approaches: +### Why not unified LMSR (even at N=3 where feasible)? -1. **Compose multiple binary LMSR pools** — one per outcome's YES/NO pair. This is N independent binary pools instead of one unified pool. Event-level coherence (Σp = 1 across YES tokens) becomes arbitrage-enforced rather than structural. Subsidy scales as N × `b · ln(2)` instead of `b · ln(N)`. +LMSR unified pool is technically feasible for N=2 (1D table) and N=3 (2D table at same total entry count). The N=3 option was declined: -2. **Switch to QMSR** for the multi-outcome case, accepting the asymmetry of using different scoring rules for different market types. +- **2D-variant tooling cost**: table generation, covenant lookup, state encoding, Merkle tree structure all differ from 1D in non-trivial ways. Realistic effort ratio ~1.3–1.5× binary, plus separate audit. +- **Small share of real markets**: N=3 prediction markets are a small fraction of total volume; binary dominates heavily. +- **Uniform implementation surface is valuable**: one pool contract type across all market shapes makes audit, indexing, routing, LP UX, and bug-fixing simpler. +- **N≥4 requires Option C anyway**: 3D table at ~16M entries is borderline; N≥5 is impractical. So unified pools would only ever cover N∈{2,3}, leaving N≥4 in Option C. Collapsing to "binary LMSR everywhere + Option C for multi-outcome" is architecturally cleaner. -Neither fallback is fully satisfactory; they trade structural properties for implementability. +### Why not unified QMSR (any N)? -### QMSR +QMSR's polynomial-inline feasibility at any N was attractive. Ultimately rejected because: -Native polynomial implementation handles any N without modification: +- **Zero production deployments** in prediction markets. 20 years of academic availability with no shipped system is a real (if path-dependent) operational risk signal. +- **Weaker first-mover incentives**: linear cost curve vs LMSR's exponential. For deadcat's AMM-as-price-oracle use case, faster information incorporation matters. +- **Domain constraint** (`Σ Δ_k ≤ b`) is benign in terms of semantics — at the boundary, pool's p hits 1 for some outcome, which is exactly where the market contract's pair-split takes over at equivalent cost — but it's still covenant complexity without clear offsetting value when LMSR+Option C covers the same use cases. -- Single unified pool covering all N outcomes -- Σp = 1 by construction -- Bounded loss `b(N-1)/(2N)`, sub-linear in N -- Per-swap witness scales as ~8N bytes plus overhead -- Domain constraint mitigated by rebasing and pair-split +QMSR remains a viable fallback if future operational experience surfaces LMSR issues. The comparative analysis in this document supports either direction; the choice reduces to risk tolerance. -### LS-LMSR +### LS-LMSR (rejected) -Shares LMSR's scaling problems plus the per-trade table invalidation issue (one dimension step more than standard LMSR at every N). Borderline at N=3 (3D table, ~16M entries), effectively impractical for N ≥ 4 in a Simplicity covenant. +Shares LMSR's scaling problems plus the per-trade table invalidation issue (one dimension step more than standard LMSR at every N). Borderline at N=3 (3D table, ~16M entries), effectively impractical for N ≥ 4 in a Simplicity covenant. Under Option C composition, LMSR's already-feasible 1D binary table does the job without LS's overhead; LS-LMSR's adaptive-b advantage is largely subsumed by admin-bump + LOB organic depth. -### LS-QMSR +### LS-QMSR (disqualified) + +Shares QMSR's scaling properties (any N feasible) plus adaptive-depth advantages. Subsidy bound `α(N-1)/(4N) · S` (verified upper bound, tight for α ≤ 2). + +For α ≥ 1, the domain constraint disappears entirely. **However**, the 50% price-display bias (see trilemma section) applies at every N, with displayed prices shrinking to `p_k = π_k/2 + 1/(2N)`. At N=10, the displayable range is only [0.05, 0.55]; a market with 100% consensus on one outcome displays at 55%. **Disqualified as a source-of-truth price oracle.** + +### FPMM / constant-product (rejected due to per-trade co-spend) + +The Gnosis-style FPMM is a natural alternative AMM family: `Π x_k = k` preserved on trades, polynomial inline, structurally Σp=1, no domain constraint, real production deployment history at Gnosis CTF. -Shares QMSR's scaling properties (any N feasible) plus the adaptive-depth advantages. Subsidy bound `α(N-1)/(4N) · S` (TODO: verify) — half the standard QMSR loss for the same effective b. +The disqualifying issue is structural: FPMM's invariant only holds over **post-split** reserves. Every trade requires atomically splitting collateral through the market contract's split-YES primitive to preserve `Π x_k = k`. In deadcat's setup, this means every pool trade co-spends the market contract's collateral UTXO, serializing all pool trades across all pools of the same market on the market's collateral UTXO. -For α ≥ 1, the domain constraint disappears entirely, eliminating the boundary concern that exists for standard QMSR at higher N. +LMSR/QMSR don't have this problem: their pricing state is the q vector (tracked in tapdata), independent of physical reserves. Trades update q without touching uninvolved outcomes' reserves, and no market co-spend is required per trade. + +This was the blocker for FPMM in deadcat. Not a property defect of FPMM — just a structural incompatibility with parallel pool trading. --- @@ -550,15 +667,15 @@ For α ≥ 1, the domain constraint disappears entirely, eliminating the boundar ### Single-Event Binary Markets -| Dimension | LMSR | QMSR | LS-LMSR | LS-QMSR (TODO: verify) | +| Dimension | LMSR | QMSR | LS-LMSR | LS-QMSR | |---|---|---|---|---| -| Properness | Unconditional | Conditional (boundary) | Unconditional | Unconditional (α≥1) | -| Bounded loss | `b·ln(2) ≈ 0.693b` | `b/4 = 0.25b` | `α·ln(2)·S` | `α·S/8` | +| Properness | Unconditional | Conditional (boundary) | Small bias ∝ 1/α (trilemma) | **50% bias toward uniform** (trilemma) | +| Bounded loss | `b·ln(2) ≈ 0.693b` | `b/4 = 0.25b` | `α·ln(2)·S` | `α·S/8` (verified) | | Subsidy efficiency vs LMSR | 1× | 2.77× better | Same as LMSR (per state) | 5.54× better than LS-LMSR | | Depth at extremes (p=0.05) | Very thick | Thin | Very thick | State-dependent | | Depth at center (p=0.5) | Thin | Uniform; thicker than LMSR for equal subsidy | Thin | Adaptive | | First-mover incentive | Strong | Moderate | Strong | Moderate | -| Domain constraint | None | `|q_Y - q_N| ≤ b` | None | None for α≥1 | +| Domain constraint | None | `\|q_Y - q_N\| ≤ b` | None | None for α≥1 | | Simplicity feasibility | Feasible (1D Merkle table) | Feasible (inline polynomial) | Feasible (2D table) | Feasible (inline polynomial) | | Per-swap witness size | ~3-5 kB | ~2-3 kB | Larger than LMSR | ~2-3 kB | | Adaptive liquidity | No | No | Yes (per total volume) | Yes (per outcome) | @@ -567,94 +684,128 @@ For α ≥ 1, the domain constraint disappears entirely, eliminating the boundar ### N-Outcome YES/NO Markets -| Dimension | LMSR (unified) | LMSR (composed binary) | QMSR | LS-QMSR (TODO: verify) | +| Dimension | LMSR (unified) | LMSR (composed binary) | QMSR | LS-QMSR | |---|---|---|---|---| | Implementable in Simplicity | Up to N=3, borderline N=4 | Any N | Any N | Any N | | Σp = 1 across YES tokens | Structural | Arbitrage-enforced | Structural | Structural | -| Bounded loss | `b·ln(N)` | `N · b · ln(2)` | `b(N-1)/(2N)` | `α(N-1)/(4N) · S` | +| Bounded loss | `b·ln(N)` | `N · b · ln(2)` | `b(N-1)/(2N)` | `α(N-1)/(4N) · S` (verified) | | Subsidy scaling with N | Logarithmic | Linear | Sub-linear | Sub-linear | | Subsidy efficiency at N=10 | Baseline | ~3× worse than unified LMSR | ~5× better than unified LMSR | ~10× better than LS-LMSR | | Per-outcome operator | No | Yes (independent pools) | No | No | | Per-swap witness size | Grows with N (Merkle proof depth) | O(1) per pool | ~8N bytes | ~8N bytes | | Domain constraint | None | None | Skew-bounded | None for α≥1 | -| Properness | Unconditional | Unconditional per pool | Conditional | Unconditional (α≥1) | +| Properness | Unconditional | Unconditional per pool | Conditional (boundary) | **50% bias toward uniform** (trilemma) | | Deployment history | Limited (N=2 mostly) | Limited | None | None | --- ## Open Questions -1. **LS-QMSR formal verification**: the construction needs an independent proof of strict properness, bounded-loss derivation, and path independence. The math has been worked through informally but hasn't been peer-reviewed or formally checked. +### Resolved + +1. ~~**LS-QMSR formal verification**~~: **Resolved.** Path independence confirmed (degree-1 homogeneity). Bounded loss confirmed as upper bound (tight for α ≤ 2). Strict properness **disproven** — displayed prices exhibit a fixed 50% shrinkage toward uniform. See the trilemma section and the LS-QMSR verification banner at the top of this document. + +2. ~~**LS-QMSR loss analysis at high N**~~: **Resolved.** The bound `α(N-1)/(4N) · S` is confirmed as correct for all N. It is tight for α ≤ 2. For α > 2, the true worst case is strictly less. The bound is conservative (not violated) at any (α, N) combination. + +3. ~~**Scoring rule choice**~~: **Resolved.** Binary LMSR chosen. Multi-outcome via Option C composition. See Decision Record. + +4. ~~**Multi-outcome pool shape**~~: **Resolved.** Option C — N binary LMSR pools composed per market. No unified multi-outcome pool contract. + +5. ~~**Liquidity model for v1**~~: **Resolved.** Admin-operated with permissionless creation. LP-tokenized pools deferred to v2. + +### Open / deferred + +6. ~~**Covenant implementation specifics for binary LMSR pool**~~: **Resolved.** Witness encoding, F-value generation (arbitrary-precision bignum), and Merkle proof format are specified in [`lmsr-pool/lmsr-deterministic-table-spec.md`](lmsr-pool/lmsr-deterministic-table-spec.md). Fixed-point Taylor is deferred to a post-v1 non-breaking optimization (committed Merkle roots are the conformance set). -2. **Smooth QMSR variants**: Nueve & Waggoner (NeurIPS 2025) propose a smoothed QMSR with a log-barrier term that eliminates the domain restriction at the cost of reintroducing transcendental arithmetic at the boundary. Could a polynomial-only smoothing exist? Could it apply to LS-QMSR similarly? +7. **Operator economics in production**: theoretical subsidy efficiency is one factor; actual operator P&L depends on trader behavior, fee structure, adverse selection, and LOB-vs-pool routing dynamics. LMSR has production history in non-Liquid environments but not in deadcat's specific setup. Real-world data will inform future parameter defaults. -3. **Hybrid / multi-pool designs**: should a single market support multiple pools simultaneously (e.g., a QMSR pool for institutional flow plus per-outcome LMSR pools for retail)? What composition rules would be required? +8. **Cross-outcome arb coherence in practice**: under Option C composition, `Σp_YES_k = 1` is arb-enforced. Empirical question: how tight do spreads stay in practice? Does it matter whether the underlying market is a single N-outcome contract (atomic arb via split-YES/merge-YES) vs. composed binary markets (multi-tx arb)? This will only be answered by real deployment data. -4. **LS-QMSR's α calibration**: for what α values does LS-QMSR provide the right balance of liquidity and price responsiveness? How should it be set in practice? +9. **LP-tokenized pools as v2**: if passive-capital demand materializes, design a tokenized-liquidity variant. Scale-invariance properties favor QMSR for LP-tokenization (LMSR's Merkle table doesn't play cleanly with dynamic b). Revisits the scoring rule choice. -5. **LS-QMSR loss analysis at high N**: the bounded-loss derivation assumes the symmetric configuration is the worst case. Is this true for all α and N? What happens at N → ∞? +10. **Higher-degree polynomial scoring rules with LS construction**: deferred indefinitely. A cubic scoring rule with quadratic prices could give LS-like adaptive liquidity with polynomial arithmetic and small price bias. Research territory; no urgency. -6. **Operator economics in production**: theoretical subsidy efficiency is one factor; actual operator P&L depends on trader behavior, fee structure, and adverse selection. None of the scoring rules have been operated at scale on Liquid in deadcat's specific setup. +11. **Smooth QMSR variants**: Nueve & Waggoner (NeurIPS 2025) propose a smoothed QMSR with a log-barrier term. Polynomial-only smoothing? Deferred — only relevant if the project revisits QMSR. -7. **Covenant implementation specifics**: even for the "feasible" scoring rules, exact witness encoding, integer scaling for fixed-point arithmetic, rounding handling, and edge cases (e.g., S near zero in LS variants) need detailed specification before implementation. +12. **Multi-pool composition on the same market**: a single market may have multiple competing pools (possibly with different b, fee_bps). Routing optimization across them is a `deadcat-core` concern. Whether to build explicit router tooling for it in v1 is TBD. --- -## Subjective Recommendations +## Decision Record -> **Note**: The remainder of this document is opinion, not factual trade-off documentation. The reasoning is laid out so future readers can evaluate it against their own priorities. +The committed pool design is **binary LMSR only, composed via Option C for multi-outcome markets, admin-operated with permissionless creation**. This section records the reasoning and acknowledged tradeoffs. -### For binary markets (in this author's view) +### For binary markets: LMSR chosen -Setting aside what's already built (none of the pool designs have been implemented yet at the time of writing), **LS-QMSR with α ≥ 1 appears to be the strongest choice on engineering merits** for binary markets in deadcat's environment: +**LMSR** (Hanson 2003) is the scoring rule. Core reasons: -- Native to Simplicity (polynomial arithmetic, no Merkle tables) -- No domain restriction (α ≥ 1) -- Better subsidy efficiency than any alternative (TODO: verify) -- Adaptive depth (thick on the popular side, thin on the longshot) -- Smaller per-swap witness than LMSR -- Composes cleanly with the existing LOB +- **Unconditionally strictly proper** — displayed prices equal true belief everywhere. No domain boundary, no bias. For deadcat's AMM-as-price-oracle role, this is a first-order property. +- **Strong first-mover incentives** via exponential cost curve. Informed traders face dramatically higher costs for delaying. This directly improves price discovery speed and the quality of the historical price record — a key deadcat value prop. +- **Extensive production track record**: Augur v1, Gnosis, corporate prediction markets. Known operational characteristics. +- **KL-divergence interpretation**: price is a sufficient statistic for the market's aggregate belief. Elegant theoretical grounding that survives into production. +- **1D Merkle table is tractable**: at depth 16, ~65k entries, ~3-5 kB witness per swap. The table-generation tooling and proof machinery are a one-time cost at pool creation and a manageable per-swap witness overhead. -The main caveats are: +Acknowledged costs: +- **Larger per-swap witness** (~3-5 kB) vs QMSR's ~2-3 kB. Ongoing per-trade chain fee, paid forever. +- **Worse subsidy efficiency** (~2.77× more collateral for equal depth at N=2). Operators commit more capital per pool. +- **Table generation tooling** must be maintained. -- **Verification status**: LS-QMSR needs the formal verification flagged in the TODO comments above -- **Zero deployment history**: no production system uses LS-QMSR; this is genuine first-mover risk -- **Loss of LMSR's KL interpretation**: the elegant "price = Bayesian-optimal aggregate belief" theorem doesn't hold; QMSR-family prices reflect beliefs but not via KL geometry +These were weighed against QMSR's advantages (smaller witness, better efficiency) and the decision went to LMSR on robustness grounds — proven math and stronger first-mover incentives matter more for a new platform where operational surprises are expensive. -If LS-QMSR's verification doesn't pan out or its first-mover risk is judged unacceptable, **standard QMSR is the next-best choice**: same Simplicity-native implementation, same subsidy efficiency advantages over LMSR, but with the domain constraint as a real (though mitigable) operational concern and slightly worse subsidy efficiency than LS-QMSR. +### For multi-outcome markets: Option C composition (N binary LMSR pools) -LMSR remains a defensible choice if unconditional properness with the KL-divergence interpretation is weighted heavily and the implementation overhead (Merkle table generation, larger witnesses) is acceptable. +For markets with N ≥ 3 outcomes, the pool layer composes **N independent binary LMSR pools**, one per outcome's YES/NO pair. Cross-outcome AMM coherence (`Σp_YES_k = 1`) is arb-enforced. -LS-LMSR is hard to recommend in this environment: the per-trade table invalidation makes it one dimensional step less practical than standard LMSR at every N. Binary is feasible but strictly inferior to standard LMSR's 1D table (same adaptive-b solution could come from other mechanisms at lower implementation cost); for N ≥ 4 it's impractical. +Rationale: +- **Uniform pool implementation**: one Simplicity contract, one audit, one `deadcat-core` integration, one LP mental model. This is architecturally significant. +- **Parallel trading**: trades on different outcomes hit different pool UTXOs. No serialization across outcomes. +- **Arb efficiency via the N-outcome market contract**: when the underlying market is an N-outcome contract (not N composed binary markets), arbitrageurs exploit its cross-outcome primitives (split-YES, merge-YES, cross-outcome swap) to close coherence gaps in a single atomic transaction. This keeps arb-enforced coherence tight. +- **Structural coherence at N=3 would have been "free" in table size** but costs meaningful implementation complexity (separate 2D covenant, tooling, audit). Given N=3 markets are a minority share of total volume, the ROI on a separate unified N=3 pool contract is weak. -### For N-outcome markets (in this author's view) +Acknowledged costs: +- **Cross-outcome price coherence is arb-enforced, not structural**. Small price discrepancies persist between arb events. +- **Per-outcome liquidity may fragment**: popular outcomes get deep pools, obscure ones stay thin. Users compose trades across the available pools. -For N-outcome markets with YES/NO per outcome, the LMSR family is increasingly problematic as N grows due to dimensional explosion. The practical choice is between: +### Alternatives considered and rejected -- **QMSR family** (standard or LS-) for unified pools — natural fit, structural Σp = 1 -- **Composed binary pools** (LMSR-based or QMSR-based) — sacrifices structural coherence for per-outcome operator flexibility +- **QMSR (unified, any N)**: strong candidate. Polynomial-inline, better subsidy efficiency, any-N support, LP-scaling-invariant. Rejected primarily because it has **zero production deployments** in prediction markets and its linear cost curve produces **weaker first-mover incentives**. Remains a viable fallback if future operational issues with LMSR surface. +- **LS-LMSR**: adaptive b via `b = αS`. Rejected because the adaptive property requires an extra dimension in the Merkle table (one dimension less feasible than standard LMSR at every N), and its small price bias means it's not unconditionally proper. Under Option C binary pools, standard LMSR's fixed b + admin bumps + LOB organic depth substitute adequately. +- **LS-QMSR**: disqualified by the trilemma — at the trader's optimum, displayed prices exhibit a fixed 50% shrinkage toward uniform (`p_k = π_k/2 + 1/(2N)`), independent of α. Breaks the AMM's source-of-truth role. +- **FPMM / constant product** (Gnosis-style): rejected because every pool trade requires atomic market co-spend to preserve `Π x_k = k` over post-split reserves. This serializes all pool trades across all pools of the same market on the market's collateral UTXO. Fatal for parallel pool trading. +- **Unified LMSR at N=3 (2D table)**: feasible with same total entries as binary 1D but different tooling, covenant logic, state encoding, audit surface. Declined because of the uniform-implementation-surface win from "binary only." +- **Higher-degree polynomial scoring rules** (cubic, quartic with LS construction): genuinely novel research territory. Deferred indefinitely — not a v1 consideration. -If structural coherence is valued (and the analysis suggests it should be for events where exactly one outcome wins), QMSR-family scoring rules are essentially required for N ≥ 4-5. +### Liquidity model: admin-operated, permissionless creation -Within the QMSR family, LS-QMSR has the same advantages over standard QMSR as in the binary case, plus the additional benefit that the no-domain-restriction property (α ≥ 1) becomes more valuable at higher N where boundary binding is more frequent. +Each pool has a single operator who: +- Chooses parameters at creation (`max_loss_sats`, `half_payout_sats`, `fee_bps`, starting state/reserves; collateral asset and oracle pubkey inherited from market) +- Provides the subsidy capital +- Can adjust reserves via an admin spend path (permissioned liquidity changes) +- Can close the pool via an admin spend path +- Earns all fees, bears all impermanent loss -### Process recommendation +Pool creation is **permissionless**: anyone can deploy a pool on any market with any parameters. Multiple competing pools per market are expected and welcomed. LPs with different opinions on fair depth (`max_loss_sats`), reserve sizing, and fee rates compete. -1. **Verify LS-QMSR formally** before any implementation commitment. The math should be checked rigorously, including the bounded-loss derivation, properness proof, and path independence argument. +LP-tokenized pools (shared ownership, deposit/withdraw mechanics, pro-rata fee distribution) were considered extensively but deferred to v2. Under admin-operation, the covenant stays simpler, the trust model is crisp, and operators have clear incentive alignment. LP-tokenization becomes an upgrade path if passive-capital demand materializes. -2. **Prototype standard QMSR first** as a fallback. Standard QMSR's properties are well-established academically; an implementation can validate the Simplicity-feasibility claims and operational characteristics independently of LS-QMSR's verification. +### Future directions -3. **Defer the binary vs N-outcome contract decision** until pool implementations are validated. The pool choice and the contract choice are technically orthogonal but interact through the pool's role in the overall trader experience. +- **Revisit QMSR** if LMSR's subsidy efficiency or witness size become real constraints at volume. +- **LP-tokenized pools as v2**: QMSR's scale-invariance makes it the better candidate for LP-tokenization than LMSR (LMSR's Merkle table doesn't play cleanly with dynamic b). If/when LP-tokenized is prioritized, the scoring rule decision may revisit. +- **Cross-outcome arb tooling**: bots and incentive mechanisms to keep multi-outcome arb-enforced coherence tight. Not a contract change; an ecosystem tooling consideration. +- **LS-QMSR revival**: only if either (a) a display-layer correction is validated (showing `2p - 1/N` instead of `p`, with edge-case handling near 0 and 1), or (b) a higher-degree polynomial scoring rule with smaller LS bias is developed. --- ## Key Files -- `docs/contracts/lmsr-pool/lmsr-pool-design.md` — current LMSR pool design +- `docs/contracts/lmsr-pool/lmsr-pool-design.md` — binary LMSR pool design (the chosen pool contract) - `docs/contracts/lmsr-pool/lmsr-deterministic-table-spec.md` — Merkle table specification -- `docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md` — multi-outcome AMM design exploration -- `docs/contracts/multi-outcome/multi-outcome-market-contract.md` — N-outcome market contract proposal -- `docs/architecture/transaction-composability-model.md` — composability framework +- `docs/contracts/multi-outcome/multi-outcome-market-contract.md` — N-outcome market contract spec (pool liquidity via Option C composition) +- `docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md` — design history record +- `docs/contracts/market-contract-principles.md` — covenant-enforced properties shared across both market contract types +- `docs/contracts/contract-specification.md` — top-level contract reference +- `docs/architecture/transaction-composability-model.md` — composability framework (witness-parameterized indices, atomic multi-contract PSETs) ## References diff --git a/docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md b/docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md index fcf972a1..2e5c3b50 100644 --- a/docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md +++ b/docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md @@ -1,6 +1,78 @@ # Design Journal: Multi-Outcome Markets and AMM Exploration -**Status**: Design exploration record. Not a specification. Summarizes a long design discussion that produced one committed design (the multi-outcome market contract) and one still-under-validation proposal (the QMSR pool). +**Status**: Historical design record. All open questions in this journal have been resolved. **See [Final Decisions](#final-decisions) for the committed landings.** The rest of the journal preserves the exploration trail for future reference. + +## Final Decisions + +After extended design iteration documented below (and further discussion in subsequent sessions), the following are the committed decisions: + +### Multi-outcome market contract: 2N tokens (YES/NO per outcome) + +The original Approach A in this journal proposed N tokens (one per outcome, Arrow-Debreu style). **This was subsequently pivoted to 2N tokens**: each outcome has both a YES token and a NO token, mirroring the binary market's token model. Reasons: + +- **Negative positions don't require (N-1) UTXOs**: with NO_k as a first-class token, users can bet against outcome k by holding a single NO_k, not a bundle of N-1 "everything-else" tokens. +- **AMM symmetry**: per-outcome binary pools (Option C composition, see below) naturally take (YES_k, NO_k) pairs, mirroring the binary LMSR pool structure. +- **Mental-model continuity**: users already understand YES/NO from the binary market. + +Trade-off accepted: 5N+2 covenant slots (vs 3N+2 for the N-token variant), 2N+1-way sibling check on unresolved-phase transitions. + +See [`multi-outcome-market-contract.md`](multi-outcome-market-contract.md) for the full spec. + +### Scoring rule: LMSR (not QMSR) + +The journal's tentative lean was "QMSR-for-all." **This was reversed.** Final decision: binary LMSR. + +Reasons the reversal happened: +- **Production track record**: LMSR has 20 years of real deployments. QMSR has zero. For a new platform, operational unknowns are expensive. +- **First-mover incentives**: LMSR's exponential cost curve creates dramatically stronger pressure to trade immediately on new information than QMSR's linear curve. For deadcat's AMM-as-price-oracle role, this matters. +- **LS-QMSR disqualified by trilemma**: analyzed in [`amm-scoring-rule-tradeoffs.md`](amm-scoring-rule-tradeoffs.md). The LS construction causes a fixed 50% shrinkage of displayed prices toward uniform, independent of α. Breaks the price-oracle role. + +QMSR remains a viable fallback if LMSR's subsidy efficiency or witness size become real operational constraints. + +### Pool shape: binary only (no unified multi-outcome pool) + +The journal's "LMSR pool scaling investigation" noted that N=3 is feasible with a 2D table of the same entry count as the binary 1D table, and N=4 is borderline with 3D tables. **The final decision is to only support binary (N=2) LMSR pools**, even though N=3 unified is technically feasible. + +Reasons: +- **Uniform implementation surface**: one pool contract type across all market shapes simplifies audit, indexing, routing, LP UX, and bug-fixing. +- **2D-variant cost isn't "free"**: same table entries, but different covenant logic, state encoding, tooling, audit overhead. +- **N=3 markets are a minority share of volume**: the ROI on a separate unified N=3 pool contract is weak. +- **N≥4 needs composition anyway**: collapsing to "binary everywhere + Option C for multi-outcome" is architecturally cleaner than "unified for N∈{2,3} + composed for N≥4." + +### Multi-outcome pool liquidity: Option C composition + +For a multi-outcome market with N ≥ 3 outcomes, AMM liquidity comes from **N independent binary LMSR pools**, one per outcome's YES/NO pair. Cross-outcome AMM coherence (`Σ p_YES_k = 1`) is **arb-enforced**, not structural. + +The multi-outcome market contract's cross-outcome primitives (split-YES, merge-YES, cross-outcome swap) enable arbitrageurs to close coherence gaps in a **single atomic transaction**, keeping arb-enforced coherence tight. This is the primary operational value of the multi-outcome market contract, surpassing the secondary oracle-containment value initially emphasized. + +### Liquidity model: admin-operated pools, permissionless creation + +Each pool has a single operator who commits subsidy, earns fees, bears impermanent loss, and can adjust reserves / close via admin-signed spend paths. **Pool creation itself is permissionless** — anyone can deploy a pool on any market with any parameters, and multiple competing pools per market are expected. + +LP-tokenized pools (shared ownership, deposit/withdraw mechanics, pro-rata fees) were considered in depth and deferred to v2. + +### Contracts kept + +- **Binary prediction market contract** — single-event YES/NO, also used as the building block for app-layer composed multi-outcome events where the outcome set can evolve. +- **Multi-outcome market contract** — 2N tokens, structural solvency, cross-outcome primitives for efficient arb and LP rotation. +- **Binary LMSR pool contract** — one pool type, serves both market contract types (directly for binary markets, composed via Option C for multi-outcome). +- **Maker order contract** — limit orders on any market. + +### Alternatives considered and rejected + +- **Unified multi-outcome LMSR at N=3** (2D table) — feasible but declined for implementation simplicity. +- **Unified multi-outcome QMSR** (any N via polynomial inline) — rejected for lack of production history and weaker first-mover incentives. +- **LS-LMSR** — dominated by standard LMSR at every N in Simplicity environment. +- **LS-QMSR** — disqualified by the properness trilemma (50% price bias). +- **FPMM / constant product** (Gnosis-style) — requires per-trade market co-spend, serializing all pool trades on the market's collateral UTXO. Rejected. +- **LP-tokenized pools in v1** — deferred to v2; admin-operated is simpler and the v1 permissionless-creation story is preserved. +- **Higher-degree polynomial scoring rules with LS construction** — genuinely novel research territory, deferred indefinitely. + +--- + +**The remainder of this document preserves the original exploration trail.** Labels like "still under validation" and "tentative" refer to the state at the time of writing, not the current state. + +--- ## What we set out to answer @@ -39,7 +111,7 @@ A new contract type that natively handles N outcomes: Keep binary markets. Add a new "event" contract that holds N binary markets' RTs and orchestrates atomic cross-market operations. -**Verdict: infeasible without modifying the binary market contract.** The existing binary market uses hardcoded output indices (`current_index() == 0/1/2`), which structurally prevents co-spending multiple markets in one transaction. Even if we modified the binary market, Approach B has capital inefficiency issues (N:1 capital lockup vs 1:1 for A). +**Verdict: still unattractive.** An earlier version of this analysis assumed the binary market used hardcoded output indices (`current_index() == 0/1/2`), which would have blocked co-spending multiple markets in one transaction. That premise is now superseded by the witness-parameterized market design. Even with that fix, Approach B remains worse than A because it adds wrapper complexity while still suffering capital inefficiency (N:1 capital lockup vs 1:1 for A). ### Approach C: Application-only composition @@ -272,7 +344,7 @@ In priority order: 3. **Draft Doc 2** (QMSR pool proposal). Now that the foundation is in place, the QMSR pool design can be concretely specified. -4. **Small updates to existing docs**: forward-reference the new docs from `../contract-specification.md` and `../../architecture/deadcat-core-design.md`'s pending refactors tables. +4. **Small updates to existing docs**: forward-reference the new docs from `../contract-specification.md` and the relevant legacy-source alignment sections of `../../architecture/deadcat-core-design.md`. 5. **QMSR implementation spike**: write an actual `.simf` for the QMSR pool to validate the math works in integer arithmetic with acceptable witness sizes. diff --git a/docs/contracts/multi-outcome/multi-outcome-market-contract.md b/docs/contracts/multi-outcome/multi-outcome-market-contract.md index 24f61aa1..509ad94c 100644 --- a/docs/contracts/multi-outcome/multi-outcome-market-contract.md +++ b/docs/contracts/multi-outcome/multi-outcome-market-contract.md @@ -1,41 +1,120 @@ # Multi-Outcome Prediction Market Contract -**Status**: Proposal — design specification for a new core contract type. Not yet implemented. +**Status**: Specified — design specification for a new core contract type. Not yet implemented. Supersedes an earlier N-token (Arrow-Debreu) variant (see [Alternatives Considered](#alternatives-considered)). The Unresolved-phase operations design was revised from an enumerated-primitives approach (6 specific spend paths) to a single generic solvency-preservation spend path — the generic path accepts any `(Δy, Δn, Δc)` that preserves the invariant, enabling atomic cross-outcome compositions like the cross-outcome swap. + +**Related**: this contract implements every principle in [market-contract-principles.md](../market-contract-principles.md) — permissionless operation within the solvency invariant, narrow oracle authority, terminal paths from every non-terminal state, RT destruction on resolution/expiry, sibling UTXO check, witness-parameterized indices, deterministic RT blinding, and the rest. The principles doc is the canonical specification of those shared properties; this doc focuses on what is specific to the multi-outcome contract (2N token model, generic Unresolved-phase transition check, slot layout, code generation). ## Motivation -The existing prediction market contract (`prediction_market.simf`) supports exactly two outcomes (YES/NO). Many real-world prediction markets require more outcomes: elections with multiple candidates, sports tournaments with many teams, awards with many nominees. Polymarket routinely runs multi-outcome events with 50-128 outcomes. +Many real-world markets require more than two outcomes: Fed rate decisions (raise/flat/lower/other), elections with multiple candidates, sports tournaments, awards with many nominees. Polymarket regularly runs multi-outcome events with 10-128 outcomes. The existing binary prediction market contract (`prediction_market.simf`) covers single-event YES/NO and can be composed into multi-outcome events at the application layer (one binary market per candidate/outcome), but that composition has structural limitations this contract addresses natively. + +**The primary value proposition of this contract is operational efficiency for multi-outcome trading.** The covenant exposes a richer set of solvency-preserving operations than composed binary markets can provide, and those operations become the arbitrage paths, hedging paths, and liquidity-rotation paths that traders actually use: + +- **Atomic cross-outcome arbitrage**: when AMM liquidity comes from composed per-outcome pools (see [Pool Composition](#pool-composition) below), cross-outcome price coherence (`Σ p_YES_k = 1`) is arb-enforced. The covenant-native `split-YES` and `merge-YES` primitives let arbitrageurs close coherence gaps in a **single atomic transaction**, keeping arb-enforced coherence tight. Composed binary markets would require multi-tx arb sequences. +- **Efficient LP/maker liquidity rotation**: a maker rotating from YES_raise exposure to YES_flat exposure can use the cross-outcome swap primitive (`YES_i + (N-2) × collateral ≡ {NO_j : j≠i}`) or split-YES + per-outcome pair-cancel atomically. Composed binary markets require multi-tx dances through each underlying market. +- **Efficient bootstrap**: a pool creator seeding N per-outcome pools can source all outcome tokens via a single split-YES / split-NO rather than N separate pair-issues. +- **Complex trading strategies**: basket positions, conditional structures, outcome-rotation trades — all lower-friction with native cross-outcome primitives. +- **Shared collateral and single market identity**: one collateral UTXO serving all outcomes; single `market_id` for oracle attestation, discovery, and indexing. Operational conveniences on top of the above. + +### Secondary value: oracle containment + +As a secondary benefit, the N-outcome contract **structurally contains oracle misbehavior**: even if the oracle signs multiple outcomes, at most one resolution can land on-chain (the first signature consumed by the covenant transitions the contract to a terminal Resolved_k state; the others have no valid spend path). With composed binary markets, an oracle that signs YES for multiple markets causes economic insolvency across the composition. + +This is real but less important in practice than the operational efficiency argument. Market users have to trust their oracle either way; structural containment only limits the blast radius on rare failure modes (key compromise, operator error). The cross-outcome primitives, by contrast, deliver continuous operational value on every trade. + +### Design properties preserved from the binary market + +- **Solvency invariant**: pre-resolution, the contract holds exactly enough collateral to cover the maximum possible payout across all N outcomes. +- **Per-outcome YES/NO tokens**: each outcome has both a YES (pays on that outcome) and a NO (pays on every other outcome), matching the mental model of the binary market N-fold. +- **Permissionless operation**: anyone can mint outcome pairs, burn pairs for collateral, split collateral into a full complement of YES (or NO) tokens, or redeem winning tokens after resolution. +- **Oracle-only resolution**: a single BIP-340 signature attests which outcome won. Covenant enforces narrow oracle authority. +- **Composability**: tokens are standard Elements assets. Any pool design, maker order, or application can build on top. + +## Pool Composition + +**AMM liquidity for this contract is provided via Option C composition**: N independent binary LMSR pools per market, one per outcome's YES/NO pair. See [`amm-scoring-rule-tradeoffs.md`](amm-scoring-rule-tradeoffs.md) for the full analysis of why Option C was chosen over unified multi-outcome pool designs. + +Briefly: +- **One pool contract type** — the binary LMSR pool — used for both binary markets and per-outcome pairs within N-outcome markets. Single Simplicity covenant to audit and maintain. +- **Parallelism**: trades on different outcomes hit different pool UTXOs. +- **Arb-enforced cross-outcome coherence** (`Σ p_YES_k = 1`), closed atomically via the market contract's cross-outcome primitives. +- **Per-outcome liquidity may fragment** (popular outcomes get deep pools, obscure ones stay thin) — accepted as the cost of Option C. + +This contract's cross-outcome primitives are what make Option C's arb-enforced coherence tight in practice. The market contract and the pool layer are designed to work together: the market contract provides the solvency-preserving operations; the pool layer provides per-outcome price discovery; arbitrage closes the loop. + +### Architectural orthogonality + +The market contract layer and the pool layer are **independent design choices**: + +- **Binary market contract** can be used alone (single YES/NO event) or composed (N binary markets per multi-outcome event, at the application layer, with oracle-discipline-enforced coherence). +- **N-outcome market contract** (this contract) provides structural market-level coherence and cross-outcome primitives. AMM liquidity comes from N binary LMSR pools composed per market. +- **Binary LMSR pool** doesn't know or care which market contract type underlies its tokens. It takes `(yes_asset, no_asset, collateral_asset)` and makes a market; those assets can come from either contract type. -This document specifies a generalization of the binary market to N mutually exclusive outcomes (N ≥ 2), preserving the existing contract's core properties: +Creators pick market contract type based on their event characteristics: -- **Simple solvency invariant**: pre-resolution, there are equal numbers of outstanding tokens across all N outcomes, fully backed by collateral such that any single-outcome resolution leaves winners with their collateral to be claimed. -- **Permissionless operation**: anyone can split collateral into outcome tokens (issuance), merge a full set of outcome tokens back into collateral (cancellation), or redeem winning tokens after resolution. No admin key, no operator. -- **Oracle-only resolution**: a single BIP-340 signature from the pre-committed oracle key attests to which outcome won. Covenants enforce the oracle's narrow authority — attesting to an outcome, nothing else. -- **Composability**: the market contract exposes tokens as standard Elements assets. Any pool design, any maker order, any application can build on top. +- **Known, exhaustive outcome set** (Fed rate decisions, sports brackets, Oscar categories) → N-outcome market contract. Gets atomic cross-outcome primitives, stronger oracle containment, single market identity. +- **Dynamic or non-exhaustive outcomes** (elections where candidates may drop out, open-ended questions with potential "other" cases) → composed binary markets. Trades off atomic primitives for flexibility in the outcome set. -The binary market is the N=2 special case of this design. In principle the two could be unified into a single code-generated family; in practice, whether to deprecate the existing binary contract or keep it alongside is a migration question deferred to a later decision. +## Design Rationale + +**Why 2N tokens instead of N?** + +A simpler variant — sometimes called the Arrow-Debreu approach — uses only N tokens, one per outcome. Holding `outcome_i` pays on outcome i and zero otherwise. A user who wants to bet *against* outcome i holds one of each other outcome's token (N-1 UTXOs). + +This project rejected that variant for UX reasons: + +1. **Negative positions require (N-1) UTXOs.** For anything but very small N, this is punishing: more UTXOs to manage, more dust, larger spend transactions, more mental overhead. +2. **Mental-model continuity with the binary market.** Users already understand YES/NO. Scaling that pattern to N-way is more intuitive than introducing "hold the complement of everything you want to bet against." +3. **AMM composition is cleaner.** Per-outcome binary LMSR pools (Option C) make markets in `YES_k` with `NO_k` as the counter-asset per pool — mirrors the binary pool design exactly. N-token would make pool design asymmetric (long positions single-token, short positions multi-token). +4. **Liquid's asset model already requires one RT per token type.** The covenant overhead of supporting 2N tokens instead of N is 2× on RT slot count, not 2× on the fundamental design. Given (1)-(3), the UTXO overhead is worth it. + +Trade-off accepted: transactions that co-spend all covenant I/O (split, merge) scale with 2N+1 instead of N+1, bounding practical N lower than the N-token design would have. The [Code Generation Strategy](#code-generation-strategy) discusses the range we intend to support. ## Overview -An N-outcome market issues N token types plus N reissuance tokens. Traders enter the market by **splitting** `collateral_per_set` collateral into one token of each of the N outcomes. They exit by **merging** one token of each outcome back into `collateral_per_set` collateral. At resolution, the oracle attests which outcome won; holders of that outcome's token redeem each token for `collateral_per_set` (full value). Losing tokens become worthless. +An N-outcome market issues **2N token types** (N YES tokens + N NO tokens) plus **2N reissuance tokens**. Token model: + +- For each outcome i ∈ [0, N-1]: tokens `YES_i` and `NO_i` represent "outcome i wins" and "outcome i does not win" respectively. +- Pre-resolution invariant: the collateral held by the contract covers the maximum possible payout across all outcomes. +- On resolution of outcome k: all `YES_k` tokens redeem for `collateral_per_pair`; all `NO_i` tokens for i ≠ k redeem for `collateral_per_pair`; `YES_i` for i ≠ k and `NO_k` are worthless. + +The fundamental per-outcome invariant carried over from the binary market: `YES_i + NO_i = collateral_per_pair`. Burning one of each redeems one unit of collateral. -This generalizes the binary market's pair-issuance/pair-cancellation model: +Additionally, the multi-outcome contract enforces a cross-outcome invariant: `sum_i YES_i = collateral_per_pair`. Burning one YES of every outcome also redeems one unit of collateral. -| Binary market | Multi-outcome market | +Both invariants together imply `sum_i NO_i = (N-1) × collateral_per_pair`: burning one NO of every outcome redeems `(N-1) × collateral_per_pair`. + +Binary market correspondence (N=2): + +| Binary market | Multi-outcome market (N outcomes) | |---|---| -| YES + NO tokens | N outcome tokens | -| `collateral_per_pair` | `collateral_per_set` | -| Issue pair (1 YES + 1 NO) | Split (1 of each outcome) | -| Cancel pair (burn 1 YES + 1 NO) | Merge (burn 1 of each outcome) | -| 2 reissuance tokens | N reissuance tokens | -| 8 covenant slots | 3N + 2 covenant slots | -| Oracle attests outcome_byte (0x00/0x01) | Oracle attests outcome_index (u8) | +| YES + NO tokens | 2N tokens (YES_i, NO_i for each outcome i) | +| `collateral_per_pair` | `collateral_per_pair` (per outcome, same convention) | +| Issue pair | Issue pair for outcome i (1 YES_i + 1 NO_i) | +| Cancel pair | Cancel pair for outcome i | +| (no analogue) | Split YES (1 of each YES_i for `collateral_per_pair`) | +| (no analogue) | Split NO (1 of each NO_i for `(N-1) × collateral_per_pair`) | +| 2 reissuance tokens | 2N reissuance tokens | +| 8 covenant slots | 5N + 2 covenant slots | +| Oracle attests outcome_byte | Oracle attests outcome_index (u8) | -The mathematical relationship between collateral and supply holds identically: +### Solvency invariant (formal) -> **Solvency invariant**: for a market at any pre-resolution state, there are `S` outstanding tokens of each of the N outcomes, backed by exactly `S × collateral_per_set` collateral locked in the market covenant. When outcome `k` wins, `S` tokens of outcome `k` can each be redeemed for `collateral_per_set`. Total payout: `S × collateral_per_set`, exactly matching the locked collateral. +Let `y_i` = outstanding supply of `YES_i`, `n_i` = outstanding supply of `NO_i`, and `C` = collateral locked. For any resolution outcome k, the payout is: -N is a compile-time constant per contract. Each supported N value has its own `.simf` file, generated from a template. See [Code Generation Strategy](#code-generation-strategy). +``` +payout(k) = collateral_per_pair × (y_k + sum_{j≠k} n_j) +``` + +The contract requires `C ≥ max_k payout(k)`. The invariant tightens this to **outcome-independence** — i.e., `y_k + sum_{j≠k} n_j` is the same value `Q` for every outcome k. Therefore: + +``` +C = collateral_per_pair × Q +``` + +Equivalently (and more useful for delta-based covenant checks): `y_k − n_k = D` for some constant `D` across all k, and `C = collateral_per_pair × Q`. The two formulations are equivalent given positive supplies. + +**Operationally**, the covenant exposes a single generic spend path for Unresolved-phase transitions: any transaction whose `(Δy, Δn, Δc)` preserves the invariant is accepted. The covenant derives the deltas from the transaction's issuance fields and burn outputs (no witness-declared state), then checks invariant preservation directly via two arithmetic conditions (see [Operations](#operations)). This makes any solvency-preserving combination of supply changes atomic in a single transaction, including compositions like cross-outcome swap that would otherwise require multi-tx sequences. ## Parameters @@ -43,400 +122,426 @@ N is a compile-time constant per contract. Each supported N value has its own `. pub struct MultiOutcomeMarketParams { pub oracle_public_key: XOnlyPublicKey, // BIP-340 Schnorr pubkey for oracle attestation pub collateral_asset_id: AssetId, // L-BTC, USDt, or other Elements asset - pub outcome_token_asset_ids: [AssetId; N], // N outcome token asset IDs (derivable from creation tx) - pub outcome_rt_asset_ids: [AssetId; N], // N reissuance token asset IDs (derivable from creation tx) - pub collateral_per_set: u64, // collateral backing one full set of N outcome tokens + pub yes_token_asset_ids: [AssetId; N], // YES_i asset IDs, derivable from creation tx + pub no_token_asset_ids: [AssetId; N], // NO_i asset IDs, derivable from creation tx + pub yes_rt_asset_ids: [AssetId; N], // YES_i reissuance tokens + pub no_rt_asset_ids: [AssetId; N], // NO_i reissuance tokens + pub base_payout: u64, // primary denomination: the per-outcome YES-expiry payout pub expiry_time: u32, // block height deadline - pub outcome_count: u8, // N — redundant with array length, included for clarity in discovery + pub outcome_count: u8, // N — redundant with array length, included for discovery clarity } ``` -`2N` of the fields are derivable from the creation transaction's issuance entropy (the asset IDs). The remaining `outcome_count + 4` fields are stored in the OP_RETURN recovery hint. See [OP_RETURN Recovery Hint](#op_return-recovery-hint). +`4N` of the fields (the asset ID arrays) are derivable from the creation transaction's issuance entropy. The remaining 4 non-derivable fields (`oracle_public_key`, `collateral_asset_id`, `base_payout`, `expiry_time`) are stored in the OP_RETURN recovery hint. See [OP_RETURN Recovery Hint](#op_return-recovery-hint). + +**Unit convention**: same as binary market — all amounts in the smallest indivisible unit of the respective asset. + +### Denomination model + +The primary covenant param is `base_payout` — the amount of collateral one YES token returns on expiry redemption. The derived quantity `cp := base_payout × N` is the total collateral backing one `(YES_i + NO_i)` pair. All issuance, cancellation, and redemption formulas in this document use `cp` for readability; implementations compute `cp = base_payout × N` inline (with `N` a file-level literal in each generated `.simf`). + +This model is unified across the binary and multi-outcome market contracts: both parameterize on `base_payout` drawn from the same 1-2-5 table. Binary markets derive `cp = base_payout × 2`; multi-outcome markets derive `cp = base_payout × N`. See [market-contract-principles.md § 12. Correct redemption rates](../market-contract-principles.md#12-correct-redemption-rates). -**Unit convention**: Same as binary market — all amounts in the smallest indivisible unit of the respective asset (satoshis for L-BTC, 10^-8 for USDt, etc.). +**Why this model rather than `cp` as the primary param**: parameterizing on `cp` directly would require the covenant to enforce `cp mod N == 0` to prevent integer-division rounding losses on expiry redemption (losses that scale linearly with pairs issued). Parameterizing on `base_payout` makes the divisibility automatic by construction — `cp = base_payout × N` is trivially divisible by N — so no runtime assertion is needed and no denomination table entry is unreachable for any supported N. The 4-bit OP_RETURN encoding is unchanged; only the semantic of the indexed value shifts from "pair cost" to "per-outcome payout unit." -**Constraints** (enforced by builder / `derive_market_params`): -- `N ≥ 2` and `N ≤ MAX_N` where `MAX_N` is determined by the set of generated `.simf` files (see [Code Generation Strategy](#code-generation-strategy)). -- `collateral_per_set` in the canonical 1-2-5 mantissa table. -- `expiry_time` snapped to 60-block boundary. -- `collateral_asset_id` in the well-known set or exotic-escape-compatible. +**Constraints**: +- **v1 supports `N ∈ {3, 4}`**. Each supported N uses its own generated `.simf` file. The binary market stays a separate hand-written contract; the multi-outcome template begins at 3 outcomes in v1. Expanding to additional N values later is non-breaking because each new N is a new contract artifact. +- `base_payout` drawn from the canonical 1-2-5 mantissa table. *(builder-enforced; recovery decodability — bucket 2 of the [self-enforcement classification](../market-contract-principles.md#covenant-self-enforcement).)* +- `expiry_time` rounded up to the next 60-block boundary. *(builder-enforced; recovery decodability.)* +- `collateral_asset_id` in the well-known set or exotic-escape-compatible. *(builder-enforced; recovery decodability.)* ## Covenant Structure -The market has **3N + 2** covenant slots, each with a unique script pubkey derivable from the contract params + slot identity: +The market has **5N + 2** covenant slots: -| Slot index | Phase | Purpose | +| Slot range | Phase | Purpose | |---|---|---| -| 0 | Dormant | Dormant RT for outcome 0 (0 outstanding sets) | -| 1 | Dormant | Dormant RT for outcome 1 (0 outstanding sets) | -| ... | Dormant | ... | -| N-1 | Dormant | Dormant RT for outcome N-1 (0 outstanding sets) | -| N | Unresolved | Unresolved RT for outcome 0 (>0 outstanding sets) | -| N+1 | Unresolved | Unresolved RT for outcome 1 (>0 outstanding sets) | -| ... | Unresolved | ... | -| 2N-1 | Unresolved | Unresolved RT for outcome N-1 (>0 outstanding sets) | -| 2N | Unresolved | Unresolved collateral slot (>0 outstanding sets) | -| 2N+1 | Resolved_0 | Collateral (outcome 0 won, awaiting redemption) | -| 2N+2 | Resolved_1 | Collateral (outcome 1 won, awaiting redemption) | -| ... | Resolved_k | ... | -| 3N | Resolved_{N-1} | Collateral (outcome N-1 won, awaiting redemption) | -| 3N+1 | Expired | Collateral (expired, awaiting redemption) | +| `0 .. N-1` | Dormant | Dormant RT for `YES_i` (no outstanding tokens) | +| `N .. 2N-1` | Dormant | Dormant RT for `NO_i` (no outstanding tokens) | +| `2N .. 3N-1` | Unresolved | Unresolved RT for `YES_i` | +| `3N .. 4N-1` | Unresolved | Unresolved RT for `NO_i` | +| `4N` | Unresolved | Unresolved collateral | +| `4N+1 .. 5N` | Resolved_k | Collateral (outcome k won, awaiting redemption), one slot per outcome | +| `5N+1` | Expired | Collateral (expired, awaiting redemption) | + +Breakdown: 2N Dormant RTs + 2N Unresolved RTs + 1 Unresolved collateral + N Resolved_k collateral + 1 Expired collateral. **Slot count by N**: -| N | Total slots | -|---|---| -| 2 | 8 (matches current binary) | -| 3 | 11 | -| 5 | 17 | -| 10 | 32 | -| 15 | 47 | +| N | Total slots | vs N-token variant | +|---|---|---| +| 2 | 12 | +4 | +| 3 | 17 | +6 | +| 5 | 27 | +10 | +| 8 | 42 | +16 | +| 10 | 52 | +20 | + +All slot scripts are static (computable at ingestion time) and pre-stored for script-based chain sync. + +**Phase semantics**: + +- **Dormant**: zero outstanding pairs of any outcome. Only the 2N RT UTXOs exist on-chain; no collateral locked. Reachable at creation and after all tokens are burned back to zero. Transitions to Unresolved (first mint), Resolved_k (dormant oracle resolution), or Expired (dormant timelock expiry). +- **Unresolved**: nonzero outstanding supply of at least one token. 2N RT UTXOs + 1 collateral UTXO exist. Transitions to Unresolved (further mint/burn), Dormant (full burn), Resolved_k (oracle resolution), or Expired (timelock expiry). +- **Resolved_k**: outcome k has won. Only the collateral UTXO exists (at the Resolved_k script). `YES_k` holders and `NO_j` holders (j ≠ k) can redeem against it. +- **Expired**: timelock passed without resolution. Collateral UTXO at the Expired script. See [Expiry Redemption Rate](#expiry-redemption-rate). + +## Operations -All slot scripts are static (computable at ingestion time) and can be pre-stored for script-based chain sync. This matches the current binary market's approach. +The covenant exposes **a single generic spend path** for Unresolved-phase transitions, plus the terminal phase-change paths (resolution, expiry, redemption). The generic path accepts any permissionless transaction whose `(Δy, Δn, Δc)` preserves the solvency invariant. -**Phase semantics** (equivalent to the binary market's covenant phases): -- **Dormant**: zero outstanding sets. Only RTs exist on-chain; no collateral is locked. Reachable at creation and after full cancellation. Can transition to Unresolved (split), Resolved (dormant oracle resolution), or Expired (dormant timelock expiry). -- **Unresolved**: nonzero outstanding sets. N RT UTXOs + 1 collateral UTXO exist. Can transition to Unresolved (subsequent split or partial merge), Dormant (full merge), Resolved_k (oracle resolution), or Expired (timelock expiry). -- **Resolved_k**: outcome k has won. Only the collateral UTXO exists (at the Resolved_k script). Winning outcome k tokens can be redeemed against it at full value. -- **Expired**: timelock has passed with no resolution. Collateral UTXO at the Expired script. See [Expiry Redemption Rate](#expiry-redemption-rate) for the redemption semantics. +### The generic solvency-preserving transition + +On every Unresolved-phase transition, the covenant derives the per-outcome deltas from the transaction's observable fields: + +- `Δy_k = (issuance amount on YES_k RT input) − (amount at YES_k burn output)` for each k +- `Δn_k = (issuance amount on NO_k RT input) − (amount at NO_k burn output)` for each k +- `Δc = (new collateral output amount) − (old collateral input amount)` + +All of these are directly observable: RT issuance via Elements issuance fields, burn amounts via OP_RETURN outputs with specific asset IDs, collateral via explicit output values. No witness-declared state, no tapdata supply tracking. + +The covenant then verifies exactly two invariant-preservation checks: + +**Check 1 — uniform side shift:** +``` +S := Δy_0 − Δn_0 +for each k in 1..N: + assert Δy_k − Δn_k == S +``` +This preserves the invariant `y_k − n_k = D` (constant across k), which is equivalent to outcome-independence of `Q`. + +**Check 2 — collateral matches ΔQ:** +``` +SumDeltaN := Σ_k Δn_k +ΔQ := S + SumDeltaN +assert Δc == ΔQ × collateral_per_pair +``` +This ties `C` to `Q` so that `C = collateral_per_pair × Q` remains exact post-transition. + +Any `(Δy, Δn, Δc)` satisfying both checks is accepted. The covenant additionally enforces the orthogonal structural invariants: sibling UTXO check across all 2N+1 covenant inputs, deterministic RT blinding on continuation RT outputs, no parasitic issuance on non-issuance inputs. These are unchanged from the standard multi-input covenant design. + +### Common operations as specific delta shapes + +All the operations a user or pool builder might want are specific instances of the generic check. These names are **wallet-layer ergonomics** (PSET builders construct txs with these specific delta shapes) — from the covenant's perspective, every one of these is the same generic spend path: + +| Operation | Δ shape | Passes check? | +|---|---|---| +| Issue pair outcome i | `Δy_i = Δn_i = sets`, all other 0, `Δc = sets·cp` | Check 1: S=0 uniformly ✓; Check 2: Δc = (0 + sets)·cp ✓ | +| Cancel pair outcome i | `Δy_i = Δn_i = −sets`, all other 0, `Δc = −sets·cp` | Symmetric ✓ | +| Split YES | `Δy_k = sets ∀k`, `Δn = 0`, `Δc = sets·cp` | S = sets uniformly ✓; Δc = (sets + 0)·cp ✓ | +| Merge YES | `Δy_k = −sets ∀k`, `Δn = 0`, `Δc = −sets·cp` | ✓ | +| Split NO | `Δn_k = sets ∀k`, `Δy = 0`, `Δc = sets·(N−1)·cp` | S = −sets uniformly ✓; Δc = (−sets + N·sets)·cp ✓ | +| Merge NO | Symmetric ✓ | ✓ | +| **Cross-outcome swap** (YES_i → {NO_j : j≠i}) | `Δy_i = −sets`, `Δn_j = sets ∀j≠i`, `Δc = sets·(N−2)·cp` | S = −sets uniformly (k=i: −sets − 0 = −sets; j≠i: 0 − sets = −sets) ✓; ΣΔn = (N−1)·sets; Δc = (−sets + (N−1)·sets)·cp = (N−2)·sets·cp ✓ | +| **Arbitrary combination** in one tx | Linear sum of above | ✓ if each component preserves invariant; compositions are also invariant-preserving | + +Under this design, **cross-outcome swap is a single-transaction primitive use of the generic path**, not a multi-tx composition. Any wallet can construct a tx with the cross-outcome-swap delta shape and the covenant accepts it. + +### What about cp, N, and numerical bounds? + +- `cp := base_payout × N` — derived from the primary covenant param `base_payout` (committed at creation) and the file-level literal `N`. `cp` is the pair cost; `collateral_per_pair` is used as a synonym in formulas below. +- `N = outcome_count` — committed at creation, fixed for the market's lifetime. Determines the iteration range for Check 1. +- Supply deltas fit in i64 (signed, bounded by reasonable token supplies). +- `ΔQ × collateral_per_pair` may require u128 intermediate for large markets — Simplicity handles u128 arithmetic via jets. + +### Why this design (over enumerated primitives) + +**Atomicity**: any composition of solvency-preserving operations happens in one transaction. Cross-outcome swap is the canonical example, but the same flexibility applies to any future operation that preserves the invariant. + +**Extensibility**: new operations don't require covenant changes. If a wallet or router wants to compose a novel sequence of deltas in one tx, the covenant accepts it as long as the invariant is preserved. No new spend paths to enumerate, test, and audit. + +**Simpler covenant code**: one spend path (the generic invariant check) replaces six enumerated primitives. N−1 equality checks + 1 value check vs. N specific primitive-verification blocks. + +**Cleaner audit**: the invariant-preservation proof is mathematical and universally quantified — prove that "if Check 1 and Check 2 pass, the post-state is invariant-preserving" once, and it covers every accepted transition. Compare to enumerated primitives, where each primitive needs its own "this operation preserves the invariant" proof. + +**No state growth**: the delta-based check uses only tx-observable data. Tapdata does not need to track per-outcome supplies, matching the existing design's minimal state commitment. ## Spend Paths | Transition | From slots | To slots | Authorization | Covenant enforces | |---|---|---|---|---| -| Initial split | 0..N-1 (all Dormant RTs) | N..2N (all Unresolved RTs), 2N (collateral) | RT spend | Collateral = sets × `collateral_per_set`; N issuances, one per RT input; deterministic RT blinding | -| Subsequent split | N..2N-1, 2N | N..2N-1, 2N | RT spend | Collateral increased by sets × `collateral_per_set`; N issuances; sibling UTXO check across all N+1 inputs | -| Partial merge | N..2N-1, 2N | N..2N-1, 2N | RT spend + token burn | Collateral decreased; 1 of each outcome token burned per set; sibling UTXO check | -| Full merge | N..2N-1, 2N | 0..N-1 | RT spend + token burn | All collateral returned; all outstanding sets burned; sibling UTXO check | -| Resolution (outcome k) | N..2N-1, 2N | 2N+1+k | Oracle BIP-340 signature | Oracle signs tagged hash of market_id + outcome_index; all N RTs burned; collateral preserved at Resolved_k script; sibling UTXO check | -| Redemption (resolved) | 2N+1+k | — | Token burn | Winning outcome k tokens burned; collateral released at full value (1 token → `collateral_per_set`) | -| Redemption (expired) | 3N+1 | — | Token burn | Any outcome token burned; collateral released at expiry redemption rate (see below) | -| Expiry | N..2N-1, 2N | 3N+1 | Timelock ≥ `expiry_time` | All N RTs burned; collateral preserved at Expired script | -| Dormant resolution | 0..N-1 | — | Oracle BIP-340 signature | All N dormant RTs consumed; no covenant outputs | -| Dormant expiry | 0..N-1 | — | Timelock ≥ `expiry_time` | All N dormant RTs consumed; no covenant outputs | +| **Generic solvency-preserving transition** (Unresolved ↔ Unresolved, Dormant → Unresolved, Unresolved → Dormant) | All 2N+1 Unresolved covenant UTXOs, or all 2N Dormant RTs if pre-state is Dormant | All 2N+1 Unresolved covenant UTXOs, or all 2N Dormant RTs if post-state reaches Q=0 | RT spend (all 2N RTs) + token burns (for negative deltas) | Sibling check across all covenant inputs; deterministic RT blinding on continuation RTs; `no_parasitic_issuance` on all inputs that aren't legitimately issuing; Check 1 (Δy_k − Δn_k uniform across k); Check 2 (Δc = (S + ΣΔn_k) × collateral_per_pair). Dormant pre-state treats all supplies as 0; Dormant post-state requires all supplies to reach 0. | +| Resolution (outcome k, from Unresolved) | All 2N Unresolved RTs, collateral | Resolved_k collateral | Oracle BIP-340 signature | Oracle signs tagged hash of market_id + outcome_index; all 2N RTs burned; collateral preserved at Resolved_k script | +| Redemption (resolved, winning YES_k) | Resolved_k | — | YES_k burn | YES_k tokens burned; collateral released at full value (1 token → `collateral_per_pair`) | +| Redemption (resolved, winning NO_j, j ≠ k) | Resolved_k | — | NO_j burn | NO_j tokens burned; collateral released at full value | +| Redemption (expired, YES_i) | Expired | — | YES_i burn | YES_i tokens burned; collateral released at yes_expiry_rate (see below) | +| Redemption (expired, NO_i) | Expired | — | NO_i burn | NO_i tokens burned; collateral released at no_expiry_rate (see below) | +| Expiry (from Unresolved) | All 2N Unresolved RTs, collateral | Expired | Timelock ≥ `expiry_time` | All 2N RTs burned; collateral preserved at Expired script | +| Dormant resolution (outcome k) | All 2N Dormant RTs | — | Oracle BIP-340 signature | All 2N RTs consumed; RT burn outputs verified; no covenant continuation outputs | +| Dormant expiry | All 2N Dormant RTs | — | Timelock ≥ `expiry_time` | All 2N RTs consumed; RT burn outputs verified; no covenant continuation outputs | -**Sibling UTXO check** (generalization of the binary market's check): all transitions that co-spend RTs and collateral verify that all N+1 covenant inputs share the same `prev_txid`. This prevents collateral substitution attacks (see [enforcement-layers.md](../../architecture/enforcement-layers.md)). +**Sibling UTXO check**: every transition in the Unresolved phase co-spends all 2N+1 covenant inputs and verifies they share the same `prev_txid`. This prevents collateral substitution attacks. See [enforcement-layers.md](../../architecture/enforcement-layers.md). -Partial merge must co-spend all N RTs to maintain the sibling invariant, same as the binary market's partial cancellation refactor. +Per-outcome operations (issue pair, cancel pair) issue or burn only two RTs worth of tokens, but must still co-spend all 2N RTs in the witness to maintain the sibling invariant. The uninvolved RTs pass through unchanged (zero issuance, no burn). ## Oracle Attestation -The oracle signs a BIP-340 tagged hash: +Uses the shared tagged-hash protocol defined in [oracle-bip340-tagged-hash.md](../../protocol/oracle-bip340-tagged-hash.md). For the multi-outcome contract, the market-specific pieces are: ``` message = tagged_hash("deadcat/oracle_attestation", market_id || outcome_index) -market_id = SHA256(outcome_token_asset_ids[0] || outcome_token_asset_ids[1] || ... || outcome_token_asset_ids[N-1]) +market_id = SHA256(yes_token_asset_ids[0] || no_token_asset_ids[0] || yes_token_asset_ids[1] || no_token_asset_ids[1] || ... || yes_token_asset_ids[N-1] || no_token_asset_ids[N-1]) outcome_index = u8, in range [0, N-1] ``` -The tag string (`"deadcat/oracle_attestation"`) matches the binary market's tag — the hash construction is identical, just with a u8 outcome_index replacing the 0x00/0x01 outcome_byte. For N=2, the hashes are distinct from the binary market (because `market_id` is computed from the two asset IDs concatenated, not from the binary-specific YES/NO pair) but the signature scheme is otherwise identical. - -Oracles signing for both binary markets and multi-outcome markets use the same key and the same tag. Domain separation is achieved via the `market_id` — a given `market_id` uniquely identifies one market (binary or multi-outcome), and the covenant verifies the signature against its specific `oracle_public_key` parameter. +Tag string matches the binary market (`"deadcat/oracle_attestation"`). Domain separation comes from `market_id`. ## State Machine -From the perspective of `deadcat-core`, the market state is one of: - ```rust pub enum MultiOutcomeMarketState { Trading { - outstanding_sets: u64, + supplies: [PairSupply; N], // y_i and n_i per outcome }, Resolved { outcome_index: u8, - outstanding_sets: u64, + winning_yes_supply: u64, // supply of YES_{outcome_index} + winning_no_supplies: [u64; N-1], // supply of NO_j for j ≠ outcome_index }, Expired { - outstanding_sets: u64, + yes_supplies: [u64; N], + no_supplies: [u64; N], }, } -``` - -`Trading` covers both Dormant (outstanding_sets = 0) and Unresolved (outstanding_sets > 0) covenant phases — the distinction is a covenant implementation detail. From the user's perspective, a market is either open for trading, resolved, or expired. -`Resolved { outstanding_sets: 0 }` and `Expired { outstanding_sets: 0 }` are terminal — all collateral has been redeemed. - -Transition diagram (simplified): - -``` - ┌─────────────┐ - │ Trading │ - │ (sets = 0) │ ── split ──> Trading (sets > 0) - └──────┬──────┘ - │ - ┌──────▼──────┐ - │ Trading │ ── split ──> Trading (more sets) - │ (sets > 0) │ ── merge ──> Trading (fewer sets or 0) - └──┬───┬───┬──┘ - │ │ │ - oracle───┘ │ └───expiry - │ │ │ - ▼ ▼ ▼ - Resolved Resolved Expired - outcome_0 outcome_k - ... ... - outcome_{N-1} ─────── each redeems to outcome-k tokens +pub struct PairSupply { + pub yes: u64, + pub no: u64, +} ``` -The outstanding_sets count changes via split (increase) and merge (decrease). Resolution and expiry transitions preserve outstanding_sets (the count at transition time), and post-resolution/expiry redemptions decrement it to zero. +`Trading` covers both Dormant (all supplies zero) and Unresolved phases. From the user's perspective, a market is either open for trading, resolved, or expired. -## Witness-Parameterized Output Indices +## Witness-Parameterized Input and Output Indices -The current binary market contract uses hardcoded absolute output indices: `jet::current_index() == 0/1/2` and outputs at positions 0, 1, 2. This is simple and unambiguous for single-contract transactions. +The covenant accepts both `in_base` and `out_base` from the witness. It asserts that the current input sits at `in_base + slot_offset`, validates the full `2N + 1` covenant-input window rooted at `in_base`, and places continuation outputs at `out_base..out_base + 2N` (2N RT outputs + 1 collateral output). This gives the contract covenant-level flexibility for future multi-contract composition while preserving correctness through bounded-window checks plus explicit script/asset verification. See [transaction-composability-model.md](../../architecture/transaction-composability-model.md) for the general framework. -**For the multi-outcome market, we propose witness-parameterized output indices** (as used in the current LMSR pool). The covenant accepts `out_base` from the witness and places outputs at positions `out_base`, `out_base+1`, ..., `out_base+N` (for the N RT outputs + 1 collateral output). +Aliasing defense: script uniqueness per slot + per-market script derivation means no output can alias another contract's output or another slot within this contract. -### Why witness-parameterized +## Expiry Redemption Rate -Composability with pools (e.g., the QMSR pool proposal) requires the market's split/merge operations to co-exist with pool swaps in a single transaction. With hardcoded indices, two different contracts cannot both have their inputs at index 0. Witness-parameterized indices allow flexible arrangement. +If the oracle does not resolve by `expiry_time`, all outcome tokens become redeemable against the Expired collateral UTXO at a pre-computed rate. The rate treats every outcome as equally probable (1/N), which is the "no information" default and preserves solvency exactly. -This matches the design pattern already established by the LMSR pool and the order contract's remainder output. See [transaction-composability-model.md](../../architecture/transaction-composability-model.md) for the general framework. +Rates (expressed in terms of the primary denomination `base_payout`): +``` +yes_expiry_rate = base_payout = cp / N +no_expiry_rate = base_payout × (N-1) = cp × (N-1) / N +``` -### Aliasing defense +Both rates are exact integers by construction, because `cp = base_payout × N`. No division is performed at covenant runtime — the covenant's expiry spend path uses `base_payout` and `base_payout × (N-1)` directly. -Output aliasing is prevented by **script uniqueness** — the 3N+2 slot scripts are unique per contract (derived from all params including the N asset IDs). Two different markets have entirely different script pubkeys. A witness-parameterized output cannot be aliased with another contract's output because the covenant verifies the exact script pubkey at the specified index. +**Solvency verification**: if all tokens redeem, total payout is: +``` +sum(y_i) × base_payout + sum(n_i) × base_payout × (N-1) + = base_payout × [Y + (N-1) × N_total] +``` +where `Y = sum(y_i)` and `N_total = sum(n_i)`. -Within a single market transition (e.g., a split), the N+1 output slots each have a distinct script. The covenant verifies each at its respective position (out_base, out_base+1, ..., out_base+N). No two outputs in the transition share a script, so no within-transition aliasing is possible. +Using the outcome-independence constraint `y_k - n_k = D` (same constant D for all k), we have `Y = N_total + N × D`, so: +``` +Y + (N-1) × N_total = N × N_total + N × D = N × (N_total + D) +``` +Therefore total payout = `base_payout × N × (N_total + D) = cp × (N_total + D) = C` exactly. ✓ -## Split and Merge Semantics +Binary case (N=2): `yes_expiry_rate = no_expiry_rate = base_payout = cp / 2` — matches the unified denomination model shared with the binary market. -### Split +**Exact redemption is structural, not asserted.** Because `base_payout` is the primary param and `cp = base_payout × N` is derived at covenant compile time, every expiry redemption rate is an integer multiple of `base_payout` — no rounding residuals can arise. The alternative (primary `cp` param + covenant `cp mod N == 0` assertion) would have required rejecting any creation with non-divisible `cp` and restricted the denomination table to N-compatible values. The primary-`base_payout` model avoids both complications. -A split transaction creates `sets` new sets of outcome tokens, locking `sets × collateral_per_set` additional collateral in the market. +## Code Generation Strategy -**Initial split** (from Dormant, outstanding_sets = 0): +Each supported N has its own hand-committed `.simf` file, produced by a Rust-based generator that applies a MiniJinja template to a per-N context. The generator runs at dev time, not at build or runtime. -``` -Inputs: - [in_base] DormantRT for outcome 0 (carries issuance: nAmount = sets, outcome_0 asset) - [in_base+1] DormantRT for outcome 1 (carries issuance: nAmount = sets, outcome_1 asset) - ... - [in_base+N-1] DormantRT for outcome N-1 - wallet input (collateral for sets × collateral_per_set, plus fees) - -Outputs: - [out_base] UnresolvedRT for outcome 0 (blinded, covenant continuation) - [out_base+1] UnresolvedRT for outcome 1 (blinded, covenant continuation) - ... - [out_base+N-1] UnresolvedRT for outcome N-1 - [out_base+N] UnresolvedCollateral (explicit, value = sets × collateral_per_set) - [token_dest_0] Outcome 0 tokens (nAmount = sets) - ... - [token_dest_{N-1}] Outcome N-1 tokens - fee, change -``` +### Supported N range (v1) -Each RT input carries an issuance of `sets` tokens of the corresponding outcome asset. The covenant verifies: +**v1 supports N ∈ {3, 4}.** N=2 continues to use the existing `prediction_market.simf` (the binary market contract, which has been deeply reviewed and is refactored in place rather than regenerated from the multi-outcome template). -- Each RT input `i` ∈ [in_base, in_base+N) carries issuance of exactly `sets` tokens of `outcome_token_asset_ids[i - in_base]`. -- The collateral output at `out_base+N` has asset `collateral_asset_id` and value ≥ sets × collateral_per_set (the excess is the wallet's contribution; the exact amount is verified via Elements per-asset balance). -- The N RT outputs at `out_base..out_base+N-1` have the correct Unresolved slot scripts and correct RTs, with deterministic blinding (see [deterministic-rt-blinding.md](../../protocol/deterministic-rt-blinding.md)). -- Sibling UTXO check: all N+1 covenant inputs share the same `prev_txid`. +The {3, 4} range is a deliberately conservative v1 scope. Transaction size scales roughly quadratically with N (per-input witness grows with N, and the number of 2N+1 covenant inputs grows with N), and Liquid's block weight limit bounds the practical ceiling. We have not yet benchmarked the compiled binary (N=2) witness size against the generic-path multi-outcome contract, so we can't predict the exact cutoff. Starting at {3, 4} unlocks simple multi-outcome use cases without committing to larger N values whose tx weights we haven't measured. -**Subsequent split** (from Unresolved, outstanding_sets > 0): same structure, but the N+1 inputs come from Unresolved slots (N..2N), and the collateral input already has `existing_sets × collateral_per_set`. The covenant verifies the collateral increase equals `sets × collateral_per_set`. +**Expansion is non-breaking.** Adding support for N=5, N=6, etc. in a future release only introduces new per-N `.simf` files (and their CMRs). Existing N=3 and N=4 markets are unaffected because each N's covenant is its own CMR-committed program; a market created against one `.simf` file has no dependence on any other. **Shrinking the supported range is breaking** (would invalidate existing markets' spend paths if their N is removed) and should not be done once markets exist in the wild. -### Merge +### Crate architecture -A merge transaction burns `sets` complete sets of outcome tokens (one of each outcome), releasing `sets × collateral_per_set` collateral. +The generator is fully decoupled from the `deadcat-core` runtime surface: -**Partial merge** (Unresolved → Unresolved with fewer sets): +- **`deadcat-codegen`** (new workspace crate, dev-only): pulls MiniJinja as a regular dep. Exposes a function (and a CLI binary for `just generate-simf`) that takes `N` and writes `multi_outcome_market_nN.simf` to the expected path inside `deadcat-core`'s contract directory. +- **`deadcat-core`**: reads the committed `.simf` files at compile time via `include_bytes!`. Has no dependency on `deadcat-codegen` or MiniJinja. Downstream consumers of `deadcat-core` receive pre-embedded `.simf` files bundled with the published crate and never see the generator's dependency tree. +- **Workspace CI**: `cargo test` at the workspace root runs `deadcat-codegen`'s drift-detection test, which regenerates each supported N's `.simf` source in-memory and asserts byte-exact equality against the committed files (including that no extra files exist in the target directory and no expected files are missing). + +File layout: ``` -Inputs: - [in_base..in_base+N-1] UnresolvedRTs (N inputs) - [in_base+N] UnresolvedCollateral - wallet inputs: sets × N outcome tokens (one of each outcome, to burn) - -Outputs: - [out_base..out_base+N-1] UnresolvedRTs (continuation) - [out_base+N] UnresolvedCollateral (value decreased by sets × collateral_per_set) - wallet output: sets × collateral_per_set collateral (to user) - N token burn outputs: each OP_RETURN with sets of one outcome asset - fee, change +crates/deadcat-codegen/ + src/ + lib.rs # fn generate(n: usize) -> String + templates/ + multi_outcome_market.simf.j2 # MiniJinja template + bin/ + generate-simf.rs # CLI entry point (just generate-simf) + tests/ + drift.rs # in-memory regen + compile + byte-match test + +crates/deadcat-core/ + contracts/ + prediction_market.simf # binary market (hand-maintained, separate contract family) + multi_outcome/ + multi_outcome_market_n3.simf # committed, generator output + multi_outcome_market_n4.simf # committed, generator output ``` -The covenant verifies: -- N token burn outputs, each burning exactly `sets` tokens of one distinct outcome asset. -- Collateral decrease equals `sets × collateral_per_set`. -- N+1 covenant input/output continuations with correct scripts and preserved RT amounts. -- Sibling UTXO check. - -**Full merge** (Unresolved → Dormant): same structure, but all outstanding sets are burned and the collateral UTXO is consumed entirely. Outputs go to Dormant RT slots (0..N-1), no collateral continuation. - -## Expiry Redemption Rate +### Template structure -**Open question**: what redemption rate should expired markets pay? +The MiniJinja template parameterizes the SimplicityHL source on `N`. Parameterized sections include: -The binary market pays half value (1 token → `collateral_per_pair / 2`) on expiry redemption. This is symmetric — both YES and NO holders get the same payout, splitting the collateral equally regardless of which side they held. +- 2N param declarations for YES/NO token asset IDs and their reissuance tokens +- 2N RT slot programs (Dormant + Unresolved per RT) +- Loops over outcomes for issuance/burn checks (unrolled at codegen time via `{% for k in range(n=N) %}`) +- Resolution dispatch: `match outcome_index { 0 => ..., N-1 => ... }` (unrolled) +- Redemption dispatch: winning-outcome selection for both YES_k and NO_j cases -For N outcomes, the natural generalization is `1 / N` value: +Template syntax uses MiniJinja's Jinja2-compatible delimiters (`{{ N }}` for substitution, `{% for ... %}...{% endfor %}` for loops, `{% if ... %}...{% endif %}` for conditionals). These do not conflict with SimplicityHL syntax, which uses bare `{ }` and `[ ]` with adjacent tokens. -``` -expiry_redemption_rate = collateral_per_set / N -``` +### Verification test -Each outcome token holder gets `collateral_per_set / N` per token. If all `S` sets' worth of tokens (across all outcomes) are redeemed, the total payout is `S × N × (collateral_per_set / N) = S × collateral_per_set` — exactly matching the locked collateral. Solvency is preserved. +The drift-detection test in `deadcat-codegen` runs on every `cargo test` invocation and performs, for each supported N: -Considerations: -- **Rounding**: `collateral_per_set / N` may not be an integer. Round down to preserve solvency (small rounding residual stays in the market and can be reclaimed after all redemptions, possibly via a final sweep). -- **Fairness**: the 1/N rate is neutral — it reflects "no information" about which outcome would have won. Alternatively, the market could distribute the collateral to ALL holders regardless of outcome, which equates to 1/N anyway. -- **Asymmetric alternatives**: we could require `collateral_per_set` to be divisible by N (via builder constraint), eliminating the rounding residual. This is a minor constraint but simplifies the covenant. +1. **Byte-match check**: regenerate the `.simf` source in-memory via the generator and assert it matches the committed file byte-for-byte. +2. **Directory consistency**: assert the committed contracts directory contains exactly the expected set of files (no drift, no stragglers, no missing entries). +3. **Compile check**: invoke the SimplicityHL compiler (as a Rust library — the same compiler `deadcat-core` uses at runtime) on the generated source with a fixed canonical test param set, asserting compilation succeeds. This catches template bugs that produce syntactically valid but semantically broken SimplicityHL. -**Recommendation**: enforce `collateral_per_set % N == 0` via builder validation. Redemption rate is `collateral_per_set / N` per token. This keeps the covenant math clean. +The compile check uses fixed test params rather than per-market params because CMR depends on the full param set (see [CMR and params](#cmr-and-params) below) — a fixed canonical param set gives a reproducible compile but its CMR is not a deployment artifact. -## Code Generation Strategy +Cost: the compile check runs the full SimplicityHL compilation pipeline per N, adding some time to `cargo test`. Acceptable at N=2 (N=3, N=4 in v1); worth watching as the range expands. -The multi-outcome market covenant is **code-generated** from a template. Each supported N has its own `.simf` file: +### CMR and params -``` -src-tauri/crates/deadcat-sdk/contract/ -├── prediction_market.simf # current binary market (N=2 legacy) -├── multi_outcome_market_n3.simf # generated -├── multi_outcome_market_n4.simf # generated -├── multi_outcome_market_n5.simf # generated -├── ... -└── multi_outcome_market_n15.simf # generated -``` +CMR (Commitment Merkle Root) in Simplicity commits to the program's combinator tree, which includes compile-time constants. In deadcat's model, covenant params (oracle pubkey, asset IDs, `base_payout`, `expiry_time`, etc.) are inlined as constants during SimplicityHL compilation — so **every distinct param set produces a distinct CMR**. This is consistent with `deadcat-core-design.md`'s `fn contract_cmr(params, network) -> Cmr` signature and the cross-contract CMR-uniqueness discussion in `transaction-composability-model.md § Script Uniqueness Guarantee`. -### Template structure +Consequence for codegen: we do **not** cache per-N CMRs at build time. The only CMR that matters is computed at market creation (and stored in `ContractId.cmr`). What we commit and verify at codegen time is the `.simf` source text, not a compiled CMR. -A Rust build script (`build.rs` or a dedicated `codegen` crate) reads a template SimplicityHL file and produces concrete `.simf` files for each N in the supported range. +**Audit-workflow TODO**: a reproducible recipe for "given the committed `.simf` at commit X and canonical test params Y, here's CMR Z" is useful for security-audit sign-off but is a tooling polish item, not a correctness requirement. Can ship alongside the audit pass rather than blocking v1. -The template has parameterized sections: -- N param declarations for outcome token asset IDs -- N param declarations for outcome RT asset IDs -- N RT slot programs (each handles the Dormant and Unresolved phases for its outcome) -- N-way loops for verifying token issuances on split -- N-way loops for verifying token burns on merge -- Resolution dispatch: `match outcome_index { 0 => ..., 1 => ..., ..., N-1 => ... }` -- Redemption dispatch: similar match over the resolved-outcome slot +## OP_RETURN Recovery Hint -The generator unrolls the loops at build time (since SimplicityHL has no loops). Each generated `.simf` is a self-contained, hand-readable program. +**Fixed portion** (independent of N, 37 bytes total with well-known collateral, matching binary): +- `base_payout` (4-bit index into the 1-2-5 denomination table; `cp = base_payout × outcome_count` is derived at decode time) +- `expiry_time` (per existing convention) +- `oracle_public_key` (32 bytes) +- `collateral_asset_id` (1 byte index into well-known set, or 32 bytes) -### Supported N values +`outcome_count` is **not stored** in the hint. Recovery derives it from the creation transaction's new-issuance count (`2N` issuances → `N` outcomes), keeping the market hint layout identical to the binary market's layout aside from the type-tag byte. -Proposed initial range: **N=3 through N=15**. +**Variable portion**: the 4N asset IDs (2N tokens + 2N RTs) are derivable from the creation transaction's issuance entropy. Not stored in the hint. -- N=2 is deferred to the migration decision: whether to regenerate the binary market from this template or keep `prediction_market.simf` as a special case. -- N=15 covers Polymarket-scale events with room to spare. Higher N values can be added later as needed. -- Each (N) adds roughly linear complexity to the generated program. Witness size scales with N. Transaction weight for split/merge scales with N (since all N+1 covenant I/O must be co-spent). +Total hint size: 37 bytes with well-known collateral, or 69 bytes with exotic collateral. -### Audit & review +See [chain-only-recovery.md](../../protocol/chain-only-recovery.md). Recovery flow: wallet scans for an asset ID that matches one of a market's `{yes,no}_token_asset_ids`, queries the issuance transaction, reads the OP_RETURN, reconstructs params, ingests the market. -Generated `.simf` files are committed to the repo alongside the template. This ensures: -- Auditors can review the exact programs that will be compiled, not a meta-description. -- Diffs between N values are clear — reviewers can verify the generator produces the expected structural generalization. -- Changes to the template trigger regeneration as part of CI, with review required for the diff. +## Relationship to the Binary Market -### Compilation caching +For the hypothetical 2-outcome member of the multi-outcome family, the structure would be very close to the binary market but not byte-for-byte identical: -Each `.simf` file's compiled CMR is cached in the `deadcat-core` build output. The per-N CMR is deterministic (given a template version). Consumers that need a specific CMR (e.g., for ingestion verification) use the cache rather than recompiling. +- 2 YES tokens (YES_0, YES_1) + 2 NO tokens (NO_0, NO_1) = 4 token types. The binary market has 2 (YES, NO) because its single-outcome framing makes `YES = YES_0 = NO_1` and `NO = NO_0 = YES_1`. The multi-outcome contract still holds 4 distinct assets even when they'd be economically equivalent. +- `5N+2 = 12` slots vs. binary's 8. +- Oracle signs u8 outcome_index rather than a single outcome_byte. -## OP_RETURN Recovery Hint +**Chosen for v1: `prediction_market.simf` stays the canonical binary market contract.** The hypothetical 2-outcome member of the multi-outcome family is not used in v1; the template serves markets with 3 or more outcomes only. Binary remains the high-volume case and the existing contract is already deeply validated; the two-token-per-outcome redundancy of forcing binary through the multi-outcome template would cost tx weight at the common case for no structural benefit. The decision can be revisited after the generator ships and we measure real tx weights, but the path of least risk is to keep the two contracts independent. -The binary market's OP_RETURN hint is ~40 bytes (fixed). The multi-outcome hint is slightly larger and variable by N: +## Security Properties -**Fixed portion** (independent of N): -- `collateral_per_set` (u9 mantissa + exponent: 2 bytes compressed) -- `expiry_time` (u24 or absolute: see existing binary convention) -- `oracle_public_key` (32 bytes) -- `collateral_asset_id` (32 bytes, or index into well-known set: 1 byte) -- `outcome_count` (u8: 1 byte) +| Property | Enforcement | +|---|---| +| Outcome-independence of Q (equivalent: `y_k − n_k = D` constant across k) | Generic Unresolved-phase spend path's Check 1: `Δy_k − Δn_k` uniform across k for every transition. Inductive from Dormant pre-state (all zero, invariant trivially holds). | +| Collateral matches Q | Generic Unresolved-phase spend path's Check 2: `Δc = (S + ΣΔn_k) × collateral_per_pair`. Inductive from Dormant pre-state (C=0, Q=0). | +| Oracle-only resolution | BIP-340 signature verification against `oracle_public_key` in the resolution spend path (separate from the generic Unresolved-phase path) | +| Correct redemption rate (resolved, winning YES_k) | Resolved_k slot's spend path releases `collateral_per_pair` per winning token burned | +| Correct redemption rate (resolved, winning NO_j, j ≠ k) | Same Resolved_k slot; covenant distinguishes winning YES_k burn from winning NO_j burn by asset ID | +| Correct redemption rate (expired) | Expired slot releases `base_payout` per YES token, `base_payout × (N-1)` per NO token. Both are exact integers by construction (primary param is `base_payout`; `cp = base_payout × N` is derived), so no rounding residuals arise and no covenant-level divisibility assertion is needed. | +| Deterministic RT blinding | Same scheme as binary market, applied to 2N RTs, on all continuation RT outputs | +| RT destruction on terminal transitions | All 2N RTs burned on resolution and expiry spend paths | +| Collateral UTXO authenticity | Sibling UTXO check across all 2N+1 covenant inputs on the generic Unresolved-phase path and on resolution/expiry paths | +| No parasitic issuance | `ensure_no_issuance` on inputs that the generic path's delta derivation doesn't account for (i.e., inputs other than RT issuance and collateral spend) | +| No double resolution | Resolution consumes all 2N RT UTXOs; no spend path from Resolved_k back to Unresolved exists | +| Invariant preservation across any composition of deltas | Linearity: if each component of a composed transition individually preserves Check 1 and Check 2, their sum does too. Formally proven once, covers every transaction that the generic path accepts. | + +See [enforcement-layers.md](../../architecture/enforcement-layers.md) for the framework. -**Variable portion** (scales with N): -- The 2N asset IDs (N outcome tokens + N RTs) are derivable from the creation transaction's issuance entropy, not stored in the hint. +## Impact on deadcat-core -Total hint size: ~40 bytes regardless of N (matching the binary hint). The variable-N data is entirely recovered from the on-chain issuance metadata. +The `ContractEngine` API generalizes naturally to this covenant shape. Wallet-layer PSET builders expose named operations for ergonomics (`build_issuance_pset`, `build_split_yes_pset`, etc.), each constructing a tx with a specific `(Δy, Δn, Δc)` delta shape. All of these builders produce transactions that route through the same generic covenant spend path — the covenant doesn't see the builder name, only the tx's observable deltas. -See [chain-only-recovery.md](../../protocol/chain-only-recovery.md) for the recovery flow. The extension to multi-outcome markets is natural: a wallet scanning for asset IDs that match one of a market's `outcome_token_asset_ids` queries the issuance transaction, reads the OP_RETURN, reconstructs the params, and ingests the market. +See `../../architecture/deadcat-core-design.md` for the full `Market` and `MultiOutcomeMarket` view-type APIs (unified `build_issuance_pset`, cross-outcome-specific `build_split_yes_pset` / `build_merge_yes_pset` / `build_split_no_pset` / `build_merge_no_pset`). Cross-outcome arb quote/build is deferred to v2; see [deadcat-core-design.md § Future: Cross-Outcome Arb API (v2)](../../architecture/deadcat-core-design.md#future-cross-outcome-arb-api-v2). The builders are unchanged in shape by the generic-path design; what changes is the covenant's internal verification logic and the ability to compose novel delta shapes in a single transaction within the same generic solvency-preserving path. -## Relationship to the Binary Market +The `Side` enum (`{ Yes, No }` in the binary contract) is preserved, now paired with `OutcomeIndex(u8)`: -For N=2, the multi-outcome market contract is structurally equivalent to the binary market: +```rust +pub struct OutcomeToken { + pub outcome: OutcomeIndex, + pub side: Side, // Yes or No +} +``` -- 2 outcome tokens ≈ YES + NO -- 2 reissuance tokens ≈ YES RT + NO RT -- 8 slots (3N + 2 = 8 for N=2) -- Oracle signs outcome_index (0 or 1) ≈ outcome_byte (0x00 or 0x01) +## Alternatives Considered -The differences: -- **`market_id` differs**: the multi-outcome market's market_id is `SHA256(asset_id_0 || asset_id_1)` while the binary market's is `SHA256(yes_asset || no_asset)`. For N=2 deployments, these would produce identical hashes if the asset ordering matches (which it does by convention: outcome 0 = YES, outcome 1 = NO). This is a cosmetic point — same input, same hash. -- **Witness-parameterized indices**: the multi-outcome market uses out_base from witness; the binary market uses hardcoded positions. The former is more flexible (enables composition with pools) at the cost of a slightly more complex spend path. -- **Partial merge requires co-spend**: the multi-outcome market always co-spends all N+1 covenant inputs on partial merge (for the sibling check). The binary market's original design had partial cancellation spending only the collateral slot; the [refactor](../../architecture/enforcement-layers.md) added RT co-spend for the sibling check. The multi-outcome contract starts with this property built in. +### N-token Arrow-Debreu variant -**Migration question** (deferred): do we: +An earlier version of this spec used **N tokens** (one per outcome) instead of 2N. In that design: -- **(a) Keep `prediction_market.simf` as the canonical N=2 contract** and use `multi_outcome_market_n{N}.simf` for N ≥ 3? The binary contract is battle-tested; new code generation introduces risk we don't need to take for the already-working case. -- **(b) Regenerate `multi_outcome_market_n2.simf` from the template** and deprecate `prediction_market.simf`? Uniform code generation, single contract family, no special-case logic in `deadcat-core`. +- `outcome_i` pays 1 unit of collateral iff outcome i wins, 0 otherwise. +- Split: pay 1 collateral → receive 1 of each outcome token. +- Merge: burn 1 of each outcome token → receive 1 collateral. +- `NO_i` is implicit: holding one of every outcome token *except* i. -Option (b) is cleaner architecturally but requires validating that the generated N=2 contract is behaviorally equivalent to the current binary market (and handling any CMR/address differences in the ecosystem). This decision can be made after the generator is built and tested — we don't need to commit now. +**Pros**: +- Fewer RTs (N instead of 2N). Smaller transactions on split/merge. Higher practical N ceiling (~15 vs ~10). +- Simpler expiry redemption rate (uniform 1/N per token). +- Conceptually closer to the financial literature (Arrow-Debreu securities). -## Security Properties +**Cons (why we rejected it)**: +- Negative positions require (N-1) UTXOs. For N=5 that's 4 UTXOs per hedge; for N=10 that's 9. Dust, fees, and mental overhead scale badly. +- Mental-model discontinuity with the binary market. New users have to learn that "betting against X" means "buying everything else." +- AMM design asymmetry. A pool on a single outcome has a natural "long" side (the outcome token) but no natural "short" side — pools either serve only long positions or synthesize NO via an N-way bundle mechanism, both of which complicate the AMM. -The multi-outcome market preserves all the security properties of the binary market, generalized from 2 to N: +The 2N design pays a 2× RT slot cost to make the YES/NO symmetry first-class. Given the binary market already trained users (and pool designs) on YES/NO thinking, this was judged worth the cost. -| Property | Enforcement | -|---|---| -| Collateral conservation on split | Covenant checks `collateral_increase = sets × collateral_per_set` | -| Equal token supply across outcomes | Covenant verifies N token issuances of equal amount on split; N token burns of equal amount on merge | -| Oracle-only resolution | BIP-340 signature verification against `oracle_public_key` | -| Correct redemption rate (resolved) | Covenant releases `collateral_per_set` per winning token | -| Correct redemption rate (expired) | Covenant releases `collateral_per_set / N` per any outcome token | -| Deterministic RT blinding | Same scheme as binary market, applied to N RTs | -| RT destruction on terminal transitions | All N RTs burned on resolution and expiry | -| Collateral UTXO authenticity | Sibling UTXO check across all N+1 covenant inputs | -| No parasitic issuance | `ensure_no_issuance` on all non-issuance paths for all N+1 inputs | -| No double resolution | Resolution consumes all N RT UTXOs; no spend path back to Unresolved | - -See [enforcement-layers.md](../../architecture/enforcement-layers.md) for the framework and the cross-layer analysis (which generalizes directly from 2 to N). +Reference: this alternative was the design specified by the pre-pivot version of this document. [`amm-scoring-rule-tradeoffs.md`](amm-scoring-rule-tradeoffs.md) has been updated to reflect the 2N pivot and the Option C pool composition decision. [`design-journal-multi-outcome-amm.md`](design-journal-multi-outcome-amm.md) records the design history. -## Impact on deadcat-core +### Binary-market composition via an event wrapper -The existing `ContractEngine` API generalizes naturally: +Keep binary markets and introduce a new "event" contract that holds N binary markets' RTs and orchestrates cross-market operations atomically. -- `ingest_market` accepts either `PredictionMarketParams` (binary, legacy) or `MultiOutcomeMarketParams` (new), via a unified `MarketParams` enum or separate methods. -- `build_split_pset` (renamed from `build_issuance_pset`) takes a market ID and a `sets` count. -- `build_merge_pset` (renamed from `build_cancellation_pset`) takes a market ID and a `sets` count. -- `build_oracle_resolve_pset` takes a market ID, `outcome_index`, and the oracle signature. -- `build_redemption_pset` takes a market ID, `outcome_index` (must match the resolved outcome), and `tokens_to_redeem`. -- `build_expire_transition_pset` and `build_expired_redemption_pset` are unchanged in shape. +Rejected: infeasible without modifying the binary market to use witness-parameterized output indices (the current contract hardcodes positions 0/1/2). Even if we modified it, capital inefficiency is N:1 vs 1:1 for the native multi-outcome design. -Rename recommendations: -- `issuance` → `split` (more accurate for N outcomes; reads naturally for N=2 too) -- `cancellation` → `merge` -- `pairs` → `sets` everywhere -- `collateral_per_pair` → `collateral_per_set` +### Application-only composition -The `Side` enum (currently `{ Yes, No }`) generalizes to `OutcomeIndex(u8)` with convenience constants `pub const YES: OutcomeIndex = OutcomeIndex(0);` and `pub const NO: OutcomeIndex = OutcomeIndex(1);` for binary markets. +Link N independent binary markets at the UI layer, with no new contracts. Coherency via oracle discipline and arbitrage. -Full `deadcat-core` API changes are out of scope for this doc — they'll be addressed in a subsequent pass over `../../architecture/deadcat-core-design.md`. +Retained as an **option for very large N** (N > the 2N-contract ceiling) and for markets whose outcome set is not provably exhaustive (e.g., the 2024 US election where Biden dropped out). Documented in the design journal as the "soft-coherency" path. -## Pending Work +## Codegen and Validation Checklist -Validation and design-completion work needed before this proposal graduates from "proposal" to "committed design": +These are implementation tasks for generating, validating, and benchmarking the committed multi-outcome contract artifacts. They are not open protocol-design questions in this spec. | Item | Purpose | |---|---| -| Prototype the code generator | Produce a generated `.simf` for N=3 and N=5. Verify the template handles all spend paths correctly. | -| Compile prototype `.simf` files | Confirm SimplicityHL compiler handles the generated code without errors. Measure program size and witness size per N. | -| Benchmark transaction weights | Measure actual vBytes for split/merge/resolution transactions at each N. Validate scaling matches theoretical estimates. | -| Validate sibling UTXO check scaling | Confirm the N+1-way prev_txid check fits in the witness budget for N up to MAX_N. | -| Decide expiry redemption rate | Confirm the 1/N rate + `collateral_per_set % N == 0` constraint is acceptable. Alternatively specify and implement rounding-residual handling. | -| Decide N=2 migration path | Choose between keeping `prediction_market.simf` and regenerating from template. | -| Write `.simf` template formally | Document the template format, parameterization mechanism, and generator algorithm. | -| Specify builder convention validation | What N values are permitted? What ordering of outcomes? What naming convention? | -| Update `../contract-specification.md` | Add the multi-outcome market as a third contract type alongside the binary market, LMSR pool, and maker order. | -| Generate test vectors | Per-N test vectors covering creation, split, merge, resolution (each outcome), redemption, expiry, and edge cases (N=2 boundary, max N, outstanding_sets = 0 terminal paths). | +| Prototype the code generator | Produce generated `.simf` for N=3 and N=4. Verify all spend paths. | +| Compile prototype `.simf` files | Confirm SimplicityHL handles generated code. Measure program size and witness size per N. | +| Benchmark transaction weights | Measure actual vBytes for issue/cancel/split/merge/resolution at each N. Validate scaling. | +| Validate sibling UTXO check scaling | Confirm the 2N+1-way `prev_txid` check fits witness budget for the v1 set `{3, 4}` and characterize headroom for future N expansion. | +| Write `.simf` template formally | Document template format, parameterization, generator algorithm. | +| Specify builder convention validation | Permitted N, outcome ordering, naming conventions. | +| Generate test vectors | Per-N vectors covering creation, each operation, resolution per outcome, redemption (winning YES and winning NO sides), expiry, and edge cases. | ## Key Files - `docs/contracts/multi-outcome/multi-outcome-market-contract.md` — this document -- `docs/contracts/contract-specification.md` — to be updated with the multi-outcome market spec -- `docs/architecture/enforcement-layers.md` — security properties generalized from binary to N -- `docs/protocol/chain-only-recovery.md` — recovery flow extends to multi-outcome via issuance indexing -- `docs/protocol/deterministic-rt-blinding.md` — RT blinding applied per-outcome +- `docs/contracts/multi-outcome/amm-scoring-rule-tradeoffs.md` — scoring-rule analysis and the pool design decision (binary LMSR + Option C composition) +- `docs/contracts/multi-outcome/design-journal-multi-outcome-amm.md` — design history record +- `docs/contracts/contract-specification.md` — top-level contract index +- `docs/contracts/market-contract-principles.md` — covenant-enforced properties shared by both market contract types +- `docs/architecture/enforcement-layers.md` — security properties generalized to 2N +- `docs/protocol/chain-only-recovery.md` — recovery flow extends naturally +- `docs/protocol/deterministic-rt-blinding.md` — RT blinding applied per-token - `docs/protocol/oracle-bip340-tagged-hash.md` — oracle attestation format extends to outcome_index -- `docs/architecture/transaction-composability-model.md` — witness-parameterized indices enable composition with pools -- `docs/contracts/prediction-market/market-dormant-terminal-paths.md` — dormant terminal paths generalize from 2 to N RTs -- Future: `src-tauri/crates/deadcat-sdk/contract/multi_outcome_market_n{N}.simf` — generated contract files -- Future: `src-tauri/crates/deadcat-sdk/codegen/multi_outcome_market_template.simf` — the generator input +- `docs/architecture/transaction-composability-model.md` — witness-parameterized indices enable atomic multi-contract PSETs (including cross-outcome arb via pool co-spend with market's split-YES / merge-YES) +- `docs/contracts/prediction-market/market-dormant-terminal-paths.md` — dormant terminal paths generalize to 2N RTs +- Future: `crates/deadcat-core/contracts/multi_outcome/multi_outcome_market_n{N}.simf` — generated contracts +- Future: `crates/deadcat-codegen/src/templates/multi_outcome_market.simf.j2` — generator input diff --git a/docs/contracts/prediction-market/collateral-per-pair-refactor.md b/docs/contracts/prediction-market/collateral-per-pair-refactor.md index 2294c9ea..4163aef7 100644 --- a/docs/contracts/prediction-market/collateral-per-pair-refactor.md +++ b/docs/contracts/prediction-market/collateral-per-pair-refactor.md @@ -1,5 +1,7 @@ # Covenant Parameter Rename: `collateral_per_token` to `collateral_per_pair` +> **Superseded**: this intermediate rename was further refactored during the unified-denomination pass. The primary covenant param now goes directly to `base_payout` (per-outcome YES-expiry payout unit), with `cp = base_payout × N` derived at covenant compile time. `N = 2` for binary markets, `N ∈ [3, MAX_N]` for multi-outcome. This unifies the binary and multi-outcome denomination models and makes expiry-redemption divisibility structural rather than builder- or covenant-enforced. See [multi-outcome-market-contract.md § Denomination model](../multi-outcome/multi-outcome-market-contract.md#denomination-model) for the final state. The historical problem statement and doubling-factor analysis below remain accurate for the `collateral_per_token` baseline. + ## Problem The current covenant parameter is `COLLATERAL_PER_TOKEN` — the collateral backing a single token. But the atomic unit of issuance is always a pair (1 YES + 1 NO). Every formula in the codebase immediately multiplies by 2: @@ -45,7 +47,9 @@ All formulas in the design doc use the simpler form: The `PredictionMarketParams` Rust struct field changes from `collateral_per_token` to `collateral_per_pair`. -## Key Files +## Legacy Source Touchpoints + +These are the current `deadcat-sdk` files where this legacy-source delta existed before the later `base_payout` unification. They remain useful as historical reference points for the superseded rename described above. - `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — rename param, simplify `collateral_for_pairs` - `src-tauri/crates/deadcat-sdk/src/prediction_market/params.rs` — rename field diff --git a/docs/contracts/prediction-market/market-dormant-terminal-paths.md b/docs/contracts/prediction-market/market-dormant-terminal-paths.md index 2f11c0c1..87ee9409 100644 --- a/docs/contracts/prediction-market/market-dormant-terminal-paths.md +++ b/docs/contracts/prediction-market/market-dormant-terminal-paths.md @@ -18,11 +18,11 @@ Add two new spend paths to the Dormant RT slot covenant programs, mirroring the ### 1. Oracle Resolution from Zero-Pair State -**Authorization**: Oracle BIP-340 Schnorr signature on `SHA256(market_id || outcome_byte)` — identical to the existing resolution path from Unresolved. +**Authorization**: Oracle BIP-340 Schnorr signature on `tagged_hash("deadcat/oracle_attestation", market_id || outcome_byte)` — identical to the existing resolution path from Unresolved. **Constraints**: - Both RT UTXOs (YES RT and NO RT) must be consumed atomically in the same transaction -- No new covenant outputs produced (both RTs extinguished) +- RT burn outputs are verified at the unspendable burn script; no covenant continuation outputs are produced - Oracle signature verified against `oracle_public_key` from market params **Result**: Market transitions directly to `ResolvedYes { outstanding_pairs: 0 }` or `ResolvedNo { outstanding_pairs: 0 }` depending on the attestation — immediately terminal. @@ -35,7 +35,7 @@ Add two new spend paths to the Dormant RT slot covenant programs, mirroring the **Constraints**: - Both RT UTXOs (YES RT and NO RT) must be consumed atomically in the same transaction -- No new covenant outputs produced (both RTs extinguished) +- RT burn outputs are verified at the unspendable burn script; no covenant continuation outputs are produced - Timelock validated against `expiry_time` parameter **Result**: Market transitions directly to `Expired { outstanding_pairs: 0 }` — immediately terminal. @@ -67,7 +67,7 @@ The caller doesn't need to know which path is taken — the engine determines it ### State Advancement -`process_transaction` identifies these transitions by: both RT outpoints spent + no new covenant outputs + market had zero outstanding pairs. Since all three dormant terminal paths produce identical observable outputs (no covenant outputs), the engine uses **witness-based path detection** — extracting the Simplicity program and witness bytes from the spending transaction's witness stack and calling `RedeemNode::decode` to determine which spend path was taken. This yields `MarketTransition::Resolved { outcome: Side }` or `MarketTransition::Expired` as appropriate. See the main design doc's [Detection Strategy and Robustness](../../architecture/deadcat-core-design.md#detection-strategy-and-robustness) section. +`process_transaction` identifies these transitions by: both RT outpoints spent + no new covenant continuation outputs + RT burn outputs present + market had zero outstanding pairs. Since all three dormant terminal paths produce identical observable continuation shape and burn outputs, the engine uses **witness-based path detection** — extracting the Simplicity program and witness bytes from the spending transaction's witness stack and calling `RedeemNode::decode` to determine which spend path was taken. This yields `MarketTransition::Resolved { outcome: Side }` or `MarketTransition::Expired` as appropriate. See the main design doc's [Detection Strategy and Robustness](../../architecture/deadcat-core-design.md#detection-strategy-and-robustness) section. ### Transition Details @@ -93,6 +93,8 @@ Both paths require atomic consumption of BOTH RT UTXOs (co-membership enforcemen The oracle resolution from zero-pair state uses the same oracle signature as resolution from Unresolved — the oracle signs the same BIP-340 tagged hash message (`tagged_hash("deadcat/oracle_attestation", market_id || outcome_byte)`) regardless of the market's pair count. No new domain string needed. See [oracle-bip340-tagged-hash.md](../../protocol/oracle-bip340-tagged-hash.md). -### Key Files +### Legacy Source Touchpoints + +These are the current `deadcat-sdk` files where this legacy-source delta exists today. The `deadcat-core` implementation should realize the same behavior in its new market contract modules. - `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — add resolution and expiry paths to Dormant RT slot programs diff --git a/docs/protocol/chain-only-recovery.md b/docs/protocol/chain-only-recovery.md index c488abc2..abf10a4c 100644 --- a/docs/protocol/chain-only-recovery.md +++ b/docs/protocol/chain-only-recovery.md @@ -11,20 +11,54 @@ This is achieved through three mechanisms: ### Design Principle: Covenants Are Permissive, Builders Are Opinionated -The Simplicity covenants accept wide parameter ranges (u64 for prices, fees, collateral amounts). The `deadcat-core` PSET builders enforce tighter constraints — only parameter values that can be losslessly round-tripped through the OP_RETURN encoding are accepted. Non-conforming values produce `CoreError::InvalidParams`. This ensures every contract created through `deadcat-core` has a decodable recovery hint. +The Simplicity covenants accept wide parameter ranges (u64 for prices, fees, collateral amounts). The `deadcat-core` APIs enforce tighter constraints — only parameter values that can be losslessly round-tripped through the canonical v1 OP_RETURN encoding are accepted. Non-conforming values surface as `ConventionError` in the pure derive helpers and `CoreError::ConventionViolation` in builders / ingestion. This keeps the supported contract surface aligned with the published recovery format. + +`deadcat-core` uses a **strict-canonical tracking policy**: if the engine creates or agrees to track a contract, the supplied params must conform to the documented recovery conventions. This avoids a mixed universe of "tracked but foreign" contracts whose mnemonic-recovery story depends on out-of-band assumptions. The one remaining caveat is non-initial pool/order ingestion via `Current` snapshots: those variants intentionally omit the creation transaction, so they cannot prove the historical hint was present on-chain. They still reject non-conforming supplied params and require a canonical parent market. Convention compliance is enforced at three layers: - **Derive functions** (`derive_order_params`, `derive_pool_params`): first line — catches convention violations at param construction time with the clearest error context - **PSET builders** (all three creation builders): defense in depth — catches violations for manually-constructed params that bypass derive functions -- **Market ingestion** (`ingest_market`): protects all downstream users — rejects non-conforming markets, since non-conforming markets break the recovery chain for any child contracts (orders, pools) and any token holder tracing back to the market +- **Ingestion** (`ingest_market`, `ingest_pool`, `ingest_persistent_order`, `ingest_ephemeral_order`): strict-canonical tracking boundary — rejects non-conforming supplied params before the engine agrees to track the contract | Contract | Creation enforcement | Ingestion enforcement | Why | |---|---|---|---| -| Market | Builder rejects | `ingest_market` rejects | Non-conforming markets break all downstream users (token holders, orders, pools) | -| Order | `derive_order_params` + builder reject | No convention check | Only the creator needs recovery; takers just fill | -| Pool | `derive_pool_params` + builder reject | No convention check | Only the operator needs recovery; traders just swap | +| Market | Builder rejects | `ingest_market` rejects and verifies the creation tx | Non-conforming markets break all downstream users (token holders, orders, pools) | +| Order | `derive_order_params` + builder reject | All order ingestion paths reject non-conforming supplied params; `Creation` additionally verifies the creation tx | Strict-canonical tracking keeps the engine's supported order universe aligned with mnemonic recovery and canonical UX assumptions | +| Pool | `derive_pool_params` + builder reject | All pool ingestion paths reject non-conforming supplied params; `Creation` additionally verifies the creation tx | Same: one canonical tracked-pool class is easier to reason about than "tracked but foreign" liquidity | + +For `PoolSnapshot::Current` and `OrderSnapshot::Current`, the caller intentionally omits the creation transaction. Those paths therefore cannot prove the historical OP_RETURN hint existed on-chain; they enforce only canonical param shape plus canonical-parent-market membership. This is the existing fast-start trust trade-off of non-initial ingestion, not a second-class convention policy. + +## Integration Contract + +Correct chain-only recovery depends on the wallet integrator providing specific inputs to `deadcat-core`. This section enumerates every precondition, the failure mode if violated, and what the engine verifies on its own. + +### Preconditions the integrator must satisfy + +| Precondition | Failure mode if violated | +|---|---| +| **Deadcat xprv derived at `m/86'/1145258324'`** (see [HD Paths](#hd-paths)). The integrator is responsible for performing the derivation before passing the key to `derive_*_params` or engine construction. | Silent. Derive functions produce different keys, reconstructed covenant scripts do not match on-chain UTXOs, recovery reports "no matches" instead of an error. | +| **Complete wallet rescan.** The caller must present every wallet-funded transaction on the target network, from the wallet's first use through the current tip. Incremental rescans must not skip block ranges. | Silent. Orders and pools whose creation txs were missed are simply absent from the recovered state. The engine has no way to know about txs it was never given. | +| **Authoritative, tip-synced `ChainSource`.** Backends must return complete, current state — not filtered or stale results. | Latent. Stale tip produces stale state. Missing txs in `transactions_in_block` or equivalent queries produce the same silent gap as incomplete rescan. | +| **`ChainSource::issuance_transaction(asset_id)` returns the first-issuance transaction**, not a subsequent reissuance. Esplora's `/asset/:asset_id` endpoint and Electrs's asset index both return this directly. | Loud. `ingest_market` re-derivation fails the script-pubkey match and returns `CoreError::InvalidCreationTx`. The error does not obviously point at the integrator's `ChainSource` implementation — integrators should treat this error as a signal to verify their issuance lookup is returning the genesis tx. | +| **Correct `Network` at engine construction.** The well-known collateral asset index (see [Well-Known Collateral Asset Index](#well-known-collateral-asset-index-4-bits)) resolves against network-specific asset IDs — mainnet L-BTC ≠ testnet L-BTC ≠ regtest L-BTC, and the v1 well-known USDt entry exists only on Liquid mainnet. | Silent. Decoded collateral asset IDs resolve to the wrong chain's policy asset or treat a mainnet-only USDt index as valid on the wrong network; downstream operations fail with "unknown asset" rather than an explicit network-mismatch error. | + +### What `deadcat-core` verifies + +- **Creation tx / OP_RETURN authenticity** — ingestion re-derives the covenant script pubkey from the parsed params and requires it match the creation tx's output script. Spoofed hints or wrong creation txs are rejected with `CoreError::InvalidCreationTx`. +- **Asset identity on ingestion** — `identify_asset` cross-checks asset IDs against registered market params. Unknown asset IDs are reported, not silently accepted. +- **Covenant state transitions** — every tx presented to `step` / `interpret_transaction` is validated against the expected covenant spend paths; invalid transitions are rejected. + +### What `deadcat-core` does not verify -Pool and order ingestion validates the parent market relationship (transitively ensuring the parent market is conforming) but does not enforce pool/order-specific conventions — a non-conforming pool or order is still fully functional for trading. +- **Completeness of the transaction set the caller provided.** The engine cannot detect "you forgot to give me tx X." Integrators must independently guarantee rescan completeness. +- **Freshness of the chain tip.** The engine processes what it is given in the order it is given; a stale backend produces stale state with no warning. +- **Derivation path of the passed xprv.** The engine trusts the caller to have derived at `m/86'/1145258324'`. A wrong-path xprv produces usable-looking derived keys that silently fail to match on-chain data. + +### Recommendation for integrators + +After recovery, sanity-check the engine's state against an independent source before exposing it to the user. At minimum, query the `ChainSource` for the current chain height and confirm the wallet's latest processed height matches — this does not catch missing historical txs but catches obviously-stale backends. + +A `verify_integration(xprv, chain_source)` helper is under consideration for a future release. It would exercise a known derivation + lookup path to convert several silent-failure integration bugs into fail-fast at construction time. Not committed for v1. ## Recovery Flows by User Type @@ -32,12 +66,12 @@ Pool and order ingestion validates the parent market relationship (transitively Token recovery is automatic — YES and NO tokens are standard Elements confidential assets at wallet addresses. Standard mnemonic-based rescan finds them. -**Labeling and redemption** require the market's `PredictionMarketParams`. The recovery path: +**Labeling and redemption** require the market's `MarketParams` (binary or multi-outcome umbrella). The recovery path: 1. Wallet rescan finds YES/NO token UTXOs with asset IDs 2. For each unknown asset ID: query `ChainSource::issuance_transaction(asset_id)` 3. The returned transaction IS the market creation tx (the asset was first issued there) -4. Read the market OP_RETURN hint → reconstruct `PredictionMarketParams` +4. Read the market OP_RETURN hint → reconstruct `MarketParams` (binary or multi-outcome, per the hint's type tag) 5. `ingest_market` with the reconstructed params + creation tx 6. `identify_asset` labels the tokens; `build_redemption_pset` enables redemption @@ -50,7 +84,7 @@ One chain query per unique asset ID. Works despite blinded reissuance token outp Markets have no on-chain "owner" (taproot internal key is NUMS), but the creation transaction is wallet-funded. Recovery: 1. Wallet rescan finds the wallet-funded market creation tx -2. Read the market OP_RETURN → reconstruct `PredictionMarketParams` (non-derivable fields from hint + derivable asset IDs from the tx's issuance entropy) +2. Read the market OP_RETURN → reconstruct `MarketParams` (non-derivable fields from hint + derivable asset IDs from the tx's issuance entropy; umbrella variant determined by the hint's type tag — binary or multi-outcome) 3. `ingest_market` ### Order Creators (Makers) @@ -61,12 +95,15 @@ Maker order UTXOs are at covenant addresses — standard wallet rescan cannot fi 2. Read the order OP_RETURN → extract `masked_index`, `market_creation_txid`, `price`, `side`, `direction`, `min_fill_lots`, `min_remainder_lots` 3. Derive mask: `HMAC(deadcat_secret_key, "deadcat/order_mask" || context)[0..2]` (context = all fields from step 2 except masked_index) 4. Unmask: `order_index = masked_index ^ mask` -5. Fetch the market creation tx by `market_creation_txid` → read market OP_RETURN → reconstruct `PredictionMarketParams` -6. Call `derive_order_params(deadcat_xprv, market_params, order_index, side, direction, price, min_fill_lots, min_remainder_lots)` to reconstruct full `MakerOrderParams` (derives maker pubkey, nonce, and everything else internally) -7. Compile the covenant, verify the script matches a creation tx output -8. `ingest_order` +5. Fetch the market creation tx by `market_creation_txid` → read market OP_RETURN → reconstruct `MarketParams` (binary or multi-outcome, determined by the market hint's type tag) +6. For each candidate `outcome` in the market's valid `OutcomeIndex` range: + - For **binary** markets, the only valid index is `OutcomeIndex::BINARY` — a single candidate. + - For **multi-outcome** markets with `MultiOutcomeMarketParams { outcome_count: N, .. }`, iterate `OutcomeIndex::new(k)` for `k in 0..N` — up to N candidates. + - Call `derive_order_params(deadcat_xprv, market_params, outcome, order_index, side, direction, price, min_fill_lots, min_remainder_lots)` to reconstruct candidate `MakerOrderParams`. + - Compile the covenant and check whether the script matches a creation tx output. First match wins. +7. `ingest_persistent_order` with the recovered params and creation transaction -Without the OP_RETURN, recovery requires brute-forcing `order_index x market x price x direction x min_fill x min_remainder` — each candidate requiring Simplicity compilation (~10-100ms). With the hint, one compilation per order to verify. +Without the OP_RETURN, recovery requires brute-forcing `order_index x outcome x market x price x direction x min_fill x min_remainder` — each candidate requiring Simplicity compilation (~10-100ms). With the hint, up to `outcome_count` compilations per order to verify. See [Recovering without a hint](#recovering-without-a-hint-non-standard) for the non-standard fallback. ### Pool Operators @@ -76,10 +113,19 @@ Pool reserve UTXOs are at covenant addresses — standard wallet rescan cannot f 2. Read the pool OP_RETURN → extract `masked_index`, `market_creation_txid`, `max_loss_sats`, `half_payout_sats`, `fee_bps`, `initial_s_index` 3. Derive mask: `HMAC(deadcat_secret_key, "deadcat/pool_mask" || context)[0..2]` (context = all fields from step 2 except masked_index) 4. Unmask: `pool_index = masked_index ^ mask` -5. Fetch market creation tx → reconstruct market params -6. Call `derive_pool_params(deadcat_xprv, market_params, pool_index, max_loss_sats, half_payout_sats, fee_bps, starting_price_bps)` to reconstruct full `LmsrPoolParams` (derives admin pubkey, Merkle root, and everything else internally). The `starting_price_bps` is derived from `initial_s_index` (extracted in step 2) via the inverse logistic function. -7. Compile the covenant for `initial_s_index`, verify the script matches a creation tx output -8. `ingest_pool` +5. Fetch market creation tx → reconstruct market params (binary or multi-outcome) +6. For each candidate `outcome` in the market's valid `OutcomeIndex` range: + - For **binary** markets, the only valid index is `OutcomeIndex::BINARY` — a single candidate. + - For **multi-outcome** markets with `MultiOutcomeMarketParams { outcome_count: N, .. }`, iterate `OutcomeIndex::new(k)` for `k in 0..N` — up to N candidates. + - Call `derive_pool_params(deadcat_xprv, market_params, outcome, pool_index, max_loss_sats, half_payout_sats, fee_bps, initial_s_index)` to reconstruct candidate `LmsrPoolParams`. `initial_s_index` is passed directly from the hint — no inverse conversion. + - Compile the covenant for `initial_s_index` and check whether the script matches a creation tx output. First match wins. +7. `ingest_pool` with `PoolSnapshot::Creation` + +### Recovering without a hint (non-standard) + +The hint-based flows above assume every deadcat contract carries a parseable `deadcat-core`-format OP_RETURN. A creation transaction without such a hint — non-conforming contract built with custom tooling, format mismatch between recovery code and hint version, or pathological on-chain data loss — cannot be recovered via the fast path. The only fallback is brute-force index scanning: for each candidate `index` in `[0, 65535]` (and, for multi-outcome markets, each candidate `outcome`), derive the contract params with those values, compile the covenant, and check the script pubkey against known covenant-address UTXOs. At ~10-100 ms per Simplicity compilation, a full sweep costs 10-100 minutes per orphaned UTXO per outcome. + +This path is **not supported** by `deadcat-core` v1. Integrators who need it can implement it against the public `derive_order_params` / `derive_pool_params` functions; it is a thin loop over indices and outcomes that compares compiled covenant scripts against the target UTXO set. Adding a shipped helper is non-breaking and can happen in a future release if real-world demand emerges. In practice, `deadcat-core`-built contracts always carry a hint, and convention enforcement at creation / creation-based ingestion rejects non-conforming ones — so this fallback is relevant only when the contract was built by a tool that bypassed `deadcat-core`, or when a caller chooses a `Current` snapshot path that omits creation-time verification. In those cases the authoring / ingesting tool is responsible for the missing proof. ## ChainSource Addition @@ -98,17 +144,21 @@ This works despite blinded reissuance token outputs because the `AssetIssuance` ### HD Paths -Recovery requires deterministic derivation of keys and secrets from the mnemonic. The wallet derives the deadcat xprv at `m/purpose'/deadcat'` and passes it to `deadcat-core`'s derive functions, which handle all child derivations internally. The internal structure: +Recovery requires deterministic derivation of keys and secrets from the mnemonic. The wallet derives the deadcat xprv at `m/86'/1145258324'` and passes it to `deadcat-core`'s derive functions, which handle all child derivations internally. The internal structure: | Path | Derives | Used for | |---|---|---| -| `m/purpose'/deadcat'/secret'` | `deadcat_secret_key` | Order nonce derivation + index masking (both orders and pools) | -| `m/purpose'/deadcat'/orders'/i` | Maker keypair at index `i` | `maker_pubkey` (covenant param) + cancel signing | -| `m/purpose'/deadcat'/pools'/i` | Admin keypair at index `i` | `admin_pubkey` (covenant) + admin/close signing | +| `m/86'/1145258324'/secret'` | `deadcat_secret_key` | Order nonce derivation + index masking (both orders and pools) | +| `m/86'/1145258324'/orders'/i` | Maker keypair at index `i` | `maker_pubkey` (covenant param) + cancel signing | +| `m/86'/1145258324'/pools'/i` | Admin keypair at index `i` | `admin_pubkey` (covenant) + admin/close signing | A single `deadcat_secret_key` is used for all HMAC operations across both contract types. Different HMAC tags (`"deadcat/order_nonce"`, `"deadcat/order_mask"`, `"deadcat/pool_mask"`) provide full cryptographic domain separation — the outputs are independent PRF evaluations even with the same key. -The exact `purpose'` value is TBD (BIP-43 registration or application-specific constant). All paths use hardened derivation — compromising the deadcat xprv cannot affect non-deadcat wallet keys. +**Path constants**. The `purpose'` value `86'` follows BIP-86 (single-key taproot) — deadcat covenants are taproot-based. The `coin_type'` value `1145258324'` is `0x44434154` = ASCII `"DCAT"`, self-documenting and within the hardened-index range (`< 2^31 - 1`). This follows the same pattern used by RGB-on-Liquid and other non-wallet protocols: claim a SLIP-0044 `coin_type` slot under a standard BIP purpose rather than introducing a new `purpose'` value. A SLIP-0044 registration PR for this coin_type is tracked as a pre-v1-ship action item. + +**Migration from deadcat-sdk**. The existing `deadcat-sdk` code uses `m/84'/1776'/...` for maker-order and pool-admin key derivation. That path is superseded by this specification. Since `deadcat-core` is pre-implementation and no on-chain contracts use the new path yet, this is a clean break with no migration. + +All paths use hardened derivation — compromising the deadcat xprv cannot affect non-deadcat wallet keys. **Interoperability**: This derivation spec is the public interoperability standard for cross-wallet recovery. Any wallet implementing Deadcat must follow these paths. `deadcat-core` provides convenience functions (`derive_order_params`, `derive_pool_params`) that accept the deadcat xprv and handle all child derivations internally — Rust integrators can use these directly. Cross-language implementations (JavaScript, Swift) must implement the derivation independently from this spec. @@ -159,17 +209,17 @@ fee_bps (2 bytes, u16 big-endian) initial_s_index (2 bytes, u16 big-endian) ``` -At recovery time, the decoder reads the OP_RETURN, decodes each field to its raw value (e.g., 9-bit mantissa×exponent → u64 for `max_loss_sats`), then serializes in this format for the HMAC. The context length does not affect on-chain size — only the 2-byte mask output appears in the OP_RETURN. +At recovery time, the decoder reads the OP_RETURN, decodes each field to its raw value (e.g., 4-bit 1-2-5 table index → u64 for `max_loss_sats`), then serializes in this format for the HMAC. The context length does not affect on-chain size — only the 2-byte mask output appears in the OP_RETURN. Including contract-specific params in the context ensures different contracts get different masks. The recovery code recomputes the same mask from the decoded OP_RETURN data. -**Known property**: Two orders with completely identical params on the same market share the same mask (leaking the XOR of their indices). This is a negligible concern — identical params already imply a CMR collision scenario the spec warns against, and the leaked information (index difference, not absolute values) requires the observer to already have linked the two transactions to the same wallet. +**Known property**: Two orders with identical non-index params on the same market share the same mask (leaking the XOR of their order indices). This is a negligible concern — distinct `order_index` values produce distinct `maker_pubkey` and `order_nonce` values (see [Key Derivation](#key-derivation) and [Order Nonce Derivation](#order-nonce-derivation)), so CMR collision is structurally prevented regardless of mask overlap. The leaked information (index XOR, not absolute indices) additionally requires the observer to have already linked both transactions to the same wallet. ## Standard Denomination Convention ### Market Denomination: 1-2-5 Table (4 bits) -`collateral_per_pair` is constrained to 16 values in the 1-2-5 series: +`base_payout` — the primary covenant denomination, representing the per-outcome YES-expiry payout unit — is constrained to 16 values in the 1-2-5 series: | Index | Value (sats) | Index | Value (sats) | |---|---|---|---| @@ -182,34 +232,32 @@ Including contract-specific params in the context ensures different contracts ge | 6 | 10,000 | 14 | 5,000,000 | | 7 | 20,000 | 15 | 10,000,000 | -This determines order price resolution: `PRICE` is an integer bounded by `collateral_per_pair`, so the number of distinct expressible probability values equals `collateral_per_pair`. At 100: 1% increments (coarse). At 10,000: 0.01% increments (fine). Markets below 1,000 have limited price resolution for limit orders but work fine for LMSR pool trading (pools use their own pricing curve). +Binary markets derive `cp = base_payout × 2`. Multi-outcome markets derive `cp = base_payout × N`. This pair-cost is the total collateral backing one `(YES_i + NO_i)` pair. Parameterizing on `base_payout` rather than `cp` makes expiry-redemption rates exact integers by construction: a YES token always pays `base_payout`; a NO token always pays `base_payout × (N-1)` at expiry. No covenant-level divisibility check is needed, and every denomination-table index is usable for every supported N. See [multi-outcome-market-contract.md § Denomination model](../contracts/multi-outcome/multi-outcome-market-contract.md#denomination-model) for the full rationale. -### Pool Denomination: 26-Value Mantissa x 10^Exponent (9 bits) +This determines order price resolution: order `PRICE` is an integer bounded by `cp = base_payout × N`, so the number of distinct expressible probability values equals `cp`. For a binary market at `base_payout = 100` (`cp = 200`): 0.5% increments. At `base_payout = 10,000` (`cp = 20,000`): 0.005% increments. Markets with low `cp` have limited price resolution for limit orders but work fine for LMSR pool trading (pools use their own pricing curve). -`max_loss_sats` and `half_payout_sats` use a two-significant-digit encoding: +### Pool Denomination: 1-2-5 Table (4 bits each) -**26 mantissa values** (5 bits): -``` -10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, -25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95 -``` +Both `max_loss_sats` and `half_payout_sats` share the same 16-value 1-2-5 table used for market `base_payout`, encoded as a 4-bit index into the table above (see [Market Denomination](#market-denomination-1-2-5-table-4-bits)). -Fine granularity at the low end (10-20: ~5-10% steps), medium in the middle (25-50: ~10-20% steps), adequate at the high end (50-95: ~5-17% steps). 6 unused 5-bit slots reserved for future expansion. +This gives 16 × 16 = 256 `(max_loss_sats, half_payout_sats)` combinations, encoded in 8 bits total (vs. 18 bits under the previous 26-mantissa × 16-exponent scheme). Value range: 100 to 10,000,000 sats per param. -**4-bit exponent** (0-15): `value = mantissa x 10^exponent`. The 4-bit exponent (not 3-bit) is necessary because non-L-BTC assets (USDT on Liquid = 10^8 units per dollar) need exponent 8+ for moderate pool sizes. +**Why this range**: pools on L-BTC-denominated markets with `base_payout ≤ 10^7` sats and subsidies in the same range fit cleanly. Pools on larger-denomination markets (e.g., USDT with `base_payout = 10^8` or larger) would need an expanded table. Expanding the table in a future release is non-breaking: each new `(max_loss_sats, half_payout_sats)` combo gets its own Merkle root, and existing pools are unaffected by new table entries. -Range: 10 x 10^0 = 10 through 95 x 10^15. Covers all practical pool parameters for any collateral asset. +**Why not a separate table with wider range for pools**: consistency with the market encoding reduces the number of distinct denomination conventions in the protocol, simplifies decoders, and keeps the committed Merkle root set (one per combo) small. The 10^7 cap is a pragmatic v1 constraint, not a structural one. ### Well-Known Collateral Asset Index (4 bits) -``` -0 = L-BTC (mainnet) -1 = USDt (Liquid) -2-14 = reserved for future well-known assets -15 = escape: full 32-byte asset ID follows -``` +The v1 mapping is keyed by network: -The lookup table is network-specific — L-BTC has different asset IDs on mainnet, testnet, and regtest. The engine knows the network from construction time. +| Index | Liquid mainnet | Liquid testnet | Liquid regtest | +|---|---|---|---| +| `0` | L-BTC policy asset `6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d` | Policy asset `144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49` | Default regtest policy asset `5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225` | +| `1` | Liquid mainnet USDt `ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2` | **Unassigned in v1** — use escape `15` for non-policy collateral | **Unassigned in v1** — use escape `15` for non-policy collateral | +| `2-14` | Reserved for future well-known assets | Reserved for future well-known assets | Reserved for future well-known assets | +| `15` | Escape: full 32-byte asset ID follows | Escape: full 32-byte asset ID follows | Escape: full 32-byte asset ID follows | + +Index `0` always means the selected network's policy asset. Index `1` is intentionally **Liquid-mainnet-only** in v1; builders on Liquid testnet and Liquid regtest must encode every non-policy collateral asset via escape `15`, and decoders should reject index `1` on those networks. ## OP_RETURN Encoding Specification @@ -217,22 +265,30 @@ All recovery hints use zero-value OP_RETURN outputs. Data must be whole bytes (p ### Type Tag -The first byte of every hint. It identifies: -1. Whether this is a deadcat hint (vs other protocols' OP_RETURNs) -2. Which contract type (market, pool, order) -3. Format version -4. For orders: side and direction flags in the low bits +V1 uses exact class-nibble assignments: + +| Hint | `type_tag` | Meaning | +|---|---|---| +| Binary market | `0x10` | Market hint for the binary market contract family. Low nibble reserved, must be zero. | +| Multi-outcome market | `0x20` | Market hint for the multi-outcome contract family. Low nibble reserved, must be zero. | +| Pool | `0x30` | Pool hint. Low nibble reserved, must be zero. | +| Order: YES / SellBase | `0x40` | Order hint with class nibble `0x4`, side bit `0`, direction bit `0`, reserved bits `00`. | +| Order: YES / SellQuote | `0x44` | Order hint with class nibble `0x4`, side bit `0`, direction bit `1`, reserved bits `00`. | +| Order: NO / SellBase | `0x48` | Order hint with class nibble `0x4`, side bit `1`, direction bit `0`, reserved bits `00`. | +| Order: NO / SellQuote | `0x4C` | Order hint with class nibble `0x4`, side bit `1`, direction bit `1`, reserved bits `00`. | + +The high nibble identifies the hint family. For market and pool hints, the low nibble is reserved and must be zero in v1. For order hints, the low nibble is structured as `[side(1)][direction(1)][reserved(2)]`, where `side = 0` means YES, `side = 1` means NO, `direction = 0` means SellBase, and `direction = 1` means SellQuote. All other byte values are reserved in v1. The type tag is a **first-pass filter**, not a guarantee. Roughly 1 in 256 random OP_RETURNs match any given type tag value. Full verification (decode all fields, compile covenant, match script) is what confirms a hint is genuine. ### Market Hint -**37 bytes** (known collateral asset) / **69 bytes** (exotic collateral with escape code): +Binary and multi-outcome market hints share the same **37-byte** layout (69 bytes with exotic collateral). They are distinguished by the `type_tag` byte: `0x10` for binary markets and `0x20` for multi-outcome markets. The rest of the layout is identical: ``` Byte 0: type_tag -- 8 bits Bytes 1-32: oracle_public_key -- 256 bits -Byte 33: [collateral_asset(4)][collateral_per_pair(4)] -- 8 bits +Byte 33: [collateral_asset(4)][base_payout(4)] -- 8 bits Bytes 34-36: expiry_time (u24, big-endian) -- 24 bits Total: 296 bits = 37 bytes ``` @@ -245,19 +301,43 @@ If `collateral_asset` index = 15 (escape): 32 additional bytes of raw `collatera |---|---|---|---| | `oracle_public_key` | Yes | 32 bytes | Not derivable — chosen by market creator | | `collateral_asset_id` | Yes (indexed) | 4 bits | Not derivable — well-known index, escape for exotic | -| `collateral_per_pair` | Yes (indexed) | 4 bits | Not derivable — 1-2-5 convention, 16 values | +| `base_payout` | Yes (indexed) | 4 bits | Not derivable — 1-2-5 convention, 16 values. Binary markets derive `cp = base_payout × 2`; multi-outcome markets derive `cp = base_payout × outcome_count`, with `outcome_count` recovered from the creation tx (see below). | | `expiry_time` | Yes (absolute) | 3 bytes | Not derivable — u24 absolute encoding (see below) | -| `yes_token_asset_id` | No | — | Derivable from creation tx issuance entropy | -| `no_token_asset_id` | No | — | Derivable from creation tx issuance entropy | -| `yes_reissuance_token_id` | No | — | Derivable from creation tx issuance entropy | -| `no_reissuance_token_id` | No | — | Derivable from creation tx issuance entropy | +| `outcome_count` | **No** | — | **Derivable from creation tx issuance count** (multi-outcome only; binary is implicitly N=2) | +| `yes_token_asset_id(s)` | No | — | Derivable from creation tx issuance entropy | +| `no_token_asset_id(s)` | No | — | Derivable from creation tx issuance entropy | +| `yes_reissuance_token_id(s)` | No | — | Derivable from creation tx issuance entropy | +| `no_reissuance_token_id(s)` | No | — | Derivable from creation tx issuance entropy | -**Expiry time encoding** (u24): `encoded = expiry_time / 60` stored as 3 bytes big-endian. Recovery: `expiry_time = encoded × 60`. The PSET builder **snaps** `expiry_time` to the nearest 60-block boundary (the covenant uses the snapped value, making the encoding lossless). At Liquid's target rate of 1 block per minute, each unit represents approximately 1 hour. The u24 range (0 to 2^24 - 1 = 16,777,215) covers block heights from the Liquid genesis block (mined September 26, 2018) to approximately the year 3931, providing 1-hour granularity with ~1,900 years of headroom. This absolute encoding was chosen over a creation-block-relative delta because the creation block height is unknown at PSET build time (the transaction hasn't been broadcast yet), making delta-based recovery produce incorrect values due to confirmation drift. +**Expiry time encoding** (u24): `encoded = expiry_time / 60` stored as 3 bytes big-endian. Recovery: `expiry_time = encoded × 60`. The PSET builder accepts any future height and **rounds `expiry_time` up to the next 60-block boundary** before constructing the covenant params; the covenant and returned params use that rounded value, making the encoding lossless. At Liquid's target rate of 1 block per minute, each unit represents approximately 1 hour. The u24 range (0 to 2^24 - 1 = 16,777,215) covers block heights from the Liquid genesis block (mined September 26, 2018) to approximately the year 3931, providing 1-hour granularity with ~1,900 years of headroom. This absolute encoding was chosen over a creation-block-relative delta because the creation block height is unknown at PSET build time (the transaction hasn't been broadcast yet), making delta-based recovery produce incorrect values due to confirmation drift. + +**Deriving `outcome_count` for multi-outcome markets.** The creation tx mints 2N token pairs (one `AssetIssuance` per outcome's YES or NO leg), so `outcome_count = issuance_count / 2` where the count is over **new-issuance** `AssetIssuance` structures with both `amount` and `inflation_keys` non-null: + +```rust +let issuance_count = creation_tx.input.iter() + .filter(|inp| { + inp.has_issuance() + && inp.asset_issuance.asset_blinding_nonce == ZERO_TWEAK + && !inp.asset_issuance.amount.is_null() + && !inp.asset_issuance.inflation_keys.is_null() + }) + .count(); +let outcome_count = (issuance_count / 2) as u8; +``` + +The filter components: +- `has_issuance()` — returns true iff the input carries a non-null `AssetIssuance` record. Peg-ins use a separate bit on the prevout and are correctly excluded. +- `asset_blinding_nonce == ZERO_TWEAK` — selects *new* issuances. Reissuances (nonzero nonce) are excluded; same for any unrelated issuances that a non-conforming tx might carry. +- `amount` and `inflation_keys` both non-null — defensive filter that rules out asymmetric ("half-issuance") records. Elements consensus permits an `AssetIssuance` with one of the two null and the other set; the deadcat convention is that every market-creation issuance mints both an asset and its reissuance token. Asymmetric records would not be produced by `build_multi_outcome_market_creation_pset` and would fail covenant-script verification downstream, but the filter rejects them at count time for a clearer error. + +The covenant script is the authoritative binding between N and the creation tx: if the derived `outcome_count` is wrong, the compiled covenant won't match any tx output and ingestion fails loudly. This makes the count-based derivation equivalent in correctness to storing `outcome_count` in the hint, but saves 1 byte and keeps binary and multi-outcome hint layouts unified. + +**v1 support note**: the binary market remains a separate contract family. The multi-outcome contract supports `outcome_count ∈ {3, 4}` in v1; expansion to additional outcome counts later is non-breaking because each supported count gets its own generated contract artifact. ### Order Hint (40 bytes) ``` -Byte 0: [format(4)][side(1)][direction(1)][reserved(2)] -- 8 bits +Byte 0: [class=0x4][side(1)][direction(1)][reserved(2)] -- 8 bits Bytes 1-2: masked_order_index (u16) -- 16 bits Bytes 3-34: market_creation_txid -- 256 bits Bytes 35-37: price (u24, big-endian) -- 24 bits @@ -274,7 +354,7 @@ Byte 39: min_remainder_lots (u8) -- 8 bits | `direction` | Yes (in type_tag) | 1 bit | Not derivable — SellBase or SellQuote | | `order_index` | Yes (masked) | 2 bytes | Needed for key derivation; XOR-masked for privacy | | `market_creation_txid` | Yes | 32 bytes | Chain-only recovery of market params | -| `price` | Yes | 3 bytes | Not derivable — u24, max ~16.8M; bounded by `collateral_per_pair` for rational orders | +| `price` | Yes | 3 bytes | Not derivable — u24, max ~16.8M; bounded by `cp = base_payout × N` for rational orders | | `min_fill_lots` | Yes | 1 byte | Not derivable — u8, range 1-255; baked into covenant script | | `min_remainder_lots` | Yes | 1 byte | Not derivable — u8, range 1-255; baked into covenant script | | `base_asset_id` | No | — | Derivable: `side` + market params → YES or NO asset ID | @@ -282,45 +362,48 @@ Byte 39: min_remainder_lots (u8) -- 8 bits | `maker_receive_spk_hash` | No | — | Derivable: mnemonic → nonce → tweak → P_order → hash | | `maker_pubkey` | No | — | Derivable: mnemonic at `order_index` | -**Builder validation** (returns `CoreError::InvalidParams` if violated): +**Builder validation** (builder returns `CoreError::ConventionViolation`; the pure derive helper returns `ConventionError` for the same class earlier in the flow): - `price <= 0xFFFFFF` (16,777,215) - `min_fill_lots` in range 1-255 - `min_remainder_lots` in range 1-255 - `order_index <= 65535` - Parent market conforms to market conventions -### Pool Hint (41 bytes) +### Pool Hint (40 bytes) ``` -Byte 0: type_tag -- 8 bits -Bytes 1-32: market_creation_txid -- 256 bits -Bits 264-272: max_loss_sats (9 bits: 5 mantissa + 4 exponent) \ -Bits 273-281: half_payout_sats (9 bits: 5 mantissa + 4 exponent) |-- 64 bits = 8 bytes -Bits 282-293: fee_bps (u12) | (exact bit-level packing -Bits 294-309: initial_s_index (u16) | across bytes is an -Bits 310-325: masked_pool_index (u16) | implementation detail) -Bits 326-327: reserved (must be zero) / - Total: 328 bits = 41 bytes +Byte 0: type_tag (`0x30`) -- 8 bits +Bytes 1-32: market_creation_txid -- 256 bits +Byte 33: [max_loss_idx(4)][half_payout_idx(4)] -- 8 bits +Byte 34: fee_bps[11:4] -- 8 bits +Byte 35: [fee_bps[3:0]][initial_s_index[15:12]] -- 8 bits +Byte 36: initial_s_index[11:4] -- 8 bits +Byte 37: [initial_s_index[3:0]][masked_pool_index[15:12]] -- 8 bits +Byte 38: masked_pool_index[11:4] -- 8 bits +Byte 39: [masked_pool_index[3:0]][reserved=0] -- 8 bits + Total: 320 bits = 40 bytes ``` +Within each bracketed byte, the first nibble is the high nibble and the second nibble is the low nibble. + **Per-field justification:** | Field | In hint | Size | Justification | |---|---|---|---| | `market_creation_txid` | Yes | 32 bytes | Chain-only recovery of market params | -| `max_loss_sats` | Yes (encoded) | 9 bits | Not derivable — 26-value mantissa x 10^exp | -| `half_payout_sats` | Yes (encoded) | 9 bits | Not derivable — same encoding | +| `max_loss_sats` | Yes (indexed) | 4 bits | Not derivable — 16-value 1-2-5 table (shared with market `base_payout` encoding) | +| `half_payout_sats` | Yes (indexed) | 4 bits | Not derivable — same 1-2-5 table | | `fee_bps` | Yes | 12 bits | Not derivable — u12, 0.01% granularity, max 40.95% | | `initial_s_index` | Yes | 16 bits | Not derivable without brute-force script matching (EC scalar mul per candidate); enables direct script verification during creation-tx ingestion | | `pool_index` | Yes (masked) | 16 bits | Needed for admin key derivation; XOR-masked | | `yes/no/collateral_asset_id` | No | — | Derivable from parent market params | -| `lmsr_table_root` | No | — | Derivable via deterministic table generation from `max_loss_sats` + `half_payout_sats` | +| `lmsr_table_root` | No | — | Derivable via deterministic table generation from `max_loss_sats` + `half_payout_sats` (see [lmsr-deterministic-table-spec.md](../contracts/lmsr-pool/lmsr-deterministic-table-spec.md)) | | `q_step_lots` | No | — | Derivable from `b` and `half_payout_sats` | | `admin_pubkey` | No | — | Derivable from mnemonic at `pool_index` | | Protocol constants | No | — | `TABLE_DEPTH`, `S_BIAS`, `S_MAX_INDEX`, `MIN_POOL_RESERVE` are fixed in the `.simf` | -**Builder validation:** -- `max_loss_sats` and `half_payout_sats` must be in the 26-value mantissa x exponent set +**Builder validation** (builder returns `CoreError::ConventionViolation`; the pure derive helper returns `ConventionError` for the same class earlier in the flow): +- `max_loss_sats` and `half_payout_sats` must be valid indices into the 16-value 1-2-5 table - `fee_bps <= 4095` (40.95%) - `initial_s_index <= 65535` - `pool_index <= 65535` diff --git a/docs/protocol/deterministic-rt-blinding.md b/docs/protocol/deterministic-rt-blinding.md index 3bea3566..1b6c6c19 100644 --- a/docs/protocol/deterministic-rt-blinding.md +++ b/docs/protocol/deterministic-rt-blinding.md @@ -19,20 +19,29 @@ The covenant must enforce that new RT outputs use deterministic blinding, making ### Creation Transaction (Initial RT Outputs) -The market creation transaction issues two reissuance tokens (YES and NO). Their blinding factors are derived via BIP-340-style tagged hashes from the defining outpoints: +The market creation transaction issues one reissuance token per outcome-side leg: +- **Binary market**: 2 legs total (`YES_0`, `NO_0`) +- **Multi-outcome market**: `2N` legs total (`YES_0`, `NO_0`, `YES_1`, `NO_1`, ..., `YES_{N-1}`, `NO_{N-1}`) + +For multi-outcome markets, the defining-input order is canonical: + +``` +input 2k = YES_k defining input +input 2k + 1 = NO_k defining input ``` -YES_RT_ABF = tagged_hash("deadcat/rt_abf", yes_defining_outpoint) -NO_RT_ABF = tagged_hash("deadcat/rt_abf", no_defining_outpoint) -YES_RT_VBF = tagged_hash("deadcat/rt_vbf", yes_defining_outpoint) -NO_RT_VBF = tagged_hash("deadcat/rt_vbf", no_defining_outpoint) +Each leg's RT blinding factors are derived via BIP-340-style tagged hashes from that leg's defining outpoint: + +``` +RT_ABF(leg) = tagged_hash("deadcat/rt_abf", leg_defining_outpoint) +RT_VBF(leg) = tagged_hash("deadcat/rt_vbf", leg_defining_outpoint) ``` Where: - `tagged_hash(tag, data) = SHA256(SHA256(tag) || SHA256(tag) || data)` (BIP-340 convention) -- `yes_defining_outpoint` — serialized outpoint of input 0 of the creation transaction (YES defining UTXO) -- `no_defining_outpoint` — serialized outpoint of input 1 of the creation transaction (NO defining UTXO) +- `leg_defining_outpoint` is the serialized outpoint of the defining input for that specific leg +- For binary markets, the canonical order is simply: input 0 = `YES_0`, input 1 = `NO_0` RT outputs always hold exactly 1 satoshi. Both ABF and VBF are publicly derivable. @@ -47,28 +56,29 @@ where cbf = v * abf + vbf = abf + vbf (for v = 1) The **combined blinding factor** `cbf = abf + vbf` (mod secp256k1 group order) is the quantity that must balance across inputs and outputs. This is the key insight for the cross-transition blinding scheme. -At creation time: +At creation time, each leg gets its own constant combined blinding factor: + ``` -YES_RT_CBF = YES_RT_ABF + YES_RT_VBF (mod n) -NO_RT_CBF = NO_RT_ABF + NO_RT_VBF (mod n) +RT_CBF(leg) = RT_ABF(leg) + RT_VBF(leg) (mod n) ``` ### Subsequent Transitions (CBF Pass-Through) -For all transitions that produce new RT outputs (issuance, cancellation), the blinding scheme is: +For all transitions that produce new RT outputs (issuance, cancellation), the blinding scheme is applied **per RT leg**: - **ABF**: Independently deterministic per transition — derived from the input outpoint being consumed: ``` - out_abf = tagged_hash("deadcat/rt_abf", spent_rt_outpoint) + out_abf(leg) = tagged_hash("deadcat/rt_abf", spent_rt_outpoint(leg)) ``` -- **CBF**: Passed through unchanged from the input: `out_cbf = in_cbf` -- **VBF**: Implied: `out_vbf = out_cbf - out_abf` (mod n). Not stored or transmitted — computed by `deadcat-core` in Rust when needed for PSET construction. +- **CBF**: Passed through unchanged from that same leg's input: `out_cbf(leg) = in_cbf(leg)` +- **VBF**: Implied: `out_vbf(leg) = out_cbf(leg) - out_abf(leg)` (mod n). Not stored or transmitted — computed by `deadcat-core` in Rust when needed for PSET construction. + +The CBF is constant for the entire lifetime of each RT leg. It is set at creation time and never changes. The ABF changes on every transition (different input outpoint → different hash), and the VBF adjusts accordingly to maintain the same CBF for that leg. -The CBF is constant for the entire lifetime of each RT (YES and NO independently). It is set at creation time and never changes. The ABF changes on every transition (different input outpoint → different hash), and the VBF adjusts accordingly to maintain the same CBF. +**Why CBF pass-through self-balances**: since `out_cbf(leg) = in_cbf(leg)` for every continuing RT leg: -**Why CBF pass-through self-balances**: Since `out_cbf = in_cbf` for both YES and NO RTs: ``` -cbf_yes_out + cbf_no_out = cbf_yes_in + cbf_no_in ✓ (identical values) +Σ_legs cbf_out(leg) = Σ_legs cbf_in(leg) ✓ ``` The RT portion of the blinding factor balance always holds, regardless of how many or few blinded wallet outputs the transaction has. The wallet's `blind_last` handles the wallet-side balance independently. This means: @@ -86,10 +96,10 @@ The RT portion of the blinding factor balance always holds, regardless of how ma To reconstruct blinding factors for any RT output in the market's history: 1. Find the market creation tx (via OP_RETURN or `issuance_transaction`) -2. Derive creation ABFs and VBFs from the defining outpoints (tagged hashes) -3. Compute `cbf = abf + vbf` (mod n) at creation time — this is constant forever -4. For any specific RT output: `abf = tagged_hash("deadcat/rt_abf", input_outpoint_that_created_it)` — derivable from chain -5. `vbf = cbf - abf` (mod n) — simple modular arithmetic in Rust +2. Derive creation ABFs and VBFs for every RT leg from the canonical defining-input order and the tagged hashes +3. Compute `cbf(leg) = abf(leg) + vbf(leg)` (mod n) at creation time — constant forever for that leg +4. For any specific RT output, derive `abf = tagged_hash("deadcat/rt_abf", input_outpoint_that_created_it)` — derivable from chain +5. Recover `vbf = cbf(leg) - abf` (mod n) — simple modular arithmetic in Rust No witness parsing needed for VBF recovery. No chain of derivations to follow. One-shot CBF computation from creation data, then ABF + modular subtraction for any specific output. @@ -145,21 +155,17 @@ fn compute_deterministic_abf(input_index: u32) -> u256 { } ``` -**Enforce CBF pass-through** by using the verified input CBF directly as the output CBF: +**Enforce CBF pass-through** by using the verified input CBF directly as the output CBF for each RT leg: ```simplicity // In issuance/cancellation paths: -// 1. Verify input RT commitments (ABF + CBF from witness) -verify_input_rt(0, YES_RT, yes_in_abf, yes_in_cbf); -verify_input_rt(1, NO_RT, no_in_abf, no_in_cbf); - -// 2. Compute deterministic output ABFs -let yes_out_abf: u256 = compute_deterministic_abf(0); -let no_out_abf: u256 = compute_deterministic_abf(1); - -// 3. CBF passes through — covenant enforces this -verify_output_rt(0, YES_RT, yes_out_abf, yes_in_cbf); // cbf_out = cbf_in -verify_output_rt(1, NO_RT, no_out_abf, no_in_cbf); // cbf_out = cbf_in +// Canonical leg order: +// binary: [YES_0, NO_0] +// multi-outcome: [YES_0, NO_0, YES_1, NO_1, ..., YES_{N-1}, NO_{N-1}] +for each continuing RT leg `leg` with input index `in_idx` and continuation output index `out_idx`: + verify_input_rt(in_idx, token_id(leg), in_abf(leg), in_cbf(leg)); + let out_abf: u256 = compute_deterministic_abf(in_idx); + verify_output_rt(out_idx, token_id(leg), out_abf, in_cbf(leg)); // cbf_out = cbf_in ``` All required jets are already in use in the existing covenant: `input_prev_outpoint`, SHA256 context APIs, `generate`, `gej_ge_add`, `gej_normalize`, `eq_256`. No new Simplicity capabilities required. @@ -186,7 +192,7 @@ The `BlindingQuad` type `(in_abf, in_vbf, out_abf, out_vbf)` shrinks. Output bli These transitions use `ensure_blinded_reissuance_burn_output`, which verifies the output commitment matches the expected RT asset (same `verify_token_commitment` pattern) AND verifies the output script is the burn script. This is security-critical: deterministic blinding makes ABFs public, so the traditional Elements safeguard (ABF secrecy prevents unauthorized reissuance) is absent. Without covenant-enforced burns, a malicious transaction builder could redirect RT tokens to a wallet address and use the Elements consensus-level reissuance mechanism to mint unbacked tokens — bypassing the Simplicity covenant entirely. -**Dormant terminal transitions** (resolution/expiry from zero outstanding pairs) consume both DormantRT slots with no outputs. These are specified in [market-dormant-terminal-paths.md](../contracts/prediction-market/market-dormant-terminal-paths.md) and will also require burn output enforcement — to be added when those paths are implemented. +**Dormant terminal transitions** (resolution/expiry from zero outstanding pairs) consume both DormantRT slots with no covenant continuation outputs. They still require covenant-enforced RT burn outputs at the unspendable burn script, for the same reason as non-dormant resolution and expiry. These are specified in [market-dormant-terminal-paths.md](../contracts/prediction-market/market-dormant-terminal-paths.md). ### What the Covenant Does NOT Enforce @@ -200,14 +206,14 @@ Since `blind_last` and `blind_non_last` always generate random `AssetBlindingFac ### Creation PSET Builder -**Current flow** (`build_creation_pset` + blinding in `sdk.rs`): +**Current flow** (`build_binary_market_creation_pset` + blinding in `sdk.rs`): 1. Creates RT outputs as unblinded placeholders -2. Marks outputs 0, 1 with `blinding_key` for `blind_last` +2. Marks the RT outputs with `blinding_key` for `blind_last` 3. `blind_last` generates random ABFs/VBFs, constructs Pedersen commitments, range proofs, surjection proofs **New flow**: 1. Create RT outputs as unblinded placeholders (same as before) -2. **Do NOT set `blinding_key`** on outputs 0, 1 — exclude them from `blind_last` +2. **Do NOT set `blinding_key`** on the RT outputs — exclude them from `blind_last` 3. Compute deterministic ABFs/VBFs from the defining outpoints via tagged hash 4. For each RT output, manually: - Construct the blinded asset generator: `secp256k1_generator_generate_blinded(token_asset_id, abf)` @@ -248,20 +254,20 @@ Same hand-rolled blinding for RT outputs, but the ABF is derived from the input ### Nostr Announcement Format **Current**: Includes `PredictionMarketAnchor` payload (creation_txid + 4 blinding factors) -**New**: Drops anchor entirely — only `PredictionMarketParams` + creation_txid needed. Discoverers derive everything from public on-chain data. +**New**: Drops anchor entirely — only `BinaryMarketParams` + creation_txid needed. Discoverers derive everything from public on-chain data. ## Functions Affected | Function | Current | After | | -------- | ------- | ----- | -| `build_creation_pset` | Marks RT outputs for `blind_last` | Manually blinds RT outputs with deterministic ABFs/VBFs | +| `build_binary_market_creation_pset` | Marks RT outputs for `blind_last` | Manually blinds RT outputs with deterministic ABFs/VBFs | | `build_issuance_pset` | Marks RT outputs for `blind_last` | Manually blinds with deterministic ABF + CBF-derived VBF | | `build_cancellation_pset` | Marks RT outputs for `blind_last` | Same as issuance | | `recover_creation_anchor` | Extracts blinding factors from blinded outputs | **Eliminated** | | `compute_issuance_entropy` | Takes ABFs from anchor | Derives ABFs from defining outpoints | | `validate_prediction_market_creation_tx` | Uses anchor blinding factors for verification | Derives blinding factors from creation tx | | Market Nostr announcement | Includes anchor payload | Drops anchor — only params + creation_txid | -| `ingest_market` (deadcat-core) | Takes `PredictionMarketParams` + `PredictionMarketAnchor` + `ChainTransaction` | Takes `PredictionMarketParams` + `ChainTransaction` only | +| `ingest_market` (deadcat-core) | Takes `BinaryMarketParams` + `PredictionMarketAnchor` + `ChainTransaction` | Takes `BinaryMarketParams` + `ChainTransaction` only | | `verify_token_commitment` (.simf) | Takes `(ABF, VBF)` from witness | Takes `(ABF, CBF)`, output ABF computed by covenant | | Issuance/cancellation paths (.simf) | Output blinding from free witness data | Output ABF enforced deterministic, CBF passed through | @@ -274,13 +280,11 @@ Same hand-rolled blinding for RT outputs, but the ABF is derived from the input ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — `verify_token_commitment` refactor, deterministic ABF computation, CBF pass-through enforcement in issuance/cancellation paths -- `src-tauri/crates/deadcat-sdk/src/prediction_market/pset/creation.rs` — creation PSET builder -- `src-tauri/crates/deadcat-sdk/src/prediction_market/assembly.rs` — `compute_issuance_entropy()`, blinding -- `src-tauri/crates/deadcat-sdk/src/prediction_market/anchor.rs` — **to be removed entirely** -- `src-tauri/crates/deadcat-sdk/src/prediction_market_scan.rs` — market validation -- `src-tauri/crates/deadcat-sdk/src/sdk.rs` — market creation flow, anchor recovery -- `src-tauri/crates/deadcat-sdk/src/announcement.rs` — Nostr announcement format +- `crates/deadcat-core/contracts/prediction_market.simf` — `verify_token_commitment` refactor, deterministic ABF computation, CBF pass-through enforcement in issuance/cancellation paths +- `crates/deadcat-core` market PSET builders — creation / issuance / cancellation RT blinding integration +- `crates/deadcat-core` market assembly logic — issuance entropy derivation and deterministic blinding support +- `crates/deadcat-core` market ingestion / validation path — creation verification and RT factor recovery +- wallet-layer announcement formats — updated to drop legacy anchor distribution entirely ## Impact on deadcat-core API @@ -290,7 +294,7 @@ The anchor elimination simplifies the `ingest_market` API: // Before: 3 parameters pub fn ingest_market( &mut self, - params: &PredictionMarketParams, + params: &BinaryMarketParams, anchor: PredictionMarketAnchor, creation_tx: &ChainTransaction, ) -> Result>; @@ -298,9 +302,9 @@ pub fn ingest_market( // After: 2 parameters pub fn ingest_market( &mut self, - params: &PredictionMarketParams, + params: &BinaryMarketParams, creation_tx: &ChainTransaction, ) -> Result>; ``` -`PredictionMarketParams` stays pure — only data needed to derive the contract's identity and addresses. No creation-time secrets. The `PredictionMarketAnchor` type and the `anchor` field on `Contract::PredictionMarket` are both eliminated. See [deadcat-core design doc](../architecture/deadcat-core-design.md) for the full API. +`BinaryMarketParams` stays pure — only data needed to derive the contract's identity and addresses. No creation-time secrets. The `PredictionMarketAnchor` type and the `anchor` field on `Contract::PredictionMarket` are both eliminated. See [deadcat-core design doc](../architecture/deadcat-core-design.md) for the full API. diff --git a/docs/protocol/oracle-bip340-tagged-hash.md b/docs/protocol/oracle-bip340-tagged-hash.md index b4c99fee..8f3e53d3 100644 --- a/docs/protocol/oracle-bip340-tagged-hash.md +++ b/docs/protocol/oracle-bip340-tagged-hash.md @@ -1,4 +1,6 @@ -# Oracle Attestation: BIP-340 Tagged Hash Migration +# Oracle Attestation: BIP-340 Tagged Hash + +This document is the authoritative oracle-attestation specification for **both** Deadcat market kinds: the binary prediction market and the multi-outcome market. Both use the same BIP-340 tagged-hash domain string and the same high-level message structure; they differ only in how `market_id` and `outcome_byte` are derived. ## Problem @@ -8,7 +10,7 @@ The current oracle signature scheme uses a plain SHA256 hash for the attestation message = SHA256(market_id || outcome_byte) ``` -Where `market_id = SHA256(yes_token_asset_id || no_token_asset_id)` and `outcome_byte` is `0x01` (YES) or `0x00` (NO). +Where `market_id` identifies the market and `outcome_byte` identifies the attested resolution. This lacks domain separation. If the oracle's signing key is used in another context that also signs `SHA256(32_bytes || 1_byte)`, a signature from that context could theoretically satisfy the covenant. Per BIP-340's rationale: "without tagged hashing a BIP340 signature could also be valid for a signature scheme where the only difference is that the arguments to the hash function are reordered." @@ -21,20 +23,72 @@ message = SHA256(SHA256("deadcat/oracle_attestation") || SHA256("deadcat/oracle_ ``` Where: -- `market_id = SHA256(yes_token_asset_id || no_token_asset_id)` — unchanged -- `outcome_byte` is `0x01` for YES, `0x00` for NO — unchanged -- The tag `"deadcat/oracle_attestation"` is UTF-8 encoded -- The double `SHA256(tag)` prefix follows the BIP-340 tagged hash convention +- The tag `"deadcat/oracle_attestation"` is UTF-8 encoded. +- `market_id` is the covenant-internal market identifier derived from the market's token asset IDs. +- `outcome_byte` is the one-byte resolution encoding for the specific market kind. +- The double `SHA256(tag)` prefix follows the BIP-340 tagged hash convention. The BIP-340 tagged hash construction prefixes the data with `SHA256(tag) || SHA256(tag)` (64 bytes). This: 1. Creates a domain-separated hash function that cannot collide with untagged SHA256 or other tagged hashes with different tags 2. Fills exactly one SHA-256 block (64 bytes), enabling an optimization: implementations can precompute the SHA-256 internal state after the first block and reuse it for every call with the same tag -## Impact on Simplicity Covenant +## Message Format + +### Shared tagged-hash rule + +Both market kinds sign the same tagged-hash envelope: + +``` +message = tagged_hash("deadcat/oracle_attestation", market_id || outcome_byte) + = SHA256(SHA256("deadcat/oracle_attestation") || SHA256("deadcat/oracle_attestation") || market_id || outcome_byte) +``` + +### Binary market + +``` +market_id = SHA256(yes_token_asset_id || no_token_asset_id) +outcome_byte = 0x01 for YES, 0x00 for NO +``` + +Binary markets have one outcome and two sides. Oracle resolution therefore encodes the winning **side**, not an outcome index. + +### Multi-outcome market + +``` +market_id = SHA256(yes_token_asset_ids[0] || no_token_asset_ids[0] + || yes_token_asset_ids[1] || no_token_asset_ids[1] + || ... + || yes_token_asset_ids[N-1] || no_token_asset_ids[N-1]) +outcome_byte = outcome_index as u8, in range [0, N-1] +``` + +Multi-outcome markets have `N` outcomes and encode the winning **outcome index** directly. The tag string is unchanged; domain separation between binary and multi-outcome markets comes from the different `market_id` derivation. + +## Impact on Simplicity Covenants + +### Affected function shape + +The binary and multi-outcome market contracts both implement the same high-level rule: + +```simplicity +fn verify_oracle_signature(outcome_byte: u8, signature: Signature) { + let market_id: u256 = compute_market_id(); + let tag_hash: u256 = /* precomputed SHA256("deadcat/oracle_attestation") */; + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag_hash); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, tag_hash); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); + let message: u256 = jet::sha_256_ctx_8_finalize(ctx); + jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); +} +``` + +The only market-specific part is how `compute_market_id()` and `outcome_byte` are formed. -### Affected Function +### Binary-market before/after example -`verify_oracle_signature` in `prediction_market.simf` (currently lines 248-259): +`verify_oracle_signature` in `prediction_market.simf` changes from the old plain SHA256 rule to the tagged-hash rule: ```simplicity // Before @@ -74,31 +128,30 @@ fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { } ``` -Note: The `SHA256("deadcat/oracle_attestation")` tag hash is a constant (deterministic from the tag string). It can be pre-computed and embedded as a literal in the `.simf` source to avoid hashing the tag string at runtime. +Note: `SHA256("deadcat/oracle_attestation")` is a constant. Implementations can precompute it and embed it directly in the `.simf` source. ### No Other Covenant Changes - The oracle public key parameter (`ORACLE_PUBLIC_KEY`) is unchanged - The signature format (64-byte BIP-340 Schnorr) is unchanged -- The `compute_market_id()` function is unchanged -- The outcome byte encoding is unchanged +- The `compute_market_id()` logic for each contract kind is unchanged except for being documented here as part of the shared protocol +- The binary and multi-outcome outcome-byte encodings are unchanged ## Impact on deadcat-core -### Standalone Function +### Canonical standalone function ```rust /// Returns the 32-byte message an oracle must BIP-340 sign to attest to a market outcome. -/// Uses tagged hash: SHA256(SHA256("deadcat/oracle_attestation") || SHA256("deadcat/oracle_attestation") || market_id || outcome_byte) -/// where market_id = SHA256(yes_token_asset_id || no_token_asset_id). +/// Uses tagged hash over (market_id || outcome_byte), where both values are already +/// specific to the target market kind. pub fn oracle_attestation_message( - yes_asset_id: &AssetId, - no_asset_id: &AssetId, - outcome_yes: bool, + market_id: MarketId, + resolution: MarketResolution, ) -> [u8; 32]; ``` -This replaces the existing `oracle_message` function in the SDK (`prediction_market/oracle.rs`). +This is the protocol-level helper. Binary-specific convenience helpers may still exist, but they are specializations of this general rule rather than the other way around. ### Engine Convenience Method @@ -106,18 +159,21 @@ This replaces the existing `oracle_message` function in the SDK (`prediction_mar pub fn oracle_attestation_spec( &self, contract_id: &ContractId, - outcome_yes: bool, + resolution: MarketResolution, ) -> Result>; pub struct OracleAttestationSpec { + pub market_id: MarketId, + pub resolution: MarketResolution, pub message: [u8; 32], pub oracle_pubkey: XOnlyPublicKey, } ``` -Looks up the market's params from the store, calls `oracle_attestation_message` internally, returns both the message and the expected oracle public key. +Looks up the market's params from the store, derives the correct `market_id`, validates that the resolution variant matches the market kind, then calls `oracle_attestation_message` internally. ## Key Files -- `src-tauri/crates/deadcat-sdk/contract/prediction_market.simf` — `verify_oracle_signature` function -- `src-tauri/crates/deadcat-sdk/src/prediction_market/oracle.rs` — `oracle_message` function (to be updated) +- `crates/deadcat-core/contracts/prediction_market.simf` — binary market oracle verification +- `crates/deadcat-core/contracts/multi_outcome/*.simf` — generated multi-outcome market oracle verification +- `crates/deadcat-core` market-oracle helper module — Rust-side attestation helper implementation target diff --git a/docs/ux/design.md b/docs/ux/design.md index 7d8c24d7..dff13796 100644 --- a/docs/ux/design.md +++ b/docs/ux/design.md @@ -17,7 +17,7 @@ The primary design goal: make covenant-based prediction markets feel as intuitiv ### 1. Protocol Complexity is an Implementation Detail -The user's mental model is: "I think YES is likely, so I buy YES tokens." They should never encounter terms like `CovenantPhase`, `SlotType`, `UnblindedPset`, or `s_index`. The UI translates protocol concepts into trading concepts: +The user's mental model is: "I think YES is likely, so I buy YES tokens." They should never encounter terms like `CovenantPhase`, `SlotType`, `PreBlindedPset`, or `s_index`. The UI translates protocol concepts into trading concepts: | Protocol concept | User-facing concept | | --- | --- | @@ -93,27 +93,27 @@ The most common user. Browses markets, buys/sells YES or NO tokens through the t ### Market Creator -Creates prediction markets via `build_creation_pset`. Defines the question, oracle, collateral asset, settlement date. Also issues initial token pairs via `build_issuance_pset`. May also act as Trader. Requires "Market maker mode" enabled. +Creates prediction markets via `build_binary_market_creation_pset` or `build_multi_outcome_market_creation_pset`. Defines the question, oracle, collateral asset, settlement date. Also issues initial token pairs via `build_issuance_pset`. May also act as Trader. Requires "Market maker mode" enabled. **Key UI surfaces**: Create Market form, Detail (issue/cancel tabs), Home (My Markets filter) -**Core API touchpoints**: `build_creation_pset`, `build_issuance_pset`, `build_cancellation_pset`, `ingest_market` +**Core API touchpoints**: `build_binary_market_creation_pset`, `build_multi_outcome_market_creation_pset`, `build_issuance_pset`, `build_cancellation_pset`, `ingest_market` ### Pool Operator -Creates and manages LMSR liquidity pools via `build_lmsr_bootstrap_pset`. Adjusts liquidity (`build_lmsr_adjust_pset`), monitors reserves, closes pools (`build_lmsr_close_pset`). Ingests pools via `PoolSnapshot::Creation` (needs full history for fee revenue tracking). Requires "Market maker mode" enabled. +Creates and manages LMSR liquidity pools via `build_lmsr_bootstrap_pset`. Adjusts liquidity (`Pool::build_adjust_pset`), monitors reserves, closes pools (`Pool::build_close_pset`). Ingests pools via `PoolSnapshot::Creation` (needs full history for fee revenue tracking). Requires "Market maker mode" enabled. **Key UI surfaces**: Pool management panel, Detail (pool reserves display), Home (My Pools) -**Core API touchpoints**: `derive_pool_params`, `estimate_bootstrap`, `build_lmsr_bootstrap_pset`, `build_lmsr_adjust_pset`, `build_lmsr_close_pset`, `pool_history` +**Core API touchpoints**: `derive_pool_params`, `estimate_bootstrap`, `build_lmsr_bootstrap_pset`, `Pool::build_adjust_pset`, `Pool::build_close_pset`, `pool_history` ### Order Maker -Places limit orders via `build_create_order_pset`. Monitors fill progress (`OrderState::Active { total_filled }`). Cancels unfilled orders (`build_cancel_order_pset`). Ingests own orders via `OrderSnapshot::Creation` (needs fill history). Requires "Market maker mode" enabled. +Places limit orders via `build_create_order_pset`. Monitors fill progress (`OrderState::Active { total_filled }`). Cancels unfilled orders (`Order::build_cancel_pset`). Ingests own orders via `OrderSnapshot::Creation` (needs fill history). Requires "Market maker mode" enabled. **Key UI surfaces**: Detail (limit order composer), My Orders list, Order fill notifications -**Core API touchpoints**: `derive_order_params`, `build_create_order_pset`, `build_cancel_order_pset`, `order_history` +**Core API touchpoints**: `derive_order_params`, `build_create_order_pset`, `Order::build_cancel_pset`, `order_history` ### Oracle diff --git a/docs/ux/stories/creator.md b/docs/ux/stories/creator.md index a9113dc1..ed52f442 100644 --- a/docs/ux/stories/creator.md +++ b/docs/ux/stories/creator.md @@ -10,9 +10,9 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig **Acceptance criteria**: - Create form collects: question text, description, category, resolution source, oracle pubkey (defaults to own Nostr pubkey), collateral asset (L-BTC default), collateral per pair (constrained to the 16-value 1-2-5 denomination table per convention), settlement date/time -- Settlement date input snaps to the nearest 60-block boundary (matching the `expiry_time` covenant convention) -- On submit: constructs `MarketCreationParams { oracle_public_key, collateral_asset_id, collateral_per_pair, expiry_time }` → `build_creation_pset` → sign → broadcast -- `build_creation_pset` returns `(UnblindedPset, PredictionMarketParams)` — the UI stores the returned full params for ingestion after confirmation +- Settlement date input rounds up to the next 60-block boundary (matching the `expiry_time` covenant convention) +- On submit: constructs `MarketCreationParams { oracle_public_key, collateral_asset_id, collateral_per_pair, expiry_time }` → `build_binary_market_creation_pset` → prepare/blind → sign → broadcast +- `build_binary_market_creation_pset` returns `(PreBlindedPset, BinaryMarketParams)` — the UI stores the returned full params for ingestion after confirmation - After confirmation: `ingest_market(params, creation_tx)` to begin tracking, then publishes a Nostr announcement event for discovery - On `CoreError::InvalidParams`: display specific validation error (e.g., "Collateral per pair must be one of: 1000, 2000, 5000, 10000...") - Convention violations caught by the builder (defense in depth) surface as user-friendly messages @@ -20,7 +20,7 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig **Interaction design**: - **Guided form**: Single-page form with clear sections. Question at top (large text input), description below (textarea), then parameters in a structured grid. - **CPT selector**: Dropdown constrained to valid 1-2-5 denominations (1000, 2000, 5000, 10000, 20000, 50000 sats etc.). Not a free-text input — impossible to enter non-conforming values. -- **Settlement picker**: Calendar + time picker. The selected datetime is converted to an estimated block height using current chain tip + ~1 min/block. The snapped block height is shown: "Settles around block 2,150,400 (~June 15, 2027)." +- **Settlement picker**: Calendar + time picker. The selected datetime is converted to an estimated block height using current chain tip + ~1 min/block. The rounded-up block height is shown: "Settles around block 2,150,400 (~June 15, 2027)." - **Oracle default**: Pre-fills with the user's own Nostr pubkey. Advanced users can paste a different oracle's pubkey. The form validates it's a valid 32-byte hex or npub. - **Cost preview**: Before submission, show estimated creation cost: "Transaction fee: ~X sats. This creates the market contract on-chain." - **Post-creation flow**: After the market is created and confirmed, prompt: "Issue initial token pairs?" This naturally leads to US-MC2. @@ -33,15 +33,15 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig **Acceptance criteria**: - Issue tab (on detail view, only visible for markets the user created) shows: pairs to issue input, collateral required (`pairs * collateral_per_pair`), current outstanding pairs -- Calls `build_issuance_pset(contract_id, pairs, yes_dest, no_dest, funding)` — returns `UnblindedPset` -- The UI calls `unblinded.prepare(wallet_blinding_pubkey)` then `pset.blind_last()` then sign (RT blinding is handled transparently) +- Calls `build_issuance_pset(contract_id, pairs, yes_dest, no_dest, funding)` — returns `PreBlindedPset` +- The UI calls `pre_blinded.prepare(wallet_blinding_pubkey)` then `pset.blind_last()` then sign (RT blinding is handled transparently) - After confirmation via `step`: wallet shows new YES and NO token balances - `yes_dest` and `no_dest` default to the wallet's own addresses (the user typically keeps both sides initially) **Interaction design**: - **Pairs input**: Numeric input with real-time cost calculation: "Issue 100 pairs = lock 500,000 sats as collateral. You'll receive 100 YES + 100 NO tokens." - **Destination choice**: By default, both token types go to the user's wallet. Advanced option (collapsed) to specify separate destinations (e.g., send NO tokens directly to a pool). -- **Blinding transparency**: The `UnblindedPset` → `prepare` → `blind_last` → sign flow is invisible to the user. They click "Issue" and see a success message. The RT blinding complexity is an implementation detail per Design Principle 1. +- **Blinding transparency**: The `PreBlindedPset` → `prepare` → `blind_last` → sign flow is invisible to the user. They click "Issue" and see a success message. The RT blinding complexity is an implementation detail per Design Principle 1. --- @@ -51,7 +51,7 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig **Acceptance criteria**: - Cancel tab shows: pairs to burn input (max = min of YES and NO token balances), collateral to reclaim (`pairs * collateral_per_pair`) -- `build_cancellation_pset(contract_id, Some(pairs), funding)` or `None` for max cancellation +- `build_cancellation_pset(contract_id, Some(pairs), funding)` or `None` for max cancellation → prepare/blind → sign → broadcast - Requires equal YES and NO token balances — if the user sold some of one side, they can only cancel pairs up to the lesser balance - After confirmation: wallet shows reduced token balances and increased L-BTC balance @@ -70,7 +70,7 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig - Two buttons: "Resolve YES" and "Resolve NO" - Clicking either triggers: `oracle_attestation_spec(contract_id, outcome_yes)` → returns `OracleAttestationSpec { message, oracle_pubkey }` - The app signs the message with the user's Nostr key (BIP-340 Schnorr signature) -- Then: `build_oracle_resolve_pset(contract_id, signature, funding)` → sign → broadcast +- Then: `build_oracle_resolve_pset(contract_id, signature, funding)` → prepare/blind → sign → broadcast - Also publishes the attestation as a Nostr event for public verifiability - After confirmation: market state transitions to `ResolvedYes` or `ResolvedNo` @@ -88,7 +88,7 @@ Personas covered: **Market Creator** and **Oracle**. See [ux-design.md](../desig **Acceptance criteria**: - Expire button appears when `current_block_height >= market.expiry_height` AND market state is `Trading` -- Calls `build_expire_transition_pset(contract_id, funding)` → sign → broadcast +- Calls `build_expire_transition_pset(contract_id, funding)` → prepare/blind → sign → broadcast - After confirmation: market state transitions to `Expired` - Any user can trigger expiry (not oracle-restricted) — the covenant enforces the timelock diff --git a/docs/ux/stories/operator.md b/docs/ux/stories/operator.md index 78bf7447..b5e4e37e 100644 --- a/docs/ux/stories/operator.md +++ b/docs/ux/stories/operator.md @@ -10,16 +10,17 @@ Personas covered: **Pool Operator** and **Order Maker**. See [ux-design.md](../d **Acceptance criteria**: - Pool creation form (accessible from market detail in maker mode) collects: max loss (sats), half payout (sats), fee (bps), starting price (bps) -- `estimate_bootstrap(max_loss_sats, half_payout_sats, starting_price_bps)` shows required capital: initial YES reserve, initial NO reserve, initial collateral reserve -- `derive_pool_params(xprv, market_params, pool_index, max_loss_sats, half_payout_sats, fee_bps, starting_price_bps)` generates params + masked index -- On `ConventionError`: display which constraint was violated (e.g., "max_loss_sats must fit the 26-value mantissa encoding") -- `build_lmsr_bootstrap_pset(params, starting_price_bps, masked_index, funding)` → sign → broadcast +- `estimate_bootstrap(max_loss_sats, half_payout_sats, starting_price_bps)` shows the canonical default bootstrap: initial YES reserve, initial NO reserve, initial collateral reserve — and returns the `initial_s_index` corresponding to the chosen `starting_price_bps` +- `derive_pool_params(xprv, market_params, outcome, pool_index, max_loss_sats, half_payout_sats, fee_bps, initial_s_index)` generates params + masked index. For binary markets, pass `OutcomeIndex::BINARY`; for multi-outcome, pass the outcome this pool serves. +- On `ConventionError`: display which constraint was violated (e.g., "max_loss_sats must be one of the 16 values in the 1-2-5 table") +- Pool creation lets the operator accept that default or override it with explicit `initial_reserves` +- `build_lmsr_bootstrap_pset(params, initial_s_index, initial_reserves, masked_index, funding)` → sign → broadcast - After confirmation: `ingest_pool(params, PoolSnapshot::Creation(creation_tx))` to begin tracking with full history **Interaction design**: -- **Capital preview**: Before creating, `estimate_bootstrap` shows a breakdown: "To start a pool at 50% YES price with 100k sats max loss, you need: 50 YES tokens, 50 NO tokens, 95,000 sats collateral. Total capital: ~195,000 sats." This helps the operator plan capital acquisition (they may need to issue token pairs first). -- **Parameter constraints**: All inputs are constrained to convention-valid values. Max loss and half payout use dropdowns or validated inputs matching the 26-value mantissa set. Fee uses a slider (0-40.95%, 0.01% steps). Starting price uses a slider (1-99%). -- **Risk disclosure**: Show max loss prominently: "Your maximum possible loss from this pool is X sats. This occurs if the price moves from Y% to 0% or 100%." +- **Capital preview**: Before creating, `estimate_bootstrap` shows a breakdown of the canonical default bootstrap and labels it clearly as a recommendation, not a hard requirement. Example: "Default bootstrap (useful 0.1%-99.9% band): X YES tokens, Y NO tokens, Z sats collateral." This helps the operator plan capital acquisition while still allowing explicit over-funding or under-funding. +- **Parameter constraints**: All inputs are constrained to convention-valid values. Max loss and half payout use dropdowns constrained to the 16-value 1-2-5 table (shared with market `base_payout` encoding). Fee uses a slider (0-40.95%, 0.01% steps). Starting price uses a slider (1-99%). +- **Risk disclosure**: Show both the curve parameter and the funded band clearly: "Full-curve LMSR loss parameter: X sats. Default bootstrap funds the useful 0.1%-99.9% band; adding more reserves extends capacity, removing reserves narrows it." --- @@ -45,7 +46,7 @@ Personas covered: **Pool Operator** and **Order Maker**. See [ux-design.md](../d **Acceptance criteria**: - Adjust form on pool detail shows current reserves and allows delta inputs -- `build_lmsr_adjust_pset(contract_id, pair_delta, collateral_delta, funding)` — pair_delta applied equally to YES and NO reserves +- `pool.build_adjust_pset(pair_delta, collateral_delta, funding)` — pair_delta applied equally to YES and NO reserves - The UI presents absolute target inputs and computes deltas internally: "Set YES/NO reserves to X" → `pair_delta = X - current` - On `CoreError::InvalidParams` (zero deltas or below minimum reserve floor): show specific error - After confirmation: reserves update in pool detail @@ -63,7 +64,7 @@ Personas covered: **Pool Operator** and **Order Maker**. See [ux-design.md](../d **Acceptance criteria**: - Close button on pool detail (only for Active pools) -- `build_lmsr_close_pset(contract_id, funding)` → sign → broadcast +- `pool.build_close_pset(funding)` → sign → broadcast - All three reserve UTXOs reclaimed atomically to `funding.return_script` - After confirmation: pool state transitions to `Closed` @@ -98,7 +99,7 @@ Personas covered: **Pool Operator** and **Order Maker**. See [ux-design.md](../d **Acceptance criteria**: - "My Orders" list shows all orders from `fetchOwnOrders`: market, direction, price, offered amount, fill status, order state - Fill progress: `OrderState::Active { offered_amount, total_filled }` → progress bar showing "X of Y sats filled" -- Cancel button calls `build_cancel_order_pset(contract_id, funding)` → sign → broadcast +- Cancel button calls `order.build_cancel_pset(funding)` → sign → broadcast - After cancellation: `OrderState::Cancelled { total_filled }` — show "Cancelled (X of Y filled before cancellation)" - Consumed orders (`OrderState::Consumed`): show "Fully filled" with green check diff --git a/docs/ux/stories/trader.md b/docs/ux/stories/trader.md index 4ad95695..79258f76 100644 --- a/docs/ux/stories/trader.md +++ b/docs/ux/stories/trader.md @@ -33,7 +33,7 @@ Personas covered: **Trader (Taker)** and **Token Holder (Recovery)**. See [ux-de - Detail view shows a trade composer with YES/NO toggle and amount input - Selecting a side and entering an amount triggers `quote_trade(market_id, TradeSpec { side, direction: Buy, amount: ExactInput(sats) }, fee_rate)` - Quote response displays: effective price (as probability %), tokens received (`total_output`), total cost (`total_input`), estimated fee -- "Confirm" button calls `build_trade_pset(quote, funding)` → sign → broadcast +- "Confirm" button calls `build_trade_pset(quote, funding)` → prepare/blind → sign → broadcast - On `CoreError::StaleQuote`: auto re-quote, flash "Price updated" indicator, show new quote - On `CoreError::InsufficientFunds { shortfalls }`: display each shortfall ("Need X more sats") - On `CoreError::NoLiquidity`: display "No liquidity available" and disable the confirm button @@ -128,7 +128,7 @@ Personas covered: **Trader (Taker)** and **Token Holder (Recovery)**. See [ux-de **Acceptance criteria**: - After wallet restore, standard rescan finds YES/NO token UTXOs (they're normal confidential assets at wallet addresses) -- Unknown asset IDs trigger the recovery flow: `ChainSource::issuance_transaction(asset_id)` → market creation tx → read OP_RETURN → reconstruct `PredictionMarketParams` → `ingest_market` → `identify_asset` +- Unknown asset IDs trigger the recovery flow: `ChainSource::issuance_transaction(asset_id)` → market creation tx → read OP_RETURN → reconstruct `MarketParams` (binary or multi-outcome per hint type tag) → `ingest_market` → `identify_asset` - After recovery, tokens display with proper names: "YES — Will BTC hit $200k by 2027?" - Redemption is available if the market is resolved/expired diff --git a/docs/ux/views.md b/docs/ux/views.md index a86fe10c..6ace68f8 100644 --- a/docs/ux/views.md +++ b/docs/ux/views.md @@ -286,7 +286,7 @@ Shown when `trending.length === 0` on the Trending view. Icon + "No markets yet" └─────────────────────────────────────┘ ``` -**State flow**: Amount input → `useQuoteTrade` mutation → quote stored in `tradeQuoteSnapshot` → "Buy YES" → `QuoteModal` opens → user confirms → `useExecuteTrade` mutation → `invoke("build_trade_pset")` → sign → broadcast → success toast + query invalidation. +**State flow**: Input change → debounce 300ms → `quote_trade` → display quote → user clicks Buy → confirm modal → `build_trade_pset` → prepare/blind → sign → broadcast → pending toast → `step` confirms → success toast. ### Quote Confirm Modal (`QuoteModal.tsx`) @@ -333,9 +333,9 @@ Calls `useMarketOps` mutation (oracle attest + execute resolution). | Category | `CategoryDropdown` | Required | Nostr event tag | | Resolution source | Text input | Optional | Nostr event content | | Collateral per pair | Constrained dropdown (1-2-5 table) | Must be valid denomination | `MarketCreationParams.collateral_per_pair` | -| Settlement date | `SettlementPicker` (calendar + time) | Must be in the future | `MarketCreationParams.expiry_time` (snapped to 60-block boundary) | +| Settlement date | `SettlementPicker` (calendar + time) | Must be in the future | `MarketCreationParams.expiry_time` (rounded up to 60-block boundary) | -**Submit flow**: Validate → `useCreateMarket` mutation → `invoke("build_creation_pset")` → sign → broadcast → `ingest_market` → publish Nostr event → navigate to detail view. +**Submit flow**: Validate → `build_binary_market_creation_pset(params, funding)` → prepare/blind `PreBlindedPset` → sign → broadcast → await confirmation → `ingest_market` → publish Nostr event → redirect to detail view. --- diff --git a/flake.nix b/flake.nix index 761f4586..e0498dd7 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ cargo + cargo-nextest clippy rustfmt git diff --git a/justfile b/justfile index 9c87c47a..8f592d14 100644 --- a/justfile +++ b/justfile @@ -41,14 +41,14 @@ cargo-clippy: cd src-tauri && cargo clippy --all-targets -- -D warnings cargo-test: - cd src-tauri && cargo test --workspace --exclude deadcat-sdk - cd src-tauri/crates/deadcat-sdk && ulimit -n 10240 && \ + cd src-tauri && ulimit -n 10240 && \ ARCH="$(uname -m)-$(uname -s | tr '[:upper:]' '[:lower:]')"; \ case "$ARCH" in \ arm64-darwin) TRIPLE="aarch64-apple-darwin" ;; \ x86_64-linux) TRIPLE="x86_64-unknown-linux-gnu" ;; \ *) echo "Unsupported platform: $ARCH" >&2; exit 1 ;; \ esac; \ - ELEMENTSD_EXEC=$PWD/tests/elementsd-$TRIPLE \ - ELECTRS_LIQUID_EXEC=$PWD/tests/electrs-$TRIPLE \ - cargo test -- --test-threads=4 + export ELEMENTSD_EXEC=$PWD/crates/deadcat-sdk/tests/elementsd-$TRIPLE; \ + export ELECTRS_LIQUID_EXEC=$PWD/crates/deadcat-sdk/tests/electrs-$TRIPLE; \ + cargo nextest run --workspace && \ + cargo test --doc --workspace diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 82f56691..1a08683d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -92,3 +92,6 @@ secp256k1-zkp = { git = "https://github.com/breez/rust-secp256k1-zkp.git", rev = # Remove once simplicity-lang ≥0.7.1 is released with the fix. simplicity-lang = { git = "https://github.com/BlockstreamResearch/rust-simplicity.git", rev = "8839c919d360b8eb4a3ade279c544448e9a2d0d3" } simplicity-sys = { git = "https://github.com/BlockstreamResearch/rust-simplicity.git", rev = "8839c919d360b8eb4a3ade279c544448e9a2d0d3" } + +[profile.test] +opt-level = 1 diff --git a/src-tauri/crates/deadcat-sdk/examples/seed_markets.rs b/src-tauri/crates/deadcat-sdk/examples/seed_markets.rs index 3368f3aa..8003308b 100644 --- a/src-tauri/crates/deadcat-sdk/examples/seed_markets.rs +++ b/src-tauri/crates/deadcat-sdk/examples/seed_markets.rs @@ -446,7 +446,7 @@ async fn setup_node(keys: Keys) -> Arc> { ..Default::default() }; - let (node, _rx) = DeadcatNode::new(keys, Network::LiquidTestnet, config); + let (node, _rx) = DeadcatNode::new(Arc::new(keys), Network::LiquidTestnet, config); let node = Arc::new(node); // Unlock wallet diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/social.rs b/src-tauri/crates/deadcat-sdk/src/discovery/social.rs index f6e45fa0..2e8e3434 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/social.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/social.rs @@ -290,7 +290,10 @@ mod tests { #[test] fn build_follow_list_event_preserves_legacy_content_and_rewrites_p_tags() { let author = Keys::generate().public_key(); - let follows = vec![hex::encode([0xaa; 32]), hex::encode([0xbb; 32])]; + let follows = vec![ + Keys::generate().public_key().to_hex(), + Keys::generate().public_key().to_hex(), + ]; let legacy = r#"{"wss://relay.example":{"read":true}}"#; let unsigned = build_follow_list_event(author, &follows, legacy).unwrap(); assert_eq!(unsigned.kind, FOLLOW_LIST_KIND); @@ -374,6 +377,9 @@ mod tests { .iter() .filter_map(|tag| tag.as_slice().first().cloned()) .collect(); - assert_eq!(tag_kinds, vec!["p".to_string(), "t".to_string()]); + assert_eq!( + tag_kinds, + vec!["p".to_string(), "t".to_string(), "client".to_string()] + ); } } diff --git a/src-tauri/crates/deadcat-sdk/src/node.rs b/src-tauri/crates/deadcat-sdk/src/node.rs index dce41dd0..53579bc3 100644 --- a/src-tauri/crates/deadcat-sdk/src/node.rs +++ b/src-tauri/crates/deadcat-sdk/src/node.rs @@ -2520,7 +2520,6 @@ mod tests { pool_id: announcement.lmsr_pool_id.clone(), market_id: announcement.market_id.clone(), creation_txid: announcement.creation_txid.clone(), - reserve_yes_outpoint: announcement.initial_reserve_outpoints[0].clone(), stored_initial_reserve_outpoints: Some( announcement .initial_reserve_outpoints @@ -2557,7 +2556,6 @@ mod tests { pool_id: announcement.lmsr_pool_id.clone(), market_id: announcement.market_id.clone(), creation_txid: announcement.creation_txid.clone(), - reserve_yes_outpoint: announcement.initial_reserve_outpoints[0].clone(), stored_initial_reserve_outpoints: Some([ format!("{}:7", announcement.creation_txid), format!("{}:8", announcement.creation_txid), @@ -2568,7 +2566,7 @@ mod tests { params_json: canonical_params_json(&announcement), lmsr_table_values: None, nostr_event_json: Some(serde_json::to_string(&event).unwrap()), - reserve_yes_outpoint: format!("{}:7", announcement.creation_txid), + reserve_yes_outpoint: announcement.initial_reserve_outpoints[0].clone(), }; let resolved = resolved_sync_metadata(Network::LiquidTestnet, &pool).unwrap(); @@ -2587,19 +2585,17 @@ mod tests { fn resolved_sync_metadata_errors_when_pool_is_unrecoverable() { let announcement = sample_pool_announcement(); let params_json = canonical_params_json(&announcement); - let reserve_yes_outpoint = format!("{}:0", announcement.creation_txid); let pool = crate::LmsrPoolSyncInfo { pool_id: announcement.lmsr_pool_id, market_id: announcement.market_id, creation_txid: announcement.creation_txid, - reserve_yes_outpoint: announcement.initial_reserve_outpoints[0].clone(), stored_initial_reserve_outpoints: None, witness_schema_version: announcement.witness_schema_version, current_s_index: announcement.current_s_index, params_json, lmsr_table_values: None, nostr_event_json: None, - reserve_yes_outpoint, + reserve_yes_outpoint: announcement.initial_reserve_outpoints[0].clone(), }; let err = resolved_sync_metadata(Network::LiquidTestnet, &pool).unwrap_err(); @@ -2618,7 +2614,7 @@ mod order_cleanup_tests { use lwk_test_util::{TEST_MNEMONIC, TestEnvBuilder, regtest_policy_asset}; use nostr_relay_builder::prelude::*; - use crate::testing::{TestStore, test_order_announcement}; + use crate::testing::{RecordedOwnOrder, TestStore, test_order_announcement}; fn sample_order_market_params(collateral_asset_id: [u8; 32]) -> PredictionMarketParams { PredictionMarketParams { @@ -2752,6 +2748,16 @@ mod order_cleanup_tests { .unwrap(); { let mut store = store.lock().unwrap(); + store.own_orders.push(RecordedOwnOrder { + params: announcement.params, + maker_pubkey: [0xaa; 32], + order_nonce: [0x11; 32], + nostr_event_id: event_id.to_hex(), + creation_txid: "test-tx".to_string(), + market_id: announcement.market_id.clone(), + direction_label: announcement.direction_label.clone(), + offered_amount: announcement.offered_amount, + }); store.pending_order_deletions.push(PendingOrderDeletion { order_id: 9, market_id: announcement.market_id.clone(),