diff --git a/Cargo.lock b/Cargo.lock index c626761..1ccc6da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,7 +895,6 @@ dependencies = [ name = "deadcat-client" version = "0.1.0-alpha" dependencies = [ - "bip39", "bitcoincore-rpc", "deadcat-contracts", "deadcat-iroh", @@ -903,11 +902,9 @@ dependencies = [ "deadcat-rpc", "deadcat-types", "elements", - "hmac", "rand 0.8.7", "serde", "serde_json", - "sha2 0.10.9", "smplx-regtest", "smplx-std", "tempfile", @@ -921,7 +918,6 @@ version = "0.1.0-alpha" dependencies = [ "deadcat-types", "elements", - "hmac", "serde_json", "sha2 0.10.9", "smplx-regtest", diff --git a/Cargo.toml b/Cargo.toml index 5e48cda..bf2946b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,9 @@ publish = false [workspace.dependencies] anyhow = "1" async-trait = "0.1" -bip39 = "2" clap = { version = "4", features = ["derive"] } elements = { version = "0.25", features = ["serde"] } hex = { version = "0.4", features = ["serde"] } -hmac = "0.12" iroh = "1" postcard = { version = "1", default-features = false, features = ["alloc"] } rand = "0.8" diff --git a/README.md b/README.md index 3a2389e..d27b2c2 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,78 @@ # deadcat-node -`deadcat-node` is the authoritative implementation of the Deadcat protocol on -Liquid. It owns the canonical SimplicityHL contracts, interprets their confirmed -chain state, indexes that state in redb, and serves evidence over Iroh. - -The node is deliberately not a wallet. Keys, wallet discovery, route selection, -PSET construction, confidential-transaction blinding, intent validation, and -signing stay on the client. - -## Current alpha scope - -The current alpha includes: - -- a binary prediction-market covenant that enforces collateral solvency; -- a persistent maker limit-order covenant; -- confirmed chain indexing through either Elements Core RPC or Esplora; -- transaction-atomic, reorg-aware redb persistence; and -- an evidence-first Iroh RPC for hosted and self-hosted nodes. - -This is not the selected production scope. -[ADR 0006](docs/adr/0006-rfq-first-liquidity-scope.md) accepts a market-only -first public release with a separate noncustodial RFQ service and client-owned -routing. `MakerOrderV1`, node-side advisory routing, and the unused LMSR -reservation remain in the alpha tree only until the accepted removal work -lands. Future AMM and DLOB designs are intentionally undecided. - -## Status - -The clean-slate v1 alpha is implemented for binary markets and maker limit -orders. It includes the canonical `.simf` covenants, wallet-agnostic PSET -builders, mnemonic-derived order-recovery primitives, confirmed-transaction -interpreters, atomic redb state/history, two-block reorg undo, -late-registration backfill, Elements RPC and Esplora chain sources, evidence -queries, advisory routing, durable subscriptions, and bounded Iroh transport. -Finalized Simplicity execution tests cover every market lifecycle path and both -order directions. -The binary-market candidate now uses fixed A/B reissuance-token commitments, -with side inferred from each raw chain output and an exact input-side -reissuance nonce. [ADR 0005](docs/adr/0005-rt-blinding-schedule.md) remains -Proposed while its [acceptance packet](docs/acceptance/binary-market-ab-v1.md) -awaits focused external review; protocol-owner approval was recorded on -2026-07-14. Its exhaustive -dual-side corpus, full-market measurements, live Elements lifecycle, recovery, -restart, and one-/two-block reorg gates are complete. -The maker-order candidate now has the same live Elements boundary: both order -directions execute partial and full covenant fills, mnemonic-derived Taproot -key cancellation, package registration and historical backfill, independent -client replay, restart, and real alternate-hash one-/two-block reorgs. The gate -also proves the intentional post-resolution split: Elements still accepts a -custom fill while official node routing refuses it. -The mandatory multi-contract liquidregtest gate extends protocol assurance from -isolated covenant lifecycles to composed chain ingestion. One real consensus -transaction advances a market and two maker orders, then is interpreted, -indexed, restarted, reorganized, and independently replayed as one atomic -transaction. That gate also drives a real three-block fork through -`RescanRequired`, stale-read refusal, an activation-checkpoint reset, process -reopen, retained-declaration replay, and return to `Ready` on the replacement -branch. -The mandatory backend-equivalence gate feeds one canonical chain and a real -alternate-hash replacement through both production chain sources. Elements RPC -and Esplora must produce identical synchronized market state, history, and raw -evidence; the gate also exercises live Esplora broadcast, transaction status, -outspend, issuance, script-history, and fee endpoints. -The redb assurance suite independently drives the store against a deterministic -seeded in-memory model across apply, retry, reopen, shallow/deep rollback, and -rebuild paths. Test-only failpoints abort every named pre-commit mutation -boundary and require an exact pre-state after reopen followed by an exact -post-state after retry; no failpoint code is present in production builds. -The mandatory process-boundary gate then spawns the production daemon and CLI -as separate processes over direct Iroh. It proves live synchronization, -registration, evidence queries, durable cursor replay, signed-transaction -relay, stable node identity across restart, deep-reorg fail-closed behavior, -the operator rebuild command, and stale-cursor rejection after rebuild. +`deadcat-node` is the authoritative implementation of Deadcat's binary +prediction-market protocol on Liquid. It owns the canonical SimplicityHL +contract, interprets confirmed chain state, indexes that state in redb, and +serves independently verifiable evidence over Iroh. + +The node is deliberately not a wallet or trading venue. Keys, wallet discovery, +PSET construction, confidential-transaction blinding, intent validation, venue +selection, and signing stay on the client. + +## Current scope + +The clean-slate alpha includes: + +- one collateral-solvent binary-market covenant; +- wallet-agnostic market creation and transition builders; +- confirmed-chain indexing through Elements Core RPC or Esplora; +- transaction-atomic, reorg-aware redb persistence; +- package registration and historical backfill for one or more markets; and +- an evidence-first, bounded Iroh RPC for hosted and self-hosted nodes. + +The earlier on-chain maker-order experiment was removed before any contract +reached testnet or mainnet. Its audit, economics ADR, and live acceptance +packets remain in `docs/` as explicitly marked historical records. + +[ADR 0006](docs/adr/0006-rfq-first-liquidity-scope.md) records the RFQ-first +direction: the planned initial venue is a separate noncustodial liquidity +service, with a client-side router responsible for quote validation and +transaction construction. A future AMM or decentralized limit-order book can +implement the same venue boundary. The RFQ service remains separate from +`deadcat-node`; future AMM and DLOB protocols are not implemented by this +repository today. + +## Assurance + +Generated and direct Simplicity execution tests cover every binary-market +lifecycle path. The mandatory live-chain gates prove: + +- the complete binary-market lifecycle on liquidregtest; +- one transaction advancing two independent markets with atomic indexing, + replay, reorg, reset, and retained-declaration rebuild behavior; +- equivalent state and evidence from the production Elements RPC and Esplora + backends; and +- the daemon, Iroh transport, and CLI across real process boundaries. + +The redb assurance suite additionally drives apply, retry, reopen, rollback, +deep-reorg, and rebuild paths against a deterministic model. Test-only +failpoints require exact pre-state recovery after an aborted mutation and exact +post-state after retry. V1 activation is immutable per production network. Liquid mainnet begins after block `3974391` (`705d699f…890c35`) and Liquid testnet begins after block -`2529866` (`78fe3d5c…2f510e`). The exact checkpoint is verified against the -backend and bound atomically with chain identity and the initial redb tip. -The native policy asset is equally immutable: Liquid uses -`6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d` -and Liquid testnet uses -`144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49`. -The daemon derives these values from `--network`; an explicit matching -`--policy-asset` is accepted, while a conflicting value is rejected before -backend access or database creation. Elements regtest remains dynamic and -requires `--policy-asset`. - -This is not yet a production release. Public Liquid testnet shakedowns, -operational backup/restore tooling, Nostr announcement ingestion, browser -packaging of the full validator, and an external security review remain. Public -operators should currently protect package -registration with `--registration-bearer-token` or an edge rate limiter: the -alpha bounds package size and concurrent verification, but per-peer admission, -a process-wide weighted evidence budget, and a stored-evidence fast path for -identical retries are still deployment hardening work. The Iroh transport -itself passes a `wasm32-unknown-unknown` -compile gate; the pinned smplx 0.0.6 runtime currently pulls native regtest -dependencies into `deadcat-client`, so that larger WASM target remains an -upstream-integration task rather than a reason to add HTTP. LMSR is -intentionally deferred. Generated smplx Rust bindings under -`crates/deadcat-contracts/src/artifacts/` are build outputs and are never -committed. +`2529866` (`78fe3d5c…2f510e`). The daemon derives each production network's +activation checkpoint and policy asset from `--network`; Elements regtest +remains dynamic and requires `--policy-asset`. + +This is still an alpha. Public Liquid testnet shakedowns, operational +backup/restore tooling, announcement ingestion, full browser packaging, and an +external security review remain before production use. ## Development -All builds and CI checks run through the pinned Nix environment: +All builds and checks run through the pinned Nix environment: ```sh nix develop .#default just ci ``` -Before the repository has an initial commit, use `nix develop path:.#default` -so Nix includes the untracked workspace files. - -The focused live-chain gates can be run independently: +Focused live-chain gates: ```sh just regtest-market-ab -just regtest-maker-orders -just regtest-multi-contract +just regtest-multi-market just regtest-backend-equivalence just regtest-process-boundary ``` @@ -133,7 +86,7 @@ just node run \ elements --url http://127.0.0.1:7041 --cookie-file ``` -Or use a lightweight Esplora source: +Or use an Esplora source: ```sh just node run \ @@ -141,19 +94,8 @@ just node run \ esplora --url https:///api/ ``` -The daemon prints its serialized Iroh endpoint address on startup and persists -a stable endpoint secret beside the database. Production networks always use -their compiled activation checkpoint and policy asset. A dynamically-created -Elements regtest chain defaults to genesis; `--baseline-height` is available -only on regtest when a later explicit test checkpoint is useful. Scanning and -valid v1 creation begin strictly after that checkpoint. Elements-backed nodes -report full public market hint coverage only when their persisted indexed tip -is `Ready` at the current source tip; Esplora-backed nodes support -chain-validated contract-package registration and report advisory discovery -coverage. - -After a fork exceeds the two-block undo window, stop the daemon and run the -local maintenance command with a backend for the same chain: +After a fork exceeds the two-block undo window, stop the daemon and rebuild +against a backend for the same chain: ```sh just node rebuild \ @@ -161,47 +103,47 @@ just node rebuild \ elements --url http://127.0.0.1:7041 --cookie-file ``` -The command verifies the stored network policy asset, genesis, and activation -hash before any destructive write, clears chain materialization, history, -index, and undo tables, preserves normalized declarations and the durable event -journal, and replays in complete-block commits. It is safe to rerun after -interruption. -Until reset, `RescanRequired` is sticky and chain-derived RPCs fail closed; -`GetInfo`, subscriptions, fee estimation, and signed-transaction relay remain -available. - -Contract identity and ingestion are deliberately separate. A compact -`ContractId` is the exact creation-anchor outpoint: the initial dormant YES RT -output for a market, or the initial covenant output for a maker order. A -portable `ContractPackage` carries complete untrusted declarations, their -dependency relationships, and the target network/genesis. The receiving node -fetches canonical chain evidence, recompiles and validates every declaration, -and registers the package atomically; the package publisher is never an +The rebuild verifies stored chain identity before clearing derived chain state, +preserves normalized market declarations and the durable event journal, and +replays complete blocks. Until reset, `RescanRequired` is sticky and +chain-derived RPCs fail closed. + +## Contract packages + +A `ContractId` is the exact initial dormant YES reissuance-token output of a +market. A portable `ContractPackage` carries one or more complete, untrusted +market declarations plus the target network and genesis hash. The receiving +node fetches canonical chain evidence, recompiles and validates every +declaration, and registers the package atomically; the publisher is never an authority for contract validity. -Register a package over Iroh with the package object itself (not an RPC -envelope): +Register the nested package object over Iroh: ```sh deadcat --endpoint-id register --file ./package.json ``` The committed -[`register_contract_package` wire fixture](fixtures/wire-v1/register-contract-package-request.json) -shows the exact strict JSON shape; `package.json` is its nested `package` value. -The CLI also accepts compact `TXID:VOUT` syntax for individual `ContractId` -arguments, while RPC JSON always uses `{"txid":"...","vout":n}`. +[`register_contract_package` fixture](fixtures/wire-v1/register-contract-package-request.json) +shows the strict JSON shape. The CLI also accepts compact `TXID:VOUT` syntax +for individual `ContractId` arguments. -Start with: +## Documentation - [Architecture](docs/architecture.md) - [V1 protocol](docs/protocol-v1.md) - [Storage, synchronization, and RPC](docs/storage-sync-rpc.md) -- [Implementation plan](docs/implementation-plan.md) -- [Proposed liquidity roadmap](docs/liquidity-roadmap.md) +- [Liquidity roadmap](docs/liquidity-roadmap.md) - [Architecture decisions](docs/adr/README.md) - [Binary-market A/B acceptance packet](docs/acceptance/binary-market-ab-v1.md) -- [Maker-order live acceptance packet](docs/acceptance/maker-orders-v1.md) -- [Multi-contract live acceptance packet](docs/acceptance/multi-contract-v1.md) +- [Multi-market assurance test](crates/deadcat-client/tests/market_regtest.rs) - [Elements RPC and Esplora backend-equivalence packet](docs/acceptance/backend-equivalence-v1.md) - [Daemon/Iroh/CLI process-boundary packet](docs/acceptance/process-boundary-v1.md) +- [Completed v1 alpha implementation record](docs/implementation-plan.md) + +Historical maker-order records: + +- [Simplicity contract audit](docs/simplicity-contract-audit-2026-07-24.md) +- [Maker-order acceptance packet](docs/acceptance/maker-orders-v1.md) +- [Heterogeneous multi-contract acceptance packet](docs/acceptance/multi-contract-v1.md) +- [ADR 0003: retired order economics](docs/adr/0003-order-economics.md) diff --git a/crates/deadcat-cli/src/main.rs b/crates/deadcat-cli/src/main.rs index 099320e..2a3de85 100644 --- a/crates/deadcat-cli/src/main.rs +++ b/crates/deadcat-cli/src/main.rs @@ -10,9 +10,7 @@ use deadcat_rpc::{ EventFilter, PageRequest, RecoveryFamily, Request, RequestEnvelope, RequestId, SCHEMA_VERSION, SnapshotCursor, }; -use deadcat_types::{ - ChainPosition, ContractId, ContractPackage, EventCursor, OrderDirection, OrderSide, -}; +use deadcat_types::{ChainPosition, ContractId, ContractPackage, EventCursor}; use elements::encode::deserialize; use elements::{AssetId, Transaction}; use serde::Serialize; @@ -62,22 +60,6 @@ enum Command { #[arg(value_parser = parse_contract_id)] market_id: ContractId, }, - /// List maker orders for a market. - ListOrders { - #[arg(value_parser = parse_contract_id)] - market_id: ContractId, - #[arg(long)] - side: Option, - #[arg(long)] - direction: Option, - #[command(flatten)] - page: PageArgs, - }, - /// Fetch a market's aggregated maker order book. - OrderBook { - #[arg(value_parser = parse_contract_id)] - market_id: ContractId, - }, /// List public chain-recovery hints. ListHints { #[arg(long)] @@ -107,19 +89,6 @@ enum Command { #[arg(long, default_value_t = 2, value_parser = nonzero_u16)] target_blocks: u16, }, - /// Ask the node for an advisory order route. The client must still verify it. - Route { - #[arg(value_parser = parse_contract_id)] - market_id: ContractId, - #[arg(long)] - side: SideArg, - #[arg(long)] - direction: DirectionArg, - #[arg(long, value_parser = nonzero_u64)] - base_amount: u64, - #[arg(long, default_value_t = 100, value_parser = nonzero_u16)] - max_orders: u16, - }, /// Broadcast a fully signed Elements transaction encoded as consensus hex. Broadcast { #[command(flatten)] @@ -210,47 +179,15 @@ impl HexSource { } } -#[derive(Clone, Copy, Debug, ValueEnum)] -enum SideArg { - Yes, - No, -} - -impl From for OrderSide { - fn from(value: SideArg) -> Self { - match value { - SideArg::Yes => Self::Yes, - SideArg::No => Self::No, - } - } -} - -#[derive(Clone, Copy, Debug, ValueEnum)] -enum DirectionArg { - SellBase, - SellQuote, -} - -impl From for OrderDirection { - fn from(value: DirectionArg) -> Self { - match value { - DirectionArg::SellBase => Self::SellBase, - DirectionArg::SellQuote => Self::SellQuote, - } - } -} - #[derive(Clone, Copy, Debug, ValueEnum)] enum FamilyArg { BinaryMarketV1, - MakerOrderV1, } impl From for RecoveryFamily { fn from(value: FamilyArg) -> Self { match value { FamilyArg::BinaryMarketV1 => Self::BinaryMarketV1, - FamilyArg::MakerOrderV1 => Self::MakerOrderV1, } } } @@ -320,20 +257,6 @@ fn command_request(command: &Command) -> Result { Command::MarketSnapshot { market_id } => Request::GetMarketSnapshot { market_id: *market_id, }, - Command::ListOrders { - market_id, - side, - direction, - page, - } => Request::ListOrders { - market_id: *market_id, - side: side.map(Into::into), - direction: direction.map(Into::into), - page: page.clone().into_request(), - }, - Command::OrderBook { market_id } => Request::GetOrderBook { - market_id: *market_id, - }, Command::ListHints { family, page } => Request::ListRecoveryHints { family: family.map(Into::into), page: page.clone().into_request(), @@ -356,19 +279,6 @@ fn command_request(command: &Command) -> Result { Command::Fee { target_blocks } => Request::EstimateFeerate { target_blocks: *target_blocks, }, - Command::Route { - market_id, - side, - direction, - base_amount, - max_orders, - } => Request::SuggestRoute { - market_id: *market_id, - side: (*side).into(), - direction: (*direction).into(), - base_amount: *base_amount, - max_orders: *max_orders, - }, Command::Broadcast { transaction } => Request::BroadcastSignedTransaction { transaction: transaction.transaction()?, }, @@ -500,17 +410,6 @@ fn nonzero_u16(value: &str) -> std::result::Result { } } -fn nonzero_u64(value: &str) -> std::result::Result { - let value: u64 = value - .parse() - .map_err(|error| format!("invalid integer: {error}"))?; - if value == 0 { - Err("value must be nonzero".to_owned()) - } else { - Ok(value) - } -} - #[cfg(test)] mod tests { use std::str::FromStr as _; @@ -571,24 +470,12 @@ mod tests { "deadcat", "--endpoint-id", ENDPOINT_ID, - "route", + "market-snapshot", &format!("{TXID}:2"), - "--side", - "yes", - "--direction", - "sell-base", - "--base-amount", - "25", ]); assert!(matches!( command_request(&cli.command).expect("request"), - Request::SuggestRoute { - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 25, - max_orders: 100, - .. - } + Request::GetMarketSnapshot { .. } )); let cli = parse(&[ @@ -615,16 +502,16 @@ mod tests { ENDPOINT_ID, "list-hints", "--family", - "maker-order-v1", + "binary-market-v1", "--cursor-json", - r#"{"as_of":{"height":42,"hash":"1111111111111111111111111111111111111111111111111111111111111111"},"event_high_watermark":{"epoch":"000102030405060708090a0b0c0d0e0f","sequence":"9"},"scope":{"recovery_hints":{"family":"maker_order_v1"}},"after_key":"0000002a0000000300000004"}"#, + r#"{"as_of":{"height":42,"hash":"1111111111111111111111111111111111111111111111111111111111111111"},"event_high_watermark":{"epoch":"000102030405060708090a0b0c0d0e0f","sequence":"9"},"scope":{"recovery_hints":{"family":"binary_market_v1"}},"after_key":"0000002a0000000300000004"}"#, "--limit", "3", ]); assert!(matches!( command_request(&cli.command).expect("request"), Request::ListRecoveryHints { - family: Some(RecoveryFamily::MakerOrderV1), + family: Some(RecoveryFamily::BinaryMarketV1), page: PageRequest { cursor: Some(SnapshotCursor { after_key, .. }), limit: 3, @@ -751,7 +638,15 @@ mod tests { #[test] fn nonzero_validators_reject_zero() { assert!(nonzero_u16("0").is_err()); - assert!(nonzero_u64("0").is_err()); assert_eq!(nonzero_u16("7").expect("valid"), 7); } + + #[test] + fn removed_maker_commands_are_not_accepted() { + for command in ["list-orders", "order-book", "route"] { + let error = Cli::try_parse_from(["deadcat", "--endpoint-id", ENDPOINT_ID, command]) + .expect_err("removed maker command"); + assert_eq!(error.kind(), ErrorKind::InvalidSubcommand); + } + } } diff --git a/crates/deadcat-client/Cargo.toml b/crates/deadcat-client/Cargo.toml index 6ad93e8..7d973c5 100644 --- a/crates/deadcat-client/Cargo.toml +++ b/crates/deadcat-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deadcat-client" -description = "Client-side Deadcat evidence verification, recovery, routing, and transaction construction." +description = "Client-side Deadcat evidence verification and market transaction construction." version.workspace = true edition.workspace = true publish.workspace = true @@ -9,14 +9,11 @@ publish.workspace = true workspace = true [dependencies] -bip39.workspace = true deadcat-contracts.workspace = true deadcat-rpc.workspace = true deadcat-types.workspace = true elements.workspace = true -hmac.workspace = true rand.workspace = true -sha2.workspace = true smplx-std.workspace = true thiserror.workspace = true diff --git a/crates/deadcat-client/src/keys.rs b/crates/deadcat-client/src/keys.rs deleted file mode 100644 index 3381b09..0000000 --- a/crates/deadcat-client/src/keys.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Mnemonic-derived Deadcat order ownership and recovery keys. - -use bip39::{Language, Mnemonic}; -use deadcat_contracts::maker_order::{ORDER_CANCEL_TWEAK_DOMAIN, ORDER_RECEIVE_TWEAK_DOMAIN}; -use deadcat_contracts::recovery::{OrderRecoveryHint, ParentMarketRef, order_mask}; -use deadcat_contracts::rt::hash_to_scalar; -use deadcat_types::{MakerOrderParams, OrderDirection, OrderSide}; -use elements::bitcoin::NetworkKind; -use elements::bitcoin::bip32::{ChildNumber, Xpriv}; -use elements::bitcoin::secp256k1::{Parity, Scalar, Secp256k1}; -use elements::{AssetId, Script}; -use sha2::{Digest as _, Sha256}; -use thiserror::Error; - -const DEADCAT_PURPOSE: u32 = 86; -const DEADCAT_COIN_TYPE: u32 = 1_145_258_324; -const SECRET_CHILD: u32 = 0; -const ORDER_CHILD: u32 = 1; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MakerOrderTerms { - pub base_asset_id: AssetId, - pub quote_asset_id: AssetId, - pub price: u32, - pub min_active_base: u32, - pub direction: OrderDirection, -} - -/// Secret material required to cancel an order and spend its private receive -/// output. This type intentionally does not implement `Debug`. -#[derive(Clone, PartialEq, Eq)] -pub struct OrderKeyMaterial { - cancel_secret_key: [u8; 32], - receive_secret_key: [u8; 32], - pub instance_id: [u8; 32], - pub maker_public_key: [u8; 32], - pub maker_was_odd: bool, - pub cancel_tweak: [u8; 32], - pub cancel_public_key: [u8; 32], - pub cancel_was_odd: bool, - pub receive_tweak: [u8; 32], - pub receive_public_key: [u8; 32], - pub receive_was_odd: bool, - pub maker_receive_spk: Script, - pub maker_receive_spk_hash: [u8; 32], -} - -/// All deterministic owner data needed to create and later recover one order. -#[derive(Clone, PartialEq, Eq)] -pub struct DerivedOwnedOrder { - pub params: MakerOrderParams, - pub recovery_hint: OrderRecoveryHint, - pub keys: OrderKeyMaterial, -} - -impl OrderKeyMaterial { - #[must_use] - pub fn maker_secret_key(&self) -> &[u8; 32] { - &self.cancel_secret_key - } - - #[must_use] - pub fn receive_secret_key(&self) -> &[u8; 32] { - &self.receive_secret_key - } - - #[must_use] - pub fn params(&self, terms: MakerOrderTerms) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: terms.base_asset_id, - quote_asset_id: terms.quote_asset_id, - price: terms.price, - min_active_base: terms.min_active_base, - direction: terms.direction, - instance_id: self.instance_id, - maker_pubkey: self.maker_public_key, - } - } -} - -/// BIP-32 keychain rooted in a BIP-39 seed. The mnemonic and seed are not -/// retained after construction. -#[derive(Clone)] -pub struct DeadcatKeychain { - master: Xpriv, -} - -impl DeadcatKeychain { - pub fn from_mnemonic(phrase: &str, passphrase: &str) -> Result { - let mnemonic = Mnemonic::parse_in_normalized(Language::English, phrase)?; - Self::from_seed(&mnemonic.to_seed(passphrase)) - } - - pub fn from_seed(seed: &[u8]) -> Result { - Ok(Self { - master: Xpriv::new_master(NetworkKind::Main, seed)?, - }) - } - - pub fn deadcat_secret_key(&self) -> Result<[u8; 32], KeyDerivationError> { - let key = self.derive(&[ - hardened(DEADCAT_PURPOSE)?, - hardened(DEADCAT_COIN_TYPE)?, - hardened(SECRET_CHILD)?, - ])?; - Ok(key.private_key.secret_bytes()) - } - - pub fn derive_order( - &self, - order_index: u16, - instance_id: [u8; 32], - ) -> Result { - let maker = self.derive(&[ - hardened(DEADCAT_PURPOSE)?, - hardened(DEADCAT_COIN_TYPE)?, - hardened(ORDER_CHILD)?, - hardened(u32::from(order_index))?, - ])?; - let maker_secret = maker.private_key; - let secp = Secp256k1::new(); - let (maker_public, maker_parity) = maker_secret.x_only_public_key(&secp); - let maker_public_key = maker_public.serialize(); - - let cancel_tweak = hash_to_scalar(ORDER_CANCEL_TWEAK_DOMAIN, &instance_id); - let cancel_scalar = Scalar::from_be_bytes(cancel_tweak) - .map_err(|_| KeyDerivationError::InvalidTweakScalar)?; - let normalized_maker_secret = match maker_parity { - Parity::Even => maker_secret, - Parity::Odd => maker_secret.negate(), - }; - let cancel_secret = normalized_maker_secret - .add_tweak(&cancel_scalar) - .map_err(|_| KeyDerivationError::TweakedKeyAtInfinity)?; - let (cancel_public, cancel_parity) = cancel_secret.x_only_public_key(&secp); - - let receive_tweak = hash_to_scalar(ORDER_RECEIVE_TWEAK_DOMAIN, &instance_id); - let receive_scalar = Scalar::from_be_bytes(receive_tweak) - .map_err(|_| KeyDerivationError::InvalidTweakScalar)?; - let receive_secret = normalized_maker_secret - .add_tweak(&receive_scalar) - .map_err(|_| KeyDerivationError::TweakedKeyAtInfinity)?; - let (receive_public, receive_parity) = receive_secret.x_only_public_key(&secp); - let receive_public_key = receive_public.serialize(); - - let mut script_bytes = Vec::with_capacity(34); - script_bytes.extend_from_slice(&[0x51, 0x20]); - script_bytes.extend_from_slice(&receive_public_key); - let maker_receive_spk = Script::from(script_bytes); - let maker_receive_spk_hash = Sha256::digest(maker_receive_spk.as_bytes()).into(); - - Ok(OrderKeyMaterial { - cancel_secret_key: cancel_secret.secret_bytes(), - receive_secret_key: receive_secret.secret_bytes(), - instance_id, - maker_public_key, - maker_was_odd: maker_parity == Parity::Odd, - cancel_tweak, - cancel_public_key: cancel_public.serialize(), - cancel_was_odd: cancel_parity == Parity::Odd, - receive_tweak, - receive_public_key, - receive_was_odd: receive_parity == Parity::Odd, - maker_receive_spk, - maker_receive_spk_hash, - }) - } - - /// Derive keys, public contract parameters, and the masked chain-recovery - /// hint together so callers cannot accidentally mix order indices. - pub fn derive_owned_order( - &self, - order_index: u16, - parent_market: ParentMarketRef, - side: OrderSide, - terms: MakerOrderTerms, - instance_id: [u8; 32], - ) -> Result { - let keys = self.derive_order(order_index, instance_id)?; - let params = keys.params(terms); - let mut recovery_hint = OrderRecoveryHint { - side, - direction: terms.direction, - masked_order_index: 0, - parent_market, - price: terms.price, - min_active_base: terms.min_active_base, - maker_pubkey: keys.maker_public_key, - }; - recovery_hint.masked_order_index = - order_index ^ order_mask(recovery_hint, &self.deadcat_secret_key()?); - Ok(DerivedOwnedOrder { - params, - recovery_hint, - keys, - }) - } - - fn derive(&self, path: &[ChildNumber]) -> Result { - Ok(self.master.derive_priv(&Secp256k1::new(), &path)?) - } -} - -fn hardened(index: u32) -> Result { - Ok(ChildNumber::from_hardened_idx(index)?) -} - -#[derive(Debug, Error)] -pub enum KeyDerivationError { - #[error("invalid BIP-39 mnemonic: {0}")] - Mnemonic(#[from] bip39::Error), - #[error("BIP-32 derivation failed: {0}")] - Bip32(#[from] elements::bitcoin::bip32::Error), - #[error("order tweak was not a reduced secp256k1 scalar")] - InvalidTweakScalar, - #[error("order receive tweak produced the point at infinity")] - TweakedKeyAtInfinity, -} - -#[cfg(test)] -mod tests { - use deadcat_contracts::maker_order::CompiledMakerOrder; - use deadcat_types::ContractId; - use elements::hashes::Hash as _; - use elements::{OutPoint, Txid}; - - use super::*; - - const MNEMONIC: &str = - "exist carry drive collect lend cereal occur much tiger just involve mean"; - const INSTANCE_A: [u8; 32] = [0x77; 32]; - const INSTANCE_B: [u8; 32] = [0x88; 32]; - - fn terms() -> MakerOrderTerms { - MakerOrderTerms { - base_asset_id: AssetId::from_slice(&[0x11; 32]).expect("base"), - quote_asset_id: AssetId::from_slice(&[0x22; 32]).expect("quote"), - price: 12_345, - min_active_base: 67, - direction: OrderDirection::SellQuote, - } - } - - #[test] - fn mnemonic_derivation_is_deterministic_and_derived_keys_match_scripts() { - let keychain = DeadcatKeychain::from_mnemonic(MNEMONIC, "").expect("keychain"); - let first = keychain.derive_order(17, INSTANCE_A).expect("derive"); - let repeated = keychain.derive_order(17, INSTANCE_A).expect("derive"); - assert_eq!( - keychain.deadcat_secret_key().expect("secret"), - [ - 0x2b, 0x58, 0x9d, 0xde, 0xba, 0xf4, 0x86, 0xbf, 0x1a, 0x8b, 0x13, 0xbe, 0x98, 0x6d, - 0x6e, 0xf3, 0x35, 0xa0, 0xc2, 0xc7, 0x90, 0x00, 0x8a, 0xcf, 0x44, 0x4c, 0xc1, 0x58, - 0x65, 0x30, 0x18, 0xd2, - ] - ); - assert_eq!( - first.maker_public_key, - [ - 0x52, 0x53, 0x14, 0x83, 0xce, 0x28, 0x08, 0xb9, 0xa0, 0xdb, 0x2e, 0x5f, 0xb5, 0x7d, - 0x12, 0x58, 0xcf, 0x82, 0x1f, 0xe0, 0x99, 0xf7, 0x83, 0xbe, 0x29, 0x6a, 0x38, 0xa3, - 0x71, 0x66, 0xab, 0xb8, - ] - ); - assert!(first == repeated); - assert_eq!(first.instance_id, INSTANCE_A); - assert_eq!(&first.maker_receive_spk.as_bytes()[..2], &[0x51, 0x20]); - assert_eq!( - &first.maker_receive_spk.as_bytes()[2..], - &first.receive_public_key - ); - assert_eq!( - <[u8; 32]>::from(Sha256::digest(first.maker_receive_spk.as_bytes())), - first.maker_receive_spk_hash - ); - - let compiled = CompiledMakerOrder::new(first.params(terms())).expect("compile"); - assert_eq!(compiled.internal_key().serialize(), first.cancel_public_key); - assert_eq!(compiled.maker_receive_spk(), &first.maker_receive_spk); - } - - #[test] - fn instance_id_separates_keys_without_changing_the_base_maker_key() { - let keychain = DeadcatKeychain::from_mnemonic(MNEMONIC, "").expect("keychain"); - let a = keychain.derive_order(17, INSTANCE_A).expect("derive"); - let b = keychain.derive_order(17, INSTANCE_B).expect("derive"); - assert_eq!(a.maker_public_key, b.maker_public_key); - assert_ne!(a.cancel_public_key, b.cancel_public_key); - assert_ne!(a.receive_public_key, b.receive_public_key); - assert_ne!(a.maker_receive_spk_hash, b.maker_receive_spk_hash); - } - - #[test] - fn different_order_indices_separate_base_and_derived_keys() { - let keychain = DeadcatKeychain::from_mnemonic(MNEMONIC, "").expect("keychain"); - let a = keychain.derive_order(17, INSTANCE_A).expect("derive"); - let b = keychain.derive_order(18, INSTANCE_A).expect("derive"); - assert_ne!(a.maker_public_key, b.maker_public_key); - assert_ne!(a.cancel_public_key, b.cancel_public_key); - assert_ne!(a.receive_public_key, b.receive_public_key); - } - - #[test] - fn owned_order_binds_one_index_to_params_and_masked_hint() { - let keychain = DeadcatKeychain::from_mnemonic(MNEMONIC, "").expect("keychain"); - let parent = ContractId::new(OutPoint::new(Txid::from_byte_array([0x66; 32]), 4)); - let owned = keychain - .derive_owned_order(513, parent.into(), OrderSide::No, terms(), INSTANCE_A) - .expect("derive"); - assert_eq!(owned.params, owned.keys.params(terms())); - assert_eq!(owned.params.instance_id, INSTANCE_A); - assert_eq!(owned.recovery_hint.side, OrderSide::No); - assert_eq!(owned.recovery_hint.parent_market, parent.into()); - assert_eq!( - owned.recovery_hint.maker_pubkey, - owned.keys.maker_public_key - ); - assert_eq!( - owned - .recovery_hint - .unmask_index(&keychain.deadcat_secret_key().expect("secret")), - 513 - ); - } -} diff --git a/crates/deadcat-client/src/lib.rs b/crates/deadcat-client/src/lib.rs index 6de1db3..2c7616e 100644 --- a/crates/deadcat-client/src/lib.rs +++ b/crates/deadcat-client/src/lib.rs @@ -1,65 +1,6 @@ //! Transport-free client verification and construction logic. -pub mod keys; -pub mod maker_builder; pub mod market_builder; pub mod validation; mod simplicity; - -use deadcat_contracts::recovery::{OrderRecoveryHint, RecoveryError}; -use thiserror::Error; - -/// Recover the candidate maker derivation index from a public order hint. -/// -/// The caller must still derive and compile the order and match its creation -/// output. XOR unmasking alone is not an ownership proof. -pub fn recover_order_candidate_index( - payload: &[u8], - deadcat_secret_key: &[u8; 32], -) -> Result { - let hint = OrderRecoveryHint::decode(payload)?; - Ok(hint.unmask_index(deadcat_secret_key)) -} - -#[derive(Debug, Error)] -pub enum ClientError { - #[error("invalid recovery hint: {0}")] - Recovery(#[from] RecoveryError), -} - -#[cfg(test)] -mod tests { - use deadcat_contracts::recovery::{OrderRecoveryHint, order_mask}; - use deadcat_types::{ContractId, OrderDirection, OrderSide}; - use elements::hashes::Hash as _; - use elements::{OutPoint, Txid}; - - use super::*; - - #[test] - fn owner_recovery_unmasks_but_requires_later_script_matching() { - let secret = [0x42; 32]; - let order_index = 17; - let mut hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - masked_order_index: 0, - parent_market: ContractId::new(OutPoint::new(Txid::from_byte_array([0x24; 32]), 3)) - .into(), - price: 5_000, - min_active_base: 100, - maker_pubkey: [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ], - }; - hint.masked_order_index = order_index ^ order_mask(hint, &secret); - - assert_eq!( - recover_order_candidate_index(&hint.encode(), &secret).expect("recover"), - order_index - ); - } -} diff --git a/crates/deadcat-client/src/maker_builder.rs b/crates/deadcat-client/src/maker_builder.rs deleted file mode 100644 index 58a6448..0000000 --- a/crates/deadcat-client/src/maker_builder.rs +++ /dev/null @@ -1,493 +0,0 @@ -//! Wallet-agnostic maker-order PSET construction and covenant finalization. - -use deadcat_contracts::SimplicityNetwork; -use deadcat_contracts::maker_order::{ - CompiledMakerOrder, MakerOrderError, create, derive_instance_id, derived_maker_order, fill, -}; -use deadcat_contracts::recovery::{OrderRecoveryHint, RecoveryError, recovery_txout}; -use deadcat_types::{MakerOrderParams, MakerOrderState, OrderDirection}; -use elements::confidential::{Asset, Nonce, Value}; -use elements::pset::PartiallySignedTransaction; -use elements::{AssetId, OutPoint, TxOut, TxOutWitness}; -use simplex::program::{ProgramTrait as _, WitnessTrait as _}; -use thiserror::Error; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MakerOrderCreationOutputs { - pub order: TxOut, - pub recovery_hint: TxOut, - pub offered_base_capacity: u64, -} - -/// Construct the two canonical outputs contributed by an order creation. -/// Wallet funding/change/fee outputs remain the caller's responsibility. -pub fn maker_order_creation_outputs( - policy_asset: AssetId, - creation_input_prevouts: &[OutPoint], - order_output_index: u32, - params: MakerOrderParams, - offered_base_capacity: u64, - hint: OrderRecoveryHint, -) -> Result { - let expected_instance_id = derive_instance_id(creation_input_prevouts, order_output_index)?; - if params.instance_id != expected_instance_id { - return Err(MakerBuilderError::InstanceIdMismatch); - } - if hint.direction != params.direction - || hint.price != params.price - || hint.min_active_base != params.min_active_base - || hint.maker_pubkey != params.maker_pubkey - { - return Err(MakerBuilderError::RecoveryHintMismatch); - } - let creation = create(params, offered_base_capacity)?; - let compiled = CompiledMakerOrder::new(params) - .map_err(|error| MakerBuilderError::Compilation(error.to_string()))?; - let held_asset = match params.direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - Ok(MakerOrderCreationOutputs { - order: explicit_txout( - held_asset, - creation.locked_amount, - compiled.script_pubkey().clone(), - ), - recovery_hint: recovery_txout(policy_asset, &hint.encode())?, - offered_base_capacity, - }) -} - -/// Mandatory exact outputs and typed state effect for one fill. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MakerFillPlan { - expected_outpoint: OutPoint, - params: MakerOrderParams, - input_locked: u64, - maker_payment: u64, - remainder_locked: Option, - filled_base: u64, - next_state: MakerOrderState, -} - -impl MakerFillPlan { - pub fn new( - expected_outpoint: OutPoint, - params: MakerOrderParams, - input_locked: u64, - fill_base: u64, - prior_total_filled_base: u64, - ) -> Result { - CompiledMakerOrder::new(params) - .map_err(|error| MakerBuilderError::Compilation(error.to_string()))?; - let price = u64::from(params.price); - let remaining_base = match params.direction { - OrderDirection::SellBase => input_locked, - OrderDirection::SellQuote => { - if price == 0 || !input_locked.is_multiple_of(price) { - return Err(MakerBuilderError::NonIntegralSellQuoteInput); - } - input_locked / price - } - }; - if fill_base > remaining_base { - return Err(MakerBuilderError::FillExceedsOrder); - } - let remainder_base = remaining_base - fill_base; - let remainder_locked = if remainder_base == 0 { - None - } else { - Some(match params.direction { - OrderDirection::SellBase => remainder_base, - OrderDirection::SellQuote => remainder_base - .checked_mul(price) - .ok_or(MakerBuilderError::ArithmeticOverflow)?, - }) - }; - let maker_payment = match params.direction { - OrderDirection::SellBase => fill_base - .checked_mul(price) - .ok_or(MakerBuilderError::ArithmeticOverflow)?, - OrderDirection::SellQuote => fill_base, - }; - let interpreted = fill( - params, - MakerOrderState::Active { - remaining_base, - total_filled_base: prior_total_filled_base, - }, - input_locked, - maker_payment, - remainder_locked, - )?; - Ok(Self { - expected_outpoint, - params, - input_locked, - maker_payment, - remainder_locked, - filled_base: interpreted.filled_base, - next_state: interpreted.next_state, - }) - } - - #[must_use] - pub const fn filled_base(&self) -> u64 { - self.filled_base - } - - #[must_use] - pub const fn maker_payment(&self) -> u64 { - self.maker_payment - } - - #[must_use] - pub const fn remainder_locked(&self) -> Option { - self.remainder_locked - } - - #[must_use] - pub const fn next_state(&self) -> MakerOrderState { - self.next_state - } - - /// Return `(absolute_output_index, exact_output)` pairs the composer must - /// install. - pub fn mandatory_outputs( - &self, - payment_index: usize, - remainder_index: Option, - ) -> Result, MakerBuilderError> { - let compiled = CompiledMakerOrder::new(self.params) - .map_err(|error| MakerBuilderError::Compilation(error.to_string()))?; - let payment_asset = match self.params.direction { - OrderDirection::SellBase => self.params.quote_asset_id, - OrderDirection::SellQuote => self.params.base_asset_id, - }; - let mut outputs = vec![( - payment_index, - explicit_txout( - payment_asset, - self.maker_payment, - compiled.maker_receive_spk().clone(), - ), - )]; - match (self.remainder_locked, remainder_index) { - (None, None) => {} - (Some(amount), Some(index)) if index != payment_index => { - let held_asset = match self.params.direction { - OrderDirection::SellBase => self.params.base_asset_id, - OrderDirection::SellQuote => self.params.quote_asset_id, - }; - outputs.push(( - index, - explicit_txout(held_asset, amount, compiled.script_pubkey().clone()), - )); - } - (Some(_), Some(_)) => return Err(MakerBuilderError::OutputAlias), - (Some(_), None) => return Err(MakerBuilderError::MissingRemainderIndex), - (None, Some(_)) => return Err(MakerBuilderError::UnexpectedRemainderIndex), - } - Ok(outputs) - } - - /// Verify the composed PSET at the exact positional anchors, execute the - /// covenant, and install its final script-path witness. - pub fn finalize( - &self, - pset: &mut PartiallySignedTransaction, - input_index: usize, - payment_index: usize, - remainder_index: Option, - network: &SimplicityNetwork, - ) -> Result<(), MakerBuilderError> { - let compiled = CompiledMakerOrder::new(self.params) - .map_err(|error| MakerBuilderError::Compilation(error.to_string()))?; - let input = pset - .inputs() - .get(input_index) - .ok_or(MakerBuilderError::InputIndexOutOfBounds)?; - if OutPoint::new(input.previous_txid, input.previous_output_index) != self.expected_outpoint - { - return Err(MakerBuilderError::WrongOrderOutpoint); - } - let witness_utxo = input - .witness_utxo - .as_ref() - .ok_or(MakerBuilderError::MissingWitnessUtxo)?; - let held_asset = match self.params.direction { - OrderDirection::SellBase => self.params.base_asset_id, - OrderDirection::SellQuote => self.params.quote_asset_id, - }; - if witness_utxo.script_pubkey != *compiled.script_pubkey() - || witness_utxo.asset != Asset::Explicit(held_asset) - || witness_utxo.value != Value::Explicit(self.input_locked) - { - return Err(MakerBuilderError::WrongOrderInput); - } - - for (index, expected) in self.mandatory_outputs(payment_index, remainder_index)? { - let actual = pset - .outputs() - .get(index) - .ok_or(MakerBuilderError::OutputIndexOutOfBounds)? - .to_txout(); - if actual != expected { - return Err(MakerBuilderError::MandatoryOutputMismatch { index }); - } - } - if pset - .inputs() - .iter() - .any(|input| input.witness_utxo.is_none()) - { - return Err(MakerBuilderError::MissingWitnessUtxo); - } - - let witness = derived_maker_order::MakerOrderWitness { - payment_index: u32::try_from(payment_index) - .map_err(|_| MakerBuilderError::OutputIndexOutOfBounds)?, - is_partial: self.remainder_locked.is_some(), - remainder_index: match remainder_index { - Some(index) => { - u32::try_from(index).map_err(|_| MakerBuilderError::OutputIndexOutOfBounds)? - } - None if payment_index == 0 => 1, - None => 0, - }, - }; - let stack = compiled - .program() - .as_ref() - .finalize(pset, &witness.build_witness(), input_index, network) - .map_err(|error| MakerBuilderError::Covenant(error.to_string()))?; - let stack = crate::simplicity::ensure_budget(stack).map_err(MakerBuilderError::Covenant)?; - pset.inputs_mut()[input_index].final_script_witness = Some(stack); - Ok(()) - } -} - -fn explicit_txout(asset: AssetId, value: u64, script_pubkey: elements::Script) -> TxOut { - TxOut { - asset: Asset::Explicit(asset), - value: Value::Explicit(value), - nonce: Nonce::Null, - script_pubkey, - witness: TxOutWitness::default(), - } -} - -#[derive(Debug, Error)] -pub enum MakerBuilderError { - #[error("maker-order economics error: {0}")] - Economics(#[from] MakerOrderError), - #[error("recovery encoding error: {0}")] - Recovery(#[from] RecoveryError), - #[error("maker-order identity derivation failed: {0}")] - Identity(#[from] deadcat_contracts::maker_order::MakerOrderIdentityError), - #[error("contract compilation failed: {0}")] - Compilation(String), - #[error("maker-order parameters do not match the canonical creation inputs and vout")] - InstanceIdMismatch, - #[error("recovery hint economics disagree with order parameters")] - RecoveryHintMismatch, - #[error("SellQuote input is not an exact multiple of price")] - NonIntegralSellQuoteInput, - #[error("fill exceeds remaining order capacity")] - FillExceedsOrder, - #[error("checked monetary arithmetic overflowed")] - ArithmeticOverflow, - #[error("partial fill requires a remainder output index")] - MissingRemainderIndex, - #[error("full fill must not supply a remainder output index")] - UnexpectedRemainderIndex, - #[error("maker payment and remainder outputs cannot alias")] - OutputAlias, - #[error("order input index is out of bounds")] - InputIndexOutOfBounds, - #[error("PSET input is missing witness_utxo evidence")] - MissingWitnessUtxo, - #[error("PSET order input does not match the compiled covenant and explicit amount")] - WrongOrderInput, - #[error("PSET order input does not spend the fill plan's exact live outpoint")] - WrongOrderOutpoint, - #[error("mandatory output index is out of bounds")] - OutputIndexOutOfBounds, - #[error("mandatory covenant output at index {index} does not match the plan")] - MandatoryOutputMismatch { index: usize }, - #[error("Simplicity covenant finalization failed: {0}")] - Covenant(String), -} - -#[cfg(test)] -mod tests { - use deadcat_types::{ContractId, OrderDirection, OrderSide}; - use elements::hashes::Hash as _; - use elements::pset::{Input as PsetInput, Output as PsetOutput}; - use elements::secp256k1_zkp::{Keypair, Secp256k1}; - use elements::{OutPoint, Txid}; - - use super::*; - - fn asset(byte: u8) -> AssetId { - AssetId::from_slice(&[byte; 32]).expect("asset") - } - - fn params(direction: OrderDirection, instance_id: [u8; 32]) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x11), - quote_asset_id: asset(0x22), - price: 7, - min_active_base: 3, - direction, - instance_id, - maker_pubkey: Keypair::from_seckey_slice(&Secp256k1::new(), &[0x31; 32]) - .expect("key") - .x_only_public_key() - .0 - .serialize(), - } - } - - #[test] - fn creation_outputs_are_exact_and_recoverable() { - let inputs = [OutPoint::new(Txid::from_byte_array([0x66; 32]), 1)]; - let order_output_index = 4; - let params = params( - OrderDirection::SellQuote, - derive_instance_id(&inputs, order_output_index).expect("instance"), - ); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 42, - parent_market: ContractId::new(OutPoint::new(Txid::from_byte_array([0x77; 32]), 2)) - .into(), - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - }; - let outputs = maker_order_creation_outputs( - asset(0x99), - &inputs, - order_output_index, - params, - 10, - hint, - ) - .expect("outputs"); - assert_eq!(outputs.order.asset, Asset::Explicit(params.quote_asset_id)); - assert_eq!(outputs.order.value, Value::Explicit(70)); - assert_eq!( - OrderRecoveryHint::decode( - deadcat_contracts::recovery::validate_recovery_txout( - &outputs.recovery_hint, - asset(0x99), - ) - .expect("envelope") - ) - .expect("hint"), - hint - ); - - let mut noncanonical = params; - noncanonical.instance_id = [0x42; 32]; - assert!(matches!( - maker_order_creation_outputs( - asset(0x99), - &inputs, - order_output_index, - noncanonical, - 10, - hint - ), - Err(MakerBuilderError::InstanceIdMismatch) - )); - } - - #[test] - fn partial_fill_plan_finalizes_real_covenant_witness() { - let live_outpoint = OutPoint::new(Txid::from_byte_array([0x88; 32]), 0); - let params = params(OrderDirection::SellBase, [0x55; 32]); - let plan = MakerFillPlan::new(live_outpoint, params, 10, 4, 9).expect("plan"); - assert_eq!(plan.maker_payment(), 28); - assert_eq!(plan.remainder_locked(), Some(6)); - assert_eq!( - plan.next_state(), - MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 13, - } - ); - - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let mut pset = PartiallySignedTransaction::new_v2(); - let mut input = PsetInput::from_prevout(live_outpoint); - input.witness_utxo = Some(explicit_txout( - params.base_asset_id, - 10, - compiled.script_pubkey().clone(), - )); - pset.add_input(input); - for (_, output) in plan.mandatory_outputs(0, Some(1)).expect("outputs") { - pset.add_output(PsetOutput::from_txout(output)); - } - let network = SimplicityNetwork::ElementsRegtest { - policy_asset: params.quote_asset_id, - }; - plan.finalize(&mut pset, 0, 0, Some(1), &network) - .expect("finalize"); - assert_eq!( - pset.inputs()[0] - .final_script_witness - .as_ref() - .expect("witness") - .len(), - 4 - ); - } - - #[test] - fn plan_rejects_dust_and_output_aliasing() { - let outpoint = OutPoint::new(Txid::from_byte_array([0x99; 32]), 3); - let params = params(OrderDirection::SellBase, [0x55; 32]); - assert!(matches!( - MakerFillPlan::new(outpoint, params, 10, 8, 0), - Err(MakerBuilderError::Economics( - MakerOrderError::RemainderBelowMinimum - )) - )); - let plan = MakerFillPlan::new(outpoint, params, 10, 4, 0).expect("plan"); - assert!(matches!( - plan.mandatory_outputs(0, Some(0)), - Err(MakerBuilderError::OutputAlias) - )); - } - - #[test] - fn fill_plan_is_bound_to_the_exact_live_outpoint() { - let expected = OutPoint::new(Txid::from_byte_array([0xaa; 32]), 1); - let wrong = OutPoint::new(Txid::from_byte_array([0xbb; 32]), 1); - let params = params(OrderDirection::SellBase, [0x55; 32]); - let plan = MakerFillPlan::new(expected, params, 10, 4, 0).expect("plan"); - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let mut pset = PartiallySignedTransaction::new_v2(); - let mut input = PsetInput::from_prevout(wrong); - input.witness_utxo = Some(explicit_txout( - params.base_asset_id, - 10, - compiled.script_pubkey().clone(), - )); - pset.add_input(input); - for (_, output) in plan.mandatory_outputs(0, Some(1)).expect("outputs") { - pset.add_output(PsetOutput::from_txout(output)); - } - let network = SimplicityNetwork::ElementsRegtest { - policy_asset: params.quote_asset_id, - }; - assert!(matches!( - plan.finalize(&mut pset, 0, 0, Some(1), &network), - Err(MakerBuilderError::WrongOrderOutpoint) - )); - } -} diff --git a/crates/deadcat-client/src/validation.rs b/crates/deadcat-client/src/validation.rs index 36ac3d7..7c9aa6b 100644 --- a/crates/deadcat-client/src/validation.rs +++ b/crates/deadcat-client/src/validation.rs @@ -4,7 +4,6 @@ //! or trusted snapshot anchor where chain authenticity matters. Internal DTO //! consistency is not proof that a node reported the canonical Liquid chain. -use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use deadcat_contracts::SimplicityNetwork; @@ -13,27 +12,23 @@ use deadcat_contracts::binary_market::{ CompiledBinaryMarket, }; use deadcat_contracts::interpret::{ - BinaryMarketLiveOutputs, BinaryMarketPath, MakerOrderSpendKind, TrackedContractOutput, - interpret_binary_market_spend, interpret_maker_order_spend, + BinaryMarketLiveOutputs, BinaryMarketPath, TrackedContractOutput, interpret_binary_market_spend, }; -use deadcat_contracts::maker_order::{CompiledMakerOrder, fill, validate_against_market}; use deadcat_contracts::rt::{RtLeg, RtSide, commitments, factors}; use deadcat_rpc::{ ContractHistoryPage, ContractParametersView, ContractStateView, ContractView, HistoryEntry, - LiveOutpoint, MarketSnapshot, OrderBookLevel, OrderBookSnapshot, RouteLeg, RouteSuggestion, - SnapshotMetadata, TransactionEvidence, + LiveOutpoint, MarketSnapshot, SnapshotMetadata, TransactionEvidence, }; use deadcat_types::{ BinaryMarketParams, BinaryMarketState, ChainAnchor, ChainPosition, ContractId, ContractKind, - ContractSyncState, MakerOrderParams, MakerOrderState, OrderDirection, OrderSide, + ContractSyncState, }; -use elements::confidential::{Asset, Nonce, Value}; +use elements::confidential::{Asset, Value}; use elements::pset::PartiallySignedTransaction; use elements::secp256k1_zkp::ZERO_TWEAK; -use elements::{BlockHash, OutPoint, Transaction, TxOutWitness}; +use elements::{BlockHash, OutPoint, Transaction}; use thiserror::Error; -use crate::maker_builder::{MakerBuilderError, MakerFillPlan}; use crate::market_builder::{BinaryMarketTransitionPlan, MarketBuilderError}; const TRANSITION_V1_MARKET_ISSUED: u16 = 0x1001; @@ -41,8 +36,6 @@ const TRANSITION_V1_MARKET_CANCELLED: u16 = 0x1002; const TRANSITION_V1_MARKET_RESOLVED: u16 = 0x1003; const TRANSITION_V1_MARKET_EXPIRED: u16 = 0x1004; const TRANSITION_V1_MARKET_REDEEMED: u16 = 0x1005; -const TRANSITION_V1_MAKER_FILLED: u16 = 0x1101; -const TRANSITION_V1_MAKER_CANCELLED: u16 = 0x1102; /// A structurally validated contract view. /// @@ -86,16 +79,6 @@ impl ValidatedContractReplay { } } -/// The route request whose advisory response is being checked. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct RouteIntent { - pub market_id: ContractId, - pub side: OrderSide, - pub direction: OrderDirection, - pub base_amount: u64, - pub max_orders: u16, -} - /// Recompile and validate every locally checkable field in a contract view. pub fn validate_contract_view( view: &ContractView, @@ -107,100 +90,17 @@ pub fn validate_contract_view( } validate_unique_live_outpoints(&view.live_outpoints)?; - match ( - view.kind, - &view.parameters, - view.state, - view.parent_market, - view.outcome_side, - ) { - ( - ContractKind::BinaryMarketV1, - ContractParametersView::BinaryMarket { params }, - ContractStateView::BinaryMarket { state }, - None, - None, - ) => { - CompiledBinaryMarket::new(*params) - .map_err(|error| ValidationError::Compilation(error.to_string()))?; - BinaryMarketEconomics::new(params.base_payout) - .and_then(|economics| economics.validate_state(state)) - .map_err(|error| ValidationError::Economics(error.to_string()))?; - validate_market_live_shape(state, &view.live_outpoints)?; - } - ( - ContractKind::MakerOrderV1, - ContractParametersView::MakerOrder { params }, - ContractStateView::MakerOrder { state }, - Some(_), - Some(_), - ) => { - CompiledMakerOrder::new(*params) - .map_err(|error| ValidationError::Compilation(error.to_string()))?; - validate_maker_state(*params, state)?; - validate_maker_live_shape(state, &view.live_outpoints)?; - } - (ContractKind::LmsrV1Reserved, _, _, _, _) => { - return Err(ValidationError::UnsupportedContractKind); - } - _ => { - return Err(ValidationError::ContractShape( - "kind, parameters, state, or parent fields disagree", - )); - } - } + let ContractParametersView::BinaryMarket { params } = &view.parameters; + let ContractStateView::BinaryMarket { state } = view.state; + CompiledBinaryMarket::new(*params) + .map_err(|error| ValidationError::Compilation(error.to_string()))?; + BinaryMarketEconomics::new(params.base_payout) + .and_then(|economics| economics.validate_state(state)) + .map_err(|error| ValidationError::Economics(error.to_string()))?; + validate_market_live_shape(state, &view.live_outpoints)?; Ok(ValidatedContractView { view: view.clone() }) } -/// Validate an order's exact relationship to its parent binary market. -pub fn validate_order_against_parent( - order: &ContractView, - market: &ContractView, -) -> Result { - validate_contract_view(market)?; - let validated = validate_contract_view(order)?; - let ContractParametersView::BinaryMarket { - params: market_params, - } = market.parameters - else { - return Err(ValidationError::ParentRelation( - "parent is not a binary market", - )); - }; - let ContractParametersView::MakerOrder { params } = order.parameters else { - return Err(ValidationError::ParentRelation( - "child is not a maker order", - )); - }; - if order.parent_market != Some(market.contract_id) { - return Err(ValidationError::ParentRelation( - "order points to a different parent market", - )); - } - if order.creation_position < market.creation_position { - return Err(ValidationError::ParentRelation( - "order creation precedes parent market creation", - )); - } - let side = order - .outcome_side - .ok_or(ValidationError::ParentRelation("order has no outcome side"))?; - let expected_base = match side { - OrderSide::Yes => market_params.yes_token_asset_id, - OrderSide::No => market_params.no_token_asset_id, - }; - validate_against_market( - params, - expected_base, - market_params.collateral_asset_id, - market_params - .collateral_per_pair() - .ok_or(ValidationError::ArithmeticOverflow)?, - ) - .map_err(|error| ValidationError::ParentEconomics(error.to_string()))?; - Ok(validated) -} - /// Validate a market snapshot against a chain anchor obtained independently /// from the user's own backend or block-header verifier. pub fn validate_market_snapshot( @@ -228,173 +128,6 @@ pub fn validate_market_snapshot( Ok(validated) } -/// Validate every presented order-book level against a same-snapshot order -/// view. This proves correctness of the presented set, but a remote index can -/// still omit an unknown order; completeness requires a local chain scan. -pub fn validate_order_book( - book: &OrderBookSnapshot, - market: &MarketSnapshot, - orders: &[ContractView], - trusted_anchor: ChainAnchor, -) -> Result<(), ValidationError> { - validate_market_snapshot(market, trusted_anchor)?; - if book.snapshot != market.snapshot || book.market_id != market.contract.contract_id { - return Err(ValidationError::SnapshotMismatch( - "order book and market do not share an identity and snapshot", - )); - } - validate_snapshot_metadata(&book.snapshot, trusted_anchor)?; - - let mut by_id = HashMap::with_capacity(orders.len()); - for order in orders { - validate_order_against_parent(order, &market.contract)?; - validate_ready_at(order, trusted_anchor)?; - if by_id.insert(order.contract_id, order).is_some() { - return Err(ValidationError::DuplicateOrder(order.contract_id)); - } - } - - let mut seen = HashSet::new(); - for level in book.asks.iter().chain(&book.bids) { - if !seen.insert(level.contract_id) { - return Err(ValidationError::DuplicateOrder(level.contract_id)); - } - let order = by_id - .get(&level.contract_id) - .ok_or(ValidationError::MissingOrder(level.contract_id))?; - validate_level(level, order)?; - } - if seen.len() != by_id.len() { - return Err(ValidationError::OrderBookMismatch( - "supplied active order views and levels are not one-to-one", - )); - } - if book - .asks - .iter() - .any(|level| level.direction != OrderDirection::SellBase) - || book - .bids - .iter() - .any(|level| level.direction != OrderDirection::SellQuote) - { - return Err(ValidationError::OrderBookMismatch( - "ask/bid direction classification is wrong", - )); - } - if !book - .asks - .windows(2) - .all(|pair| ask_key(&pair[0]) <= ask_key(&pair[1])) - || !book - .bids - .windows(2) - .all(|pair| bid_key(&pair[0]) <= bid_key(&pair[1])) - { - return Err(ValidationError::OrderBookMismatch( - "order-book levels are not in canonical order", - )); - } - Ok(()) -} - -/// Recompute the canonical greedy route over supplied, validated orders and -/// compare it byte-for-byte with the node's advisory result. -/// -/// As with [`validate_order_book`], the caller must source the complete order -/// set independently if omission resistance is required. -pub fn validate_route_suggestion( - route: &RouteSuggestion, - intent: RouteIntent, - market: &MarketSnapshot, - orders: &[ContractView], - trusted_anchor: ChainAnchor, -) -> Result<(), ValidationError> { - validate_market_snapshot(market, trusted_anchor)?; - if intent.base_amount == 0 || intent.max_orders == 0 || intent.max_orders > 1_000 { - return Err(ValidationError::InvalidRouteIntent); - } - if route.snapshot != market.snapshot - || route.market_id != intent.market_id - || intent.market_id != market.contract.contract_id - { - return Err(ValidationError::SnapshotMismatch( - "route does not belong to the requested market snapshot", - )); - } - if !matches!(market.state, BinaryMarketState::Trading { .. }) { - return Err(ValidationError::RouteMismatch( - "official routing is disabled after market termination", - )); - } - - let mut candidates = Vec::new(); - let mut ids = HashSet::new(); - for order in orders { - validate_order_against_parent(order, &market.contract)?; - validate_ready_at(order, trusted_anchor)?; - if !ids.insert(order.contract_id) { - return Err(ValidationError::DuplicateOrder(order.contract_id)); - } - if order.outcome_side == Some(intent.side) - && matches!(order.parameters, ContractParametersView::MakerOrder { params } if params.direction == intent.direction) - && matches!( - order.state, - ContractStateView::MakerOrder { - state: MakerOrderState::Active { .. } - } - ) - { - candidates.push(order); - } - } - candidates.sort_by(|left, right| route_order_cmp(left, right, intent.direction)); - - let mut remaining = intent.base_amount; - let mut expected_legs = Vec::new(); - let mut total_base = 0_u64; - let mut total_quote = 0_u64; - for order in candidates { - if remaining == 0 || expected_legs.len() == usize::from(intent.max_orders) { - break; - } - let (params, state) = maker_parts(order)?; - let MakerOrderState::Active { remaining_base, .. } = state else { - continue; - }; - let Some(base_amount) = - feasible_route_fill(remaining_base, u64::from(params.min_active_base), remaining) - else { - continue; - }; - validate_route_fill(params, state, base_amount)?; - let quote_amount = base_amount - .checked_mul(u64::from(params.price)) - .ok_or(ValidationError::ArithmeticOverflow)?; - total_base = total_base - .checked_add(base_amount) - .ok_or(ValidationError::ArithmeticOverflow)?; - total_quote = total_quote - .checked_add(quote_amount) - .ok_or(ValidationError::ArithmeticOverflow)?; - remaining -= base_amount; - expected_legs.push(RouteLeg { - order_id: order.contract_id, - base_amount, - quote_amount, - }); - } - if route.legs != expected_legs - || route.total_base != total_base - || route.total_quote != total_quote - { - return Err(ValidationError::RouteMismatch( - "route legs or totals differ from local recomputation", - )); - } - Ok(()) -} - /// Replay a complete ordered history through the canonical contract /// interpreters and compare the resulting state and live outpoints with the /// reported view. `trusted_snapshot_anchor` and `is_canonical` must come from @@ -408,7 +141,6 @@ pub fn validate_route_suggestion( /// scripts/outpoints. pub fn replay_contract_history( expected: &ContractView, - parent_market: Option<&ContractView>, history: &ContractHistoryPage, creation: &TransactionEvidence, transitions: &[TransactionEvidence], @@ -460,18 +192,8 @@ where previous = entry.position; } - match &expected.parameters { - ContractParametersView::BinaryMarket { params } => { - replay_market(expected, *params, history, creation, transitions)? - } - ContractParametersView::MakerOrder { params } => { - let parent = parent_market.ok_or(ValidationError::ParentRelation( - "maker replay requires its parent market view", - ))?; - validate_order_against_parent(expected, parent)?; - replay_maker(expected, *params, history, creation, transitions)?; - } - } + let ContractParametersView::BinaryMarket { params } = expected.parameters; + replay_market(expected, params, history, creation, transitions)?; Ok(ValidatedContractReplay { contract: ValidatedContractView { view: expected.clone(), @@ -480,26 +202,6 @@ where }) } -/// Validate a composed maker fill PSET without mutating the caller's PSET. -pub fn validate_maker_fill_pset_intent( - plan: &MakerFillPlan, - pset: &PartiallySignedTransaction, - input_index: usize, - payment_index: usize, - remainder_index: Option, - network: &SimplicityNetwork, -) -> Result<(), ValidationError> { - let mut staged = pset.clone(); - plan.finalize( - &mut staged, - input_index, - payment_index, - remainder_index, - network, - )?; - Ok(()) -} - /// Validate a composed market transition PSET without mutating the caller's /// PSET. This executes every affected Simplicity covenant on a clone. pub fn validate_market_pset_intent( @@ -514,108 +216,6 @@ pub fn validate_market_pset_intent( Ok(()) } -fn replay_maker( - expected: &ContractView, - params: MakerOrderParams, - history: &ContractHistoryPage, - creation: &TransactionEvidence, - transitions: &[TransactionEvidence], -) -> Result<(), ValidationError> { - let compiled = CompiledMakerOrder::new(params) - .map_err(|error| ValidationError::Compilation(error.to_string()))?; - let index = - usize::try_from(expected.contract_id.vout()).map_err(|_| ValidationError::IndexOverflow)?; - let output = - creation - .transaction - .output - .get(index) - .ok_or(ValidationError::CreationMismatch( - "maker ContractId output does not exist", - ))?; - if output.script_pubkey != *compiled.script_pubkey() { - return Err(ValidationError::CreationMismatch( - "maker ContractId output does not use the compiled order script", - )); - } - if output.nonce != Nonce::Null || output.witness != TxOutWitness::default() { - return Err(ValidationError::CreationMismatch( - "maker creation output is not canonical explicit form", - )); - } - let Asset::Explicit(asset) = output.asset else { - return Err(ValidationError::CreationMismatch( - "maker creation asset is confidential", - )); - }; - let Value::Explicit(locked) = output.value else { - return Err(ValidationError::CreationMismatch( - "maker creation value is confidential", - )); - }; - let expected_asset = match params.direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - if asset != expected_asset { - return Err(ValidationError::CreationMismatch( - "maker creation holds the wrong asset", - )); - } - let remaining_base = match params.direction { - OrderDirection::SellBase => locked, - OrderDirection::SellQuote => { - let price = u64::from(params.price); - if !locked.is_multiple_of(price) { - return Err(ValidationError::CreationMismatch( - "SellQuote creation value is not an exact price multiple", - )); - } - locked / price - } - }; - if remaining_base < u64::from(params.min_active_base) { - return Err(ValidationError::CreationMismatch( - "maker creation capacity is below its minimum", - )); - } - let index = u32::try_from(index).map_err(|_| ValidationError::IndexOverflow)?; - let mut state = MakerOrderState::Active { - remaining_base, - total_filled_base: 0, - }; - let mut live = Some(TrackedContractOutput { - outpoint: OutPoint::new(creation.txid, index), - txout: (*output).clone(), - }); - for (entry, evidence) in history.entries.iter().zip(transitions) { - let current = live.as_ref().ok_or(ValidationError::HistoryMismatch( - "history spends a terminal maker order", - ))?; - let interpreted = - interpret_maker_order_spend(params, state, current, &evidence.transaction) - .map_err(|error| ValidationError::Interpretation(error.to_string()))?; - let (kind, payload) = encode_maker_transition(interpreted.kind); - validate_transition_record(entry, kind, &payload)?; - state = interpreted.after; - live = interpreted.continuation; - } - if expected.state != (ContractStateView::MakerOrder { state }) { - return Err(ValidationError::FinalStateMismatch); - } - let live = live - .map(|output| LiveOutpoint { - role: 0, - outpoint: output.outpoint, - }) - .into_iter() - .collect::>(); - if !same_live_set(&live, &expected.live_outpoints) { - return Err(ValidationError::FinalLiveOutpointsMismatch); - } - Ok(()) -} - fn replay_market( expected: &ContractView, params: BinaryMarketParams, @@ -849,189 +449,6 @@ fn validate_market_live_shape( Ok(()) } -fn validate_maker_state( - params: MakerOrderParams, - state: MakerOrderState, -) -> Result<(), ValidationError> { - if let MakerOrderState::Active { - remaining_base, - total_filled_base, - } = state - { - if remaining_base < u64::from(params.min_active_base) { - return Err(ValidationError::Economics( - "active maker remainder is below its minimum".to_owned(), - )); - } - remaining_base - .checked_add(total_filled_base) - .ok_or(ValidationError::ArithmeticOverflow)?; - } - Ok(()) -} - -fn validate_maker_live_shape( - state: MakerOrderState, - live: &[LiveOutpoint], -) -> Result<(), ValidationError> { - let valid = match state { - MakerOrderState::Active { .. } => live.len() == 1 && live[0].role == 0, - MakerOrderState::Consumed | MakerOrderState::Cancelled => live.is_empty(), - }; - if !valid { - return Err(ValidationError::LiveOutpointShape( - "maker live output disagrees with materialized state", - )); - } - Ok(()) -} - -fn validate_level(level: &OrderBookLevel, order: &ContractView) -> Result<(), ValidationError> { - let (params, state) = maker_parts(order)?; - let MakerOrderState::Active { remaining_base, .. } = state else { - return Err(ValidationError::OrderBookMismatch( - "terminal order was included in the book", - )); - }; - if level.side - != order - .outcome_side - .ok_or(ValidationError::OrderBookMismatch("order side is missing"))? - || level.direction != params.direction - || level.price != params.price - || level.remaining_base != remaining_base - || level.creation_position != order.creation_position - { - return Err(ValidationError::OrderBookMismatch( - "order-book level disagrees with its contract view", - )); - } - Ok(()) -} - -fn maker_parts( - order: &ContractView, -) -> Result<(MakerOrderParams, MakerOrderState), ValidationError> { - match (&order.parameters, order.state) { - ( - ContractParametersView::MakerOrder { params }, - ContractStateView::MakerOrder { state }, - ) => Ok((*params, state)), - _ => Err(ValidationError::ContractShape( - "expected maker parameters and state", - )), - } -} - -fn validate_route_fill( - params: MakerOrderParams, - state: MakerOrderState, - base_amount: u64, -) -> Result<(), ValidationError> { - let MakerOrderState::Active { remaining_base, .. } = state else { - return Err(ValidationError::RouteMismatch("route uses terminal order")); - }; - let input_locked = match params.direction { - OrderDirection::SellBase => remaining_base, - OrderDirection::SellQuote => remaining_base - .checked_mul(u64::from(params.price)) - .ok_or(ValidationError::ArithmeticOverflow)?, - }; - let remainder_base = - remaining_base - .checked_sub(base_amount) - .ok_or(ValidationError::RouteMismatch( - "route fill exceeds order capacity", - ))?; - let remainder_locked = (remainder_base != 0) - .then(|| match params.direction { - OrderDirection::SellBase => Ok(remainder_base), - OrderDirection::SellQuote => remainder_base - .checked_mul(u64::from(params.price)) - .ok_or(ValidationError::ArithmeticOverflow), - }) - .transpose()?; - let maker_payment = match params.direction { - OrderDirection::SellBase => base_amount - .checked_mul(u64::from(params.price)) - .ok_or(ValidationError::ArithmeticOverflow)?, - OrderDirection::SellQuote => base_amount, - }; - fill(params, state, input_locked, maker_payment, remainder_locked) - .map_err(|error| ValidationError::RouteEconomics(error.to_string()))?; - Ok(()) -} - -const fn feasible_route_fill(capacity: u64, minimum: u64, requested: u64) -> Option { - if capacity < minimum || requested < minimum { - return None; - } - if requested >= capacity { - return Some(capacity); - } - let remainder = capacity - requested; - if remainder >= minimum { - return Some(requested); - } - let largest_partial = capacity - minimum; - if largest_partial >= minimum && largest_partial <= requested { - Some(largest_partial) - } else { - None - } -} - -fn ask_key(level: &OrderBookLevel) -> (u8, u32, ChainPosition, [u8; 36]) { - ( - side_byte(level.side), - level.price, - level.creation_position, - level.contract_id.to_fixed_key(), - ) -} - -fn bid_key(level: &OrderBookLevel) -> (u8, Reverse, ChainPosition, [u8; 36]) { - ( - side_byte(level.side), - Reverse(level.price), - level.creation_position, - level.contract_id.to_fixed_key(), - ) -} - -const fn side_byte(side: OrderSide) -> u8 { - match side { - OrderSide::Yes => 0, - OrderSide::No => 1, - } -} - -fn route_order_cmp( - left: &ContractView, - right: &ContractView, - direction: OrderDirection, -) -> std::cmp::Ordering { - let left_params = match left.parameters { - ContractParametersView::MakerOrder { params } => params, - ContractParametersView::BinaryMarket { .. } => unreachable!("validated maker order"), - }; - let right_params = match right.parameters { - ContractParametersView::MakerOrder { params } => params, - ContractParametersView::BinaryMarket { .. } => unreachable!("validated maker order"), - }; - let price = match direction { - OrderDirection::SellBase => left_params.price.cmp(&right_params.price), - OrderDirection::SellQuote => right_params.price.cmp(&left_params.price), - }; - price - .then_with(|| left.creation_position.cmp(&right.creation_position)) - .then_with(|| { - left.contract_id - .to_fixed_key() - .cmp(&right.contract_id.to_fixed_key()) - }) -} - fn unique_defining_input( transaction: &Transaction, expected_asset: elements::AssetId, @@ -1246,28 +663,6 @@ fn encode_market_transition( (kind, payload) } -fn encode_maker_transition(kind: MakerOrderSpendKind) -> (u16, Vec) { - match kind { - MakerOrderSpendKind::Fill(fill) => { - let mut payload = Vec::with_capacity(25); - payload.extend_from_slice(&fill.filled_base.to_be_bytes()); - payload.extend_from_slice(&fill.maker_payment.to_be_bytes()); - match fill.remaining_locked { - Some(remaining) => { - payload.push(1); - payload.extend_from_slice(&remaining.to_be_bytes()); - } - None => { - payload.push(0); - payload.extend_from_slice(&0_u64.to_be_bytes()); - } - } - (TRANSITION_V1_MAKER_FILLED, payload) - } - MakerOrderSpendKind::Cancel => (TRANSITION_V1_MAKER_CANCELLED, Vec::new()), - } -} - const fn outcome_byte(outcome: BinaryOutcome) -> u8 { match outcome { BinaryOutcome::Yes => 0, @@ -1277,18 +672,12 @@ const fn outcome_byte(outcome: BinaryOutcome) -> u8 { #[derive(Debug, Error)] pub enum ValidationError { - #[error("unsupported contract kind")] - UnsupportedContractKind, #[error("invalid contract shape: {0}")] ContractShape(&'static str), #[error("contract compilation failed: {0}")] Compilation(String), #[error("invalid contract economics: {0}")] Economics(String), - #[error("invalid parent relation: {0}")] - ParentRelation(&'static str), - #[error("invalid parent-market economics: {0}")] - ParentEconomics(String), #[error("duplicate live role or invalid live-output shape: {0}")] LiveOutpointShape(&'static str), #[error("snapshot mismatch: {0}")] @@ -1297,20 +686,6 @@ pub enum ValidationError { UntrustedSnapshotAnchor, #[error("contract is not ready at the requested snapshot")] ContractNotReadyAtSnapshot, - #[error("duplicate order {0:?}")] - DuplicateOrder(ContractId), - #[error("missing order view for level {0:?}")] - MissingOrder(ContractId), - #[error("invalid order book: {0}")] - OrderBookMismatch(&'static str), - #[error("invalid route request")] - InvalidRouteIntent, - #[error("invalid route: {0}")] - RouteMismatch(&'static str), - #[error("route violates maker economics: {0}")] - RouteEconomics(String), - #[error("checked arithmetic overflowed")] - ArithmeticOverflow, #[error("history response is paginated; complete replay is impossible")] IncompleteHistory, #[error("invalid history: {0}")] @@ -1342,8 +717,6 @@ pub enum ValidationError { FinalLiveOutpointsMismatch, #[error("transaction index does not fit u32")] IndexOverflow, - #[error("maker PSET intent is invalid: {0}")] - MakerIntent(#[from] MakerBuilderError), #[error("market PSET intent is invalid: {0}")] MarketIntent(#[from] MarketBuilderError), } @@ -1360,7 +733,7 @@ mod tests { use elements::pset::{Input as PsetInput, Output as PsetOutput}; use elements::secp256k1_zkp::{Keypair, Secp256k1, Tweak}; use elements::{ - AssetId, AssetIssuance, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, TxOut, + AssetId, AssetIssuance, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, TxOutWitness, Txid, }; @@ -1442,8 +815,6 @@ mod tests { outstanding_pairs: 0, }, }, - parent_market: None, - outcome_side: None, live_outpoints: vec![ LiveOutpoint { role: BinaryMarketSlot::DormantYesRt as u8, @@ -1457,59 +828,9 @@ mod tests { } } - fn order_view( - market: &ContractView, - at: ChainAnchor, - creation_byte: u8, - price: u32, - direction: OrderDirection, - ) -> ContractView { - let ContractParametersView::BinaryMarket { - params: market_params, - } = market.parameters - else { - unreachable!() - }; - let params = MakerOrderParams { - base_asset_id: market_params.yes_token_asset_id, - quote_asset_id: market_params.collateral_asset_id, - price, - min_active_base: 3, - direction, - instance_id: [creation_byte; 32], - maker_pubkey: key(0x41), - }; - let creation_txid = txid(creation_byte); - CompiledMakerOrder::new(params).expect("compile"); - let contract_id = ContractId::new(OutPoint::new(creation_txid, 0)); - ContractView { - contract_id, - kind: ContractKind::MakerOrderV1, - sync_state: ContractSyncState::Ready { synced_through: at }, - creation_position: position(2, u32::from(creation_byte)), - parameters: ContractParametersView::MakerOrder { params }, - state: ContractStateView::MakerOrder { - state: MakerOrderState::Active { - remaining_base: 10, - total_filled_base: 0, - }, - }, - parent_market: Some(market.contract_id), - outcome_side: Some(OrderSide::Yes), - live_outpoints: vec![LiveOutpoint { - role: 0, - outpoint: OutPoint::new(creation_txid, 0), - }], - } - } - fn market_snapshot(view: ContractView, at: ChainAnchor) -> MarketSnapshot { - let ContractParametersView::BinaryMarket { params } = view.parameters else { - unreachable!() - }; - let ContractStateView::BinaryMarket { state } = view.state else { - unreachable!() - }; + let ContractParametersView::BinaryMarket { params } = view.parameters; + let ContractStateView::BinaryMarket { state } = view.state; MarketSnapshot { snapshot: SnapshotMetadata { as_of: at, @@ -1525,21 +846,6 @@ mod tests { } } - fn level(order: &ContractView) -> OrderBookLevel { - let (params, state) = maker_parts(order).expect("maker"); - let MakerOrderState::Active { remaining_base, .. } = state else { - unreachable!() - }; - OrderBookLevel { - contract_id: order.contract_id, - side: order.outcome_side.expect("side"), - direction: params.direction, - price: params.price, - remaining_base, - creation_position: order.creation_position, - } - } - #[test] fn contract_shape_and_snapshot_anchor_fail_closed() { let tip = anchor(8, 0x80); @@ -1561,368 +867,6 @@ mod tests { )); } - #[test] - fn parent_assets_price_and_book_fields_are_recomputed() { - let tip = anchor(8, 0x80); - let market = market_view(tip); - let order = order_view(&market, tip, 0x31, 5, OrderDirection::SellBase); - validate_order_against_parent(&order, &market).expect("valid relation"); - let snapshot = market_snapshot(market.clone(), tip); - let mut book = OrderBookSnapshot { - snapshot: snapshot.snapshot.clone(), - market_id: market.contract_id, - asks: vec![level(&order)], - bids: Vec::new(), - }; - validate_order_book(&book, &snapshot, std::slice::from_ref(&order), tip) - .expect("valid book"); - - book.asks[0].price += 1; - assert!(matches!( - validate_order_book(&book, &snapshot, std::slice::from_ref(&order), tip), - Err(ValidationError::OrderBookMismatch(_)) - )); - - let mut wrong_asset = order; - let ContractParametersView::MakerOrder { params } = &mut wrong_asset.parameters else { - unreachable!() - }; - params.base_asset_id = market - .parameters - .clone() - .into_binary_market() - .expect("market") - .no_token_asset_id; - assert!(matches!( - validate_order_against_parent(&wrong_asset, &market), - Err(ValidationError::ParentEconomics(_)) - )); - } - - #[test] - fn route_is_recomputed_with_minimum_remainder_and_exact_quote() { - let tip = anchor(8, 0x80); - let market = market_view(tip); - let snapshot = market_snapshot(market.clone(), tip); - let order = order_view(&market, tip, 0x31, 5, OrderDirection::SellBase); - let intent = RouteIntent { - market_id: market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 8, - max_orders: 2, - }; - // Filling eight would leave two, below the order's minimum. The - // canonical route therefore fills seven and leaves three. - let mut route = RouteSuggestion { - snapshot: snapshot.snapshot.clone(), - market_id: market.contract_id, - legs: vec![RouteLeg { - order_id: order.contract_id, - base_amount: 7, - quote_amount: 35, - }], - total_base: 7, - total_quote: 35, - }; - validate_route_suggestion(&route, intent, &snapshot, std::slice::from_ref(&order), tip) - .expect("valid route"); - route.legs[0].quote_amount = 34; - assert!(matches!( - validate_route_suggestion(&route, intent, &snapshot, std::slice::from_ref(&order), tip), - Err(ValidationError::RouteMismatch(_)) - )); - } - - #[test] - fn maker_history_replay_rejects_payload_and_chain_tampering() { - let final_tip = anchor(3, 0x83); - let market = market_view(final_tip); - let mut expected = order_view(&market, final_tip, 0x31, 5, OrderDirection::SellBase); - let params = match expected.parameters { - ContractParametersView::MakerOrder { params } => params, - ContractParametersView::BinaryMarket { .. } => unreachable!(), - }; - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let creation_tx = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![explicit_output( - params.base_asset_id, - 10, - compiled.script_pubkey().clone(), - )], - }; - expected.contract_id = ContractId::new(OutPoint::new(creation_tx.txid(), 0)); - expected.creation_position = position(2, 0); - expected.state = ContractStateView::MakerOrder { - state: MakerOrderState::Cancelled, - }; - expected.live_outpoints.clear(); - - let mut input = TxIn { - previous_output: OutPoint::new(creation_tx.txid(), 0), - sequence: Sequence::MAX, - ..TxIn::default() - }; - input.witness.script_witness = vec![vec![1; 64]]; - let cancel_tx = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: vec![input], - output: Vec::new(), - }; - let creation = TransactionEvidence { - position: position(2, 0), - block_hash: block(0x82), - txid: creation_tx.txid(), - transaction: creation_tx, - affected_contract_ids: vec![expected.contract_id], - }; - let cancellation = TransactionEvidence { - position: position(3, 0), - block_hash: final_tip.hash, - txid: cancel_tx.txid(), - transaction: cancel_tx, - affected_contract_ids: vec![expected.contract_id], - }; - let history = ContractHistoryPage { - snapshot: SnapshotMetadata { - as_of: final_tip, - event_high_watermark: deadcat_types::EventCursor { - epoch: [8; 16], - sequence: 10, - }, - }, - contract_id: expected.contract_id, - entries: vec![HistoryEntry { - position: cancellation.position, - txid: cancellation.txid, - transition_kind: TRANSITION_V1_MAKER_CANCELLED, - transition_payload: Vec::new(), - }], - next: None, - }; - let creation_wtxid = creation.transaction.wtxid(); - let cancellation_wtxid = cancellation.transaction.wtxid(); - let canonical = |pos: ChainPosition, hash: BlockHash, transaction: &Transaction| { - (pos == position(2, 0) && hash == block(0x82) && transaction.wtxid() == creation_wtxid) - || (pos == position(3, 0) - && hash == final_tip.hash - && transaction.wtxid() == cancellation_wtxid) - }; - let replay = replay_contract_history( - &expected, - Some(&market), - &history, - &creation, - std::slice::from_ref(&cancellation), - final_tip, - canonical, - ) - .expect("verified replay"); - assert_eq!(replay.transition_count(), 1); - - let mut bad_history = history.clone(); - bad_history.entries[0].transition_payload.push(0); - assert!(matches!( - replay_contract_history( - &expected, - Some(&market), - &bad_history, - &creation, - std::slice::from_ref(&cancellation), - final_tip, - canonical - ), - Err(ValidationError::TransitionRecordMismatch { .. }) - )); - assert!(matches!( - replay_contract_history( - &expected, - Some(&market), - &history, - &creation, - std::slice::from_ref(&cancellation), - final_tip, - |_, _, _| false - ), - Err(ValidationError::NonCanonicalEvidence { .. }) - )); - - let mut witness_mutated = cancellation.clone(); - witness_mutated.transaction.input[0].witness.script_witness[0][0] ^= 1; - assert_eq!(witness_mutated.transaction.txid(), cancellation.txid); - assert_ne!( - witness_mutated.transaction.wtxid(), - cancellation.transaction.wtxid() - ); - assert!(matches!( - replay_contract_history( - &expected, - Some(&market), - &history, - &creation, - &[witness_mutated], - final_tip, - canonical, - ), - Err(ValidationError::NonCanonicalEvidence { .. }) - )); - - let mut omitted_contract = cancellation; - omitted_contract.affected_contract_ids.clear(); - assert!(matches!( - replay_contract_history( - &expected, - Some(&market), - &history, - &creation, - &[omitted_contract], - final_tip, - canonical - ), - Err(ValidationError::EvidenceContractMissing(id)) if id == expected.contract_id - )); - } - - #[test] - fn maker_creation_replay_uses_the_exact_nominated_output() { - let tip = anchor(2, 0x82); - let market = market_view(tip); - let template = order_view(&market, tip, 0x31, 5, OrderDirection::SellBase); - let ContractParametersView::MakerOrder { params } = template.parameters else { - unreachable!() - }; - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let order_output = - explicit_output(params.base_asset_id, 10, compiled.script_pubkey().clone()); - let creation_tx = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![ - order_output.clone(), - order_output, - explicit_output(params.base_asset_id, 10, Script::new()), - ], - }; - let ids = [ - ContractId::new(OutPoint::new(creation_tx.txid(), 0)), - ContractId::new(OutPoint::new(creation_tx.txid(), 1)), - ContractId::new(OutPoint::new(creation_tx.txid(), 2)), - ]; - let evidence = TransactionEvidence { - position: position(2, 0), - block_hash: tip.hash, - txid: creation_tx.txid(), - transaction: creation_tx, - affected_contract_ids: ids.to_vec(), - }; - let evidence_wtxid = evidence.transaction.wtxid(); - - for (vout, contract_id) in ids[..2].iter().copied().enumerate() { - let mut expected = template.clone(); - expected.contract_id = contract_id; - expected.creation_position = evidence.position; - expected.state = ContractStateView::MakerOrder { - state: MakerOrderState::Active { - remaining_base: 10, - total_filled_base: 0, - }, - }; - expected.live_outpoints = vec![LiveOutpoint { - role: 0, - outpoint: OutPoint::new(evidence.txid, u32::try_from(vout).expect("vout")), - }]; - let history = ContractHistoryPage { - snapshot: SnapshotMetadata { - as_of: tip, - event_high_watermark: deadcat_types::EventCursor { - epoch: [0x82; 16], - sequence: 1, - }, - }, - contract_id, - entries: Vec::new(), - next: None, - }; - replay_contract_history( - &expected, - Some(&market), - &history, - &evidence, - &[], - tip, - |position, hash, transaction| { - position == evidence.position - && hash == tip.hash - && transaction.wtxid() == evidence_wtxid - }, - ) - .expect("identical maker output has an independent exact-vout identity"); - } - - let mut wrong_script = template; - wrong_script.contract_id = ids[2]; - wrong_script.creation_position = evidence.position; - let history = ContractHistoryPage { - snapshot: SnapshotMetadata { - as_of: tip, - event_high_watermark: deadcat_types::EventCursor { - epoch: [0x82; 16], - sequence: 1, - }, - }, - contract_id: ids[2], - entries: Vec::new(), - next: None, - }; - assert!(matches!( - replay_contract_history( - &wrong_script, - Some(&market), - &history, - &evidence, - &[], - tip, - |position, hash, transaction| { - position == evidence.position - && hash == tip.hash - && transaction.wtxid() == evidence_wtxid - }, - ), - Err(ValidationError::CreationMismatch(message)) - if message.contains("compiled order script") - )); - - let missing_id = ContractId::new(OutPoint::new(evidence.txid, 3)); - let mut missing = wrong_script; - missing.contract_id = missing_id; - let mut missing_evidence = evidence; - missing_evidence.affected_contract_ids.push(missing_id); - let mut missing_history = history; - missing_history.contract_id = missing_id; - assert!(matches!( - replay_contract_history( - &missing, - Some(&market), - &missing_history, - &missing_evidence, - &[], - tip, - |position, hash, transaction| { - position == missing_evidence.position - && hash == tip.hash - && transaction.wtxid() == missing_evidence.transaction.wtxid() - }, - ), - Err(ValidationError::CreationMismatch(message)) - if message.contains("does not exist") - )); - } - fn new_issuance_input(outpoint: OutPoint) -> TxIn { TxIn { previous_output: outpoint, @@ -1997,8 +941,6 @@ mod tests { outstanding_pairs: 0, }, }, - parent_market: None, - outcome_side: None, live_outpoints: vec![ LiveOutpoint { role: BinaryMarketSlot::DormantYesRt as u8, @@ -2032,7 +974,6 @@ mod tests { let evidence_wtxid = evidence.transaction.wtxid(); replay_contract_history( &expected, - None, &history, &evidence, &[], @@ -2053,7 +994,6 @@ mod tests { assert!(matches!( replay_contract_history( &wrong_anchor_view, - None, &wrong_anchor_history, &wrong_anchor_evidence, &[], @@ -2091,7 +1031,6 @@ mod tests { assert!(matches!( replay_contract_history( &bad_expected, - None, &bad_history, &bad_evidence, &[], @@ -2217,8 +1156,6 @@ mod tests { outstanding_pairs: pairs, }, }, - parent_market: None, - outcome_side: None, live_outpoints: vec![ LiveOutpoint { role: BinaryMarketSlot::UnresolvedYesRt as u8, @@ -2289,7 +1226,6 @@ mod tests { let replay = replay_contract_history( &expected, - None, &history, &creation, std::slice::from_ref(&issuance), @@ -2314,7 +1250,6 @@ mod tests { assert!(matches!( replay_contract_history( &expected, - None, &wrong_side_history, &creation, &[wrong_side], @@ -2343,7 +1278,6 @@ mod tests { assert!(matches!( replay_contract_history( &expected, - None, &wrong_nonce_history, &creation, &[wrong_nonce], @@ -2365,7 +1299,6 @@ mod tests { assert!(matches!( replay_contract_history( &expected, - None, &wrong_history, &creation, &[issuance], @@ -2377,41 +1310,7 @@ mod tests { } #[test] - fn signing_intent_validation_is_non_mutating_and_rejects_tampering() { - let market = market_view(anchor(8, 0x80)); - let order = order_view(&market, anchor(8, 0x80), 0x31, 5, OrderDirection::SellBase); - let ContractParametersView::MakerOrder { params } = order.parameters else { - unreachable!() - }; - let live_outpoint = OutPoint::new(txid(0x31), 0); - let plan = MakerFillPlan::new(live_outpoint, params, 10, 4, 0).expect("plan"); - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let mut pset = PartiallySignedTransaction::new_v2(); - let mut input = PsetInput::from_prevout(live_outpoint); - input.witness_utxo = Some(explicit_output( - params.base_asset_id, - 10, - compiled.script_pubkey().clone(), - )); - pset.add_input(input); - for (_, output) in plan.mandatory_outputs(0, Some(1)).expect("outputs") { - pset.add_output(PsetOutput::from_txout(output)); - } - let network = SimplicityNetwork::ElementsRegtest { - policy_asset: params.quote_asset_id, - }; - let original = pset.clone(); - validate_maker_fill_pset_intent(&plan, &pset, 0, 0, Some(1), &network) - .expect("maker intent"); - assert_eq!(pset, original); - pset.outputs_mut()[0].amount = Some(19); - assert!(matches!( - validate_maker_fill_pset_intent(&plan, &pset, 0, 0, Some(1), &network), - Err(ValidationError::MakerIntent( - MakerBuilderError::MandatoryOutputMismatch { .. } - )) - )); - + fn market_signing_intent_validation_is_non_mutating_and_rejects_tampering() { let params = base_market_params(); let compiled = CompiledBinaryMarket::new(params).expect("compile market"); let rt_input = |outpoint, asset_id, leg, slot| { @@ -2481,17 +1380,4 @@ mod tests { )) )); } - - trait MarketParamsExt { - fn into_binary_market(self) -> Option; - } - - impl MarketParamsExt for ContractParametersView { - fn into_binary_market(self) -> Option { - match self { - Self::BinaryMarket { params } => Some(params), - Self::MakerOrder { .. } => None, - } - } - } } diff --git a/crates/deadcat-client/tests/market_regtest.rs b/crates/deadcat-client/tests/market_regtest.rs index dbcb9c5..74480a6 100644 --- a/crates/deadcat-client/tests/market_regtest.rs +++ b/crates/deadcat-client/tests/market_regtest.rs @@ -3,8 +3,7 @@ //! This test is ignored by ordinary `cargo test` because it starts an isolated //! `elementsd` + Electrs pair. It is required by `just ci` through the explicit //! `just regtest` suite. Its focused recipes are `just regtest-market-ab` and -//! `just regtest-maker-orders`, `just regtest-multi-market`, -//! `just regtest-multi-contract`, +//! `just regtest-multi-market`, //! `just regtest-backend-equivalence`, and `just regtest-process-boundary`. #[path = "support/process.rs"] @@ -16,26 +15,20 @@ use std::sync::Arc; use std::time::Duration; use bitcoincore_rpc::{Auth, Client, RpcApi}; -use deadcat_client::keys::{DeadcatKeychain, DerivedOwnedOrder, MakerOrderTerms}; -use deadcat_client::maker_builder::{MakerFillPlan, maker_order_creation_outputs}; use deadcat_client::market_builder::{ BinaryMarketCreationPlan, BinaryMarketLiveInputs, BinaryMarketTransitionPlan, MarketCreationContext, MarketIssuanceEntropies, MarketRtInput, OracleAttestation, }; -use deadcat_client::recover_order_candidate_index; use deadcat_client::validation::replay_contract_history; use deadcat_contracts::SimplicityNetwork; use deadcat_contracts::binary_market::{ BinaryMarketAction, BinaryMarketEconomics, BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome, CompiledBinaryMarket, derived_binary_market, }; -use deadcat_contracts::maker_order::{CompiledMakerOrder, derive_instance_id}; use deadcat_contracts::market_crypto::{ BinaryOutcome as OracleOutcome, derive_issuance_assets, oracle_message, }; -use deadcat_contracts::recovery::{ - MarketCollateral, MarketRecoveryHint, OrderRecoveryHint, validate_recovery_txout, -}; +use deadcat_contracts::recovery::{MarketCollateral, MarketRecoveryHint}; use deadcat_contracts::rt::{RtLeg, RtSide, add_mod_order, cbf, factors, infer_side}; use deadcat_iroh::RequestHandler as _; use deadcat_node::chain::elements_rpc::{ @@ -43,35 +36,27 @@ use deadcat_node::chain::elements_rpc::{ }; use deadcat_node::chain::esplora::{EsploraChainSource, EsploraConfig}; use deadcat_node::chain::{ChainSource as _, ChainSourceError, TransactionStatus}; -use deadcat_node::interpreter::{ - DeadcatInterpreter, TRANSITION_V1_MAKER_CANCELLED, TRANSITION_V1_MAKER_FILLED, - TRANSITION_V1_MARKET_ISSUED, -}; +use deadcat_node::interpreter::{DeadcatInterpreter, TRANSITION_V1_MARKET_ISSUED}; use deadcat_node::registration::RegistrationVerifier; use deadcat_node::rpc_handler::{NodeRpcHandler, RpcHandlerConfig}; use deadcat_node::store::{ - BlockDelta, ChainIdentity as StoreChainIdentity, ContractParameters, ContractState, - OrderBookEntry, Store, StoreError, StoredEvent, + BlockDelta, ChainIdentity as StoreChainIdentity, ContractParameters, ContractState, Store, + StoreError, StoredEvent, }; use deadcat_node::sync::{SyncCoordinator, SyncOutcome}; use deadcat_rpc::{ BackendKind, ContractHistoryPage, ContractStateView, ContractView, Event, EventEnvelope, - PageRequest, RecoveryFamily, Request, Response, RpcErrorCode, SyncStatus, TransactionEvidence, + Request, Response, RpcErrorCode, SyncStatus, TransactionEvidence, }; use deadcat_types::{ BinaryMarketParams, BinaryMarketState, CONTRACT_PACKAGE_FORMAT_VERSION, ChainAnchor, ChainIdentity, ChainPosition, ContractDeclaration, ContractDescriptor, ContractId, - ContractPackage, ContractSyncState, LiquidNetwork, MakerOrderParams, MakerOrderState, - OrderDirection, OrderSide, + ContractPackage, ContractSyncState, LiquidNetwork, }; use elements::confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}; -use elements::hashes::Hash as _; use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTransaction}; -use elements::schnorr::TapTweak as _; use elements::secp256k1_zkp::rand::thread_rng; use elements::secp256k1_zkp::{Keypair, Message, Secp256k1, SecretKey, SurjectionProof, Tweak}; -use elements::sighash::{Prevouts, SchnorrSighashType, SighashCache}; -use elements::taproot::{TapLeafHash, TapNodeHash}; use elements::{ AssetId, BlockHash, OutPoint, Script, Transaction, TxOut, TxOutSecrets, TxOutWitness, }; @@ -755,30 +740,6 @@ struct WalletUtxo { txout: TxOut, } -const MAKER_MNEMONIC: &str = - "exist carry drive collect lend cereal occur much tiger just involve mean"; -const MAKER_PRICE: u32 = 7; -const MAKER_MINIMUM: u32 = 3; -const MAKER_CAPACITY: u64 = 10; -const COMPOSED_ISSUANCE_PAIRS: u64 = 10; -const COMPOSED_SELL_BASE_FILL: u64 = 3; - -struct LiveMakerOrder { - order_index: u16, - side: OrderSide, - owned: DerivedOwnedOrder, - contract_id: ContractId, - output: WalletUtxo, - hint_vout: u32, -} - -#[derive(Clone)] -struct ComposedTransactionPlans { - market: BinaryMarketTransitionPlan, - sell_base: MakerFillPlan, - sell_quote: MakerFillPlan, -} - #[derive(Clone)] struct ComposedMarketPlans { first: BinaryMarketTransitionPlan, @@ -792,323 +753,6 @@ fn explicit_value(txout: &TxOut) -> u64 { value } -fn maker_terms( - market: BinaryMarketParams, - side: OrderSide, - direction: OrderDirection, -) -> MakerOrderTerms { - MakerOrderTerms { - base_asset_id: match side { - OrderSide::Yes => market.yes_token_asset_id, - OrderSide::No => market.no_token_asset_id, - }, - quote_asset_id: market.collateral_asset_id, - price: MAKER_PRICE, - min_active_base: MAKER_MINIMUM, - direction, - } -} - -#[allow(clippy::too_many_arguments)] -fn create_maker_orders( - signer: &Signer, - rpc: &Client, - miner: &ElementsRpc, - keychain: &DeadcatKeychain, - market: &CreatedMarket, - yes_tokens: &WalletUtxo, - no_tokens: &WalletUtxo, - funding: &Funding, -) -> ( - Transaction, - AcceptedTx, - Vec, - WalletUtxo, - WalletUtxo, -) { - let parent_market = ContractId::new(OutPoint::new(market.transaction.txid(), 0)).into(); - let definitions = [ - (0_u16, OrderSide::Yes, OrderDirection::SellBase), - (1, OrderSide::No, OrderDirection::SellQuote), - (2, OrderSide::No, OrderDirection::SellBase), - (3, OrderSide::Yes, OrderDirection::SellBase), - ]; - let creation_prevouts = [yes_tokens.outpoint, no_tokens.outpoint, funding.outpoint]; - let owned = definitions - .into_iter() - .enumerate() - .map(|(position, (index, side, direction))| { - let terms = maker_terms(market.params, side, direction); - let order_vout = u32::try_from(position * 2).expect("order vout"); - let instance_id = - derive_instance_id(&creation_prevouts, order_vout).expect("order identity"); - let owned = keychain - .derive_owned_order(index, parent_market, side, terms, instance_id) - .expect("derive mnemonic-owned maker order"); - (index, side, owned, order_vout) - }) - .collect::>(); - - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input(yes_tokens.outpoint, yes_tokens.txout.clone())); - pset.add_input(pset_input(no_tokens.outpoint, no_tokens.txout.clone())); - pset.add_input(pset_input(funding.outpoint, funding.txout.clone())); - - let mut positions = Vec::with_capacity(owned.len()); - for (index, side, owned, expected_order_vout) in owned { - let order_vout = u32::try_from(pset.outputs().len()).expect("order vout"); - assert_eq!(order_vout, expected_order_vout); - let creation = maker_order_creation_outputs( - market.params.collateral_asset_id, - &creation_prevouts, - order_vout, - owned.params, - MAKER_CAPACITY, - owned.recovery_hint, - ) - .expect("canonical maker-order outputs"); - pset.add_output(PsetOutput::from_txout(creation.order)); - let hint_vout = u32::try_from(pset.outputs().len()).expect("hint vout"); - pset.add_output(PsetOutput::from_txout(creation.recovery_hint)); - positions.push((index, side, owned, order_vout, hint_vout)); - } - - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.yes_token_asset_id, - explicit_value(&yes_tokens.txout) - MAKER_CAPACITY * 2, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.no_token_asset_id, - explicit_value(&no_tokens.txout) - MAKER_CAPACITY, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.collateral_asset_id, - FUNDING_VALUE - MAKER_CAPACITY * u64::from(MAKER_PRICE) - FEE, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(TxOut::new_fee( - FEE, - market.params.collateral_asset_id, - ))); - sign_input(signer, &mut pset, 0); - sign_input(signer, &mut pset, 1); - sign_input(signer, &mut pset, 2); - let transaction = pset.extract_tx().expect("maker creation transaction"); - - for (_, _, owned, order_vout, hint_vout) in &positions { - let capacity = MAKER_CAPACITY; - let expected = maker_order_creation_outputs( - market.params.collateral_asset_id, - &creation_prevouts, - *order_vout, - owned.params, - capacity, - owned.recovery_hint, - ) - .expect("rebuild canonical maker outputs"); - assert_eq!(transaction.output[*order_vout as usize], expected.order); - assert_eq!( - transaction.output[*hint_vout as usize], - expected.recovery_hint - ); - } - - let accepted = accept_broadcast_mine(rpc, miner, &transaction); - let orders = positions - .into_iter() - .map( - |(order_index, side, owned, order_vout, hint_vout)| LiveMakerOrder { - order_index, - side, - owned, - contract_id: ContractId::new(OutPoint::new(transaction.txid(), order_vout)), - output: wallet_utxo(&transaction, order_vout as usize), - hint_vout, - }, - ) - .collect(); - ( - transaction.clone(), - accepted, - orders, - wallet_utxo(&transaction, 8), - wallet_utxo(&transaction, 9), - ) -} - -#[allow(clippy::too_many_arguments)] -fn build_composed_issuance_and_maker_fills( - signer: &Signer, - network: &SimplicityNetwork, - market: &CreatedMarket, - before: BinaryMarketState, - market_live: &BinaryMarketLiveInputs, - collateral_txout: &TxOut, - sell_base: &LiveMakerOrder, - sell_quote: &LiveMakerOrder, - funding: &Funding, -) -> (PartiallySignedTransaction, ComposedTransactionPlans) { - assert_eq!(sell_base.owned.params.direction, OrderDirection::SellBase); - assert_eq!(sell_base.side, OrderSide::Yes); - assert_eq!(sell_quote.owned.params.direction, OrderDirection::SellQuote); - assert_eq!(sell_quote.side, OrderSide::No); - - let market_plan = BinaryMarketTransitionPlan::new( - market.params, - before, - BinaryMarketAction::Issue { - pairs: COMPOSED_ISSUANCE_PAIRS, - }, - market_live.clone(), - None, - ) - .expect("composed market issuance plan"); - let sell_base_plan = MakerFillPlan::new( - sell_base.output.outpoint, - sell_base.owned.params, - explicit_value(&sell_base.output.txout), - COMPOSED_SELL_BASE_FILL, - 0, - ) - .expect("composed SellBase fill plan"); - let sell_quote_plan = MakerFillPlan::new( - sell_quote.output.outpoint, - sell_quote.owned.params, - explicit_value(&sell_quote.output.txout), - MAKER_CAPACITY, - 0, - ) - .expect("composed SellQuote fill plan"); - assert_eq!( - sell_base_plan.next_state(), - MakerOrderState::Active { - remaining_base: 7, - total_filled_base: 3, - } - ); - assert_eq!(sell_quote_plan.next_state(), MakerOrderState::Consumed); - - let yes_rt = market_live.yes_rt.as_ref().expect("live YES RT"); - let no_rt = market_live.no_rt.as_ref().expect("live NO RT"); - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input(yes_rt.outpoint, yes_rt.txout.clone())); - pset.add_input(pset_input(no_rt.outpoint, no_rt.txout.clone())); - pset.add_input(pset_input( - market_live.collateral.expect("live market collateral"), - collateral_txout.clone(), - )); - pset.add_input(pset_input( - sell_base.output.outpoint, - sell_base.output.txout.clone(), - )); - pset.add_input(pset_input( - sell_quote.output.outpoint, - sell_quote.output.txout.clone(), - )); - pset.add_input(pset_input(funding.outpoint, funding.txout.clone())); - - // The market owns output indices 0..=2. This composition chooses maker - // payment indices 3 and 4 and continuation index 5, but the maker covenant - // does not couple any of those positions to its input index. Install the - // union in absolute order so - // no individual plan gets to assume ownership of the whole transaction. - let mut mandatory = market_plan - .mandatory_outputs(0) - .expect("composed market outputs"); - mandatory.extend( - sell_base_plan - .mandatory_outputs(3, Some(5)) - .expect("composed SellBase outputs"), - ); - mandatory.extend( - sell_quote_plan - .mandatory_outputs(4, None) - .expect("composed SellQuote outputs"), - ); - mandatory.sort_by_key(|(index, _)| *index); - assert_eq!(mandatory.len(), 6); - for (expected_index, (actual_index, output)) in mandatory.into_iter().enumerate() { - assert_eq!(actual_index, expected_index); - pset.add_output(PsetOutput::from_txout(output)); - } - - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.yes_token_asset_id, - COMPOSED_ISSUANCE_PAIRS + COMPOSED_SELL_BASE_FILL, - signer.get_address().script_pubkey(), - ))); - let sell_quote_locked = explicit_value(&sell_quote.output.txout); - let wallet_quote = sell_quote_locked - .checked_sub(sell_base_plan.maker_payment()) - .expect("SellQuote proceeds cover SellBase payment"); - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.collateral_asset_id, - wallet_quote, - signer.get_address().script_pubkey(), - ))); - - let economics = BinaryMarketEconomics::new(market.params.base_payout).expect("economics"); - let BinaryMarketState::Trading { - outstanding_pairs: old_pairs, - } = before - else { - panic!("composed issuance must start in Trading") - }; - let BinaryMarketState::Trading { - outstanding_pairs: new_pairs, - } = market_plan.after() - else { - panic!("composed issuance must end in Trading") - }; - let old_collateral = economics - .collateral_for_pairs(old_pairs) - .expect("old composed collateral"); - let new_collateral = economics - .collateral_for_pairs(new_pairs) - .expect("new composed collateral"); - assert_eq!(explicit_value(collateral_txout), old_collateral); - let funding_change = FUNDING_VALUE - .checked_add(old_collateral) - .and_then(|value| value.checked_sub(new_collateral)) - .and_then(|value| value.checked_sub(FEE)) - .expect("composed funding change"); - pset.add_output(PsetOutput::from_txout(explicit_txout( - market.params.collateral_asset_id, - funding_change, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(TxOut::new_fee( - FEE, - market.params.collateral_asset_id, - ))); - assert_eq!(pset.inputs().len(), 6); - assert_eq!(pset.outputs().len(), 10); - - market_plan - .configure_reissuance_inputs(&mut pset, 0, market.entropies) - .expect("configure composed reissuances"); - market_plan - .finalize(&mut pset, 0, 0, network) - .expect("finalize composed market covenants"); - sell_base_plan - .finalize(&mut pset, 3, 3, Some(5), network) - .expect("finalize composed SellBase covenant"); - sell_quote_plan - .finalize(&mut pset, 4, 4, None, network) - .expect("finalize composed SellQuote covenant"); - - ( - pset, - ComposedTransactionPlans { - market: market_plan, - sell_base: sell_base_plan, - sell_quote: sell_quote_plan, - }, - ) -} - #[allow(clippy::too_many_arguments)] fn build_composed_mixed_market_issuances( signer: &Signer, @@ -1278,278 +922,6 @@ fn build_composed_mixed_market_issuances( } #[allow(clippy::too_many_arguments)] -fn build_sell_base_fill( - signer: &Signer, - network: &SimplicityNetwork, - params: MakerOrderParams, - order: &WalletUtxo, - funding: &Funding, - fill_base: u64, - prior_total_filled_base: u64, -) -> (PartiallySignedTransaction, MakerFillPlan) { - let input_locked = explicit_value(&order.txout); - let plan = MakerFillPlan::new( - order.outpoint, - params, - input_locked, - fill_base, - prior_total_filled_base, - ) - .expect("SellBase fill plan"); - let remainder_index = plan.remainder_locked().map(|_| 1); - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input(order.outpoint, order.txout.clone())); - pset.add_input(pset_input(funding.outpoint, funding.txout.clone())); - for (_, output) in plan - .mandatory_outputs(0, remainder_index) - .expect("SellBase mandatory outputs") - { - pset.add_output(PsetOutput::from_txout(output)); - } - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.base_asset_id, - fill_base, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.quote_asset_id, - FUNDING_VALUE - plan.maker_payment() - FEE, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(TxOut::new_fee( - FEE, - params.quote_asset_id, - ))); - plan.finalize(&mut pset, 0, 0, remainder_index, network) - .expect("finalize SellBase covenant"); - (pset, plan) -} - -#[allow(clippy::too_many_arguments)] -fn build_sell_quote_fill( - signer: &Signer, - network: &SimplicityNetwork, - params: MakerOrderParams, - order: &WalletUtxo, - taker_base: &WalletUtxo, - funding: &Funding, - fill_base: u64, - prior_total_filled_base: u64, -) -> (PartiallySignedTransaction, MakerFillPlan) { - let input_locked = explicit_value(&order.txout); - let taker_base_value = explicit_value(&taker_base.txout); - assert!(taker_base_value >= fill_base); - let plan = MakerFillPlan::new( - order.outpoint, - params, - input_locked, - fill_base, - prior_total_filled_base, - ) - .expect("SellQuote fill plan"); - let remainder_index = plan.remainder_locked().map(|_| 1); - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input(order.outpoint, order.txout.clone())); - pset.add_input(pset_input(taker_base.outpoint, taker_base.txout.clone())); - pset.add_input(pset_input(funding.outpoint, funding.txout.clone())); - for (_, output) in plan - .mandatory_outputs(0, remainder_index) - .expect("SellQuote mandatory outputs") - { - pset.add_output(PsetOutput::from_txout(output)); - } - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.quote_asset_id, - fill_base * u64::from(params.price), - signer.get_address().script_pubkey(), - ))); - if taker_base_value > fill_base { - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.base_asset_id, - taker_base_value - fill_base, - signer.get_address().script_pubkey(), - ))); - } - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.quote_asset_id, - FUNDING_VALUE - FEE, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(TxOut::new_fee( - FEE, - params.quote_asset_id, - ))); - plan.finalize(&mut pset, 0, 0, remainder_index, network) - .expect("finalize SellQuote covenant"); - (pset, plan) -} - -fn build_maker_cancellation( - signer: &Signer, - params: MakerOrderParams, - order: &WalletUtxo, - funding: &Funding, -) -> PartiallySignedTransaction { - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input(order.outpoint, order.txout.clone())); - pset.add_input(pset_input(funding.outpoint, funding.txout.clone())); - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.base_asset_id, - explicit_value(&order.txout), - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(explicit_txout( - params.quote_asset_id, - FUNDING_VALUE - FEE, - signer.get_address().script_pubkey(), - ))); - pset.add_output(PsetOutput::from_txout(TxOut::new_fee( - FEE, - params.quote_asset_id, - ))); - pset -} - -fn sign_maker_cancellation( - pset: &mut PartiallySignedTransaction, - order_input_index: usize, - owned: &DerivedOwnedOrder, - genesis_hash: BlockHash, - apply_tap_tweak: bool, -) { - let compiled = CompiledMakerOrder::new(owned.params).expect("compile cancellation order"); - assert!(compiled.control_block().merkle_branch.as_inner().is_empty()); - let leaf = TapLeafHash::from_script( - &Script::from(compiled.cmr().to_vec()), - compiled.control_block().leaf_version, - ); - let root = TapNodeHash::from_byte_array(leaf.to_byte_array()); - let secp = Secp256k1::new(); - let maker_keypair = Keypair::from_seckey_slice(&secp, owned.keys.maker_secret_key()) - .expect("mnemonic-derived maker keypair"); - assert_eq!( - maker_keypair.x_only_public_key().0.serialize(), - compiled.internal_key().serialize() - ); - - let signing_keypair = if apply_tap_tweak { - let tweaked = maker_keypair.tap_tweak(&secp, Some(root)); - assert_eq!( - Script::new_v1_p2tr_tweaked(tweaked.public_parts().0), - *compiled.script_pubkey() - ); - tweaked.to_inner() - } else { - maker_keypair - }; - let unsigned = pset - .extract_tx() - .expect("cancellation unsigned transaction"); - let prevouts = pset - .inputs() - .iter() - .map(|input| input.witness_utxo.clone().expect("cancellation prevout")) - .collect::>(); - let sighash = SighashCache::new(&unsigned) - .taproot_key_spend_signature_hash( - order_input_index, - &Prevouts::All(&prevouts), - SchnorrSighashType::Default, - genesis_hash, - ) - .expect("Elements Taproot key-spend sighash"); - let signature = secp.sign_schnorr_no_aux_rand( - &Message::from_digest(sighash.to_byte_array()), - &signing_keypair, - ); - pset.inputs_mut()[order_input_index].final_script_witness = - Some(vec![signature.as_ref().to_vec()]); -} - -fn assert_mnemonic_order_recovery( - keychain: &DeadcatKeychain, - market: &CreatedMarket, - creation: &Transaction, - order: &LiveMakerOrder, -) { - let payload = validate_recovery_txout( - &creation.output[order.hint_vout as usize], - market.params.collateral_asset_id, - ) - .expect("canonical order recovery envelope"); - let hint = OrderRecoveryHint::decode(payload).expect("decode order recovery hint"); - assert_eq!(hint, order.owned.recovery_hint); - assert_eq!( - hint.parent_market, - ContractId::new(OutPoint::new(market.transaction.txid(), 0)).into() - ); - let deadcat_secret = keychain.deadcat_secret_key().expect("Deadcat secret"); - let candidate = recover_order_candidate_index(payload, &deadcat_secret) - .expect("recover candidate order index"); - assert_eq!(candidate, order.order_index); - let terms = maker_terms(market.params, hint.side, hint.direction); - let creation_prevouts = creation - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let instance_id = derive_instance_id(&creation_prevouts, order.contract_id.vout()) - .expect("recover canonical order identity"); - let recovered = keychain - .derive_owned_order(candidate, hint.parent_market, hint.side, terms, instance_id) - .expect("rederive recovered order"); - let compiled = CompiledMakerOrder::new(recovered.params).expect("compile recovered order"); - let held_asset = match recovered.params.direction { - OrderDirection::SellBase => recovered.params.base_asset_id, - OrderDirection::SellQuote => recovered.params.quote_asset_id, - }; - let order_output = &creation.output[order.contract_id.vout() as usize]; - assert_eq!(order_output.script_pubkey, *compiled.script_pubkey()); - assert_eq!(order_output.asset, Asset::Explicit(held_asset)); - assert_eq!(order_output.nonce, Nonce::Null); - assert_eq!(order_output.witness, TxOutWitness::default()); - let expected = maker_order_creation_outputs( - market.params.collateral_asset_id, - &creation_prevouts, - order.contract_id.vout(), - recovered.params, - MAKER_CAPACITY, - hint, - ) - .expect("rebuild recovered order"); - assert_eq!(order_output, &expected.order); - assert_eq!( - creation.output[order.hint_vout as usize], - expected.recovery_hint - ); - - let foreign = DeadcatKeychain::from_mnemonic( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - "", - ) - .expect("foreign mnemonic"); - let foreign_secret = foreign.deadcat_secret_key().expect("foreign secret"); - let foreign_candidate = recover_order_candidate_index(payload, &foreign_secret) - .expect("foreign hint still unmasks to a candidate"); - let foreign_order = foreign - .derive_owned_order( - foreign_candidate, - hint.parent_market, - hint.side, - terms, - instance_id, - ) - .expect("derive foreign candidate"); - let foreign_compiled = - CompiledMakerOrder::new(foreign_order.params).expect("compile foreign candidate"); - assert!( - creation - .output - .iter() - .all(|output| output.script_pubkey != *foreign_compiled.script_pubkey()) - ); -} - fn node_elements_auth(auth: &Auth) -> ElementsRpcAuth { match auth { Auth::None => ElementsRpcAuth::None, @@ -1637,7 +1009,6 @@ async fn assert_rpc_contract_replay( handler: &NodeRpcHandler, source: &ElementsRpcChainSource, contract_id: ContractId, - parent_market: Option<&ContractView>, ) -> (ContractView, ContractHistoryPage) { let view = rpc_contract_view(handler, contract_id).await; let history = rpc_contract_history(handler, contract_id).await; @@ -1670,7 +1041,6 @@ async fn assert_rpc_contract_replay( let trusted_tip = source.tip().await.expect("independent canonical tip"); let replay = replay_contract_history( &view, - parent_market, &history, &creation, &transitions, @@ -1687,27 +1057,13 @@ async fn assert_rpc_contract_replay( (view, history) } -fn stored_maker_state(store: &Store, contract_id: ContractId) -> MakerOrderState { - let record = store - .contract(contract_id) - .expect("read maker contract") - .expect("registered maker contract"); - assert!(matches!(record.sync_state, ContractSyncState::Ready { .. })); - let ContractState::MakerOrder(state) = record.state else { - panic!("maker ContractId resolved to non-maker state") - }; - state -} - fn stored_market_state(store: &Store, contract_id: ContractId) -> BinaryMarketState { let record = store .contract(contract_id) .expect("read market contract") .expect("registered market contract"); assert!(matches!(record.sync_state, ContractSyncState::Ready { .. })); - let ContractState::BinaryMarket(state) = record.state else { - panic!("market ContractId resolved to non-market state") - }; + let ContractState::BinaryMarket(state) = record.state; state } @@ -1741,35 +1097,6 @@ fn assert_tracked_outpoints( } } -fn ready_order_rows(store: &Store, market_id: ContractId) -> Vec<(ContractId, OrderBookEntry)> { - let mut rows = store - .ready_orders(market_id, None, None, None, 100) - .expect("ready orders") - .items - .into_iter() - .map(|row| (row.contract.contract_id, row.entry)) - .collect::>(); - rows.sort_by_key(|(contract_id, _)| *contract_id); - rows -} - -fn maker_fill_payload(plan: &MakerFillPlan) -> Vec { - let mut payload = Vec::with_capacity(25); - payload.extend_from_slice(&plan.filled_base().to_be_bytes()); - payload.extend_from_slice(&plan.maker_payment().to_be_bytes()); - match plan.remainder_locked() { - Some(remaining) => { - payload.push(1); - payload.extend_from_slice(&remaining.to_be_bytes()); - } - None => { - payload.push(0); - payload.extend_from_slice(&0_u64.to_be_bytes()); - } - } - payload -} - struct TokenPair { yes: WalletUtxo, no: WalletUtxo, @@ -3373,7 +2700,11 @@ async fn elements_and_esplora_backends_index_the_same_live_chain() { #[tokio::test(flavor = "current_thread")] #[ignore = "starts elementsd and liquid-enabled Electrs from the Nix development shell"] -async fn maker_order_lifecycle_is_accepted_by_elementsd() { +async fn multi_market_transaction_is_accepted_and_indexed_by_elementsd() { + const FIRST_INITIAL_PAIRS: u64 = 30; + const FIRST_COMPOSED_PAIRS: u64 = 10; + const SECOND_COMPOSED_PAIRS: u64 = 7; + let (client, signer) = Regtest::from_config(&RegtestConfig::default()).expect("regtest environment"); let network = SimplicityNetwork::default_regtest(); @@ -3393,1898 +2724,7 @@ async fn maker_order_lifecycle_is_accepted_by_elementsd() { hash: BlockHash::from_str(&funding_accepted.block_hash).expect("baseline hash"), }; let expiry_height = baseline.height.checked_add(1_000).expect("future expiry"); - let market = create_market( - &signer, - &rpc, - &miner, - policy_asset, - &funding[0], - &funding[1], - expiry_height, - ); - let (issuance, trading) = build_issuance( - &signer, - &network, - market.params, - market.entropies, - BinaryMarketState::Trading { - outstanding_pairs: 0, - }, - &dormant_live(&market.transaction), - None, - &funding[2], - 30, - RtSide::A, - false, - ); - assert_eq!( - trading, - BinaryMarketState::Trading { - outstanding_pairs: 30 - } - ); - accept_broadcast_mine(&rpc, &miner, &issuance); - - let keychain = - DeadcatKeychain::from_mnemonic(MAKER_MNEMONIC, "").expect("test Deadcat keychain"); - let (order_creation, _, orders, _yes_change, no_change) = create_maker_orders( - &signer, - &rpc, - &miner, - &keychain, - &market, - &wallet_utxo(&issuance, 3), - &wallet_utxo(&issuance, 4), - &funding[3], - ); - assert_eq!( - orders - .iter() - .map(|order| order.contract_id.vout()) - .collect::>(), - vec![0, 2, 4, 6] - ); - for order in &orders { - assert_mnemonic_order_recovery(&keychain, &market, &order_creation, order); - } - - let sell_base = &orders[0]; - let (sell_base_partial_pset, sell_base_partial_plan) = build_sell_base_fill( - &signer, - &network, - sell_base.owned.params, - &sell_base.output, - &funding[4], - 3, - 0, - ); - assert_eq!( - sell_base_partial_plan.next_state(), - MakerOrderState::Active { - remaining_base: 7, - total_filled_base: 3, - } - ); - let mut wrong_payment = sell_base_partial_pset.clone(); - wrong_payment.outputs_mut()[0].amount = Some(22); - wrong_payment.outputs_mut()[3].amount = Some(FUNDING_VALUE - 22 - FEE); - sign_input(&signer, &mut wrong_payment, 1); - let wrong_payment = wrong_payment - .extract_tx() - .expect("wrong-payment transaction"); - let wrong_payment_rejection = - assert_mempool_rejects(&rpc, &wrong_payment, "maker_wrong_payment_amount"); - - let mut wrong_receive_script = sell_base_partial_pset.clone(); - wrong_receive_script.outputs_mut()[0].script_pubkey = signer.get_address().script_pubkey(); - sign_input(&signer, &mut wrong_receive_script, 1); - let wrong_receive_script = wrong_receive_script - .extract_tx() - .expect("wrong receive-script transaction"); - let wrong_receive_rejection = - assert_mempool_rejects(&rpc, &wrong_receive_script, "maker_wrong_receive_script"); - - let mut below_minimum = sell_base_partial_pset.clone(); - below_minimum.outputs_mut()[0].amount = Some(14); - below_minimum.outputs_mut()[1].amount = Some(8); - below_minimum.outputs_mut()[2].amount = Some(2); - below_minimum.outputs_mut()[3].amount = Some(FUNDING_VALUE - 14 - FEE); - sign_input(&signer, &mut below_minimum, 1); - let below_minimum = below_minimum - .extract_tx() - .expect("below-minimum transaction"); - let below_minimum_rejection = - assert_mempool_rejects(&rpc, &below_minimum, "maker_fill_below_minimum"); - - let mut sell_base_partial_pset = sell_base_partial_pset; - sign_input(&signer, &mut sell_base_partial_pset, 1); - let sell_base_partial = sell_base_partial_pset - .extract_tx() - .expect("SellBase partial transaction"); - accept_broadcast_mine(&rpc, &miner, &sell_base_partial); - let (mut sell_base_full_pset, sell_base_full_plan) = build_sell_base_fill( - &signer, - &network, - sell_base.owned.params, - &wallet_utxo(&sell_base_partial, 1), - &funding[5], - 7, - 3, - ); - assert_eq!(sell_base_full_plan.next_state(), MakerOrderState::Consumed); - sign_input(&signer, &mut sell_base_full_pset, 1); - let sell_base_full = sell_base_full_pset - .extract_tx() - .expect("SellBase full transaction"); - accept_broadcast_mine(&rpc, &miner, &sell_base_full); - - let sell_quote = &orders[1]; - let (sell_quote_partial_pset, sell_quote_partial_plan) = build_sell_quote_fill( - &signer, - &network, - sell_quote.owned.params, - &sell_quote.output, - &no_change, - &funding[6], - 3, - 0, - ); - assert_eq!( - sell_quote_partial_plan.next_state(), - MakerOrderState::Active { - remaining_base: 7, - total_filled_base: 3, - } - ); - let mut wrong_remainder = sell_quote_partial_pset.clone(); - wrong_remainder.outputs_mut()[1].amount = Some(48); - wrong_remainder.outputs_mut()[2].amount = Some(22); - sign_input(&signer, &mut wrong_remainder, 1); - sign_input(&signer, &mut wrong_remainder, 2); - let wrong_remainder = wrong_remainder - .extract_tx() - .expect("wrong-remainder transaction"); - let wrong_remainder_rejection = - assert_mempool_rejects(&rpc, &wrong_remainder, "maker_wrong_remainder_amount"); - - let mut wrong_remainder_script = sell_quote_partial_pset.clone(); - wrong_remainder_script.outputs_mut()[1].script_pubkey = signer.get_address().script_pubkey(); - sign_input(&signer, &mut wrong_remainder_script, 1); - sign_input(&signer, &mut wrong_remainder_script, 2); - let wrong_remainder_script = wrong_remainder_script - .extract_tx() - .expect("wrong-remainder-script transaction"); - let wrong_remainder_script_rejection = assert_mempool_rejects( - &rpc, - &wrong_remainder_script, - "maker_wrong_remainder_script", - ); - - let mut sell_quote_partial_pset = sell_quote_partial_pset; - sign_input(&signer, &mut sell_quote_partial_pset, 1); - sign_input(&signer, &mut sell_quote_partial_pset, 2); - let sell_quote_partial = sell_quote_partial_pset - .extract_tx() - .expect("SellQuote partial transaction"); - accept_broadcast_mine(&rpc, &miner, &sell_quote_partial); - let (mut sell_quote_full_pset, sell_quote_full_plan) = build_sell_quote_fill( - &signer, - &network, - sell_quote.owned.params, - &wallet_utxo(&sell_quote_partial, 1), - &wallet_utxo(&sell_quote_partial, 3), - &funding[7], - 7, - 3, - ); - assert_eq!(sell_quote_full_plan.next_state(), MakerOrderState::Consumed); - sign_input(&signer, &mut sell_quote_full_pset, 1); - sign_input(&signer, &mut sell_quote_full_pset, 2); - let sell_quote_full = sell_quote_full_pset - .extract_tx() - .expect("SellQuote full transaction"); - accept_broadcast_mine(&rpc, &miner, &sell_quote_full); - - let cancelled = &orders[2]; - let cancellation_pset = build_maker_cancellation( - &signer, - cancelled.owned.params, - &cancelled.output, - &funding[8], - ); - let mut untweaked_cancel = cancellation_pset.clone(); - sign_input(&signer, &mut untweaked_cancel, 1); - sign_maker_cancellation( - &mut untweaked_cancel, - 0, - &cancelled.owned, - genesis_hash, - false, - ); - let untweaked_cancel = untweaked_cancel - .extract_tx() - .expect("untweaked cancellation transaction"); - let untweaked_cancel_rejection = - assert_mempool_rejects(&rpc, &untweaked_cancel, "maker_untweaked_cancellation_key"); - let mut cancellation_pset = cancellation_pset; - sign_input(&signer, &mut cancellation_pset, 1); - sign_maker_cancellation( - &mut cancellation_pset, - 0, - &cancelled.owned, - genesis_hash, - true, - ); - let cancellation = cancellation_pset - .extract_tx() - .expect("maker cancellation transaction"); - accept_broadcast_mine(&rpc, &miner, &cancellation); - - let (resolution, resolved_state) = build_active_resolution( - &signer, - &network, - market.params, - trading, - &active_live(&issuance), - &issuance.output[2], - &funding[9], - BinaryOutcome::Yes, - RtSide::B, - ); - assert!(matches!( - resolved_state, - BinaryMarketState::ResolvedYes { .. } - )); - let resolution_accepted = accept_broadcast_mine(&rpc, &miner, &resolution); - - // Feed the exact same chain through the production node path. The store - // starts before market creation, scans the parent from its public hint, - // then accepts all four maker declarations late and backfills their full - // histories before any order becomes routable. - let source = Arc::new( - ElementsRpcChainSource::new(ElementsRpcConfig::new( - client.rpc_url(), - node_elements_auth(&client.auth()), - )) - .expect("production Elements chain source"), - ); - let database_directory = tempfile::tempdir().expect("maker node database directory"); - let database_path = database_directory.path().join("deadcat.redb"); - let store = Arc::new(Store::open(&database_path).expect("open maker node store")); - store - .initialize_chain( - StoreChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash, - policy_asset, - }, - baseline, - ) - .expect("initialize maker node chain"); - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, policy_asset); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=initial_sync"); - let SyncOutcome::Ready(initial_sync) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("initial production node sync") - else { - panic!("live maker chain unexpectedly required a rescan") - }; - assert!(initial_sync.blocks_applied >= 9); - let resolution_tip = source.tip().await.expect("resolution tip"); - let market_id = ContractId::new(OutPoint::new(market.transaction.txid(), 0)); - let discovered_market = store - .contract(market_id) - .expect("read discovered market") - .expect("market auto-discovered from its canonical hint"); - assert_eq!( - discovered_market.state, - ContractState::BinaryMarket(resolved_state) - ); - assert!(orders.iter().all(|order| { - store - .contract(order.contract_id) - .expect("order lookup") - .is_some() - })); - - let package = ContractPackage { - format_version: CONTRACT_PACKAGE_FORMAT_VERSION, - chain: ChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash, - }, - roots: orders.iter().map(|order| order.contract_id).collect(), - declarations: orders - .iter() - .map(|order| ContractDeclaration { - contract_id: order.contract_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: order.side, - params: order.owned.params, - }, - }) - .chain(std::iter::once(ContractDeclaration { - contract_id: market_id, - descriptor: ContractDescriptor::BinaryMarketV1 { - params: market.params, - }, - })) - .collect(), - }; - let handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&store), - node_rpc_config(genesis_hash, policy_asset, baseline, resolution_tip), - ) - .expect("production node RPC handler"); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=package_registration"); - let Response::RegistrationAccepted { registration } = node_response( - &handler, - Request::RegisterContractPackage { - package: package.clone(), - bearer_token: None, - }, - ) - .await - else { - panic!("registration returned the wrong response") - }; - assert_eq!(registration.roots, package.roots); - assert_eq!(registration.contracts.len(), 5); - for receipt in ®istration.contracts { - assert!( - receipt.already_registered, - "chain-discovered markets and orders should make package registration idempotent" - ); - } - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=idempotent_resync"); - let SyncOutcome::Ready(backfill) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("maker idempotent resync") - else { - panic!("maker idempotent resync unexpectedly required a rescan") - }; - assert_eq!(backfill.backfill_blocks_applied, 0); - assert_eq!( - stored_maker_state(&store, orders[0].contract_id), - MakerOrderState::Consumed - ); - assert_eq!( - stored_maker_state(&store, orders[1].contract_id), - MakerOrderState::Consumed - ); - assert_eq!( - stored_maker_state(&store, orders[2].contract_id), - MakerOrderState::Cancelled - ); - assert_eq!( - stored_maker_state(&store, orders[3].contract_id), - MakerOrderState::Active { - remaining_base: MAKER_CAPACITY, - total_filled_base: 0, - } - ); - for (order, expected_kinds) in orders.iter().zip([ - vec![TRANSITION_V1_MAKER_FILLED, TRANSITION_V1_MAKER_FILLED], - vec![TRANSITION_V1_MAKER_FILLED, TRANSITION_V1_MAKER_FILLED], - vec![TRANSITION_V1_MAKER_CANCELLED], - vec![], - ]) { - let history = store - .contract_history(order.contract_id) - .expect("maker history after backfill"); - assert_eq!( - history - .iter() - .map(|entry| entry.transition.kind) - .collect::>(), - expected_kinds - ); - } - let active_orders = store - .ready_orders(market_id, None, None, None, 10) - .expect("ready maker orders"); - assert_eq!(active_orders.items.len(), 1); - assert_eq!( - active_orders.items[0].contract.contract_id, - orders[3].contract_id - ); - - let Response::RecoveryHints { page: hints } = node_response( - &handler, - Request::ListRecoveryHints { - family: Some(RecoveryFamily::MakerOrderV1), - page: PageRequest { - cursor: None, - limit: 100, - }, - }, - ) - .await - else { - panic!("recovery-hint query returned the wrong response") - }; - let creation_hints = hints - .hints - .iter() - .filter(|hint| hint.creation_txid == order_creation.txid()) - .collect::>(); - assert_eq!(creation_hints.len(), 4); - assert_eq!( - creation_hints - .iter() - .map(|hint| hint.location.output_index) - .collect::>(), - vec![1, 3, 5, 7] - ); - for order in &orders { - let record = creation_hints - .iter() - .find(|record| record.location.output_index == order.hint_vout) - .expect("RPC recovery record for every created order"); - assert_eq!(record.associated_contract, Some(order.contract_id)); - let on_chain_payload = validate_recovery_txout( - &order_creation.output[order.hint_vout as usize], - policy_asset, - ) - .expect("on-chain recovery hint payload"); - assert_eq!(record.payload, on_chain_payload); - assert_eq!(record.payload, order.owned.recovery_hint.encode()); - } - - let route_error = handler - .handle( - [0x55; 32], - Request::SuggestRoute { - market_id, - side: orders[3].side, - direction: orders[3].owned.params.direction, - base_amount: MAKER_CAPACITY, - max_orders: 1, - }, - ) - .await - .expect_err("official routing must stop after parent resolution"); - assert_eq!(route_error.code, RpcErrorCode::CovenantInvariantViolation); - - let post_resolution = &orders[3]; - let (mut post_resolution_fill_pset, post_resolution_plan) = build_sell_base_fill( - &signer, - &network, - post_resolution.owned.params, - &post_resolution.output, - &funding[10], - MAKER_CAPACITY, - 0, - ); - assert_eq!(post_resolution_plan.next_state(), MakerOrderState::Consumed); - sign_input(&signer, &mut post_resolution_fill_pset, 1); - let post_resolution_fill = post_resolution_fill_pset - .extract_tx() - .expect("post-resolution maker fill"); - assert_eq!( - test_mempool_accept(&rpc, &post_resolution_fill).allowed, - Some(true), - "maker covenant intentionally remains consensus-fillable after parent resolution" - ); - let post_resolution_accepted = accept_broadcast_mine(&rpc, &miner, &post_resolution_fill); - - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=post_fill_sync"); - let SyncOutcome::Ready(post_fill_sync) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index post-resolution custom fill") - else { - panic!("post-resolution fill unexpectedly required a rescan") - }; - assert_eq!(post_fill_sync.blocks_applied, 1); - assert_eq!( - stored_maker_state(&store, post_resolution.contract_id), - MakerOrderState::Consumed - ); - assert_eq!( - store - .contract_history(post_resolution.contract_id) - .expect("post-resolution order history") - .iter() - .map(|entry| entry.transition.kind) - .collect::>(), - vec![TRANSITION_V1_MAKER_FILLED] - ); - assert!( - store - .ready_orders(market_id, None, None, None, 10) - .expect("empty terminal order book") - .items - .is_empty() - ); - - // Replace the final two blocks with a different canonical branch while - // explicitly controlling which mempool transactions enter each block. - // This exercises the production coordinator's full two-block rollback and - // replay boundary with the same semantic state on different block hashes. - let mining_address = signer.get_address().to_unconfidential().to_string(); - let mine_exact = |txids: Vec| -> String { - let result: JsonValue = rpc - .call( - "generateblock", - &[json!(mining_address.clone()), json!(txids)], - ) - .expect("mine exact regtest block"); - result["hash"] - .as_str() - .expect("generateblock hash") - .to_owned() - }; - let invalidated: JsonValue = rpc - .call( - "invalidateblock", - &[json!(resolution_accepted.block_hash.clone())], - ) - .expect("invalidate resolution block"); - assert!(invalidated.is_null()); - let replacement_resolution_hash = mine_exact(vec![resolution.txid().to_string()]); - let replacement_post_fill_hash = mine_exact(vec![post_resolution_fill.txid().to_string()]); - assert_ne!(replacement_resolution_hash, resolution_accepted.block_hash); - assert_ne!( - replacement_post_fill_hash, - post_resolution_accepted.block_hash - ); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=two_block_reorg"); - let SyncOutcome::Ready(two_block_reorg) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("two-block live reorg") - else { - panic!("two-block live reorg exceeded retention") - }; - assert_eq!(two_block_reorg.blocks_rolled_back, 2); - assert_eq!(two_block_reorg.blocks_applied, 2); - assert_eq!( - stored_maker_state(&store, post_resolution.contract_id), - MakerOrderState::Consumed - ); - - // Replace only the post-resolution fill block with an empty block. The - // parent remains resolved while the maker order returns to Active, proving - // that official routing still refuses it. Mine the still-valid custom fill - // one block later and index it again. - let invalidated: JsonValue = rpc - .call( - "invalidateblock", - &[json!(replacement_post_fill_hash.clone())], - ) - .expect("invalidate post-resolution fill block"); - assert!(invalidated.is_null()); - let empty_replacement_hash = mine_exact(vec![]); - assert_ne!(empty_replacement_hash, replacement_post_fill_hash); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=one_block_reorg"); - let SyncOutcome::Ready(one_block_reorg) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("one-block live reorg") - else { - panic!("one-block live reorg exceeded retention") - }; - assert_eq!(one_block_reorg.blocks_rolled_back, 1); - assert_eq!(one_block_reorg.blocks_applied, 1); - assert_eq!( - store - .contract(market_id) - .expect("resolved market after one-block reorg") - .expect("resolved market after one-block reorg") - .state, - ContractState::BinaryMarket(resolved_state) - ); - assert_eq!( - stored_maker_state(&store, post_resolution.contract_id), - MakerOrderState::Active { - remaining_base: MAKER_CAPACITY, - total_filled_base: 0, - } - ); - let restored_record = store - .contract(post_resolution.contract_id) - .expect("restored order lookup") - .expect("restored active order"); - assert_eq!(restored_record.outpoints.len(), 1); - assert_eq!( - restored_record.outpoints[0].outpoint, - post_resolution.output.outpoint - ); - assert!( - store - .contract_history(post_resolution.contract_id) - .expect("rolled-back order history") - .is_empty() - ); - let restored_book = store - .ready_orders(market_id, None, None, None, 10) - .expect("restored active order index"); - assert_eq!(restored_book.items.len(), 1); - assert_eq!( - restored_book.items[0].contract.contract_id, - post_resolution.contract_id - ); - assert_eq!(restored_book.items[0].entry.remaining_base, MAKER_CAPACITY); - let route_error = handler - .handle( - [0x55; 32], - Request::SuggestRoute { - market_id, - side: post_resolution.side, - direction: post_resolution.owned.params.direction, - base_amount: MAKER_CAPACITY, - max_orders: 1, - }, - ) - .await - .expect_err("routing stays disabled while the custom fill is rolled back"); - assert_eq!(route_error.code, RpcErrorCode::CovenantInvariantViolation); - - let final_post_fill_hash = mine_exact(vec![post_resolution_fill.txid().to_string()]); - assert_ne!(final_post_fill_hash, post_resolution_accepted.block_hash); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=post_reorg_fill_replay"); - let SyncOutcome::Ready(post_reorg_fill_replay) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index post-reorg custom fill") - else { - panic!("post-reorg fill unexpectedly required a rescan") - }; - assert_eq!(post_reorg_fill_replay.blocks_applied, 1); - assert_eq!( - stored_maker_state(&store, post_resolution.contract_id), - MakerOrderState::Consumed - ); - - // Close every redb handle, reopen the database, retry the package and sync, - // then independently replay RPC history/evidence through client logic. - drop(handler); - drop(store); - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=restart"); - let reopened = Arc::new(Store::open(&database_path).expect("reopen maker node store")); - let SyncOutcome::Ready(restart_sync) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("idempotent sync after restart") - else { - panic!("restarted live maker node unexpectedly required a rescan") - }; - assert_eq!(restart_sync.blocks_applied, 0); - assert_eq!(restart_sync.blocks_rolled_back, 0); - assert_eq!(restart_sync.backfill_blocks_applied, 0); - let final_tip = source.tip().await.expect("final canonical tip"); - let reopened_handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&reopened), - node_rpc_config(genesis_hash, policy_asset, baseline, final_tip), - ) - .expect("reopened production node RPC handler"); - let Response::RegistrationAccepted { - registration: repeated, - } = node_response( - &reopened_handler, - Request::RegisterContractPackage { - package: package.clone(), - bearer_token: None, - }, - ) - .await - else { - panic!("idempotent registration returned the wrong response") - }; - assert_eq!(repeated.roots, package.roots); - assert_eq!(repeated.contracts.len(), package.declarations.len()); - for declaration in &package.declarations { - let receipt = repeated - .contracts - .iter() - .find(|receipt| receipt.contract_id == declaration.contract_id) - .expect("idempotent receipt for every declared contract"); - assert!(receipt.already_registered); - } - - eprintln!("DEADCAT_MAKER_REGTEST_PHASE=client_replay"); - let (parent_view, parent_history) = - assert_rpc_contract_replay(&reopened_handler, source.as_ref(), market_id, None).await; - assert!(matches!( - parent_view.state, - ContractStateView::BinaryMarket { - state: BinaryMarketState::ResolvedYes { .. } - } - )); - assert!( - parent_history - .entries - .iter() - .any(|entry| entry.txid == resolution.txid()) - ); - for (index, order) in orders.iter().enumerate() { - let (view, history) = assert_rpc_contract_replay( - &reopened_handler, - source.as_ref(), - order.contract_id, - Some(&parent_view), - ) - .await; - let expected_state = match index { - 0 | 1 | 3 => MakerOrderState::Consumed, - 2 => MakerOrderState::Cancelled, - _ => unreachable!(), - }; - assert_eq!( - view.state, - ContractStateView::MakerOrder { - state: expected_state, - } - ); - let expected_kinds = match index { - 0 | 1 => vec![TRANSITION_V1_MAKER_FILLED, TRANSITION_V1_MAKER_FILLED], - 2 => vec![TRANSITION_V1_MAKER_CANCELLED], - 3 => vec![TRANSITION_V1_MAKER_FILLED], - _ => unreachable!(), - }; - assert_eq!( - history - .entries - .iter() - .map(|entry| entry.transition_kind) - .collect::>(), - expected_kinds - ); - } - - let report = json!({ - "schema": "deadcat.maker-regtest.v1", - "market_id": ContractId::new(OutPoint::new(market.transaction.txid(), 0)).to_string(), - "order_ids": orders.iter().map(|order| order.contract_id.to_string()).collect::>(), - "canonical_resolution_block": replacement_resolution_hash, - "canonical_post_resolution_fill_block": final_post_fill_hash, - "negative_tests": [ - wrong_payment_rejection, - wrong_receive_rejection, - below_minimum_rejection, - wrong_remainder_rejection, - wrong_remainder_script_rejection, - untweaked_cancel_rejection, - ], - }); - eprintln!( - "DEADCAT_MAKER_REGTEST_METRICS={}", - serde_json::to_string(&report).expect("serialize maker metrics") - ); -} - -#[tokio::test(flavor = "current_thread")] -#[ignore = "starts elementsd and liquid-enabled Electrs from the Nix development shell"] -async fn multi_market_transaction_is_accepted_and_indexed_by_elementsd() { - const FIRST_INITIAL_PAIRS: u64 = 30; - const FIRST_COMPOSED_PAIRS: u64 = 10; - const SECOND_COMPOSED_PAIRS: u64 = 7; - - let (client, signer) = - Regtest::from_config(&RegtestConfig::default()).expect("regtest environment"); - let network = SimplicityNetwork::default_regtest(); - let policy_asset = network.policy_asset(); - let miner = ElementsRpc::new(client.rpc_url(), client.auth()).expect("Elements RPC"); - let rpc = Client::new(&client.rpc_url(), client.auth()).expect("raw Elements RPC"); - let genesis_hash = BlockHash::from_str( - &rpc.get_block_hash(0) - .expect("regtest genesis block") - .to_string(), - ) - .expect("Elements genesis hash"); - let (_funding_tx, funding_accepted, funding) = - prepare_funding(&signer, &rpc, &miner, policy_asset); - let baseline = ChainAnchor { - height: u32::try_from(funding_accepted.block_height).expect("baseline height"), - hash: BlockHash::from_str(&funding_accepted.block_hash).expect("baseline hash"), - }; - let expiry_height = baseline.height.checked_add(1_000).expect("future expiry"); - let first_market = create_market( - &signer, - &rpc, - &miner, - policy_asset, - &funding[0], - &funding[1], - expiry_height, - ); - let second_market = create_market( - &signer, - &rpc, - &miner, - policy_asset, - &funding[2], - &funding[3], - expiry_height, - ); - let initial_state = BinaryMarketState::Trading { - outstanding_pairs: 0, - }; - let (first_issuance, first_pre_state) = build_issuance( - &signer, - &network, - first_market.params, - first_market.entropies, - initial_state, - &dormant_live(&first_market.transaction), - None, - &funding[4], - FIRST_INITIAL_PAIRS, - RtSide::A, - false, - ); - accept_broadcast_mine(&rpc, &miner, &first_issuance); - let second_pre_state = initial_state; - assert_eq!( - first_pre_state, - BinaryMarketState::Trading { - outstanding_pairs: FIRST_INITIAL_PAIRS, - } - ); - assert_eq!( - second_pre_state, - BinaryMarketState::Trading { - outstanding_pairs: 0, - } - ); - - let source = Arc::new( - ElementsRpcChainSource::new(ElementsRpcConfig::new( - client.rpc_url(), - node_elements_auth(&client.auth()), - )) - .expect("production Elements chain source"), - ); - let database_directory = tempfile::tempdir().expect("multi-market database directory"); - let database_path = database_directory.path().join("deadcat.redb"); - let store = Arc::new(Store::open(&database_path).expect("open multi-market store")); - store - .initialize_chain( - StoreChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash, - policy_asset, - }, - baseline, - ) - .expect("initialize multi-market chain"); - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, policy_asset); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=initial_sync"); - let SyncOutcome::Ready(initial_sync) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("initial multi-market sync") - else { - panic!("initial multi-market sync unexpectedly required a rescan") - }; - assert_eq!(initial_sync.blocks_applied, 3); - - let first_id = ContractId::new(OutPoint::new(first_market.transaction.txid(), 0)); - let second_id = ContractId::new(OutPoint::new(second_market.transaction.txid(), 0)); - assert_ne!(first_id, second_id); - assert_eq!(stored_market_state(&store, first_id), first_pre_state); - assert_eq!(stored_market_state(&store, second_id), second_pre_state); - assert_tracked_outpoints( - &store, - first_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(first_issuance.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(first_issuance.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(first_issuance.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &store, - second_id, - vec![ - ( - BinaryMarketSlot::DormantYesRt as u8, - OutPoint::new(second_market.transaction.txid(), 0), - ), - ( - BinaryMarketSlot::DormantNoRt as u8, - OutPoint::new(second_market.transaction.txid(), 1), - ), - ], - ); - - let package = ContractPackage { - format_version: CONTRACT_PACKAGE_FORMAT_VERSION, - chain: ChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash, - }, - roots: vec![first_id, second_id], - declarations: vec![ - ContractDeclaration { - contract_id: first_id, - descriptor: ContractDescriptor::BinaryMarketV1 { - params: first_market.params, - }, - }, - ContractDeclaration { - contract_id: second_id, - descriptor: ContractDescriptor::BinaryMarketV1 { - params: second_market.params, - }, - }, - ], - }; - let registration_tip = source.tip().await.expect("registration tip"); - let handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&store), - node_rpc_config(genesis_hash, policy_asset, baseline, registration_tip), - ) - .expect("multi-market RPC handler"); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=package_registration"); - let Response::RegistrationAccepted { registration } = node_response( - &handler, - Request::RegisterContractPackage { - package: package.clone(), - bearer_token: None, - }, - ) - .await - else { - panic!("multi-market registration returned the wrong response") - }; - assert_eq!(registration.roots, package.roots); - assert_eq!(registration.contracts.len(), 2); - assert!( - registration - .contracts - .iter() - .all(|receipt| receipt.already_registered) - ); - let first_pre_history = store - .contract_history(first_id) - .expect("first pre-composition market history"); - let second_pre_history = store - .contract_history(second_id) - .expect("second pre-composition market history"); - assert_eq!(first_pre_history.len(), 1); - assert!(second_pre_history.is_empty()); - assert_eq!(first_pre_history[0].txid, first_issuance.txid()); - - let (base_pset, plans) = build_composed_mixed_market_issuances( - &signer, - &network, - &first_market, - first_pre_state, - &active_live(&first_issuance), - &first_issuance.output[2], - FIRST_COMPOSED_PAIRS, - &second_market, - second_pre_state, - &dormant_live(&second_market.transaction), - SECOND_COMPOSED_PAIRS, - &funding[5], - ); - let first_post_state = BinaryMarketState::Trading { - outstanding_pairs: FIRST_INITIAL_PAIRS + FIRST_COMPOSED_PAIRS, - }; - let second_post_state = BinaryMarketState::Trading { - outstanding_pairs: SECOND_COMPOSED_PAIRS, - }; - assert_eq!(plans.first.after(), first_post_state); - assert_eq!(plans.second.after(), second_post_state); - assert_eq!(plans.first.path() as u8, 1); - assert_eq!(plans.second.path() as u8, 0); - - // Keep the first market leg and all asset/value balances valid while - // redirecting only the second market's collateral continuation. The - // second covenant must reject, making the complete transaction invalid. - let mut wrong_second_continuation = base_pset.clone(); - assert_ne!( - wrong_second_continuation.outputs()[5].script_pubkey, - signer.get_address().script_pubkey() - ); - wrong_second_continuation.outputs_mut()[5].script_pubkey = signer.get_address().script_pubkey(); - sign_input(&signer, &mut wrong_second_continuation, 5); - let wrong_second_continuation = wrong_second_continuation - .extract_tx() - .expect("balanced wrong-second-market transaction"); - let wrong_second_rejection = assert_mempool_rejects( - &rpc, - &wrong_second_continuation, - "multi_market_wrong_second_collateral_continuation", - ); - - let mut valid_pset = base_pset; - sign_input(&signer, &mut valid_pset, 5); - let composed = valid_pset - .extract_tx() - .expect("composed two-market transaction"); - assert_eq!(composed.input.len(), 6); - assert_eq!(composed.output.len(), 12); - assert_reissuances_at( - &composed, - 0, - first_market.entropies, - RtSide::B, - FIRST_COMPOSED_PAIRS, - ); - assert_reissuances_at( - &composed, - 3, - second_market.entropies, - RtSide::A, - SECOND_COMPOSED_PAIRS, - ); - assert_rt_pair_at(&composed, 0, first_market.params, RtSide::A); - assert_rt_pair_at(&composed, 3, second_market.params, RtSide::B); - assert_eq!(composed.output[2].value, Value::Explicit(8_000)); - assert_eq!(composed.output[5].value, Value::Explicit(1_400)); - assert_eq!( - composed.output[10].value, - Value::Explicit( - FUNDING_VALUE - (FIRST_COMPOSED_PAIRS + SECOND_COMPOSED_PAIRS) * BASE_PAYOUT * 2 - FEE - ) - ); - assert_eq!(composed.output[11].value, Value::Explicit(FEE)); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=consensus_acceptance"); - let composed_accepted = accept_broadcast_mine(&rpc, &miner, &composed); - let original_block_hash = - BlockHash::from_str(&composed_accepted.block_hash).expect("original composed block hash"); - let before_composed_cursor = store - .event_high_watermark() - .expect("event cursor before composed indexing"); - - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=atomic_indexing"); - let SyncOutcome::Ready(applied) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index composed two-market transaction") - else { - panic!("composed two-market sync unexpectedly required a rescan") - }; - assert_eq!(applied.blocks_applied, 1); - assert_eq!(applied.blocks_rolled_back, 0); - assert_eq!(stored_market_state(&store, first_id), first_post_state); - assert_eq!(stored_market_state(&store, second_id), second_post_state); - assert_tracked_outpoints( - &store, - first_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &store, - second_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 3), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 4), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 5), - ), - ], - ); - for spent in [ - OutPoint::new(first_issuance.txid(), 0), - OutPoint::new(first_issuance.txid(), 1), - OutPoint::new(first_issuance.txid(), 2), - OutPoint::new(second_market.transaction.txid(), 0), - OutPoint::new(second_market.transaction.txid(), 1), - ] { - assert!( - store - .outpoint_owner(spent) - .expect("read spent owner") - .is_none() - ); - } - - let first_post_history = store - .contract_history(first_id) - .expect("first post-composition market history"); - let second_post_history = store - .contract_history(second_id) - .expect("second post-composition market history"); - assert_eq!(first_post_history.len(), 2); - assert_eq!(second_post_history.len(), 1); - let first_entry = first_post_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("first composed market transition"); - let second_entry = second_post_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("second composed market transition"); - let original_position = first_entry.position; - assert_eq!(second_entry.position, original_position); - assert_eq!( - original_position.block_height, - u32::try_from(composed_accepted.block_height).expect("composed height") - ); - for (entry, before, after, plan) in [ - (first_entry, first_pre_state, first_post_state, &plans.first), - ( - second_entry, - second_pre_state, - second_post_state, - &plans.second, - ), - ] { - assert_eq!(entry.old_state, ContractState::BinaryMarket(before)); - assert_eq!(entry.new_state, ContractState::BinaryMarket(after)); - assert_eq!(entry.transition.kind, TRANSITION_V1_MARKET_ISSUED); - let BinaryMarketTransition::Issued { - pairs, - collateral_locked, - } = plan.transition() - else { - panic!("composed market plan was not an issuance") - }; - let mut expected_payload = vec![plan.path() as u8]; - expected_payload.extend_from_slice(&pairs.to_be_bytes()); - expected_payload.extend_from_slice(&collateral_locked.to_be_bytes()); - assert_eq!(entry.transition.payload, expected_payload); - } - - let mut expected_affected = vec![first_id, second_id]; - expected_affected.sort(); - let expected_markets = expected_affected.clone(); - let composed_events = store - .events_after(Some(before_composed_cursor), 100) - .expect("events from composed indexing"); - let applied_events = composed_events - .iter() - .filter(|event| { - matches!( - &event.event, - StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() - ) - }) - .collect::>(); - assert_eq!(applied_events.len(), 1); - let StoredEvent::TransactionApplied { - anchor, - txid, - position, - affected_contract_ids, - affected_market_ids, - } = &applied_events[0].event - else { - unreachable!("filtered to the composed TransactionApplied event") - }; - assert_eq!( - *anchor, - ChainAnchor { - height: original_position.block_height, - hash: original_block_hash, - } - ); - assert_eq!(*txid, composed.txid()); - assert_eq!(*position, original_position); - assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &expected_markets); - let post_apply_cursor = store - .event_high_watermark() - .expect("event cursor after composed indexing"); - let original_evidence = store - .transaction(original_position) - .expect("read composed evidence") - .expect("one shared composed evidence row"); - assert_eq!(original_evidence.block_hash, original_block_hash); - assert_eq!(original_evidence.txid, composed.txid()); - assert_eq!( - original_evidence.raw_tx, - elements::encode::serialize(&composed) - ); - assert_eq!(original_evidence.affected_contract_ids, expected_affected); - for (vout, expected_output) in composed.output.iter().enumerate() { - let stored = store - .output(OutPoint::new(composed.txid(), vout as u32)) - .expect("read composed output evidence") - .expect("composed output reference"); - assert_eq!(stored.position, original_position); - assert_eq!(&stored.output, expected_output); - } - - drop(handler); - drop(store); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=restart"); - let reopened = Arc::new(Store::open(&database_path).expect("reopen multi-market store")); - let SyncOutcome::Ready(restart_sync) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("idempotent multi-market restart sync") - else { - panic!("restarted multi-market node unexpectedly required a rescan") - }; - assert_eq!(restart_sync.blocks_applied, 0); - assert_eq!(restart_sync.blocks_rolled_back, 0); - assert_eq!(restart_sync.backfill_blocks_applied, 0); - assert_eq!( - reopened - .event_high_watermark() - .expect("restarted event cursor"), - post_apply_cursor - ); - assert_eq!( - reopened - .contract_history(first_id) - .expect("restarted first history"), - first_post_history - ); - assert_eq!( - reopened - .contract_history(second_id) - .expect("restarted second history"), - second_post_history - ); - assert_eq!( - reopened - .transaction(original_position) - .expect("restarted composed evidence"), - Some(original_evidence.clone()) - ); - - let mining_address = signer.get_address().to_unconfidential().to_string(); - let mine_exact = |txids: Vec| -> String { - let result: JsonValue = rpc - .call( - "generateblock", - &[json!(mining_address.clone()), json!(txids)], - ) - .expect("mine exact multi-market regtest block"); - result["hash"] - .as_str() - .expect("generateblock hash") - .to_owned() - }; - - // Move the shared transaction one block later on a two-block fork. Both - // histories and the one evidence row must move together. - let _original_successor_hash = mine_exact(vec![]); - let SyncOutcome::Ready(successor_sync) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index original empty successor") - else { - panic!("empty successor unexpectedly required a rescan") - }; - assert_eq!(successor_sync.blocks_applied, 1); - let original_branch_tip = source.tip().await.expect("original branch tip"); - let before_two_block_cursor = reopened - .event_high_watermark() - .expect("event cursor before two-block reorg"); - let invalidated: JsonValue = rpc - .call( - "invalidateblock", - &[json!(composed_accepted.block_hash.clone())], - ) - .expect("invalidate original composed block"); - assert!(invalidated.is_null()); - let two_block_ancestor = source.tip().await.expect("two-block reorg ancestor"); - let empty_at_original_height = mine_exact(vec![]); - let moved_block_hash_string = mine_exact(vec![composed.txid().to_string()]); - assert_ne!(empty_at_original_height, composed_accepted.block_hash); - let moved_block_hash = - BlockHash::from_str(&moved_block_hash_string).expect("moved composed block hash"); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=two_block_reorg"); - let SyncOutcome::Ready(two_block_reorg) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("two-block composed reorg") - else { - panic!("two-block composed reorg exceeded retention") - }; - assert_eq!(two_block_reorg.blocks_rolled_back, 2); - assert_eq!(two_block_reorg.blocks_applied, 2); - assert_eq!(stored_market_state(&reopened, first_id), first_post_state); - assert_eq!(stored_market_state(&reopened, second_id), second_post_state); - let moved_first_history = reopened - .contract_history(first_id) - .expect("moved first history"); - let moved_second_history = reopened - .contract_history(second_id) - .expect("moved second history"); - let moved_position = moved_first_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("moved first market transition") - .position; - assert_eq!( - moved_position.block_height, - original_position.block_height + 1 - ); - assert_eq!( - moved_second_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("moved second market transition") - .position, - moved_position - ); - let two_block_events = reopened - .events_after(Some(before_two_block_cursor), 100) - .expect("events from two-block reorg"); - let rollback_events = two_block_events - .iter() - .filter(|event| matches!(&event.event, StoredEvent::ChainRolledBack { .. })) - .collect::>(); - assert_eq!(rollback_events.len(), 1); - let StoredEvent::ChainRolledBack { - old_tip, - new_tip, - orphaned_positions, - affected_contract_ids, - affected_market_ids, - } = &rollback_events[0].event - else { - unreachable!("filtered to a ChainRolledBack event") - }; - assert_eq!(*old_tip, original_branch_tip); - assert_eq!(*new_tip, two_block_ancestor); - assert_eq!(orphaned_positions, &[original_position]); - assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &expected_markets); - let moved_applied_events = two_block_events - .iter() - .filter(|event| { - matches!( - &event.event, - StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() - ) - }) - .collect::>(); - assert_eq!(moved_applied_events.len(), 1); - assert!( - rollback_events[0].cursor.sequence < moved_applied_events[0].cursor.sequence, - "subscription consumers must observe rollback before replacement apply" - ); - let moved_evidence = reopened - .transaction(moved_position) - .expect("moved evidence lookup") - .expect("moved shared evidence row"); - assert_eq!(moved_evidence.block_hash, moved_block_hash); - assert_eq!(moved_evidence.raw_tx, original_evidence.raw_tx); - assert_eq!(moved_evidence.affected_contract_ids, expected_affected); - assert!( - reopened - .transaction(original_position) - .expect("orphaned original evidence lookup") - .is_none() - ); - - // Replace the moved transaction with an empty block. Both markets must - // return to their exact pre-transaction state in one coordinator update. - let moved_branch_tip = source.tip().await.expect("moved branch tip"); - let before_one_block_cursor = reopened - .event_high_watermark() - .expect("event cursor before one-block rollback"); - let invalidated: JsonValue = rpc - .call("invalidateblock", &[json!(moved_block_hash_string.clone())]) - .expect("invalidate moved composed block"); - assert!(invalidated.is_null()); - let one_block_ancestor = source.tip().await.expect("one-block reorg ancestor"); - let empty_replacement_hash = mine_exact(vec![]); - assert_ne!(empty_replacement_hash, moved_block_hash_string); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=one_block_atomic_rollback"); - let SyncOutcome::Ready(one_block_reorg) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("one-block composed rollback") - else { - panic!("one-block composed rollback exceeded retention") - }; - assert_eq!(one_block_reorg.blocks_rolled_back, 1); - assert_eq!(one_block_reorg.blocks_applied, 1); - assert_eq!(stored_market_state(&reopened, first_id), first_pre_state); - assert_eq!(stored_market_state(&reopened, second_id), second_pre_state); - assert_eq!( - reopened - .contract_history(first_id) - .expect("rolled-back first history"), - first_pre_history - ); - assert_eq!( - reopened - .contract_history(second_id) - .expect("rolled-back second history"), - second_pre_history - ); - assert!( - reopened - .transaction(moved_position) - .expect("rolled-back moved evidence") - .is_none() - ); - for vout in 0..composed.output.len() { - assert!( - reopened - .output(OutPoint::new(composed.txid(), vout as u32)) - .expect("rolled-back output lookup") - .is_none() - ); - } - let one_block_events = reopened - .events_after(Some(before_one_block_cursor), 100) - .expect("events from one-block rollback"); - let rollback_events = one_block_events - .iter() - .filter(|event| matches!(&event.event, StoredEvent::ChainRolledBack { .. })) - .collect::>(); - assert_eq!(rollback_events.len(), 1); - let StoredEvent::ChainRolledBack { - old_tip, - new_tip, - orphaned_positions, - affected_contract_ids, - affected_market_ids, - } = &rollback_events[0].event - else { - unreachable!("filtered to a one-block ChainRolledBack event") - }; - assert_eq!(*old_tip, moved_branch_tip); - assert_eq!(*new_tip, one_block_ancestor); - assert_eq!(orphaned_positions, &[moved_position]); - assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &expected_markets); - assert!(one_block_events.iter().all(|event| { - !matches!( - &event.event, - StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() - ) - })); - - // Mine the same transaction once more and independently replay both market - // histories through only the public RPC evidence surface. - let before_final_cursor = reopened - .event_high_watermark() - .expect("event cursor before final remine"); - let final_block_hash_string = mine_exact(vec![composed.txid().to_string()]); - let final_block_hash = - BlockHash::from_str(&final_block_hash_string).expect("final composed block hash"); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=canonical_remine"); - let SyncOutcome::Ready(final_sync) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index final composed remine") - else { - panic!("final composed remine unexpectedly required a rescan") - }; - assert_eq!(final_sync.blocks_applied, 1); - assert_eq!(final_sync.blocks_rolled_back, 0); - assert_eq!(stored_market_state(&reopened, first_id), first_post_state); - assert_eq!(stored_market_state(&reopened, second_id), second_post_state); - let final_first_history = reopened - .contract_history(first_id) - .expect("final first history"); - let final_second_history = reopened - .contract_history(second_id) - .expect("final second history"); - let final_position = final_first_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("final first transition") - .position; - assert_eq!( - final_second_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("final second transition") - .position, - final_position - ); - assert_eq!( - final_position.block_height, - original_position.block_height + 2 - ); - let final_evidence = reopened - .transaction(final_position) - .expect("final evidence lookup") - .expect("final shared evidence row"); - assert_eq!(final_evidence.block_hash, final_block_hash); - assert_eq!(final_evidence.raw_tx, original_evidence.raw_tx); - assert_eq!(final_evidence.affected_contract_ids, expected_affected); - let final_events = reopened - .events_after(Some(before_final_cursor), 100) - .expect("events from final remine"); - assert_eq!( - final_events - .iter() - .filter(|event| { - matches!( - &event.event, - StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() - ) - }) - .count(), - 1 - ); - - let final_tip = source.tip().await.expect("final multi-market tip"); - let reopened_handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&reopened), - node_rpc_config(genesis_hash, policy_asset, baseline, final_tip), - ) - .expect("final multi-market RPC handler"); - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=client_replay"); - for (market_id, expected_state, expected_history_len) in [ - (first_id, first_post_state, 2), - (second_id, second_post_state, 1), - ] { - let (view, history) = - assert_rpc_contract_replay(&reopened_handler, source.as_ref(), market_id, None).await; - assert_eq!( - view.state, - ContractStateView::BinaryMarket { - state: expected_state, - } - ); - assert_eq!(history.entries.len(), expected_history_len); - assert_eq!( - history - .entries - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("RPC composed transition") - .position, - final_position - ); - } - let rpc_evidence = rpc_transaction_evidence(&reopened_handler, final_position).await; - assert_eq!(rpc_evidence.block_hash, final_block_hash); - assert_eq!(rpc_evidence.transaction, composed); - assert_eq!(rpc_evidence.affected_contract_ids, expected_affected); - - // Push the shared transition outside the two-block undo window, replace - // the complete suffix, and prove an activation-based rebuild preserves - // both retained market declarations and rematerializes them atomically. - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_reorg_invalidation"); - let _stale_successor_one = mine_exact(vec![]); - let _stale_successor_two = mine_exact(vec![]); - let SyncOutcome::Ready(stale_successor_sync) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("index successors beyond undo retention") - else { - panic!("successor indexing unexpectedly required a rescan") - }; - assert_eq!(stale_successor_sync.blocks_applied, 2); - let stale_branch_tip = source.tip().await.expect("stale branch tip"); - let before_deep_reorg_cursor = reopened - .event_high_watermark() - .expect("cursor before deep reorg"); - let retained_before_rebuild = [first_id, second_id] - .into_iter() - .map(|contract_id| { - ( - contract_id, - reopened - .retained_declaration(contract_id) - .expect("retained declaration lookup") - .expect("market declaration retained"), - ) - }) - .collect::>(); - - let invalidated: JsonValue = rpc - .call("invalidateblock", &[json!(final_block_hash_string.clone())]) - .expect("invalidate composed block beyond undo retention"); - assert!(invalidated.is_null()); - let deep_empty_hash_string = mine_exact(vec![]); - let deep_composed_hash_string = mine_exact(vec![composed.txid().to_string()]); - let _deep_successor_hash_string = mine_exact(vec![]); - let deep_composed_hash = - BlockHash::from_str(&deep_composed_hash_string).expect("deep replacement block hash"); - assert_ne!(deep_empty_hash_string, final_block_hash_string); - assert_ne!(deep_composed_hash_string, final_block_hash_string); - let deep_source_tip = source.tip().await.expect("deep replacement tip"); - assert_eq!(deep_source_tip.height, stale_branch_tip.height); - - let SyncOutcome::RescanRequired { - indexed_tip, - source_tip, - } = SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("detect real three-block fork") - else { - panic!("three-block fork did not enter RescanRequired") - }; - assert_eq!(indexed_tip, stale_branch_tip); - assert_eq!(source_tip, deep_source_tip); - assert_eq!( - reopened.sync_status().expect("invalidated status"), - deadcat_rpc::SyncStatus::RescanRequired - ); - let invalidated_cursor = reopened - .event_high_watermark() - .expect("invalidated event cursor"); - assert_ne!(invalidated_cursor.epoch, before_deep_reorg_cursor.epoch); - assert_eq!(invalidated_cursor.sequence, 1); - assert_eq!(stored_market_state(&reopened, first_id), first_post_state); - assert_eq!(stored_market_state(&reopened, second_id), second_post_state); - assert!(matches!( - reopened.events_after(Some(before_deep_reorg_cursor), 1), - Err(StoreError::StaleCursor { .. }) - )); - let stale_read = reopened_handler - .handle( - [0x92; 32], - Request::GetContract { - contract_id: first_id, - }, - ) - .await - .expect_err("known-stale RPC state must fail closed"); - assert_eq!(stale_read.code, RpcErrorCode::RescanRequired); - drop(reopened_handler); - drop(reopened); - - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_rebuild_reset"); - let invalidated_store = Store::open(&database_path).expect("reopen invalidated store"); - assert_eq!( - invalidated_store - .sync_status() - .expect("reopened invalidated status"), - deadcat_rpc::SyncStatus::RescanRequired - ); - for (contract_id, declaration) in &retained_before_rebuild { - assert_eq!( - invalidated_store - .retained_declaration(*contract_id) - .expect("reopened retained declaration"), - Some(*declaration) - ); - } - let reset_cursor = invalidated_store - .reset_for_rebuild() - .expect("explicit activation reset"); - assert_eq!(reset_cursor.epoch, invalidated_cursor.epoch); - assert_eq!(invalidated_store.tip().expect("reset tip"), Some(baseline)); - assert_eq!( - invalidated_store.sync_status().expect("reset status"), - deadcat_rpc::SyncStatus::Syncing - ); - for (contract_id, declaration) in &retained_before_rebuild { - assert!( - invalidated_store - .contract(*contract_id) - .expect("cleared contract lookup") - .is_none() - ); - assert_eq!( - invalidated_store - .retained_declaration(*contract_id) - .expect("retained declaration after reset"), - Some(*declaration) - ); - } - drop(invalidated_store); - - eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_rebuild_replay"); - let rebuilt = Arc::new(Store::open(&database_path).expect("reopen reset store")); - let SyncOutcome::Ready(deep_rebuild) = - SyncCoordinator::new(source.as_ref(), rebuilt.as_ref(), &interpreter) - .rebuild_to_tip() - .await - .expect("resume explicit rebuild after reopen") - else { - panic!("replacement branch changed deeply during rebuild") - }; - assert!(deep_rebuild.blocks_applied > 0); - assert_eq!(deep_rebuild.indexed_tip, deep_source_tip); - assert_eq!( - rebuilt.sync_status().expect("rebuilt status"), - deadcat_rpc::SyncStatus::Ready - ); - assert_eq!( - rebuilt - .event_high_watermark() - .expect("rebuilt cursor") - .epoch, - invalidated_cursor.epoch - ); - assert_eq!(stored_market_state(&rebuilt, first_id), first_post_state); - assert_eq!(stored_market_state(&rebuilt, second_id), second_post_state); - assert_tracked_outpoints( - &rebuilt, - first_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &rebuilt, - second_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 3), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 4), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 5), - ), - ], - ); - for (contract_id, declaration) in &retained_before_rebuild { - assert_eq!( - rebuilt - .retained_declaration(*contract_id) - .expect("retained declaration after replay"), - Some(*declaration) - ); - } - let deep_first_history = rebuilt - .contract_history(first_id) - .expect("deep rebuilt first history"); - let deep_second_history = rebuilt - .contract_history(second_id) - .expect("deep rebuilt second history"); - let deep_position = deep_first_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("deep rebuilt first transition") - .position; - assert_eq!( - deep_second_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("deep rebuilt second transition") - .position, - deep_position - ); - assert_eq!(deep_position.block_height, final_position.block_height + 1); - let deep_evidence = rebuilt - .transaction(deep_position) - .expect("deep rebuilt evidence lookup") - .expect("deep rebuilt shared evidence"); - assert_eq!(deep_evidence.block_hash, deep_composed_hash); - assert_eq!(deep_evidence.txid, composed.txid()); - assert_eq!(deep_evidence.raw_tx, elements::encode::serialize(&composed)); - assert_eq!(deep_evidence.affected_contract_ids, expected_affected); - assert!(matches!( - rebuilt.events_after(Some(before_deep_reorg_cursor), 1), - Err(StoreError::StaleCursor { .. }) - )); - - let deep_handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&rebuilt), - node_rpc_config(genesis_hash, policy_asset, baseline, deep_source_tip), - ) - .expect("deep rebuilt RPC handler"); - for (market_id, expected_state, expected_history_len) in [ - (first_id, first_post_state, 2), - (second_id, second_post_state, 1), - ] { - let (view, history) = - assert_rpc_contract_replay(&deep_handler, source.as_ref(), market_id, None).await; - assert_eq!( - view.state, - ContractStateView::BinaryMarket { - state: expected_state, - } - ); - assert_eq!(history.entries.len(), expected_history_len); - assert_eq!( - history - .entries - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("deep RPC composed transition") - .position, - deep_position - ); - } - - let report = json!({ - "schema": "deadcat.multi-market-regtest.v1", - "market_ids": [first_id.to_string(), second_id.to_string()], - "txid": composed.txid().to_string(), - "inputs": composed.input.len(), - "outputs": composed.output.len(), - "mempool_vsize": composed_accepted.mempool_vsize, - "original_position": original_position, - "original_block_hash": composed_accepted.block_hash, - "moved_position": moved_position, - "moved_block_hash": moved_block_hash_string, - "final_position": final_position, - "final_block_hash": final_block_hash_string, - "deep_rebuild_position": deep_position, - "deep_rebuild_block_hash": deep_composed_hash_string, - "negative_test": wrong_second_rejection, - }); - eprintln!( - "DEADCAT_MULTI_MARKET_REGTEST_METRICS={}", - serde_json::to_string(&report).expect("serialize multi-market metrics") - ); -} - -#[tokio::test(flavor = "current_thread")] -#[ignore = "starts elementsd and liquid-enabled Electrs from the Nix development shell"] -async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { - let (client, signer) = - Regtest::from_config(&RegtestConfig::default()).expect("regtest environment"); - let network = SimplicityNetwork::default_regtest(); - let policy_asset = network.policy_asset(); - let miner = ElementsRpc::new(client.rpc_url(), client.auth()).expect("Elements RPC"); - let rpc = Client::new(&client.rpc_url(), client.auth()).expect("raw Elements RPC"); - let genesis_hash = BlockHash::from_str( - &rpc.get_block_hash(0) - .expect("regtest genesis block") - .to_string(), - ) - .expect("Elements genesis hash"); - let (_funding_tx, funding_accepted, funding) = - prepare_funding(&signer, &rpc, &miner, policy_asset); - let baseline = ChainAnchor { - height: u32::try_from(funding_accepted.block_height).expect("baseline height"), - hash: BlockHash::from_str(&funding_accepted.block_hash).expect("baseline hash"), - }; - let expiry_height = baseline.height.checked_add(1_000).expect("future expiry"); - let market = create_market( + let first_market = create_market( &signer, &rpc, &miner, @@ -5293,50 +2733,46 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { &funding[1], expiry_height, ); - let initial_market_state = BinaryMarketState::Trading { + let second_market = create_market( + &signer, + &rpc, + &miner, + policy_asset, + &funding[2], + &funding[3], + expiry_height, + ); + let initial_state = BinaryMarketState::Trading { outstanding_pairs: 0, }; - let (issuance, trading_thirty) = build_issuance( + let (first_issuance, first_pre_state) = build_issuance( &signer, &network, - market.params, - market.entropies, - initial_market_state, - &dormant_live(&market.transaction), + first_market.params, + first_market.entropies, + initial_state, + &dormant_live(&first_market.transaction), None, - &funding[2], - 30, + &funding[4], + FIRST_INITIAL_PAIRS, RtSide::A, false, ); + accept_broadcast_mine(&rpc, &miner, &first_issuance); + let second_pre_state = initial_state; assert_eq!( - trading_thirty, + first_pre_state, BinaryMarketState::Trading { - outstanding_pairs: 30, + outstanding_pairs: FIRST_INITIAL_PAIRS, } ); - accept_broadcast_mine(&rpc, &miner, &issuance); - - let keychain = - DeadcatKeychain::from_mnemonic(MAKER_MNEMONIC, "").expect("test Deadcat keychain"); - let (_order_creation, _, orders, _yes_change, _no_change) = create_maker_orders( - &signer, - &rpc, - &miner, - &keychain, - &market, - &wallet_utxo(&issuance, 3), - &wallet_utxo(&issuance, 4), - &funding[3], + assert_eq!( + second_pre_state, + BinaryMarketState::Trading { + outstanding_pairs: 0, + } ); - let sell_base = &orders[0]; - let sell_quote = &orders[1]; - assert_eq!(sell_base.owned.params.direction, OrderDirection::SellBase); - assert_eq!(sell_quote.owned.params.direction, OrderDirection::SellQuote); - // Chain scanning discovers the public market and all four canonical maker - // hints. The package below is therefore an idempotent declaration of the - // two orders participating in the composed transaction. let source = Arc::new( ElementsRpcChainSource::new(ElementsRpcConfig::new( client.rpc_url(), @@ -5344,9 +2780,9 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { )) .expect("production Elements chain source"), ); - let database_directory = tempfile::tempdir().expect("multi-contract database directory"); + let database_directory = tempfile::tempdir().expect("multi-market database directory"); let database_path = database_directory.path().join("deadcat.redb"); - let store = Arc::new(Store::open(&database_path).expect("open multi-contract store")); + let store = Arc::new(Store::open(&database_path).expect("open multi-market store")); store .initialize_chain( StoreChainIdentity { @@ -5356,27 +2792,56 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { }, baseline, ) - .expect("initialize multi-contract chain"); + .expect("initialize multi-market chain"); let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, policy_asset); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=initial_sync"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=initial_sync"); let SyncOutcome::Ready(initial_sync) = SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) .sync_to_tip() .await - .expect("initial multi-contract sync") + .expect("initial multi-market sync") else { - panic!("initial multi-contract sync unexpectedly required a rescan") + panic!("initial multi-market sync unexpectedly required a rescan") }; assert_eq!(initial_sync.blocks_applied, 3); - let registration_tip = source.tip().await.expect("registration tip"); - let market_id = ContractId::new(OutPoint::new(market.transaction.txid(), 0)); - assert_eq!(stored_market_state(&store, market_id), trading_thirty); - assert!(orders.iter().all(|order| { - store - .contract(order.contract_id) - .expect("order lookup") - .is_some() - })); + + let first_id = ContractId::new(OutPoint::new(first_market.transaction.txid(), 0)); + let second_id = ContractId::new(OutPoint::new(second_market.transaction.txid(), 0)); + assert_ne!(first_id, second_id); + assert_eq!(stored_market_state(&store, first_id), first_pre_state); + assert_eq!(stored_market_state(&store, second_id), second_pre_state); + assert_tracked_outpoints( + &store, + first_id, + vec![ + ( + BinaryMarketSlot::UnresolvedYesRt as u8, + OutPoint::new(first_issuance.txid(), 0), + ), + ( + BinaryMarketSlot::UnresolvedNoRt as u8, + OutPoint::new(first_issuance.txid(), 1), + ), + ( + BinaryMarketSlot::UnresolvedCollateral as u8, + OutPoint::new(first_issuance.txid(), 2), + ), + ], + ); + assert_tracked_outpoints( + &store, + second_id, + vec![ + ( + BinaryMarketSlot::DormantYesRt as u8, + OutPoint::new(second_market.transaction.txid(), 0), + ), + ( + BinaryMarketSlot::DormantNoRt as u8, + OutPoint::new(second_market.transaction.txid(), 1), + ), + ], + ); let package = ContractPackage { format_version: CONTRACT_PACKAGE_FORMAT_VERSION, @@ -5384,39 +2849,30 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { network: LiquidNetwork::ElementsRegtest, genesis_hash, }, - roots: vec![sell_base.contract_id, sell_quote.contract_id], + roots: vec![first_id, second_id], declarations: vec![ ContractDeclaration { - contract_id: sell_base.contract_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: sell_base.side, - params: sell_base.owned.params, - }, - }, - ContractDeclaration { - contract_id: sell_quote.contract_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: sell_quote.side, - params: sell_quote.owned.params, + contract_id: first_id, + descriptor: ContractDescriptor::BinaryMarketV1 { + params: first_market.params, }, }, ContractDeclaration { - contract_id: market_id, + contract_id: second_id, descriptor: ContractDescriptor::BinaryMarketV1 { - params: market.params, + params: second_market.params, }, }, ], }; + let registration_tip = source.tip().await.expect("registration tip"); let handler = NodeRpcHandler::new( Arc::clone(&source), Arc::clone(&store), node_rpc_config(genesis_hash, policy_asset, baseline, registration_tip), ) - .expect("multi-contract RPC handler"); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=package_registration"); + .expect("multi-market RPC handler"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=package_registration"); let Response::RegistrationAccepted { registration } = node_response( &handler, Request::RegisterContractPackage { @@ -5426,183 +2882,103 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { ) .await else { - panic!("multi-contract registration returned the wrong response") + panic!("multi-market registration returned the wrong response") }; assert_eq!(registration.roots, package.roots); - assert_eq!(registration.contracts.len(), 3); - for receipt in ®istration.contracts { - assert!( - receipt.already_registered, - "all canonical declarations should already be chain-discovered" - ); - } - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=idempotent_resync"); - let SyncOutcome::Ready(backfill) = - SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("multi-contract idempotent resync") - else { - panic!("multi-contract idempotent resync unexpectedly required a rescan") - }; - assert_eq!(backfill.backfill_blocks_applied, 0); - - let pre_order_state = MakerOrderState::Active { - remaining_base: MAKER_CAPACITY, - total_filled_base: 0, - }; - assert_eq!(stored_market_state(&store, market_id), trading_thirty); - assert_eq!( - stored_maker_state(&store, sell_base.contract_id), - pre_order_state - ); - assert_eq!( - stored_maker_state(&store, sell_quote.contract_id), - pre_order_state - ); - assert_tracked_outpoints( - &store, - market_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(issuance.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(issuance.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(issuance.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &store, - sell_base.contract_id, - vec![(0, sell_base.output.outpoint)], - ); - assert_tracked_outpoints( - &store, - sell_quote.contract_id, - vec![(0, sell_quote.output.outpoint)], - ); - let expected_pre_order_book = ready_order_rows(&store, market_id); - assert_eq!(expected_pre_order_book.len(), orders.len()); - for (contract_id, entry) in &expected_pre_order_book { - let order = orders - .iter() - .find(|order| order.contract_id == *contract_id) - .expect("publicly discovered maker order"); - assert_eq!(entry.market_id, market_id); - assert_eq!(entry.side, order.side); - assert_eq!(entry.direction, order.owned.params.direction); - assert_eq!(entry.price, MAKER_PRICE); - assert_eq!(entry.remaining_base, MAKER_CAPACITY); - assert_eq!( - entry.creation_position, - store - .contract(*contract_id) - .expect("read order creation position") - .expect("registered order") - .creation_position - ); - } - let mut expected_post_order_book = expected_pre_order_book.clone(); - expected_post_order_book - .iter_mut() - .find(|(contract_id, _)| *contract_id == sell_base.contract_id) - .expect("pre-composition SellBase book row") - .1 - .remaining_base = 7; - expected_post_order_book.retain(|(contract_id, _)| *contract_id != sell_quote.contract_id); - let pre_market_history = store - .contract_history(market_id) - .expect("pre-composition market history"); - assert_eq!(pre_market_history.len(), 1); - assert_eq!(pre_market_history[0].txid, issuance.txid()); - assert!( - store - .contract_history(sell_base.contract_id) - .expect("pre-composition SellBase history") - .is_empty() - ); + assert_eq!(registration.contracts.len(), 2); assert!( - store - .contract_history(sell_quote.contract_id) - .expect("pre-composition SellQuote history") - .is_empty() + registration + .contracts + .iter() + .all(|receipt| receipt.already_registered) ); + let first_pre_history = store + .contract_history(first_id) + .expect("first pre-composition market history"); + let second_pre_history = store + .contract_history(second_id) + .expect("second pre-composition market history"); + assert_eq!(first_pre_history.len(), 1); + assert!(second_pre_history.is_empty()); + assert_eq!(first_pre_history[0].txid, first_issuance.txid()); - let (base_pset, plans) = build_composed_issuance_and_maker_fills( + let (base_pset, plans) = build_composed_mixed_market_issuances( &signer, &network, - &market, - trading_thirty, - &active_live(&issuance), - &issuance.output[2], - sell_base, - sell_quote, - &funding[4], - ); - assert_eq!( - plans.market.after(), - BinaryMarketState::Trading { - outstanding_pairs: 40, - } - ); - assert_eq!( - plans.sell_base.next_state(), - MakerOrderState::Active { - remaining_base: 7, - total_filled_base: 3, - } + &first_market, + first_pre_state, + &active_live(&first_issuance), + &first_issuance.output[2], + FIRST_COMPOSED_PAIRS, + &second_market, + second_pre_state, + &dormant_live(&second_market.transaction), + SECOND_COMPOSED_PAIRS, + &funding[5], ); - assert_eq!(plans.sell_quote.next_state(), MakerOrderState::Consumed); + let first_post_state = BinaryMarketState::Trading { + outstanding_pairs: FIRST_INITIAL_PAIRS + FIRST_COMPOSED_PAIRS, + }; + let second_post_state = BinaryMarketState::Trading { + outstanding_pairs: SECOND_COMPOSED_PAIRS, + }; + assert_eq!(plans.first.after(), first_post_state); + assert_eq!(plans.second.after(), second_post_state); + assert_eq!(plans.first.path() as u8, 1); + assert_eq!(plans.second.path() as u8, 0); - // Preserve every asset/value balance and all other covenant anchors while - // redirecting only the full SellQuote maker payment. Consensus must reject - // the whole transaction rather than apply the valid market/SellBase legs. - let mut wrong_sell_quote_receive = base_pset.clone(); + // Keep the first market leg and all asset/value balances valid while + // redirecting only the second market's collateral continuation. The + // second covenant must reject, making the complete transaction invalid. + let mut wrong_second_continuation = base_pset.clone(); assert_ne!( - wrong_sell_quote_receive.outputs()[4].script_pubkey, + wrong_second_continuation.outputs()[5].script_pubkey, signer.get_address().script_pubkey() ); - wrong_sell_quote_receive.outputs_mut()[4].script_pubkey = signer.get_address().script_pubkey(); - sign_input(&signer, &mut wrong_sell_quote_receive, 5); - let wrong_sell_quote_receive = wrong_sell_quote_receive + wrong_second_continuation.outputs_mut()[5].script_pubkey = signer.get_address().script_pubkey(); + sign_input(&signer, &mut wrong_second_continuation, 5); + let wrong_second_continuation = wrong_second_continuation .extract_tx() - .expect("balanced wrong-SellQuote transaction"); - let wrong_sell_quote_rejection = assert_mempool_rejects( + .expect("balanced wrong-second-market transaction"); + let wrong_second_rejection = assert_mempool_rejects( &rpc, - &wrong_sell_quote_receive, - "multi_contract_wrong_sell_quote_receive_script", + &wrong_second_continuation, + "multi_market_wrong_second_collateral_continuation", ); let mut valid_pset = base_pset; sign_input(&signer, &mut valid_pset, 5); let composed = valid_pset .extract_tx() - .expect("composed market and maker transaction"); + .expect("composed two-market transaction"); assert_eq!(composed.input.len(), 6); - assert_eq!(composed.output.len(), 10); - assert_reissuances( + assert_eq!(composed.output.len(), 12); + assert_reissuances_at( &composed, - market.entropies, + 0, + first_market.entropies, RtSide::B, - COMPOSED_ISSUANCE_PAIRS, + FIRST_COMPOSED_PAIRS, + ); + assert_reissuances_at( + &composed, + 3, + second_market.entropies, + RtSide::A, + SECOND_COMPOSED_PAIRS, ); - assert_rt_pair(&composed, market.params, RtSide::A, false); + assert_rt_pair_at(&composed, 0, first_market.params, RtSide::A); + assert_rt_pair_at(&composed, 3, second_market.params, RtSide::B); assert_eq!(composed.output[2].value, Value::Explicit(8_000)); - assert_eq!(composed.output[3].value, Value::Explicit(21)); - assert_eq!(composed.output[4].value, Value::Explicit(10)); - assert_eq!(composed.output[5].value, Value::Explicit(7)); - assert_eq!(composed.output[6].value, Value::Explicit(13)); - assert_eq!(composed.output[7].value, Value::Explicit(49)); - assert_eq!(composed.output[8].value, Value::Explicit(97_000)); - assert_eq!(composed.output[9].value, Value::Explicit(FEE)); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=consensus_acceptance"); + assert_eq!(composed.output[5].value, Value::Explicit(1_400)); + assert_eq!( + composed.output[10].value, + Value::Explicit( + FUNDING_VALUE - (FIRST_COMPOSED_PAIRS + SECOND_COMPOSED_PAIRS) * BASE_PAYOUT * 2 - FEE + ) + ); + assert_eq!(composed.output[11].value, Value::Explicit(FEE)); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=consensus_acceptance"); let composed_accepted = accept_broadcast_mine(&rpc, &miner, &composed); let original_block_hash = BlockHash::from_str(&composed_accepted.block_hash).expect("original composed block hash"); @@ -5610,37 +2986,22 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .event_high_watermark() .expect("event cursor before composed indexing"); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=atomic_indexing"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=atomic_indexing"); let SyncOutcome::Ready(applied) = SyncCoordinator::new(source.as_ref(), store.as_ref(), &interpreter) .sync_to_tip() .await - .expect("index composed transaction") + .expect("index composed two-market transaction") else { - panic!("composed transaction sync unexpectedly required a rescan") + panic!("composed two-market sync unexpectedly required a rescan") }; assert_eq!(applied.blocks_applied, 1); assert_eq!(applied.blocks_rolled_back, 0); - - let post_market_state = BinaryMarketState::Trading { - outstanding_pairs: 40, - }; - let post_sell_base_state = MakerOrderState::Active { - remaining_base: 7, - total_filled_base: 3, - }; - assert_eq!(stored_market_state(&store, market_id), post_market_state); - assert_eq!( - stored_maker_state(&store, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&store, sell_quote.contract_id), - MakerOrderState::Consumed - ); + assert_eq!(stored_market_state(&store, first_id), first_post_state); + assert_eq!(stored_market_state(&store, second_id), second_post_state); assert_tracked_outpoints( &store, - market_id, + first_id, vec![ ( BinaryMarketSlot::UnresolvedYesRt as u8, @@ -5656,22 +3017,30 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { ), ], ); - assert_tracked_outpoints( - &store, - sell_base.contract_id, - vec![(0, OutPoint::new(composed.txid(), 5))], - ); - assert_tracked_outpoints(&store, sell_quote.contract_id, vec![]); - assert_eq!( - ready_order_rows(&store, market_id), - expected_post_order_book - ); + assert_tracked_outpoints( + &store, + second_id, + vec![ + ( + BinaryMarketSlot::UnresolvedYesRt as u8, + OutPoint::new(composed.txid(), 3), + ), + ( + BinaryMarketSlot::UnresolvedNoRt as u8, + OutPoint::new(composed.txid(), 4), + ), + ( + BinaryMarketSlot::UnresolvedCollateral as u8, + OutPoint::new(composed.txid(), 5), + ), + ], + ); for spent in [ - OutPoint::new(issuance.txid(), 0), - OutPoint::new(issuance.txid(), 1), - OutPoint::new(issuance.txid(), 2), - sell_base.output.outpoint, - sell_quote.output.outpoint, + OutPoint::new(first_issuance.txid(), 0), + OutPoint::new(first_issuance.txid(), 1), + OutPoint::new(first_issuance.txid(), 2), + OutPoint::new(second_market.transaction.txid(), 0), + OutPoint::new(second_market.transaction.txid(), 1), ] { assert!( store @@ -5681,79 +3050,54 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { ); } - let post_market_history = store - .contract_history(market_id) - .expect("post-composition market history"); - let post_sell_base_history = store - .contract_history(sell_base.contract_id) - .expect("post-composition SellBase history"); - let post_sell_quote_history = store - .contract_history(sell_quote.contract_id) - .expect("post-composition SellQuote history"); - assert_eq!(post_market_history.len(), 2); - assert_eq!(post_sell_base_history.len(), 1); - assert_eq!(post_sell_quote_history.len(), 1); - let market_entry = post_market_history + let first_post_history = store + .contract_history(first_id) + .expect("first post-composition market history"); + let second_post_history = store + .contract_history(second_id) + .expect("second post-composition market history"); + assert_eq!(first_post_history.len(), 2); + assert_eq!(second_post_history.len(), 1); + let first_entry = first_post_history + .iter() + .find(|entry| entry.txid == composed.txid()) + .expect("first composed market transition"); + let second_entry = second_post_history .iter() .find(|entry| entry.txid == composed.txid()) - .expect("composed market transition"); - let sell_base_entry = &post_sell_base_history[0]; - let sell_quote_entry = &post_sell_quote_history[0]; - let original_position = market_entry.position; - assert_eq!(sell_base_entry.position, original_position); - assert_eq!(sell_quote_entry.position, original_position); + .expect("second composed market transition"); + let original_position = first_entry.position; + assert_eq!(second_entry.position, original_position); assert_eq!( original_position.block_height, u32::try_from(composed_accepted.block_height).expect("composed height") ); - assert_eq!( - market_entry.old_state, - ContractState::BinaryMarket(trading_thirty) - ); - assert_eq!( - market_entry.new_state, - ContractState::BinaryMarket(post_market_state) - ); - assert_eq!(market_entry.transition.kind, TRANSITION_V1_MARKET_ISSUED); - let BinaryMarketTransition::Issued { - pairs, - collateral_locked, - } = plans.market.transition() - else { - panic!("composed market plan was not an issuance") - }; - let mut expected_market_payload = vec![1_u8]; // SubsequentIssuance path. - expected_market_payload.extend_from_slice(&pairs.to_be_bytes()); - expected_market_payload.extend_from_slice(&collateral_locked.to_be_bytes()); - assert_eq!(market_entry.transition.payload, expected_market_payload); - assert_eq!( - sell_base_entry.old_state, - ContractState::MakerOrder(pre_order_state) - ); - assert_eq!( - sell_base_entry.new_state, - ContractState::MakerOrder(post_sell_base_state) - ); - assert_eq!(sell_base_entry.transition.kind, TRANSITION_V1_MAKER_FILLED); - assert_eq!( - sell_base_entry.transition.payload, - maker_fill_payload(&plans.sell_base) - ); - assert_eq!( - sell_quote_entry.old_state, - ContractState::MakerOrder(pre_order_state) - ); - assert_eq!( - sell_quote_entry.new_state, - ContractState::MakerOrder(MakerOrderState::Consumed) - ); - assert_eq!(sell_quote_entry.transition.kind, TRANSITION_V1_MAKER_FILLED); - assert_eq!( - sell_quote_entry.transition.payload, - maker_fill_payload(&plans.sell_quote) - ); + for (entry, before, after, plan) in [ + (first_entry, first_pre_state, first_post_state, &plans.first), + ( + second_entry, + second_pre_state, + second_post_state, + &plans.second, + ), + ] { + assert_eq!(entry.old_state, ContractState::BinaryMarket(before)); + assert_eq!(entry.new_state, ContractState::BinaryMarket(after)); + assert_eq!(entry.transition.kind, TRANSITION_V1_MARKET_ISSUED); + let BinaryMarketTransition::Issued { + pairs, + collateral_locked, + } = plan.transition() + else { + panic!("composed market plan was not an issuance") + }; + let mut expected_payload = vec![plan.path() as u8]; + expected_payload.extend_from_slice(&pairs.to_be_bytes()); + expected_payload.extend_from_slice(&collateral_locked.to_be_bytes()); + assert_eq!(entry.transition.payload, expected_payload); + } - let mut expected_affected = vec![market_id, sell_base.contract_id, sell_quote.contract_id]; + let mut expected_affected = vec![first_id, second_id]; expected_affected.sort(); let composed_events = store .events_after(Some(before_composed_cursor), 100) @@ -5773,7 +3117,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { txid, position, affected_contract_ids, - affected_market_ids, } = &applied_events[0].event else { unreachable!("filtered to the composed TransactionApplied event") @@ -5788,7 +3131,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { assert_eq!(*txid, composed.txid()); assert_eq!(*position, original_position); assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &[market_id]); let post_apply_cursor = store .event_high_watermark() .expect("event cursor after composed indexing"); @@ -5796,7 +3138,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .transaction(original_position) .expect("read composed evidence") .expect("one shared composed evidence row"); - assert_eq!(original_evidence.position, original_position); assert_eq!(original_evidence.block_hash, original_block_hash); assert_eq!(original_evidence.txid, composed.txid()); assert_eq!( @@ -5813,19 +3154,17 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { assert_eq!(&stored.output, expected_output); } - // A real close/reopen must preserve the atomic state and the one shared - // full-witness evidence allocation without duplicating any history. drop(handler); drop(store); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=restart"); - let reopened = Arc::new(Store::open(&database_path).expect("reopen multi-contract store")); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=restart"); + let reopened = Arc::new(Store::open(&database_path).expect("reopen multi-market store")); let SyncOutcome::Ready(restart_sync) = SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) .sync_to_tip() .await - .expect("idempotent multi-contract restart sync") + .expect("idempotent multi-market restart sync") else { - panic!("restarted multi-contract node unexpectedly required a rescan") + panic!("restarted multi-market node unexpectedly required a rescan") }; assert_eq!(restart_sync.blocks_applied, 0); assert_eq!(restart_sync.blocks_rolled_back, 0); @@ -5836,60 +3175,17 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .expect("restarted event cursor"), post_apply_cursor ); - assert_eq!(stored_market_state(&reopened, market_id), post_market_state); - assert_eq!( - stored_maker_state(&reopened, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&reopened, sell_quote.contract_id), - MakerOrderState::Consumed - ); - assert_tracked_outpoints( - &reopened, - market_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &reopened, - sell_base.contract_id, - vec![(0, OutPoint::new(composed.txid(), 5))], - ); - assert_tracked_outpoints(&reopened, sell_quote.contract_id, vec![]); - assert_eq!( - ready_order_rows(&reopened, market_id), - expected_post_order_book - ); - assert_eq!( - reopened - .contract_history(market_id) - .expect("restarted market history"), - post_market_history - ); assert_eq!( reopened - .contract_history(sell_base.contract_id) - .expect("restarted SellBase history"), - post_sell_base_history + .contract_history(first_id) + .expect("restarted first history"), + first_post_history ); assert_eq!( reopened - .contract_history(sell_quote.contract_id) - .expect("restarted SellQuote history"), - post_sell_quote_history + .contract_history(second_id) + .expect("restarted second history"), + second_post_history ); assert_eq!( reopened @@ -5897,14 +3193,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .expect("restarted composed evidence"), Some(original_evidence.clone()) ); - for (vout, expected_output) in composed.output.iter().enumerate() { - let stored = reopened - .output(OutPoint::new(composed.txid(), vout as u32)) - .expect("restarted output lookup") - .expect("restarted output evidence"); - assert_eq!(stored.position, original_position); - assert_eq!(&stored.output, expected_output); - } let mining_address = signer.get_address().to_unconfidential().to_string(); let mine_exact = |txids: Vec| -> String { @@ -5913,17 +3201,15 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { "generateblock", &[json!(mining_address.clone()), json!(txids)], ) - .expect("mine exact multi-contract regtest block"); + .expect("mine exact multi-market regtest block"); result["hash"] .as_str() .expect("generateblock hash") .to_owned() }; - // Add a successor, then replace the original composed block and successor - // with an empty block followed by the same transaction. The txid/outpoints - // do not change, so the assertions below deliberately key on position and - // block hash to prove canonical evidence was rolled back and rewritten. + // Move the shared transaction one block later on a two-block fork. Both + // histories and the one evidence row must move together. let _original_successor_hash = mine_exact(vec![]); let SyncOutcome::Ready(successor_sync) = SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) @@ -5949,10 +3235,9 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { let empty_at_original_height = mine_exact(vec![]); let moved_block_hash_string = mine_exact(vec![composed.txid().to_string()]); assert_ne!(empty_at_original_height, composed_accepted.block_hash); - assert_ne!(moved_block_hash_string, composed_accepted.block_hash); let moved_block_hash = BlockHash::from_str(&moved_block_hash_string).expect("moved composed block hash"); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=two_block_reorg"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=two_block_reorg"); let SyncOutcome::Ready(two_block_reorg) = SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) .sync_to_tip() @@ -5963,99 +3248,30 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { }; assert_eq!(two_block_reorg.blocks_rolled_back, 2); assert_eq!(two_block_reorg.blocks_applied, 2); - assert_eq!(stored_market_state(&reopened, market_id), post_market_state); - assert_eq!( - stored_maker_state(&reopened, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&reopened, sell_quote.contract_id), - MakerOrderState::Consumed - ); - assert_tracked_outpoints( - &reopened, - market_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &reopened, - sell_base.contract_id, - vec![(0, OutPoint::new(composed.txid(), 5))], - ); - assert_tracked_outpoints(&reopened, sell_quote.contract_id, vec![]); - assert_eq!( - ready_order_rows(&reopened, market_id), - expected_post_order_book - ); - let moved_market_history = reopened - .contract_history(market_id) - .expect("moved market history"); - let moved_sell_base_history = reopened - .contract_history(sell_base.contract_id) - .expect("moved SellBase history"); - let moved_sell_quote_history = reopened - .contract_history(sell_quote.contract_id) - .expect("moved SellQuote history"); - let moved_position = moved_market_history + assert_eq!(stored_market_state(&reopened, first_id), first_post_state); + assert_eq!(stored_market_state(&reopened, second_id), second_post_state); + let moved_first_history = reopened + .contract_history(first_id) + .expect("moved first history"); + let moved_second_history = reopened + .contract_history(second_id) + .expect("moved second history"); + let moved_position = moved_first_history .iter() .find(|entry| entry.txid == composed.txid()) - .expect("moved market transition") + .expect("moved first market transition") .position; assert_eq!( moved_position.block_height, original_position.block_height + 1 ); - assert_eq!(moved_market_history.len(), post_market_history.len()); - assert_eq!(moved_market_history[0], pre_market_history[0]); - let moved_market_entry = moved_market_history - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("moved market history entry"); - assert_eq!(moved_market_entry.old_state, market_entry.old_state); - assert_eq!(moved_market_entry.new_state, market_entry.new_state); - assert_eq!(moved_market_entry.transition, market_entry.transition); - assert_eq!(moved_sell_base_history.len(), 1); - assert_eq!(moved_sell_quote_history.len(), 1); - assert_eq!(moved_sell_base_history[0].position, moved_position); - assert_eq!(moved_sell_quote_history[0].position, moved_position); - assert_eq!(moved_sell_base_history[0].txid, sell_base_entry.txid); - assert_eq!( - moved_sell_base_history[0].old_state, - sell_base_entry.old_state - ); - assert_eq!( - moved_sell_base_history[0].new_state, - sell_base_entry.new_state - ); assert_eq!( - moved_sell_base_history[0].transition, - sell_base_entry.transition - ); - assert_eq!(moved_sell_quote_history[0].txid, sell_quote_entry.txid); - assert_eq!( - moved_sell_quote_history[0].old_state, - sell_quote_entry.old_state - ); - assert_eq!( - moved_sell_quote_history[0].new_state, - sell_quote_entry.new_state - ); - assert_eq!( - moved_sell_quote_history[0].transition, - sell_quote_entry.transition + moved_second_history + .iter() + .find(|entry| entry.txid == composed.txid()) + .expect("moved second market transition") + .position, + moved_position ); let two_block_events = reopened .events_after(Some(before_two_block_cursor), 100) @@ -6070,7 +3286,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { new_tip, orphaned_positions, affected_contract_ids, - affected_market_ids, } = &rollback_events[0].event else { unreachable!("filtered to a ChainRolledBack event") @@ -6079,7 +3294,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { assert_eq!(*new_tip, two_block_ancestor); assert_eq!(orphaned_positions, &[original_position]); assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &[market_id]); let moved_applied_events = two_block_events .iter() .filter(|event| { @@ -6090,106 +3304,26 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { }) .collect::>(); assert_eq!(moved_applied_events.len(), 1); - assert_eq!( - rollback_events[0].cursor.epoch, - moved_applied_events[0].cursor.epoch - ); assert!( rollback_events[0].cursor.sequence < moved_applied_events[0].cursor.sequence, "subscription consumers must observe rollback before replacement apply" ); - let StoredEvent::TransactionApplied { - anchor, - position, - affected_contract_ids, - affected_market_ids, - .. - } = &moved_applied_events[0].event - else { - unreachable!("filtered to the moved TransactionApplied event") - }; - assert_eq!( - *anchor, - ChainAnchor { - height: moved_position.block_height, - hash: moved_block_hash, - } - ); - assert_eq!(*position, moved_position); - assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &[market_id]); - assert!( - reopened - .transaction(original_position) - .expect("orphaned original evidence lookup") - .is_none() - ); let moved_evidence = reopened .transaction(moved_position) .expect("moved evidence lookup") .expect("moved shared evidence row"); assert_eq!(moved_evidence.block_hash, moved_block_hash); - assert_eq!(moved_evidence.txid, composed.txid()); assert_eq!(moved_evidence.raw_tx, original_evidence.raw_tx); assert_eq!(moved_evidence.affected_contract_ids, expected_affected); - for (vout, expected_output) in composed.output.iter().enumerate() { - let stored = reopened - .output(OutPoint::new(composed.txid(), vout as u32)) - .expect("moved output lookup") - .expect("moved output evidence"); - assert_eq!(stored.position, moved_position); - assert_eq!(&stored.output, expected_output); - } - - let moved_tip = source.tip().await.expect("moved canonical tip"); - let moved_handler = NodeRpcHandler::new( - Arc::clone(&source), - Arc::clone(&reopened), - node_rpc_config(genesis_hash, policy_asset, baseline, moved_tip), - ) - .expect("moved-branch RPC handler"); - let (moved_parent_view, moved_parent_rpc_history) = - assert_rpc_contract_replay(&moved_handler, source.as_ref(), market_id, None).await; - assert_eq!( - moved_parent_view.state, - ContractStateView::BinaryMarket { - state: post_market_state, - } - ); - assert_eq!( - moved_parent_rpc_history - .entries - .iter() - .find(|entry| entry.txid == composed.txid()) - .expect("moved RPC market transition") - .position, - moved_position + assert!( + reopened + .transaction(original_position) + .expect("orphaned original evidence lookup") + .is_none() ); - for (order, expected_state) in [ - (sell_base, post_sell_base_state), - (sell_quote, MakerOrderState::Consumed), - ] { - let (view, history) = assert_rpc_contract_replay( - &moved_handler, - source.as_ref(), - order.contract_id, - Some(&moved_parent_view), - ) - .await; - assert_eq!( - view.state, - ContractStateView::MakerOrder { - state: expected_state - } - ); - assert_eq!(history.entries.len(), 1); - assert_eq!(history.entries[0].position, moved_position); - } - drop(moved_handler); - // Replace the moved transaction with an empty block. Every contract, - // history, live-outpoint owner, output reference, and order-book row must - // return to the exact pre-transaction state in one coordinator update. + // Replace the moved transaction with an empty block. Both markets must + // return to their exact pre-transaction state in one coordinator update. let moved_branch_tip = source.tip().await.expect("moved branch tip"); let before_one_block_cursor = reopened .event_high_watermark() @@ -6201,75 +3335,30 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { let one_block_ancestor = source.tip().await.expect("one-block reorg ancestor"); let empty_replacement_hash = mine_exact(vec![]); assert_ne!(empty_replacement_hash, moved_block_hash_string); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=one_block_atomic_rollback"); - let SyncOutcome::Ready(one_block_reorg) = - SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) - .sync_to_tip() - .await - .expect("one-block composed rollback") - else { - panic!("one-block composed rollback exceeded retention") - }; - assert_eq!(one_block_reorg.blocks_rolled_back, 1); - assert_eq!(one_block_reorg.blocks_applied, 1); - assert_eq!(stored_market_state(&reopened, market_id), trading_thirty); - assert_eq!( - stored_maker_state(&reopened, sell_base.contract_id), - pre_order_state - ); - assert_eq!( - stored_maker_state(&reopened, sell_quote.contract_id), - pre_order_state - ); - assert_tracked_outpoints( - &reopened, - market_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(issuance.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(issuance.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(issuance.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &reopened, - sell_base.contract_id, - vec![(0, sell_base.output.outpoint)], - ); - assert_tracked_outpoints( - &reopened, - sell_quote.contract_id, - vec![(0, sell_quote.output.outpoint)], - ); - assert_eq!( - ready_order_rows(&reopened, market_id), - expected_pre_order_book - ); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=one_block_atomic_rollback"); + let SyncOutcome::Ready(one_block_reorg) = + SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) + .sync_to_tip() + .await + .expect("one-block composed rollback") + else { + panic!("one-block composed rollback exceeded retention") + }; + assert_eq!(one_block_reorg.blocks_rolled_back, 1); + assert_eq!(one_block_reorg.blocks_applied, 1); + assert_eq!(stored_market_state(&reopened, first_id), first_pre_state); + assert_eq!(stored_market_state(&reopened, second_id), second_pre_state); assert_eq!( reopened - .contract_history(market_id) - .expect("rolled-back market history"), - pre_market_history - ); - assert!( - reopened - .contract_history(sell_base.contract_id) - .expect("rolled-back SellBase history") - .is_empty() + .contract_history(first_id) + .expect("rolled-back first history"), + first_pre_history ); - assert!( + assert_eq!( reopened - .contract_history(sell_quote.contract_id) - .expect("rolled-back SellQuote history") - .is_empty() + .contract_history(second_id) + .expect("rolled-back second history"), + second_pre_history ); assert!( reopened @@ -6277,12 +3366,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .expect("rolled-back moved evidence") .is_none() ); - assert!( - reopened - .transaction(original_position) - .expect("rolled-back original evidence") - .is_none() - ); for vout in 0..composed.output.len() { assert!( reopened @@ -6304,7 +3387,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { new_tip, orphaned_positions, affected_contract_ids, - affected_market_ids, } = &rollback_events[0].event else { unreachable!("filtered to a one-block ChainRolledBack event") @@ -6313,7 +3395,6 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { assert_eq!(*new_tip, one_block_ancestor); assert_eq!(orphaned_positions, &[moved_position]); assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &[market_id]); assert!(one_block_events.iter().all(|event| { !matches!( &event.event, @@ -6321,16 +3402,15 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { ) })); - // The invalidated transaction is back in the mempool. Mine it one block - // later and prove all three contracts and their shared evidence recover on - // the new canonical position. + // Mine the same transaction once more and independently replay both market + // histories through only the public RPC evidence surface. let before_final_cursor = reopened .event_high_watermark() .expect("event cursor before final remine"); let final_block_hash_string = mine_exact(vec![composed.txid().to_string()]); let final_block_hash = BlockHash::from_str(&final_block_hash_string).expect("final composed block hash"); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=canonical_remine"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=canonical_remine"); let SyncOutcome::Ready(final_sync) = SyncCoordinator::new(source.as_ref(), reopened.as_ref(), &interpreter) .sync_to_tip() @@ -6341,187 +3421,94 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { }; assert_eq!(final_sync.blocks_applied, 1); assert_eq!(final_sync.blocks_rolled_back, 0); - assert_eq!(stored_market_state(&reopened, market_id), post_market_state); - assert_eq!( - stored_maker_state(&reopened, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&reopened, sell_quote.contract_id), - MakerOrderState::Consumed - ); - assert_tracked_outpoints( - &reopened, - market_id, - vec![ - ( - BinaryMarketSlot::UnresolvedYesRt as u8, - OutPoint::new(composed.txid(), 0), - ), - ( - BinaryMarketSlot::UnresolvedNoRt as u8, - OutPoint::new(composed.txid(), 1), - ), - ( - BinaryMarketSlot::UnresolvedCollateral as u8, - OutPoint::new(composed.txid(), 2), - ), - ], - ); - assert_tracked_outpoints( - &reopened, - sell_base.contract_id, - vec![(0, OutPoint::new(composed.txid(), 5))], - ); - assert_tracked_outpoints(&reopened, sell_quote.contract_id, vec![]); - assert_eq!( - ready_order_rows(&reopened, market_id), - expected_post_order_book - ); - let final_market_history = reopened - .contract_history(market_id) - .expect("final market history"); - let final_sell_base_history = reopened - .contract_history(sell_base.contract_id) - .expect("final SellBase history"); - let final_sell_quote_history = reopened - .contract_history(sell_quote.contract_id) - .expect("final SellQuote history"); - let final_position = final_market_history + assert_eq!(stored_market_state(&reopened, first_id), first_post_state); + assert_eq!(stored_market_state(&reopened, second_id), second_post_state); + let final_first_history = reopened + .contract_history(first_id) + .expect("final first history"); + let final_second_history = reopened + .contract_history(second_id) + .expect("final second history"); + let final_position = final_first_history .iter() .find(|entry| entry.txid == composed.txid()) - .expect("final market transition") + .expect("final first transition") .position; assert_eq!( - final_position.block_height, - original_position.block_height + 2 - ); - assert_eq!(final_sell_base_history[0].position, final_position); - assert_eq!(final_sell_quote_history[0].position, final_position); - assert_eq!( - final_market_history + final_second_history .iter() .find(|entry| entry.txid == composed.txid()) - .expect("final market entry") - .transition, - market_entry.transition - ); - assert_eq!( - final_sell_base_history[0].transition, - sell_base_entry.transition + .expect("final second transition") + .position, + final_position ); assert_eq!( - final_sell_quote_history[0].transition, - sell_quote_entry.transition + final_position.block_height, + original_position.block_height + 2 ); let final_evidence = reopened .transaction(final_position) .expect("final evidence lookup") .expect("final shared evidence row"); assert_eq!(final_evidence.block_hash, final_block_hash); - assert_eq!(final_evidence.txid, composed.txid()); assert_eq!(final_evidence.raw_tx, original_evidence.raw_tx); assert_eq!(final_evidence.affected_contract_ids, expected_affected); - for (vout, expected_output) in composed.output.iter().enumerate() { - let stored = reopened - .output(OutPoint::new(composed.txid(), vout as u32)) - .expect("final output lookup") - .expect("final output evidence"); - assert_eq!(stored.position, final_position); - assert_eq!(&stored.output, expected_output); - } let final_events = reopened .events_after(Some(before_final_cursor), 100) .expect("events from final remine"); - let final_applied_events = final_events - .iter() - .filter(|event| { - matches!( - &event.event, - StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() - ) - }) - .collect::>(); - assert_eq!(final_applied_events.len(), 1); - let StoredEvent::TransactionApplied { - anchor, - position, - affected_contract_ids, - affected_market_ids, - .. - } = &final_applied_events[0].event - else { - unreachable!("filtered to the final TransactionApplied event") - }; assert_eq!( - *anchor, - ChainAnchor { - height: final_position.block_height, - hash: final_block_hash, - } + final_events + .iter() + .filter(|event| { + matches!( + &event.event, + StoredEvent::TransactionApplied { txid, .. } if *txid == composed.txid() + ) + }) + .count(), + 1 ); - assert_eq!(*position, final_position); - assert_eq!(affected_contract_ids, &expected_affected); - assert_eq!(affected_market_ids, &[market_id]); - // Finally consume only public RPC views/evidence and independently fetch - // canonical blocks to replay all three histories through client logic. - let final_tip = source.tip().await.expect("final multi-contract tip"); + let final_tip = source.tip().await.expect("final multi-market tip"); let reopened_handler = NodeRpcHandler::new( Arc::clone(&source), Arc::clone(&reopened), node_rpc_config(genesis_hash, policy_asset, baseline, final_tip), ) - .expect("final multi-contract RPC handler"); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=client_replay"); - let (parent_view, parent_history) = - assert_rpc_contract_replay(&reopened_handler, source.as_ref(), market_id, None).await; - assert_eq!( - parent_view.state, - ContractStateView::BinaryMarket { - state: post_market_state, - } - ); - assert_eq!(parent_history.entries.len(), 2); - let (sell_base_view, sell_base_history) = assert_rpc_contract_replay( - &reopened_handler, - source.as_ref(), - sell_base.contract_id, - Some(&parent_view), - ) - .await; - assert_eq!( - sell_base_view.state, - ContractStateView::MakerOrder { - state: post_sell_base_state, - } - ); - assert_eq!(sell_base_history.entries.len(), 1); - assert_eq!(sell_base_history.entries[0].position, final_position); - let (sell_quote_view, sell_quote_history) = assert_rpc_contract_replay( - &reopened_handler, - source.as_ref(), - sell_quote.contract_id, - Some(&parent_view), - ) - .await; - assert_eq!( - sell_quote_view.state, - ContractStateView::MakerOrder { - state: MakerOrderState::Consumed, - } - ); - assert_eq!(sell_quote_history.entries.len(), 1); - assert_eq!(sell_quote_history.entries[0].position, final_position); + .expect("final multi-market RPC handler"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=client_replay"); + for (market_id, expected_state, expected_history_len) in [ + (first_id, first_post_state, 2), + (second_id, second_post_state, 1), + ] { + let (view, history) = + assert_rpc_contract_replay(&reopened_handler, source.as_ref(), market_id).await; + assert_eq!( + view.state, + ContractStateView::BinaryMarket { + state: expected_state, + } + ); + assert_eq!(history.entries.len(), expected_history_len); + assert_eq!( + history + .entries + .iter() + .find(|entry| entry.txid == composed.txid()) + .expect("RPC composed transition") + .position, + final_position + ); + } let rpc_evidence = rpc_transaction_evidence(&reopened_handler, final_position).await; assert_eq!(rpc_evidence.block_hash, final_block_hash); assert_eq!(rpc_evidence.transaction, composed); assert_eq!(rpc_evidence.affected_contract_ids, expected_affected); - // Push the composed block outside the two-block undo window, replace the - // complete three-block suffix, and exercise the explicit activation-based - // rebuild boundary against real Elements consensus. - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=deep_reorg_invalidation"); + // Push the shared transition outside the two-block undo window, replace + // the complete suffix, and prove an activation-based rebuild preserves + // both retained market declarations and rematerializes them atomically. + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_reorg_invalidation"); let _stale_successor_one = mine_exact(vec![]); let _stale_successor_two = mine_exact(vec![]); let SyncOutcome::Ready(stale_successor_sync) = @@ -6537,7 +3524,7 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { let before_deep_reorg_cursor = reopened .event_high_watermark() .expect("cursor before deep reorg"); - let retained_before_rebuild = [market_id, sell_base.contract_id, sell_quote.contract_id] + let retained_before_rebuild = [first_id, second_id] .into_iter() .map(|contract_id| { ( @@ -6545,7 +3532,7 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { reopened .retained_declaration(contract_id) .expect("retained declaration lookup") - .expect("package declaration retained"), + .expect("market declaration retained"), ) }) .collect::>(); @@ -6585,47 +3572,26 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .expect("invalidated event cursor"); assert_ne!(invalidated_cursor.epoch, before_deep_reorg_cursor.epoch); assert_eq!(invalidated_cursor.sequence, 1); - assert_eq!(stored_market_state(&reopened, market_id), post_market_state); - assert_eq!( - stored_maker_state(&reopened, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&reopened, sell_quote.contract_id), - MakerOrderState::Consumed - ); + assert_eq!(stored_market_state(&reopened, first_id), first_post_state); + assert_eq!(stored_market_state(&reopened, second_id), second_post_state); assert!(matches!( reopened.events_after(Some(before_deep_reorg_cursor), 1), Err(StoreError::StaleCursor { .. }) )); - let stale_read = reopened_handler .handle( - [0x91; 32], + [0x92; 32], Request::GetContract { - contract_id: market_id, + contract_id: first_id, }, ) .await .expect_err("known-stale RPC state must fail closed"); assert_eq!(stale_read.code, RpcErrorCode::RescanRequired); - let Response::Info { info } = reopened_handler - .handle([0x91; 32], Request::GetInfo) - .await - .expect("GetInfo during invalidation") - else { - panic!("unexpected invalidated GetInfo response") - }; - assert_eq!(info.sync_status, deadcat_rpc::SyncStatus::RescanRequired); - assert_eq!(info.indexed_tip, stale_branch_tip); - assert!(!info.discovery.canonical_market_complete); drop(reopened_handler); drop(reopened); - // Reopen before reset, atomically clear to the persisted activation - // checkpoint, then reopen again before replay. This simulates both sides - // of an operator process interruption without relying on in-memory state. - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=deep_rebuild_reset"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_rebuild_reset"); let invalidated_store = Store::open(&database_path).expect("reopen invalidated store"); assert_eq!( invalidated_store @@ -6666,7 +3632,7 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { } drop(invalidated_store); - eprintln!("DEADCAT_MULTI_CONTRACT_REGTEST_PHASE=deep_rebuild_replay"); + eprintln!("DEADCAT_MULTI_MARKET_REGTEST_PHASE=deep_rebuild_replay"); let rebuilt = Arc::new(Store::open(&database_path).expect("reopen reset store")); let SyncOutcome::Ready(deep_rebuild) = SyncCoordinator::new(source.as_ref(), rebuilt.as_ref(), &interpreter) @@ -6689,18 +3655,43 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { .epoch, invalidated_cursor.epoch ); - assert_eq!(stored_market_state(&rebuilt, market_id), post_market_state); - assert_eq!( - stored_maker_state(&rebuilt, sell_base.contract_id), - post_sell_base_state - ); - assert_eq!( - stored_maker_state(&rebuilt, sell_quote.contract_id), - MakerOrderState::Consumed + assert_eq!(stored_market_state(&rebuilt, first_id), first_post_state); + assert_eq!(stored_market_state(&rebuilt, second_id), second_post_state); + assert_tracked_outpoints( + &rebuilt, + first_id, + vec![ + ( + BinaryMarketSlot::UnresolvedYesRt as u8, + OutPoint::new(composed.txid(), 0), + ), + ( + BinaryMarketSlot::UnresolvedNoRt as u8, + OutPoint::new(composed.txid(), 1), + ), + ( + BinaryMarketSlot::UnresolvedCollateral as u8, + OutPoint::new(composed.txid(), 2), + ), + ], ); - assert_eq!( - ready_order_rows(&rebuilt, market_id), - expected_post_order_book + assert_tracked_outpoints( + &rebuilt, + second_id, + vec![ + ( + BinaryMarketSlot::UnresolvedYesRt as u8, + OutPoint::new(composed.txid(), 3), + ), + ( + BinaryMarketSlot::UnresolvedNoRt as u8, + OutPoint::new(composed.txid(), 4), + ), + ( + BinaryMarketSlot::UnresolvedCollateral as u8, + OutPoint::new(composed.txid(), 5), + ), + ], ); for (contract_id, declaration) in &retained_before_rebuild { assert_eq!( @@ -6710,30 +3701,26 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { Some(*declaration) ); } - - let deep_market_history = rebuilt - .contract_history(market_id) - .expect("deep rebuilt market history"); - let deep_position = deep_market_history + let deep_first_history = rebuilt + .contract_history(first_id) + .expect("deep rebuilt first history"); + let deep_second_history = rebuilt + .contract_history(second_id) + .expect("deep rebuilt second history"); + let deep_position = deep_first_history .iter() .find(|entry| entry.txid == composed.txid()) - .expect("deep rebuilt composed market transition") + .expect("deep rebuilt first transition") .position; - assert_eq!(deep_position.block_height, final_position.block_height + 1); - assert_eq!( - rebuilt - .contract_history(sell_base.contract_id) - .expect("deep rebuilt SellBase history")[0] - .position, - deep_position - ); assert_eq!( - rebuilt - .contract_history(sell_quote.contract_id) - .expect("deep rebuilt SellQuote history")[0] + deep_second_history + .iter() + .find(|entry| entry.txid == composed.txid()) + .expect("deep rebuilt second transition") .position, deep_position ); + assert_eq!(deep_position.block_height, final_position.block_height + 1); let deep_evidence = rebuilt .transaction(deep_position) .expect("deep rebuilt evidence lookup") @@ -6753,34 +3740,33 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { node_rpc_config(genesis_hash, policy_asset, baseline, deep_source_tip), ) .expect("deep rebuilt RPC handler"); - let (deep_parent_view, deep_parent_history) = - assert_rpc_contract_replay(&deep_handler, source.as_ref(), market_id, None).await; - assert_eq!(deep_parent_history.entries.len(), 2); - for (order, expected_state) in [ - (&sell_base, post_sell_base_state), - (&sell_quote, MakerOrderState::Consumed), + for (market_id, expected_state, expected_history_len) in [ + (first_id, first_post_state, 2), + (second_id, second_post_state, 1), ] { - let (view, history) = assert_rpc_contract_replay( - &deep_handler, - source.as_ref(), - order.contract_id, - Some(&deep_parent_view), - ) - .await; + let (view, history) = + assert_rpc_contract_replay(&deep_handler, source.as_ref(), market_id).await; assert_eq!( view.state, - ContractStateView::MakerOrder { - state: expected_state + ContractStateView::BinaryMarket { + state: expected_state, } ); - assert_eq!(history.entries.len(), 1); - assert_eq!(history.entries[0].position, deep_position); + assert_eq!(history.entries.len(), expected_history_len); + assert_eq!( + history + .entries + .iter() + .find(|entry| entry.txid == composed.txid()) + .expect("deep RPC composed transition") + .position, + deep_position + ); } let report = json!({ - "schema": "deadcat.multi-contract-regtest.v1", - "market_id": market_id.to_string(), - "order_ids": [sell_base.contract_id.to_string(), sell_quote.contract_id.to_string()], + "schema": "deadcat.multi-market-regtest.v1", + "market_ids": [first_id.to_string(), second_id.to_string()], "txid": composed.txid().to_string(), "inputs": composed.input.len(), "outputs": composed.output.len(), @@ -6793,11 +3779,11 @@ async fn multi_contract_transaction_is_accepted_and_indexed_by_elementsd() { "final_block_hash": final_block_hash_string, "deep_rebuild_position": deep_position, "deep_rebuild_block_hash": deep_composed_hash_string, - "negative_test": wrong_sell_quote_rejection, + "negative_test": wrong_second_rejection, }); eprintln!( - "DEADCAT_MULTI_CONTRACT_REGTEST_METRICS={}", - serde_json::to_string(&report).expect("serialize multi-contract metrics") + "DEADCAT_MULTI_MARKET_REGTEST_METRICS={}", + serde_json::to_string(&report).expect("serialize multi-market metrics") ); } diff --git a/crates/deadcat-client/tests/simplicity_budget.rs b/crates/deadcat-client/tests/simplicity_budget.rs index b6b0e1d..f7e857b 100644 --- a/crates/deadcat-client/tests/simplicity_budget.rs +++ b/crates/deadcat-client/tests/simplicity_budget.rs @@ -1,4 +1,3 @@ -use deadcat_client::maker_builder::MakerFillPlan; use deadcat_client::market_builder::{ BinaryMarketLiveInputs, BinaryMarketTransitionPlan, MarketIssuanceEntropies, MarketRtInput, OracleAttestation, @@ -12,12 +11,11 @@ use deadcat_contracts::interpret::strip_taproot_annex; use deadcat_contracts::interpret::{ BinaryMarketLiveOutputs, TrackedContractOutput, interpret_binary_market_spend_with_compiled, }; -use deadcat_contracts::maker_order::CompiledMakerOrder; use deadcat_contracts::market_crypto::{ BinaryOutcome as OracleOutcome, derive_issuance_assets, oracle_message, }; use deadcat_contracts::rt::{RtLeg, RtSide, commitments, factors}; -use deadcat_types::{BinaryMarketParams, BinaryMarketState, MakerOrderParams, OrderDirection}; +use deadcat_types::{BinaryMarketParams, BinaryMarketState}; use elements::confidential::{Asset, Nonce, Value}; use elements::hashes::Hash as _; use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTransaction}; @@ -1875,78 +1873,3 @@ fn every_terminal_market_slot_rejects_a_false_slot_witness() { ); } } - -fn maker_params(direction: OrderDirection) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x91), - quote_asset_id: asset(0x92), - price: 7, - min_active_base: 3, - direction, - instance_id: [0x44; 32], - maker_pubkey: Keypair::from_seckey_slice(&Secp256k1::new(), &[0x41; 32]) - .expect("maker key") - .x_only_public_key() - .0 - .serialize(), - } -} - -fn finalized_maker_stack(direction: OrderDirection, partial: bool) -> Vec> { - let params = maker_params(direction); - let input_locked = match direction { - OrderDirection::SellBase => 10, - OrderDirection::SellQuote => 70, - }; - let fill_base = if partial { 4 } else { 10 }; - let remainder_index = partial.then_some(1); - let live_outpoint = OutPoint::new(Txid::from_byte_array([0xa1; 32]), 0); - let plan = MakerFillPlan::new(live_outpoint, params, input_locked, fill_base, 0) - .expect("maker fill plan"); - let compiled = CompiledMakerOrder::new(params).expect("compile maker order"); - let held_asset = match direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input( - live_outpoint, - explicit_txout(held_asset, input_locked, compiled.script_pubkey().clone()), - )); - for (_, output) in plan - .mandatory_outputs(0, remainder_index) - .expect("maker outputs") - { - pset.add_output(PsetOutput::from_txout(output)); - } - let network = SimplicityNetwork::ElementsRegtest { - policy_asset: params.quote_asset_id, - }; - plan.finalize(&mut pset, 0, 0, remainder_index, &network) - .expect("finalize maker order"); - pset.inputs()[0] - .final_script_witness - .clone() - .expect("final maker witness") -} - -#[test] -fn every_finalized_maker_fill_stack_has_sufficient_simplicity_budget() { - let mut failures = Vec::new(); - for direction in [OrderDirection::SellBase, OrderDirection::SellQuote] { - for partial in [false, true] { - let shape = if partial { "partial" } else { "full" }; - let stack = finalized_maker_stack(direction, partial); - let _ = record_budget( - format!("maker-{direction:?}-{shape}"), - &stack, - &mut failures, - ); - } - } - let report = failure_report(&failures); - assert!( - failures.is_empty(), - "underbudget finalized maker stacks:\n{report}" - ); -} diff --git a/crates/deadcat-contracts/Cargo.toml b/crates/deadcat-contracts/Cargo.toml index 72e3aa6..6200261 100644 --- a/crates/deadcat-contracts/Cargo.toml +++ b/crates/deadcat-contracts/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] deadcat-types.workspace = true elements.workspace = true -hmac.workspace = true sha2.workspace = true smplx-std.workspace = true thiserror.workspace = true diff --git a/crates/deadcat-contracts/Simplex.toml b/crates/deadcat-contracts/Simplex.toml index fa39938..07349b6 100644 --- a/crates/deadcat-contracts/Simplex.toml +++ b/crates/deadcat-contracts/Simplex.toml @@ -1,6 +1,6 @@ [build] src_dir = "./simplicityhl" -simf_files = ["binary_market.simf", "maker_order.simf"] +simf_files = ["binary_market.simf"] out_dir = "./src/artifacts" [regtest] diff --git a/crates/deadcat-contracts/simplicityhl/maker_order.simf b/crates/deadcat-contracts/simplicityhl/maker_order.simf deleted file mode 100644 index a7921d2..0000000 --- a/crates/deadcat-contracts/simplicityhl/maker_order.simf +++ /dev/null @@ -1,181 +0,0 @@ -// Deadcat persistent maker limit order v1. -// -// The Taproot internal key is the instance-derived maker cancellation key and -// provides the only cancellation path. This Simplicity leaf is a -// permissionless exact-price fill path. - -fn not(bit: bool) -> bool { - ::into(jet::complement_1(::into(bit))) -} - -fn safe_add(a: u64, b: u64) -> u64 { - let (carry, sum): (bool, u64) = jet::add_64(a, b); - assert!(not(carry)); - sum -} - -fn safe_subtract(a: u64, b: u64) -> u64 { - let (borrow, difference): (bool, u64) = jet::subtract_64(a, b); - assert!(not(borrow)); - difference -} - -fn safe_multiply(a: u64, b: u64) -> u64 { - let product: u128 = jet::multiply_64(a, b); - let (high, low): (u64, u64) = ::into(product); - assert!(jet::is_zero_64(high)); - low -} - -fn widen_u32(value: u32) -> u64 { - <(u32, u32)>::into((0, value)) -} - -fn ensure_nonzero(value: u64) { - assert!(not(jet::is_zero_64(value))); -} - -fn get_input_explicit(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); - let explicit_asset: u256 = unwrap_right::<(u1, u256)>(asset); - let explicit_amount: u64 = unwrap_right::<(u1, u256)>(amount); - (explicit_asset, explicit_amount) -} - -fn get_output_explicit(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); - let explicit_asset: u256 = unwrap_right::<(u1, u256)>(asset); - let explicit_amount: u64 = unwrap_right::<(u1, u256)>(amount); - (explicit_asset, explicit_amount) -} - -fn ensure_no_issuance(index: u32) { - let asset_amount: Option = unwrap(jet::issuance_asset_amount(index)); - let token_amount: Option = unwrap(jet::issuance_token_amount(index)); - let entropy: Option = unwrap(jet::issuance_entropy(index)); - assert!(is_none::(asset_amount)); - assert!(is_none::(token_amount)); - assert!(is_none::(entropy)); -} - -fn validate_parameters() { - assert!(not(jet::eq_256(param::BASE_ASSET_ID, param::QUOTE_ASSET_ID))); - ensure_nonzero(widen_u32(param::PRICE)); - ensure_nonzero(widen_u32(param::MIN_ACTIVE_BASE)); -} - -fn ensure_maker_payment(index: u32, asset: u256) -> u64 { - let (actual_asset, amount): (u256, u64) = get_output_explicit(index); - assert!(jet::eq_256(actual_asset, asset)); - assert!(jet::eq_256( - unwrap(jet::output_script_hash(index)), - param::MAKER_RECEIVE_SPK_HASH, - )); - amount -} - -fn ensure_remainder(index: u32, input_index: u32, asset: u256) -> u64 { - let (actual_asset, amount): (u256, u64) = get_output_explicit(index); - assert!(jet::eq_256(actual_asset, asset)); - assert!(jet::eq_256( - unwrap(jet::output_script_hash(index)), - unwrap(jet::input_script_hash(input_index)), - )); - amount -} - -fn fill_sell_base( - input_index: u32, - payment_index: u32, - is_partial: bool, - remainder_index: u32, -) { - let (input_asset, input_amount): (u256, u64) = get_input_explicit(input_index); - assert!(jet::eq_256(input_asset, param::BASE_ASSET_ID)); - - let maker_payment: u64 = ensure_maker_payment(payment_index, param::QUOTE_ASSET_ID); - let price: u64 = widen_u32(param::PRICE); - let minimum: u64 = widen_u32(param::MIN_ACTIVE_BASE); - - match is_partial { - false => { - assert!(jet::eq_64(maker_payment, safe_multiply(input_amount, price))); - assert!(jet::le_64(minimum, input_amount)); - }, - true => { - let remainder: u64 = ensure_remainder( - remainder_index, - input_index, - param::BASE_ASSET_ID, - ); - ensure_nonzero(remainder); - let filled: u64 = safe_subtract(input_amount, remainder); - ensure_nonzero(filled); - assert!(jet::eq_64(maker_payment, safe_multiply(filled, price))); - assert!(jet::le_64(minimum, filled)); - assert!(jet::le_64(minimum, remainder)); - }, - }; -} - -fn fill_sell_quote( - input_index: u32, - payment_index: u32, - is_partial: bool, - remainder_index: u32, -) { - let (input_asset, input_amount): (u256, u64) = get_input_explicit(input_index); - assert!(jet::eq_256(input_asset, param::QUOTE_ASSET_ID)); - - let filled: u64 = ensure_maker_payment(payment_index, param::BASE_ASSET_ID); - let price: u64 = widen_u32(param::PRICE); - let minimum: u64 = widen_u32(param::MIN_ACTIVE_BASE); - ensure_nonzero(filled); - assert!(jet::le_64(minimum, filled)); - let quote_filled: u64 = safe_multiply(filled, price); - - match is_partial { - false => { - assert!(jet::eq_64(quote_filled, input_amount)); - }, - true => { - let remainder: u64 = ensure_remainder( - remainder_index, - input_index, - param::QUOTE_ASSET_ID, - ); - ensure_nonzero(remainder); - assert!(jet::eq_64(safe_add(quote_filled, remainder), input_amount)); - assert!(jet::le_64(safe_multiply(minimum, price), remainder)); - }, - }; -} - -fn main() { - validate_parameters(); - let input_index: u32 = jet::current_index(); - ensure_no_issuance(input_index); - - // Canonical order instances have distinct covenant and payment scripts, so - // both exact outputs may be placed freely by the spending transaction. - let payment_index: u32 = witness::PAYMENT_INDEX; - let is_partial: bool = witness::IS_PARTIAL; - let remainder_index: u32 = witness::REMAINDER_INDEX; - // Keep the two witness words structurally distinguishable even when the - // full-fill branch does not inspect a remainder output. - assert!(not(jet::eq_32(payment_index, remainder_index))); - match param::DIRECTION_SELL_QUOTE { - false => fill_sell_base( - input_index, - payment_index, - is_partial, - remainder_index, - ), - true => fill_sell_quote( - input_index, - payment_index, - is_partial, - remainder_index, - ), - }; -} diff --git a/crates/deadcat-contracts/src/interpret.rs b/crates/deadcat-contracts/src/interpret.rs index eb3684c..c74f78e 100644 --- a/crates/deadcat-contracts/src/interpret.rs +++ b/crates/deadcat-contracts/src/interpret.rs @@ -8,13 +8,11 @@ use simplex::simplicityhl::simplicity::{BitIter, HasCmr as _, RedeemNode, Value} use thiserror::Error; mod binary_market; -mod maker_order; pub use binary_market::{ BinaryMarketContinuation, BinaryMarketInterpretation, BinaryMarketLiveOutputs, BinaryMarketPath, interpret_binary_market_spend, interpret_binary_market_spend_with_compiled, }; -pub use maker_order::{MakerOrderInterpretation, MakerOrderSpendKind, interpret_maker_order_spend}; /// A tracked covenant output with the previous output data needed to interpret /// explicit amounts and confidential value classes from a confirmed spend. @@ -45,12 +43,8 @@ pub enum InterpretError { AmbiguousInterpretation, #[error("transaction contradicts its decoded covenant witness: {0}")] Inconsistent(&'static str), - #[error("maker-order economics rejected the spend: {0}")] - MakerEconomics(#[from] crate::maker_order::MakerOrderError), #[error("binary-market economics rejected the spend: {0}")] BinaryEconomics(#[from] crate::binary_market::BinaryMarketError), - #[error("maker-order compilation failed: {0}")] - MakerCompilation(#[from] crate::maker_order::CompiledMakerOrderError), #[error("binary-market compilation failed: {0}")] BinaryCompilation(#[from] crate::binary_market::CompiledBinaryMarketError), #[error("transaction index does not fit the v1 u32 witness domain")] diff --git a/crates/deadcat-contracts/src/interpret/maker_order.rs b/crates/deadcat-contracts/src/interpret/maker_order.rs deleted file mode 100644 index 27ad647..0000000 --- a/crates/deadcat-contracts/src/interpret/maker_order.rs +++ /dev/null @@ -1,204 +0,0 @@ -use deadcat_types::{MakerOrderParams, MakerOrderState, OrderDirection}; -use elements::confidential::{Asset, Value}; -use elements::{OutPoint, Transaction}; - -use super::{ - InterpretError, TrackedContractOutput, decode_simplicity_witness, locate_input, output_at, - strip_taproot_annex, -}; -use crate::maker_order::{CompiledMakerOrder, MakerOrderFill, cancel, fill}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MakerOrderSpendKind { - Fill(MakerOrderFill), - Cancel, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MakerOrderInterpretation { - pub kind: MakerOrderSpendKind, - pub before: MakerOrderState, - pub after: MakerOrderState, - pub spent_outpoint: OutPoint, - pub input_index: u32, - pub payment_index: Option, - pub remainder_index: Option, - pub continuation: Option, - pub annex_present: bool, -} - -pub fn interpret_maker_order_spend( - params: MakerOrderParams, - before: MakerOrderState, - live_output: &TrackedContractOutput, - transaction: &Transaction, -) -> Result { - let compiled = CompiledMakerOrder::new(params)?; - if live_output.txout.script_pubkey != *compiled.script_pubkey() { - return Err(InterpretError::InvalidTrackedOutput( - "maker script does not match compiled parameters", - )); - } - let input_index = locate_input(transaction, live_output.outpoint)?; - let input_index_u32 = u32::try_from(input_index).map_err(|_| InterpretError::IndexOverflow)?; - let (input_asset, input_locked) = explicit_asset_value(&live_output.txout).ok_or( - InterpretError::InvalidTrackedOutput("maker input is not explicit"), - )?; - let expected_input_asset = match params.direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - if input_asset != expected_input_asset { - return Err(InterpretError::InvalidTrackedOutput( - "maker input asset is wrong", - )); - } - let MakerOrderState::Active { remaining_base, .. } = before else { - return Err(InterpretError::InvalidTrackedOutput( - "terminal maker order still has a live output", - )); - }; - let expected_locked = match params.direction { - OrderDirection::SellBase => remaining_base, - OrderDirection::SellQuote => remaining_base - .checked_mul(u64::from(params.price)) - .ok_or(crate::maker_order::MakerOrderError::ArithmeticOverflow)?, - }; - if input_locked != expected_locked { - return Err(InterpretError::InvalidTrackedOutput( - "maker input amount disagrees with state", - )); - } - - let stack = &transaction.input[input_index].witness.script_witness; - let (core_stack, annex) = strip_taproot_annex(stack); - if core_stack.len() == 1 { - if !matches!(core_stack[0].len(), 64 | 65) { - return Err(InterpretError::BadWitnessStack { - len: core_stack.len(), - }); - } - let after = cancel(before)?; - return Ok(MakerOrderInterpretation { - kind: MakerOrderSpendKind::Cancel, - before, - after, - spent_outpoint: live_output.outpoint, - input_index: input_index_u32, - payment_index: None, - remainder_index: None, - continuation: None, - annex_present: annex.is_some(), - }); - } - - let decoded = decode_simplicity_witness(stack)?; - if decoded.cmr() != compiled.cmr() { - return Err(InterpretError::CmrMismatch); - } - if decoded.control_block() != compiled.control_block().serialize() { - return Err(InterpretError::Inconsistent("maker control block mismatch")); - } - if transaction.input[input_index].has_issuance() { - return Err(InterpretError::Inconsistent( - "maker script spend carries issuance", - )); - } - let partial_flags = decoded.bool_values(); - if partial_flags.len() != 1 { - return Err(if partial_flags.is_empty() { - InterpretError::MissingWitness("IS_PARTIAL") - } else { - InterpretError::AmbiguousInterpretation - }); - } - let is_partial = partial_flags[0]; - let indices = decoded.u32_values(); - if indices.len() != 2 { - return Err(if indices.is_empty() { - InterpretError::MissingWitness("PAYMENT_INDEX and REMAINDER_INDEX") - } else { - InterpretError::AmbiguousInterpretation - }); - } - let payment_index = indices[0]; - let remainder_index_witness = indices[1]; - if payment_index == remainder_index_witness { - return Err(InterpretError::Inconsistent( - "maker output witness indices alias", - )); - } - let payment_output = output_at(transaction, payment_index)?; - let (payment_asset, maker_payment) = explicit_asset_value(payment_output).ok_or( - InterpretError::Inconsistent("maker payment is not explicit"), - )?; - let expected_payment_asset = match params.direction { - OrderDirection::SellBase => params.quote_asset_id, - OrderDirection::SellQuote => params.base_asset_id, - }; - if payment_asset != expected_payment_asset - || payment_output.script_pubkey != *compiled.maker_receive_spk() - { - return Err(InterpretError::Inconsistent( - "maker payment asset or destination is wrong", - )); - } - - let remainder_locked = if !is_partial { - None - } else { - let remainder_output = output_at(transaction, remainder_index_witness)?; - if remainder_output.script_pubkey != *compiled.script_pubkey() { - return Err(InterpretError::Inconsistent( - "witness-designated remainder script is wrong", - )); - } - let (asset, amount) = explicit_asset_value(remainder_output).ok_or( - InterpretError::Inconsistent("witness-designated remainder is not explicit"), - )?; - if asset != expected_input_asset { - return Err(InterpretError::Inconsistent( - "witness-designated remainder asset is wrong", - )); - } - Some(amount) - }; - let remainder_index = if remainder_locked.is_some() { - Some(remainder_index_witness) - } else { - None - }; - - let interpreted = fill( - params, - before, - input_locked, - maker_payment, - remainder_locked, - )?; - let continuation = remainder_index.map(|index| TrackedContractOutput { - outpoint: OutPoint::new(transaction.txid(), index), - txout: transaction.output[index as usize].clone(), - }); - Ok(MakerOrderInterpretation { - kind: MakerOrderSpendKind::Fill(interpreted), - before, - after: interpreted.next_state, - spent_outpoint: live_output.outpoint, - input_index: input_index_u32, - payment_index: Some(payment_index), - remainder_index, - continuation, - annex_present: annex.is_some(), - }) -} - -fn explicit_asset_value(output: &elements::TxOut) -> Option<(elements::AssetId, u64)> { - let Asset::Explicit(asset) = output.asset else { - return None; - }; - let Value::Explicit(value) = output.value else { - return None; - }; - Some((asset, value)) -} diff --git a/crates/deadcat-contracts/src/lib.rs b/crates/deadcat-contracts/src/lib.rs index e598ff3..cd9f140 100644 --- a/crates/deadcat-contracts/src/lib.rs +++ b/crates/deadcat-contracts/src/lib.rs @@ -5,7 +5,6 @@ mod artifacts; pub mod binary_market; pub mod interpret; -pub mod maker_order; pub mod market_crypto; pub mod recovery; pub mod rt; diff --git a/crates/deadcat-contracts/src/maker_order.rs b/crates/deadcat-contracts/src/maker_order.rs deleted file mode 100644 index f70a03d..0000000 --- a/crates/deadcat-contracts/src/maker_order.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Pure maker-order economics shared by interpretation and transaction builders. - -pub use deadcat_types::MakerOrderState; -use deadcat_types::{MakerOrderParams, OrderDirection}; -use elements::OutPoint; -use elements::hashes::Hash as _; -use thiserror::Error; - -mod compiled; - -pub use crate::artifacts::maker_order::MakerOrderProgram; -pub use crate::artifacts::maker_order::derived_maker_order; -pub use compiled::{CompiledMakerOrder, CompiledMakerOrderError}; - -use crate::rt::tagged_hash; - -pub const ORDER_INPUTS_DOMAIN: &str = "deadcat/order-inputs/v1"; -pub const ORDER_INSTANCE_DOMAIN: &str = "deadcat/order-instance/v1"; -pub const ORDER_CANCEL_TWEAK_DOMAIN: &str = "deadcat/order-cancel/v1"; -pub const ORDER_RECEIVE_TWEAK_DOMAIN: &str = "deadcat/order-receive/v1"; - -/// Derive the stable public identity of one canonical order creation. -/// -/// Input ordering is deliberately ignored. Any change to the input set or the -/// reserved order output position changes the result. -pub fn derive_instance_id( - input_prevouts: &[OutPoint], - order_output_index: u32, -) -> Result<[u8; 32], MakerOrderIdentityError> { - if input_prevouts.is_empty() { - return Err(MakerOrderIdentityError::NoCreationInputs); - } - let input_count = u32::try_from(input_prevouts.len()) - .map_err(|_| MakerOrderIdentityError::TooManyCreationInputs)?; - let mut serialized = input_prevouts - .iter() - .map(|outpoint| { - let mut bytes = [0_u8; 36]; - bytes[..32].copy_from_slice(&outpoint.txid.to_byte_array()); - bytes[32..].copy_from_slice(&outpoint.vout.to_be_bytes()); - bytes - }) - .collect::>(); - serialized.sort_unstable(); - - let mut inputs_message = Vec::with_capacity(4 + serialized.len() * 36); - inputs_message.extend_from_slice(&input_count.to_be_bytes()); - for outpoint in serialized { - inputs_message.extend_from_slice(&outpoint); - } - let inputs_commitment = tagged_hash(ORDER_INPUTS_DOMAIN, &inputs_message); - - let mut instance_message = [0_u8; 36]; - instance_message[..32].copy_from_slice(&inputs_commitment); - instance_message[32..].copy_from_slice(&order_output_index.to_be_bytes()); - Ok(tagged_hash(ORDER_INSTANCE_DOMAIN, &instance_message)) -} - -#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] -pub enum MakerOrderIdentityError { - #[error("canonical maker-order creation requires at least one transaction input")] - NoCreationInputs, - #[error("maker-order creation input count exceeds u32")] - TooManyCreationInputs, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MakerOrderFill { - pub filled_base: u64, - pub maker_payment: u64, - pub remaining_locked: Option, - pub next_state: MakerOrderState, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MakerOrderCreation { - pub locked_amount: u64, - pub state: MakerOrderState, -} - -#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] -pub enum MakerOrderError { - #[error("base and quote assets must be distinct")] - SameAsset, - #[error("price must be nonzero")] - ZeroPrice, - #[error("minimum active base must be nonzero")] - ZeroMinimum, - #[error("price exceeds the parent market collateral per pair")] - PriceAboveMarket, - #[error("order base asset does not match its parent market side")] - WrongBaseAsset, - #[error("order quote asset does not match parent market collateral")] - WrongQuoteAsset, - #[error("offered capacity is below the active minimum")] - CapacityBelowMinimum, - #[error("order is not active")] - NotActive, - #[error("tracked input amount does not match materialized state")] - StateInputMismatch, - #[error("fill amount is below the active minimum")] - FillBelowMinimum, - #[error("partial remainder is below the active minimum")] - RemainderBelowMinimum, - #[error("partial remainder must be nonzero and smaller than the input")] - InvalidRemainder, - #[error("maker payment does not satisfy the exact-price equation")] - WrongMakerPayment, - #[error("checked monetary arithmetic overflowed")] - ArithmeticOverflow, -} - -/// Validate the constraints committed by the covenant itself. -pub fn validate_params(params: MakerOrderParams) -> Result<(), MakerOrderError> { - if params.base_asset_id == params.quote_asset_id { - return Err(MakerOrderError::SameAsset); - } - if params.price == 0 { - return Err(MakerOrderError::ZeroPrice); - } - if params.min_active_base == 0 { - return Err(MakerOrderError::ZeroMinimum); - } - Ok(()) -} - -/// Validate the additional relationship supplied by a verified parent market. -pub fn validate_against_market( - params: MakerOrderParams, - expected_base_asset: elements::AssetId, - collateral_asset: elements::AssetId, - collateral_per_pair: u64, -) -> Result<(), MakerOrderError> { - validate_params(params)?; - if params.base_asset_id != expected_base_asset { - return Err(MakerOrderError::WrongBaseAsset); - } - if params.quote_asset_id != collateral_asset { - return Err(MakerOrderError::WrongQuoteAsset); - } - if u64::from(params.price) > collateral_per_pair { - return Err(MakerOrderError::PriceAboveMarket); - } - Ok(()) -} - -pub fn create( - params: MakerOrderParams, - offered_base_capacity: u64, -) -> Result { - validate_params(params)?; - if offered_base_capacity < u64::from(params.min_active_base) { - return Err(MakerOrderError::CapacityBelowMinimum); - } - let locked_amount = locked_for_base(params, offered_base_capacity)?; - Ok(MakerOrderCreation { - locked_amount, - state: MakerOrderState::Active { - remaining_base: offered_base_capacity, - total_filled_base: 0, - }, - }) -} - -/// Interpret one exact-price script-path fill. -/// -/// `input_locked` and `maker_payment` are the explicit amounts introspected by -/// the covenant. `remainder_locked` is `None` for a full fill and the -/// witness-selected continuation amount for a partial fill. -pub fn fill( - params: MakerOrderParams, - state: MakerOrderState, - input_locked: u64, - maker_payment: u64, - remainder_locked: Option, -) -> Result { - validate_params(params)?; - let MakerOrderState::Active { - remaining_base, - total_filled_base, - } = state - else { - return Err(MakerOrderError::NotActive); - }; - if input_locked != locked_for_base(params, remaining_base)? { - return Err(MakerOrderError::StateInputMismatch); - } - - let minimum = u64::from(params.min_active_base); - let price = u64::from(params.price); - let (filled_base, next_remaining_base) = match params.direction { - OrderDirection::SellBase => match remainder_locked { - None => (remaining_base, None), - Some(remainder) => { - if remainder == 0 || remainder >= input_locked { - return Err(MakerOrderError::InvalidRemainder); - } - let filled = input_locked - .checked_sub(remainder) - .ok_or(MakerOrderError::ArithmeticOverflow)?; - (filled, Some(remainder)) - } - }, - OrderDirection::SellQuote => { - let filled = maker_payment; - let quote_filled = filled - .checked_mul(price) - .ok_or(MakerOrderError::ArithmeticOverflow)?; - match remainder_locked { - None => { - if quote_filled != input_locked { - return Err(MakerOrderError::WrongMakerPayment); - } - (filled, None) - } - Some(remainder) => { - if remainder == 0 || remainder >= input_locked { - return Err(MakerOrderError::InvalidRemainder); - } - if quote_filled.checked_add(remainder) != Some(input_locked) { - return Err(MakerOrderError::WrongMakerPayment); - } - if remainder % price != 0 { - return Err(MakerOrderError::WrongMakerPayment); - } - (filled, Some(remainder / price)) - } - } - } - }; - - if filled_base < minimum { - return Err(MakerOrderError::FillBelowMinimum); - } - if let Some(remainder_base) = next_remaining_base - && remainder_base < minimum - { - return Err(MakerOrderError::RemainderBelowMinimum); - } - if filled_base > remaining_base { - return Err(MakerOrderError::WrongMakerPayment); - } - - let exact_payment = match params.direction { - OrderDirection::SellBase => filled_base - .checked_mul(price) - .ok_or(MakerOrderError::ArithmeticOverflow)?, - OrderDirection::SellQuote => filled_base, - }; - if maker_payment != exact_payment { - return Err(MakerOrderError::WrongMakerPayment); - } - - let total_filled_base = total_filled_base - .checked_add(filled_base) - .ok_or(MakerOrderError::ArithmeticOverflow)?; - let next_state = match next_remaining_base { - Some(remaining_base) => MakerOrderState::Active { - remaining_base, - total_filled_base, - }, - None => MakerOrderState::Consumed, - }; - - Ok(MakerOrderFill { - filled_base, - maker_payment, - remaining_locked: remainder_locked, - next_state, - }) -} - -pub fn cancel(state: MakerOrderState) -> Result { - match state { - MakerOrderState::Active { .. } => Ok(MakerOrderState::Cancelled), - MakerOrderState::Consumed | MakerOrderState::Cancelled => Err(MakerOrderError::NotActive), - } -} - -fn locked_for_base(params: MakerOrderParams, base_capacity: u64) -> Result { - match params.direction { - OrderDirection::SellBase => Ok(base_capacity), - OrderDirection::SellQuote => base_capacity - .checked_mul(u64::from(params.price)) - .ok_or(MakerOrderError::ArithmeticOverflow), - } -} - -#[cfg(test)] -mod tests { - use elements::AssetId; - - use super::*; - - fn params(direction: OrderDirection) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: AssetId::from_slice(&[1; 32]).expect("base"), - quote_asset_id: AssetId::from_slice(&[2; 32]).expect("quote"), - price: 7, - min_active_base: 3, - direction, - instance_id: [3; 32], - maker_pubkey: [4; 32], - } - } - - #[test] - fn instance_identity_is_order_independent_but_creation_specific() { - let first = OutPoint::new(elements::Txid::from_byte_array([0x11; 32]), 2); - let second = OutPoint::new(elements::Txid::from_byte_array([0x22; 32]), 7); - - let canonical = derive_instance_id(&[first, second], 4).expect("identity"); - assert_eq!( - canonical, - derive_instance_id(&[second, first], 4).expect("identity") - ); - assert_ne!( - canonical, - derive_instance_id(&[first, second], 5).expect("identity") - ); - assert_ne!( - canonical, - derive_instance_id(&[first], 4).expect("identity") - ); - assert_eq!( - derive_instance_id(&[], 4), - Err(MakerOrderIdentityError::NoCreationInputs) - ); - } - - #[test] - fn sell_base_partial_and_full_are_exact() { - let params = params(OrderDirection::SellBase); - let creation = create(params, 10).expect("create"); - assert_eq!(creation.locked_amount, 10); - - let partial = fill(params, creation.state, 10, 28, Some(6)).expect("partial"); - assert_eq!(partial.filled_base, 4); - assert_eq!(partial.remaining_locked, Some(6)); - let full = fill(params, partial.next_state, 6, 42, None).expect("full"); - assert_eq!(full.next_state, MakerOrderState::Consumed); - assert_eq!(full.filled_base, 6); - } - - #[test] - fn sell_quote_partial_and_full_preserve_integral_capacity() { - let params = params(OrderDirection::SellQuote); - let creation = create(params, 10).expect("create"); - assert_eq!(creation.locked_amount, 70); - - let partial = fill(params, creation.state, 70, 4, Some(42)).expect("partial"); - assert_eq!( - partial.next_state, - MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 4, - } - ); - let full = fill(params, partial.next_state, 42, 6, None).expect("full"); - assert_eq!(full.next_state, MakerOrderState::Consumed); - } - - #[test] - fn rejects_dust_remainders_and_wrong_payments() { - let base = params(OrderDirection::SellBase); - let creation = create(base, 10).expect("create"); - assert_eq!( - fill(base, creation.state, 10, 56, Some(2)), - Err(MakerOrderError::RemainderBelowMinimum) - ); - assert_eq!( - fill(base, creation.state, 10, 29, Some(6)), - Err(MakerOrderError::WrongMakerPayment) - ); - - let quote = params(OrderDirection::SellQuote); - let creation = create(quote, 10).expect("create"); - assert_eq!( - fill(quote, creation.state, 70, 4, Some(41)), - Err(MakerOrderError::WrongMakerPayment) - ); - } - - #[test] - fn validates_parent_relationship_and_overflow() { - let params = params(OrderDirection::SellQuote); - assert_eq!( - validate_against_market(params, params.base_asset_id, params.quote_asset_id, 6), - Err(MakerOrderError::PriceAboveMarket) - ); - - let mut huge = params; - huge.price = u32::MAX; - assert_eq!( - create(huge, u64::MAX), - Err(MakerOrderError::ArithmeticOverflow) - ); - } - - #[test] - fn cancellation_is_terminal() { - let state = create(params(OrderDirection::SellBase), 10) - .expect("create") - .state; - assert_eq!(cancel(state), Ok(MakerOrderState::Cancelled)); - assert_eq!( - cancel(MakerOrderState::Cancelled), - Err(MakerOrderError::NotActive) - ); - } -} diff --git a/crates/deadcat-contracts/src/maker_order/compiled.rs b/crates/deadcat-contracts/src/maker_order/compiled.rs deleted file mode 100644 index ac6a3b1..0000000 --- a/crates/deadcat-contracts/src/maker_order/compiled.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Validation-first compilation of the canonical maker-order covenant. - -use elements::Script; -use elements::secp256k1_zkp::{Scalar, Secp256k1, XOnlyPublicKey}; -use elements::taproot::{ControlBlock, TaprootBuilder, TaprootBuilderError}; -use sha2::{Digest as _, Sha256}; -use simplex::program::ArgumentsTrait as _; -use simplex::simplicityhl::CompiledProgram; -use simplex::simplicityhl::simplicity::{HasCmr as _, leaf_version}; -use thiserror::Error; - -use super::{ - MakerOrderParams, ORDER_CANCEL_TWEAK_DOMAIN, ORDER_RECEIVE_TWEAK_DOMAIN, validate_params, -}; -use crate::artifacts::maker_order::{MakerOrderProgram, derived_maker_order}; -use crate::rt::hash_to_scalar; - -/// A validated order covenant and its maker-cancellable Taproot output. -#[derive(Clone, Debug)] -pub struct CompiledMakerOrder { - params: MakerOrderParams, - arguments: derived_maker_order::MakerOrderArguments, - cmr: [u8; 32], - internal_key: XOnlyPublicKey, - maker_receive_spk: Script, - script_pubkey: Script, - control_block: ControlBlock, -} - -impl CompiledMakerOrder { - pub fn new(params: MakerOrderParams) -> Result { - validate_params(params).map_err(CompiledMakerOrderError::InvalidEconomics)?; - let maker_key = XOnlyPublicKey::from_slice(¶ms.maker_pubkey) - .map_err(|_| CompiledMakerOrderError::InvalidMakerPublicKey)?; - let secp = Secp256k1::verification_only(); - let internal_key = tweak_key( - &secp, - maker_key, - ORDER_CANCEL_TWEAK_DOMAIN, - params.instance_id, - )?; - let receive_key = tweak_key( - &secp, - maker_key, - ORDER_RECEIVE_TWEAK_DOMAIN, - params.instance_id, - )?; - let maker_receive_spk = p2tr_key_script(receive_key); - let maker_receive_spk_hash = Sha256::digest(maker_receive_spk.as_bytes()).into(); - let arguments = contract_arguments(params, maker_receive_spk_hash); - let compiled = CompiledProgram::new( - MakerOrderProgram::SOURCE, - arguments.build_arguments(), - false, - ) - .map_err(CompiledMakerOrderError::Compilation)?; - let cmr_node = compiled.commit().cmr(); - let mut cmr = [0_u8; 32]; - cmr.copy_from_slice(cmr_node.as_ref()); - - let version = leaf_version(); - let program_leaf_script = Script::from(cmr.to_vec()); - let spend_info = TaprootBuilder::new() - .add_leaf_with_ver(0, program_leaf_script.clone(), version)? - .finalize(&secp, internal_key)?; - let control_block = spend_info - .control_block(&(program_leaf_script, version)) - .ok_or(CompiledMakerOrderError::MissingControlBlock)?; - let script_pubkey = Script::new_v1_p2tr_tweaked(spend_info.output_key()); - - Ok(Self { - params, - arguments, - cmr, - internal_key, - maker_receive_spk, - script_pubkey, - control_block, - }) - } - - #[must_use] - pub const fn params(&self) -> MakerOrderParams { - self.params - } - - #[must_use] - pub const fn cmr(&self) -> [u8; 32] { - self.cmr - } - - #[must_use] - pub const fn internal_key(&self) -> XOnlyPublicKey { - self.internal_key - } - - #[must_use] - pub fn maker_receive_spk(&self) -> &Script { - &self.maker_receive_spk - } - - #[must_use] - pub fn script_pubkey(&self) -> &Script { - &self.script_pubkey - } - - #[must_use] - pub const fn control_block(&self) -> &ControlBlock { - &self.control_block - } - - /// Recreate the validated generated program for witness satisfaction. - #[must_use] - pub fn program(&self) -> MakerOrderProgram { - MakerOrderProgram::new(self.arguments.clone()).with_taproot_pubkey(self.internal_key) - } -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum CompiledMakerOrderError { - #[error("invalid maker-order economics: {0}")] - InvalidEconomics(super::MakerOrderError), - #[error("maker public key is not a valid x-only secp256k1 key")] - InvalidMakerPublicKey, - #[error("derived maker-order tweak is not a valid scalar")] - InvalidTweakScalar, - #[error("derived maker-order key is the point at infinity")] - TweakedKeyAtInfinity, - #[error("failed to compile maker-order SimplicityHL: {0}")] - Compilation(String), - #[error("failed to build maker-order Taproot tree: {0}")] - Taproot(#[from] TaprootBuilderError), - #[error("compiled Taproot tree did not contain its program leaf")] - MissingControlBlock, -} - -fn contract_arguments( - params: MakerOrderParams, - maker_receive_spk_hash: [u8; 32], -) -> derived_maker_order::MakerOrderArguments { - derived_maker_order::MakerOrderArguments { - base_asset_id: params.base_asset_id.into_inner().to_byte_array(), - quote_asset_id: params.quote_asset_id.into_inner().to_byte_array(), - price: params.price, - min_active_base: params.min_active_base, - maker_receive_spk_hash, - direction_sell_quote: params.direction == deadcat_types::OrderDirection::SellQuote, - } -} - -fn tweak_key( - secp: &Secp256k1, - key: XOnlyPublicKey, - domain: &str, - instance_id: [u8; 32], -) -> Result { - let tweak = Scalar::from_be_bytes(hash_to_scalar(domain, &instance_id)) - .map_err(|_| CompiledMakerOrderError::InvalidTweakScalar)?; - key.add_tweak(secp, &tweak) - .map(|(key, _)| key) - .map_err(|_| CompiledMakerOrderError::TweakedKeyAtInfinity) -} - -fn p2tr_key_script(key: XOnlyPublicKey) -> Script { - let mut bytes = Vec::with_capacity(34); - bytes.extend_from_slice(&[0x51, 0x20]); - bytes.extend_from_slice(&key.serialize()); - Script::from(bytes) -} - -#[cfg(test)] -mod tests { - use elements::AssetId; - use elements::schnorr::TweakedPublicKey; - use simplex::provider::SimplicityNetwork; - - use super::*; - - const VALID_XONLY: [u8; 32] = [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, - 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, - 0x3a, 0xc0, - ]; - - fn asset(byte: u8) -> AssetId { - AssetId::from_slice(&[byte; 32]).expect("asset") - } - - fn params() -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x11), - quote_asset_id: asset(0x22), - price: 12_345, - min_active_base: 67, - direction: deadcat_types::OrderDirection::SellQuote, - instance_id: [0x33; 32], - maker_pubkey: VALID_XONLY, - } - } - - #[test] - fn direct_taproot_materialization_matches_generated_program() { - let params = params(); - let compiled = CompiledMakerOrder::new(params).expect("compile"); - let network = SimplicityNetwork::ElementsRegtest { - policy_asset: params.quote_asset_id, - }; - assert_eq!( - compiled.script_pubkey(), - &compiled.program().get_script_pubkey(&network) - ); - assert_eq!(compiled.control_block().size(), 33); - - let secp = Secp256k1::verification_only(); - let output_key = XOnlyPublicKey::from_slice(&compiled.script_pubkey().as_bytes()[2..34]) - .expect("output key"); - assert!(compiled.control_block().verify_taproot_commitment( - &secp, - &TweakedPublicKey::new(output_key), - &Script::from(compiled.cmr().to_vec()), - )); - } - - #[test] - fn params_map_exactly_and_compilation_is_deterministic() { - let params = params(); - let first = CompiledMakerOrder::new(params).expect("compile"); - let second = CompiledMakerOrder::new(params).expect("compile"); - assert_eq!(first.cmr(), second.cmr()); - assert_eq!( - first.cmr(), - [ - 0xd1, 0xde, 0x83, 0x2f, 0x42, 0x54, 0x7f, 0xd6, 0xb2, 0xe9, 0xd3, 0xdb, 0x51, 0x7f, - 0x9e, 0x3f, 0x8f, 0x8f, 0x87, 0xe0, 0xf4, 0xf3, 0xac, 0x3e, 0x4e, 0x7c, 0x99, 0xe1, - 0x17, 0xf9, 0xcb, 0x63, - ] - ); - assert_eq!( - first.arguments.base_asset_id, - params.base_asset_id.into_inner().to_byte_array() - ); - assert_eq!(first.arguments.price, params.price); - assert!(first.arguments.direction_sell_quote); - } - - #[test] - fn economics_instance_and_maker_key_change_commitments() { - let params = params(); - let original = CompiledMakerOrder::new(params).expect("compile"); - let mut changed_direction = params; - changed_direction.direction = deadcat_types::OrderDirection::SellBase; - assert_ne!( - original.cmr(), - CompiledMakerOrder::new(changed_direction) - .expect("compile") - .cmr() - ); - - let mut changed_instance = params; - changed_instance.instance_id = [0x44; 32]; - let changed_instance = CompiledMakerOrder::new(changed_instance).expect("compile"); - assert_ne!(original.cmr(), changed_instance.cmr()); - assert_ne!(original.script_pubkey(), changed_instance.script_pubkey()); - assert_ne!( - original.maker_receive_spk(), - changed_instance.maker_receive_spk() - ); - - let mut changed_maker = params; - changed_maker.maker_pubkey = [2; 32]; - let changed_maker = CompiledMakerOrder::new(changed_maker).expect("compile"); - assert_ne!(original.cmr(), changed_maker.cmr()); - assert_ne!(original.script_pubkey(), changed_maker.script_pubkey()); - assert_ne!( - original.maker_receive_spk(), - changed_maker.maker_receive_spk() - ); - } - - #[test] - fn invalid_params_fail_before_materialization() { - let mut invalid = params(); - invalid.price = 0; - assert_eq!( - CompiledMakerOrder::new(invalid).expect_err("zero price"), - CompiledMakerOrderError::InvalidEconomics(super::super::MakerOrderError::ZeroPrice) - ); - - invalid = params(); - invalid.maker_pubkey = [0; 32]; - assert_eq!( - CompiledMakerOrder::new(invalid).expect_err("invalid key"), - CompiledMakerOrderError::InvalidMakerPublicKey - ); - } -} diff --git a/crates/deadcat-contracts/src/recovery.rs b/crates/deadcat-contracts/src/recovery.rs index 96cacd3..ba1f6cd 100644 --- a/crates/deadcat-contracts/src/recovery.rs +++ b/crates/deadcat-contracts/src/recovery.rs @@ -1,19 +1,10 @@ //! Byte-exact v1 OP_RETURN recovery payloads. -use deadcat_types::{ContractId, OrderDirection, OrderSide}; use elements::confidential::{Asset, Nonce, Value}; -use elements::hashes::Hash as _; -use elements::secp256k1_zkp::XOnlyPublicKey; -use elements::{AssetId, Script, TxOut, TxOutWitness, Txid}; -use hmac::{Hmac, Mac}; -use sha2::Sha256; +use elements::{AssetId, Script, TxOut, TxOutWitness}; use thiserror::Error; pub const MARKET_V1_TAG: u8 = 0x10; -pub const ORDER_YES_SELL_BASE_V1_TAG: u8 = 0x40; -pub const ORDER_YES_SELL_QUOTE_V1_TAG: u8 = 0x44; -pub const ORDER_NO_SELL_BASE_V1_TAG: u8 = 0x48; -pub const ORDER_NO_SELL_QUOTE_V1_TAG: u8 = 0x4c; pub const BASE_PAYOUTS: [u64; 16] = [ 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 50_000, 100_000, 200_000, 500_000, @@ -22,22 +13,16 @@ pub const BASE_PAYOUTS: [u64; 16] = [ pub const MARKET_KNOWN_PAYLOAD_LEN: usize = 38; pub const MARKET_EXOTIC_PAYLOAD_LEN: usize = 70; -pub const ORDER_PAYLOAD_LEN: usize = 79; -pub const MAX_RECOVERY_PAYLOAD_LEN: usize = 80; +pub const MAX_RECOVERY_PAYLOAD_LEN: usize = MARKET_EXOTIC_PAYLOAD_LEN; /// Build the exact canonical OP_RETURN used by v1 recovery outputs. pub fn recovery_script(payload: &[u8]) -> Result { if payload.is_empty() || payload.len() > MAX_RECOVERY_PAYLOAD_LEN { return Err(RecoveryError::InvalidPayloadLength(payload.len())); } - let mut bytes = Vec::with_capacity(payload.len() + 3); + let mut bytes = Vec::with_capacity(payload.len() + 2); bytes.push(0x6a); - if payload.len() <= 75 { - bytes.push(payload.len() as u8); - } else { - bytes.push(0x4c); - bytes.push(payload.len() as u8); - } + bytes.push(payload.len() as u8); bytes.extend_from_slice(payload); Ok(Script::from(bytes)) } @@ -48,19 +33,12 @@ pub fn parse_recovery_script(script: &Script) -> Result<&[u8], RecoveryError> { if bytes.len() < 3 || bytes[0] != 0x6a { return Err(RecoveryError::InvalidRecoveryScript); } - let (payload_len, header_len) = match bytes[1] { - 1..=75 => (usize::from(bytes[1]), 2), - 0x4c if bytes.len() >= 4 - && (76..=MAX_RECOVERY_PAYLOAD_LEN).contains(&usize::from(bytes[2])) => - { - (usize::from(bytes[2]), 3) - } - _ => return Err(RecoveryError::InvalidRecoveryScript), - }; - if bytes.len() != payload_len + header_len { + let payload_len = usize::from(bytes[1]); + if payload_len == 0 || payload_len > MAX_RECOVERY_PAYLOAD_LEN || bytes.len() != payload_len + 2 + { return Err(RecoveryError::InvalidRecoveryScript); } - Ok(&bytes[header_len..]) + Ok(&bytes[2..]) } /// Construct the canonical zero-value policy-asset recovery output. @@ -172,153 +150,6 @@ impl MarketRecoveryHint { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ParentMarketRef { - Existing(ContractId), - SameTransaction { vout: u32 }, -} - -impl ParentMarketRef { - #[must_use] - pub fn resolve(self, creation_txid: Txid) -> ContractId { - match self { - Self::Existing(contract_id) => contract_id, - Self::SameTransaction { vout } => { - ContractId::new(elements::OutPoint::new(creation_txid, vout)) - } - } - } - - fn fixed_key(self) -> [u8; 36] { - match self { - Self::Existing(contract_id) => contract_id.to_fixed_key(), - Self::SameTransaction { vout } => { - let mut key = [0_u8; 36]; - key[32..].copy_from_slice(&vout.to_be_bytes()); - key - } - } - } -} - -impl From for ParentMarketRef { - fn from(value: ContractId) -> Self { - Self::Existing(value) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct OrderRecoveryHint { - pub side: OrderSide, - pub direction: OrderDirection, - pub masked_order_index: u16, - pub parent_market: ParentMarketRef, - pub price: u32, - pub min_active_base: u32, - pub maker_pubkey: [u8; 32], -} - -impl OrderRecoveryHint { - #[must_use] - pub const fn tag(self) -> u8 { - match (self.side, self.direction) { - (OrderSide::Yes, OrderDirection::SellBase) => ORDER_YES_SELL_BASE_V1_TAG, - (OrderSide::Yes, OrderDirection::SellQuote) => ORDER_YES_SELL_QUOTE_V1_TAG, - (OrderSide::No, OrderDirection::SellBase) => ORDER_NO_SELL_BASE_V1_TAG, - (OrderSide::No, OrderDirection::SellQuote) => ORDER_NO_SELL_QUOTE_V1_TAG, - } - } - - #[must_use] - pub fn encode(self) -> [u8; ORDER_PAYLOAD_LEN] { - let mut payload = [0_u8; ORDER_PAYLOAD_LEN]; - payload[0] = self.tag(); - payload[1..3].copy_from_slice(&self.masked_order_index.to_be_bytes()); - let parent = self.parent_market.fixed_key(); - payload[3..39].copy_from_slice(&parent); - payload[39..43].copy_from_slice(&self.price.to_be_bytes()); - payload[43..47].copy_from_slice(&self.min_active_base.to_be_bytes()); - payload[47..79].copy_from_slice(&self.maker_pubkey); - payload - } - - pub fn decode(payload: &[u8]) -> Result { - if payload.len() != ORDER_PAYLOAD_LEN { - return Err(RecoveryError::InvalidLength { - expected: "79", - actual: payload.len(), - }); - } - let (side, direction) = decode_order_tag(payload[0])?; - let masked_order_index = u16::from_be_bytes([payload[1], payload[2]]); - let parent_txid = Txid::from_byte_array(payload[3..35].try_into().expect("fixed slice")); - let parent_vout = u32::from_be_bytes(payload[35..39].try_into().expect("fixed slice")); - let price = u32::from_be_bytes(payload[39..43].try_into().expect("fixed slice")); - let min_active_base = u32::from_be_bytes(payload[43..47].try_into().expect("fixed slice")); - let maker_pubkey: [u8; 32] = payload[47..79].try_into().expect("fixed slice"); - if price == 0 { - return Err(RecoveryError::ZeroPrice); - } - if min_active_base == 0 { - return Err(RecoveryError::ZeroMinimum); - } - XOnlyPublicKey::from_slice(&maker_pubkey) - .map_err(|_| RecoveryError::InvalidMakerPublicKey)?; - - let parent_market = if parent_txid == Txid::all_zeros() { - ParentMarketRef::SameTransaction { vout: parent_vout } - } else { - ParentMarketRef::Existing(ContractId::new(elements::OutPoint::new( - parent_txid, - parent_vout, - ))) - }; - - Ok(Self { - side, - direction, - masked_order_index, - parent_market, - price, - min_active_base, - maker_pubkey, - }) - } - - /// Recover the candidate derivation index. Script compilation/matching is - /// still required because every foreign hint also maps to some `u16`. - pub fn unmask_index(self, deadcat_secret_key: &[u8; 32]) -> u16 { - self.masked_order_index ^ order_mask(self, deadcat_secret_key) - } -} - -#[must_use] -pub fn order_mask(hint: OrderRecoveryHint, deadcat_secret_key: &[u8; 32]) -> u16 { - let mut mac = Hmac::::new_from_slice(deadcat_secret_key).expect("HMAC accepts any key"); - mac.update(b"deadcat/order_mask"); - mac.update(&hint.parent_market.fixed_key()); - mac.update(&hint.price.to_be_bytes()); - mac.update(&[match hint.side { - OrderSide::Yes => 0, - OrderSide::No => 1, - }]); - mac.update(&[hint.direction.protocol_byte()]); - mac.update(&hint.min_active_base.to_be_bytes()); - mac.update(&hint.maker_pubkey); - let bytes = mac.finalize().into_bytes(); - u16::from_be_bytes([bytes[0], bytes[1]]) -} - -fn decode_order_tag(tag: u8) -> Result<(OrderSide, OrderDirection), RecoveryError> { - match tag { - ORDER_YES_SELL_BASE_V1_TAG => Ok((OrderSide::Yes, OrderDirection::SellBase)), - ORDER_YES_SELL_QUOTE_V1_TAG => Ok((OrderSide::Yes, OrderDirection::SellQuote)), - ORDER_NO_SELL_BASE_V1_TAG => Ok((OrderSide::No, OrderDirection::SellBase)), - ORDER_NO_SELL_QUOTE_V1_TAG => Ok((OrderSide::No, OrderDirection::SellQuote)), - _ => Err(RecoveryError::UnknownTag(tag)), - } -} - fn validate_expiry_height(height: u32) -> Result<(), RecoveryError> { if height == 0 || height >= 500_000_000 { return Err(RecoveryError::InvalidExpiryHeight(height)); @@ -345,14 +176,8 @@ pub enum RecoveryError { InvalidAssetId, #[error("expiry height must be in 1..500000000, got {0}")] InvalidExpiryHeight(u32), - #[error("order price must be nonzero")] - ZeroPrice, - #[error("order minimum must be nonzero")] - ZeroMinimum, - #[error("recovery payload must contain 1..=80 bytes, got {0}")] + #[error("recovery payload must contain 1..=70 bytes, got {0}")] InvalidPayloadLength(usize), - #[error("order recovery hint contains an invalid maker x-only public key")] - InvalidMakerPublicKey, #[error("invalid recovery OP_RETURN script")] InvalidRecoveryScript, #[error("invalid recovery output asset, value, nonce, or proofs")] @@ -393,59 +218,11 @@ mod tests { } #[test] - fn order_round_trip_and_mask_are_stable() { - let hint = OrderRecoveryHint { - side: OrderSide::No, - direction: OrderDirection::SellQuote, - masked_order_index: 0x1234, - parent_market: ContractId::new(elements::OutPoint::new( - Txid::from_byte_array([0x44; 32]), - 9, - )) - .into(), - price: 75_000, - min_active_base: 25, - maker_pubkey: [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ], - }; - let payload = hint.encode(); - assert_eq!(payload.len(), ORDER_PAYLOAD_LEN); - assert_eq!(payload[0], ORDER_NO_SELL_QUOTE_V1_TAG); - assert_eq!(OrderRecoveryHint::decode(&payload), Ok(hint)); - - let mask = order_mask(hint, &[0x55; 32]); - assert_eq!(hint.unmask_index(&[0x55; 32]), 0x1234 ^ mask); - } - - #[test] - fn malformed_lengths_and_zero_order_fields_are_rejected() { + fn malformed_market_lengths_are_rejected() { assert!(matches!( MarketRecoveryHint::decode(&[MARKET_V1_TAG; 39]), Err(RecoveryError::InvalidLength { .. }) )); - - let mut order = OrderRecoveryHint { - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - masked_order_index: 0, - parent_market: ParentMarketRef::SameTransaction { vout: 0 }, - price: 1, - min_active_base: 1, - maker_pubkey: [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ], - } - .encode(); - order[39..43].fill(0); - assert_eq!( - OrderRecoveryHint::decode(&order), - Err(RecoveryError::ZeroPrice) - ); } #[test] @@ -472,62 +249,4 @@ mod tests { Err(RecoveryError::InvalidRecoveryScript) ); } - - #[test] - fn order_payload_uses_canonical_pushdata1() { - let policy = AssetId::from_slice(&[0x66; 32]).expect("asset"); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - masked_order_index: 1, - parent_market: ContractId::new(elements::OutPoint::new( - Txid::from_byte_array([0x88; 32]), - 3, - )) - .into(), - price: 2, - min_active_base: 1, - maker_pubkey: [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ], - }; - let payload = hint.encode(); - let output = recovery_txout(policy, &payload).expect("output"); - assert_eq!( - &output.script_pubkey.as_bytes()[..3], - &[0x6a, 0x4c, ORDER_PAYLOAD_LEN as u8] - ); - assert_eq!( - validate_recovery_txout(&output, policy), - Ok(payload.as_slice()) - ); - } - - #[test] - fn same_transaction_parent_uses_zero_txid_sentinel_and_resolves() { - let current_txid = Txid::from_byte_array([0x99; 32]); - let hint = OrderRecoveryHint { - side: OrderSide::No, - direction: OrderDirection::SellQuote, - masked_order_index: 5, - parent_market: ParentMarketRef::SameTransaction { vout: 2 }, - price: 10, - min_active_base: 3, - maker_pubkey: [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ], - }; - let payload = hint.encode(); - assert_eq!(&payload[3..35], &[0_u8; 32]); - let decoded = OrderRecoveryHint::decode(&payload).expect("decode"); - assert_eq!(decoded, hint); - assert_eq!( - decoded.parent_market.resolve(current_txid), - ContractId::new(elements::OutPoint::new(current_txid, 2)) - ); - } } diff --git a/crates/deadcat-contracts/tests/covenant_execution.rs b/crates/deadcat-contracts/tests/covenant_execution.rs index 30d786d..96ed03b 100644 --- a/crates/deadcat-contracts/tests/covenant_execution.rs +++ b/crates/deadcat-contracts/tests/covenant_execution.rs @@ -8,10 +8,9 @@ mod support; use deadcat_contracts::binary_market::{ BinaryMarketSlot, CompiledBinaryMarket, derived_binary_market, }; -use deadcat_contracts::maker_order::{CompiledMakerOrder, derived_maker_order}; use deadcat_contracts::market_crypto::derive_issuance_assets; use deadcat_contracts::rt::{RtFactors, RtLeg, RtSide, factors}; -use deadcat_types::{BinaryMarketParams, MakerOrderParams, OrderDirection}; +use deadcat_types::BinaryMarketParams; use elements::confidential::{Asset, Nonce, Value}; use elements::hashes::Hash as _; use elements::pset::PartiallySignedTransaction; @@ -21,325 +20,16 @@ use elements::{ }; use simplex::program::{ProgramTrait as _, WitnessTrait as _}; -use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output, script}; +use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output}; -fn maker_key() -> [u8; 32] { +fn oracle_key() -> [u8; 32] { Keypair::from_seckey_slice(&Secp256k1::new(), &[0x31; 32]) - .expect("valid maker key") + .expect("valid oracle key") .x_only_public_key() .0 .serialize() } -fn maker_params(direction: OrderDirection) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x11), - quote_asset_id: asset(0x22), - price: 7, - min_active_base: 3, - direction, - instance_id: [0x42; 32], - maker_pubkey: maker_key(), - } -} - -#[derive(Clone)] -struct RemainderOutput { - amount: u64, - output_index: usize, - asset: AssetId, - script_pubkey: Script, -} - -#[derive(Clone)] -struct MakerFillCase { - direction: OrderDirection, - input_index: usize, - input_amount: u64, - payment_amount: u64, - payment_index: usize, - remainder: Option, - remainder_witness_index: u32, - attach_issuance: bool, -} - -impl MakerFillCase { - fn full(direction: OrderDirection, input_amount: u64, payment_amount: u64) -> Self { - Self { - direction, - input_index: 0, - input_amount, - payment_amount, - payment_index: 0, - remainder: None, - remainder_witness_index: 1, - attach_issuance: false, - } - } - - fn partial( - direction: OrderDirection, - input_amount: u64, - payment_amount: u64, - remainder_amount: u64, - ) -> Self { - let params = maker_params(direction); - Self { - direction, - input_index: 0, - input_amount, - payment_amount, - payment_index: 0, - remainder: Some(RemainderOutput { - amount: remainder_amount, - output_index: 1, - asset: match direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }, - // Empty means "use the order continuation script" in the builder. - script_pubkey: Script::new(), - }), - remainder_witness_index: 1, - attach_issuance: false, - } - } -} - -fn execute_maker_fill(case: MakerFillCase) -> Result<(), Box> { - let params = maker_params(case.direction); - let compiled = CompiledMakerOrder::new(params)?; - let is_partial = case.remainder.is_some(); - let order_script = compiled.script_pubkey().clone(); - let input_asset = match case.direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - let payment_asset = match case.direction { - OrderDirection::SellBase => params.quote_asset_id, - OrderDirection::SellQuote => params.base_asset_id, - }; - - let mut pset = PartiallySignedTransaction::new_v2(); - for index in 0..=case.input_index { - let mut input = if index == case.input_index { - pset_input( - 0xa0 + u8::try_from(index)?, - u32::try_from(index)?, - explicit_txout(input_asset, case.input_amount, order_script.clone()), - ) - } else { - pset_input( - 0x90 + u8::try_from(index)?, - u32::try_from(index)?, - explicit_txout(params.quote_asset_id, 1, script(0x90)), - ) - }; - if index == case.input_index && case.attach_issuance { - input.issuance_value_amount = Some(1); - input.issuance_asset_entropy = Some([0x88; 32]); - } - pset.add_input(input); - } - - let remainder_output_index = case - .remainder - .as_ref() - .map_or(0, |remainder| remainder.output_index); - let last_output = case - .payment_index - .max(remainder_output_index) - .max(case.input_index); - let mut outputs = vec![explicit_txout(params.quote_asset_id, 1, script(0x99)); last_output + 1]; - outputs[case.payment_index] = explicit_txout( - payment_asset, - case.payment_amount, - compiled.maker_receive_spk().clone(), - ); - if let Some(mut remainder) = case.remainder { - if remainder.script_pubkey.is_empty() { - remainder.script_pubkey = order_script; - } - outputs[remainder.output_index] = - explicit_txout(remainder.asset, remainder.amount, remainder.script_pubkey); - } - for output in outputs { - pset.add_output(pset_output(output)); - } - - let witness = derived_maker_order::MakerOrderWitness { - payment_index: u32::try_from(case.payment_index)?, - is_partial, - remainder_index: case.remainder_witness_index, - }; - let net = network(params.quote_asset_id); - compiled - .program() - .as_ref() - .execute(&pset, &witness.build_witness(), case.input_index, &net)?; - Ok(()) -} - -#[test] -fn maker_sell_base_full_and_partial_execute() { - execute_maker_fill(MakerFillCase::full(OrderDirection::SellBase, 10, 70)) - .expect("full SellBase fill"); - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellBase, 10, 28, 6)) - .expect("partial SellBase fill"); -} - -#[test] -fn maker_sell_quote_full_and_partial_execute() { - execute_maker_fill(MakerFillCase::full(OrderDirection::SellQuote, 70, 10)) - .expect("full SellQuote fill"); - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellQuote, 70, 4, 42)) - .expect("partial SellQuote fill"); -} - -#[test] -fn maker_rejects_inexact_payments_and_dust_remainders() { - assert!( - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellBase, 10, 29, 6,)).is_err() - ); - assert!( - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellQuote, 70, 4, 41,)).is_err() - ); - assert!( - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellBase, 10, 56, 2,)).is_err() - ); - assert!( - execute_maker_fill(MakerFillCase::partial(OrderDirection::SellQuote, 70, 8, 14,)).is_err() - ); -} - -#[test] -fn maker_partial_branch_does_not_evaluate_overflowing_full_fill_product() { - execute_maker_fill(MakerFillCase::partial( - OrderDirection::SellBase, - u64::MAX, - 21, - u64::MAX - 3, - )) - .expect("partial branch only multiplies the three-atom fill"); -} - -#[test] -fn maker_rejects_wrong_remainder_script_and_alias() { - let mut wrong_script = MakerFillCase::partial(OrderDirection::SellBase, 10, 28, 6); - wrong_script - .remainder - .as_mut() - .expect("remainder") - .script_pubkey = script(0x55); - assert!(execute_maker_fill(wrong_script).is_err()); - - let mut alias = MakerFillCase::partial(OrderDirection::SellBase, 10, 28, 6); - alias.remainder_witness_index = 0; - assert!(execute_maker_fill(alias).is_err()); -} - -#[test] -fn maker_rejects_attached_issuance() { - let mut case = MakerFillCase::full(OrderDirection::SellBase, 10, 70); - case.attach_issuance = true; - assert!(execute_maker_fill(case).is_err()); -} - -#[test] -fn maker_payment_and_remainder_indices_are_flexible() { - let mut valid = MakerFillCase::full(OrderDirection::SellBase, 10, 70); - valid.input_index = 1; - valid.payment_index = 1; - valid.remainder_witness_index = 0; - execute_maker_fill(valid).expect("payment at current input index"); - - let mut independently_placed = MakerFillCase::partial(OrderDirection::SellBase, 10, 28, 6); - independently_placed.input_index = 2; - independently_placed.payment_index = 1; - independently_placed - .remainder - .as_mut() - .expect("remainder") - .output_index = 0; - independently_placed.remainder_witness_index = 0; - execute_maker_fill(independently_placed).expect("independent output positions"); -} - -#[test] -fn canonical_instances_prevent_cross_order_output_aliasing() { - let first_params = maker_params(OrderDirection::SellBase); - let mut second_params = first_params; - second_params.instance_id = [0x43; 32]; - let first = CompiledMakerOrder::new(first_params).expect("first order"); - let second = CompiledMakerOrder::new(second_params).expect("second order"); - assert_ne!(first.script_pubkey(), second.script_pubkey()); - assert_ne!(first.maker_receive_spk(), second.maker_receive_spk()); - - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(pset_input( - 0xa1, - 0, - explicit_txout( - first_params.base_asset_id, - 10, - first.script_pubkey().clone(), - ), - )); - pset.add_input(pset_input( - 0xa2, - 0, - explicit_txout( - second_params.base_asset_id, - 10, - second.script_pubkey().clone(), - ), - )); - pset.add_output(pset_output(explicit_txout( - first_params.quote_asset_id, - 28, - first.maker_receive_spk().clone(), - ))); - pset.add_output(pset_output(explicit_txout( - first_params.base_asset_id, - 6, - first.script_pubkey().clone(), - ))); - let witness = derived_maker_order::MakerOrderWitness { - payment_index: 0, - is_partial: true, - remainder_index: 1, - }; - let net = network(first_params.quote_asset_id); - first - .program() - .as_ref() - .execute(&pset, &witness.build_witness(), 0, &net) - .expect("first order owns the selected outputs"); - assert!( - second - .program() - .as_ref() - .execute(&pset, &witness.build_witness(), 1, &net) - .is_err(), - "a distinct canonical instance accepted another order's outputs" - ); - - // At the covenant level, a creator can deliberately clone the same - // instance and script. Both inputs then accept the aliased outputs. Node - // creation verification rejects this noncanonical construction. - let mut duplicate_pset = pset; - duplicate_pset.inputs_mut()[1].witness_utxo = Some(explicit_txout( - first_params.base_asset_id, - 10, - first.script_pubkey().clone(), - )); - first - .program() - .as_ref() - .execute(&duplicate_pset, &witness.build_witness(), 1, &net) - .expect("noncanonical duplicate demonstrates the documented boundary"); -} - fn confidential_rt_txout(asset_id: AssetId, factors: RtFactors, script_pubkey: Script) -> TxOut { let secp = Secp256k1::new(); let asset_blinder = Tweak::from_inner(factors.abf).expect("valid test ABF"); @@ -357,7 +47,7 @@ fn confidential_rt_txout(asset_id: AssetId, factors: RtFactors, script_pubkey: S fn binary_params() -> BinaryMarketParams { BinaryMarketParams { - oracle_public_key: maker_key(), + oracle_public_key: oracle_key(), collateral_asset_id: asset(0x61), yes_token_asset_id: asset(0x62), no_token_asset_id: asset(0x63), diff --git a/crates/deadcat-contracts/tests/interpret.rs b/crates/deadcat-contracts/tests/interpret.rs index 7fa1b8f..f3563da 100644 --- a/crates/deadcat-contracts/tests/interpret.rs +++ b/crates/deadcat-contracts/tests/interpret.rs @@ -6,14 +6,11 @@ use deadcat_contracts::binary_market::{ BinaryMarketAction, BinaryMarketSlot, CompiledBinaryMarket, derived_binary_market, }; use deadcat_contracts::interpret::{ - BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, MakerOrderSpendKind, - TrackedContractOutput, interpret_binary_market_spend, interpret_maker_order_spend, + BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, TrackedContractOutput, + interpret_binary_market_spend, }; -use deadcat_contracts::maker_order::{CompiledMakerOrder, derived_maker_order}; use deadcat_contracts::rt::{RtFactors, RtLeg, RtSide, factors}; -use deadcat_types::{ - BinaryMarketParams, BinaryMarketState, MakerOrderParams, MakerOrderState, OrderDirection, -}; +use deadcat_types::{BinaryMarketParams, BinaryMarketState}; use elements::confidential::{Asset, Nonce, Value}; use elements::hashes::Hash as _; use elements::pset::PartiallySignedTransaction; @@ -31,213 +28,6 @@ fn key(seed: u8) -> [u8; 32] { .serialize() } -fn maker_params(direction: OrderDirection) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x11), - quote_asset_id: asset(0x22), - price: 7, - min_active_base: 3, - direction, - instance_id: [0x42; 32], - maker_pubkey: key(0x31), - } -} - -struct MakerScenario { - params: MakerOrderParams, - before: MakerOrderState, - live: TrackedContractOutput, - transaction: Transaction, -} - -fn maker_fill_scenario( - direction: OrderDirection, - partial: bool, - decoy_remainder: bool, - annex: bool, -) -> MakerScenario { - let params = maker_params(direction); - let compiled = CompiledMakerOrder::new(params).expect("compile order"); - let input_amount = match direction { - OrderDirection::SellBase => 10, - OrderDirection::SellQuote => 70, - }; - let payment = match (direction, partial) { - (OrderDirection::SellBase, false) => 70, - (OrderDirection::SellBase, true) => 28, - (OrderDirection::SellQuote, false) => 10, - (OrderDirection::SellQuote, true) => 4, - }; - let input_asset = match direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - let payment_asset = match direction { - OrderDirection::SellBase => params.quote_asset_id, - OrderDirection::SellQuote => params.base_asset_id, - }; - let previous = OutPoint::new(elements::Txid::from_byte_array([0xa1; 32]), 0); - let live_txout = explicit_txout(input_asset, input_amount, compiled.script_pubkey().clone()); - let mut pset = PartiallySignedTransaction::new_v2(); - let mut input = pset_input(0xa1, 0, live_txout.clone()); - input.previous_txid = previous.txid; - pset.add_input(input); - pset.add_output(pset_output(explicit_txout( - payment_asset, - payment, - compiled.maker_receive_spk().clone(), - ))); - - let remainder_index = if partial { - if decoy_remainder { - pset.add_output(pset_output(explicit_txout( - input_asset, - 5, - compiled.script_pubkey().clone(), - ))); - 2 - } else { - 1 - } - } else { - // A full fill must not adopt a same-script decoy. - pset.add_output(pset_output(explicit_txout( - input_asset, - 6, - compiled.script_pubkey().clone(), - ))); - 1 - }; - if partial { - let remainder = match direction { - OrderDirection::SellBase => 6, - OrderDirection::SellQuote => 42, - }; - pset.add_output(pset_output(explicit_txout( - input_asset, - remainder, - compiled.script_pubkey().clone(), - ))); - } - - let witness = derived_maker_order::MakerOrderWitness { - payment_index: 0, - is_partial: partial, - remainder_index, - }; - let net = network(params.quote_asset_id); - let mut stack = compiled - .program() - .as_ref() - .finalize(&pset, &witness.build_witness(), 0, &net) - .expect("finalize maker fill"); - if annex { - stack.push(vec![0x50, 0x01]); - } - let mut transaction = pset.extract_tx().expect("extract maker tx"); - transaction.input[0].witness.script_witness = stack; - MakerScenario { - params, - before: MakerOrderState::Active { - remaining_base: 10, - total_filled_base: 0, - }, - live: TrackedContractOutput { - outpoint: previous, - txout: live_txout, - }, - transaction, - } -} - -#[test] -fn interprets_all_maker_fill_transitions_from_finalized_transactions() { - for direction in [OrderDirection::SellBase, OrderDirection::SellQuote] { - let full = maker_fill_scenario(direction, false, false, false); - let interpreted = - interpret_maker_order_spend(full.params, full.before, &full.live, &full.transaction) - .expect("interpret full fill"); - assert!(matches!(interpreted.kind, MakerOrderSpendKind::Fill(_))); - assert_eq!(interpreted.after, MakerOrderState::Consumed); - assert!(interpreted.continuation.is_none()); - - let partial = maker_fill_scenario(direction, true, false, false); - let interpreted = interpret_maker_order_spend( - partial.params, - partial.before, - &partial.live, - &partial.transaction, - ) - .expect("interpret partial fill"); - assert_eq!( - interpreted.after, - MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 4, - } - ); - assert_eq!(interpreted.remainder_index, Some(1)); - } -} - -#[test] -fn maker_interpreter_uses_designated_remainder_and_handles_annex() { - let scenario = maker_fill_scenario(OrderDirection::SellBase, true, true, true); - let interpreted = interpret_maker_order_spend( - scenario.params, - scenario.before, - &scenario.live, - &scenario.transaction, - ) - .expect("interpret designated remainder"); - assert_eq!(interpreted.remainder_index, Some(2)); - assert_eq!( - interpreted - .continuation - .expect("continuation") - .outpoint - .vout, - 2 - ); - assert!(interpreted.annex_present); -} - -#[test] -fn maker_key_spend_is_cancellation_after_annex_stripping() { - let params = maker_params(OrderDirection::SellBase); - let compiled = CompiledMakerOrder::new(params).expect("compile order"); - let previous = OutPoint::new(elements::Txid::from_byte_array([0xb1; 32]), 0); - let live_txout = explicit_txout(params.base_asset_id, 10, compiled.script_pubkey().clone()); - let mut pset = PartiallySignedTransaction::new_v2(); - let mut input = pset_input(0xb1, 0, live_txout.clone()); - input.previous_txid = previous.txid; - pset.add_input(input); - pset.add_output(pset_output(explicit_txout( - params.base_asset_id, - 10, - script(0x77), - ))); - let mut transaction = pset.extract_tx().expect("extract cancellation"); - transaction.input[0].witness.script_witness = vec![vec![1; 64], vec![0x50, 0x99]]; - let before = MakerOrderState::Active { - remaining_base: 10, - total_filled_base: 0, - }; - let interpreted = interpret_maker_order_spend( - params, - before, - &TrackedContractOutput { - outpoint: previous, - txout: live_txout, - }, - &transaction, - ) - .expect("interpret cancellation"); - assert_eq!(interpreted.kind, MakerOrderSpendKind::Cancel); - assert_eq!(interpreted.after, MakerOrderState::Cancelled); - assert!(interpreted.annex_present); -} - fn binary_params() -> BinaryMarketParams { BinaryMarketParams { oracle_public_key: key(0x41), @@ -425,14 +215,7 @@ fn market_interpreter_uses_witness_output_base_not_first_matching_script() { } #[test] -fn interpreters_reject_tampered_designated_outputs_and_control_blocks() { - let mut maker = maker_fill_scenario(OrderDirection::SellBase, true, false, false); - maker.transaction.output[1].value = elements::confidential::Value::Explicit(5); - assert!( - interpret_maker_order_spend(maker.params, maker.before, &maker.live, &maker.transaction,) - .is_err() - ); - +fn market_interpreter_rejects_tampered_control_blocks() { let mut market = resolved_redemption_scenario(false, false, false); let compiled = CompiledBinaryMarket::new(market.params).expect("compile market"); market.transaction.input[0].witness.script_witness[3] = compiled diff --git a/crates/deadcat-contracts/tests/support/mod.rs b/crates/deadcat-contracts/tests/support/mod.rs index 4f887bc..e7de1f0 100644 --- a/crates/deadcat-contracts/tests/support/mod.rs +++ b/crates/deadcat-contracts/tests/support/mod.rs @@ -12,6 +12,7 @@ pub(crate) fn network(policy_asset: AssetId) -> SimplicityNetwork { SimplicityNetwork::ElementsRegtest { policy_asset } } +#[allow(dead_code)] pub(crate) fn script(byte: u8) -> Script { Script::from(vec![0x6a, 0x01, byte]) } diff --git a/crates/deadcat-node/src/discovery.rs b/crates/deadcat-node/src/discovery.rs index 49cfe7a..89bbbab 100644 --- a/crates/deadcat-node/src/discovery.rs +++ b/crates/deadcat-node/src/discovery.rs @@ -1,9 +1,8 @@ //! Recovery-hint scanning. Hints identify candidates; compilation confirms them. use deadcat_contracts::recovery::{ - MARKET_V1_TAG, MarketCollateral, MarketRecoveryHint, ORDER_NO_SELL_BASE_V1_TAG, - ORDER_NO_SELL_QUOTE_V1_TAG, ORDER_YES_SELL_BASE_V1_TAG, ORDER_YES_SELL_QUOTE_V1_TAG, - OrderRecoveryHint, parse_recovery_script, validate_recovery_txout, + MARKET_V1_TAG, MarketCollateral, MarketRecoveryHint, parse_recovery_script, + validate_recovery_txout, }; use deadcat_rpc::RecoveryFamily; use deadcat_types::{ChainPosition, LiquidNetwork, RecoveryHintLocation}; @@ -64,9 +63,6 @@ pub fn scan_transaction_hints( } Ok(()) } - RecoveryFamily::MakerOrderV1 => OrderRecoveryHint::decode(payload) - .map(|_| ()) - .map_err(|error| error.to_string()), }); match validation { @@ -92,10 +88,6 @@ pub fn scan_transaction_hints( fn recognized_family(tag: Option) -> Option { match tag? { MARKET_V1_TAG => Some(RecoveryFamily::BinaryMarketV1), - ORDER_YES_SELL_BASE_V1_TAG - | ORDER_YES_SELL_QUOTE_V1_TAG - | ORDER_NO_SELL_BASE_V1_TAG - | ORDER_NO_SELL_QUOTE_V1_TAG => Some(RecoveryFamily::MakerOrderV1), _ => None, } } diff --git a/crates/deadcat-node/src/interpreter.rs b/crates/deadcat-node/src/interpreter.rs index 7e23c72..27cd014 100644 --- a/crates/deadcat-node/src/interpreter.rs +++ b/crates/deadcat-node/src/interpreter.rs @@ -10,21 +10,16 @@ use std::sync::Arc; use deadcat_contracts::binary_market::{BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome}; use deadcat_contracts::interpret::{ - BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, MakerOrderSpendKind, - TrackedContractOutput, interpret_binary_market_spend, interpret_maker_order_spend, + BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, TrackedContractOutput, + interpret_binary_market_spend, }; use deadcat_rpc::RecoveryFamily; -use deadcat_types::{ - ContractDescriptor, ContractId, ContractKind, ContractSyncState, LiquidNetwork, MakerOrderState, -}; +use deadcat_types::{ContractDescriptor, ContractId, ContractSyncState, LiquidNetwork}; use elements::{AssetId, OutPoint, Transaction}; use thiserror::Error; use crate::discovery::scan_transaction_hints; -use crate::registration::{ - verify_binary_market_creation_shared, verify_maker_order_creation_shared, - verify_maker_order_hint_creation_shared, -}; +use crate::registration::verify_binary_market_creation_shared; use crate::store::{ ContractParameters, ContractRecord, ContractState, OutpointOwner, StateUpdate, StoreError, TrackedOutpoint, TransitionRecord, @@ -35,24 +30,20 @@ use crate::sync::{ }; /// Transition tags reserve the high nibble for the payload version, the next -/// nibble for the contract family (`0` market, `1` maker), and the low byte for -/// the operation. Payload integers are fixed-width big-endian values. +/// nibble for the contract family (`0` market), and the low byte for the +/// operation. Payload integers are fixed-width big-endian values. /// /// Market payloads begin with the one-byte [`BinaryMarketPath`], followed by: /// issued `(pairs:u64, collateral:u64)`, cancelled /// `(pairs:u64, collateral:u64, full:u8)`, resolved /// `(outcome:u8, collateral:u64)`, expired `(collateral:u64)`, or redeemed /// `(outcome:u8, tokens:u64, collateral:u64, complete:u8)`. Outcomes encode -/// YES as zero and NO as one. Maker fills encode -/// `(filled_base:u64, payment:u64, has_remaining:u8, remaining_locked:u64)`; -/// cancellation has an empty payload. +/// YES as zero and NO as one. pub const TRANSITION_V1_MARKET_ISSUED: u16 = 0x1001; pub const TRANSITION_V1_MARKET_CANCELLED: u16 = 0x1002; pub const TRANSITION_V1_MARKET_RESOLVED: u16 = 0x1003; pub const TRANSITION_V1_MARKET_EXPIRED: u16 = 0x1004; pub const TRANSITION_V1_MARKET_REDEEMED: u16 = 0x1005; -pub const TRANSITION_V1_MAKER_FILLED: u16 = 0x1101; -pub const TRANSITION_V1_MAKER_CANCELLED: u16 = 0x1102; #[derive(Clone, Copy, Debug)] pub struct DeadcatInterpreter { @@ -104,25 +95,19 @@ impl ChainInterpreter for DeadcatInterpreter { .recovery_hints .iter() .any(|hint| hint.family == RecoveryFamily::BinaryMarketV1); - let has_order_hint = result - .recovery_hints - .iter() - .any(|hint| hint.family == RecoveryFamily::MakerOrderV1); - let shared_transaction = (!retained.is_empty() || has_market_hint || has_order_hint) - .then(|| Arc::new(transaction.clone())); + let shared_transaction = + (!retained.is_empty() || has_market_hint).then(|| Arc::new(transaction.clone())); // Explicit declarations are durable watch intent, not authority. // Recompile every retained market against this exact canonical - // transaction before considering any retained child orders. + // transaction. for declaration in retained.iter().filter(|declaration| { matches!( declaration.descriptor, ContractDescriptor::BinaryMarketV1 { .. } ) }) { - let ContractDescriptor::BinaryMarketV1 { params } = declaration.descriptor else { - unreachable!("filtered to market declarations") - }; + let ContractDescriptor::BinaryMarketV1 { params } = declaration.descriptor; if let Ok(mut verified) = verify_binary_market_creation_shared( Arc::clone( shared_transaction @@ -179,124 +164,6 @@ impl ChainInterpreter for DeadcatInterpreter { hint.associated_contract = Some(contract_id); } } - - // Retained declarations remain useful for durable watch intent, - // but canonical maker hints now contain enough public information - // to derive and validate an order independently. - for declaration in retained.iter().filter(|declaration| { - matches!( - declaration.descriptor, - ContractDescriptor::MakerOrderV1 { .. } - ) - }) { - let ContractDescriptor::MakerOrderV1 { - parent_market, - side, - params, - } = declaration.descriptor - else { - unreachable!("filtered to maker declarations") - }; - let same_transaction_parent = result - .created_contracts - .iter() - .find(|record| record.contract_id == parent_market); - let stored_parent; - let parent = if let Some(parent) = same_transaction_parent { - parent - } else { - let Some(parent) = contract_in_context(context, parent_market)? else { - continue; - }; - stored_parent = parent; - &stored_parent - }; - if let Ok(mut verified) = verify_maker_order_creation_shared( - Arc::clone( - shared_transaction - .as_ref() - .expect("retained declarations allocate shared transaction evidence"), - ), - context.position, - context.anchor, - self.policy_asset, - declaration.contract_id, - parent, - side, - params, - ) { - verified.record.sync_state = ContractSyncState::Ready { - synced_through: context.anchor, - }; - retain_canonical_creation( - context, - &mut result.created_contracts, - verified.record, - )?; - } - } - - for hint_index in result - .recovery_hints - .iter() - .filter(|hint| hint.family == RecoveryFamily::MakerOrderV1) - .map(|hint| hint.output_index) - .collect::>() - { - let Some(hint) = result - .recovery_hints - .iter() - .find(|hint| hint.output_index == hint_index) - .and_then(|hint| { - deadcat_contracts::recovery::OrderRecoveryHint::decode(&hint.payload).ok() - }) - else { - continue; - }; - let parent_market = hint.parent_market.resolve(transaction.txid()); - let same_transaction_parent = result - .created_contracts - .iter() - .find(|record| record.contract_id == parent_market); - let stored_parent; - let parent = if let Some(parent) = same_transaction_parent { - parent - } else { - let Some(parent) = contract_in_context(context, parent_market)? else { - continue; - }; - stored_parent = parent; - &stored_parent - }; - if let Ok(mut verified) = verify_maker_order_hint_creation_shared( - Arc::clone( - shared_transaction - .as_ref() - .expect("maker hints allocate shared transaction evidence"), - ), - context.position, - context.anchor, - self.policy_asset, - hint_index, - parent, - ) { - verified.record.sync_state = ContractSyncState::Ready { - synced_through: context.anchor, - }; - let contract_id = verified.record.contract_id; - retain_canonical_creation( - context, - &mut result.created_contracts, - verified.record, - )?; - let associated = result - .recovery_hints - .iter_mut() - .find(|candidate| candidate.output_index == hint_index) - .ok_or(NodeInterpretError::MissingAssociatedHint)?; - associated.associated_contract = Some(contract_id); - } - } } let targets = match context.mode { @@ -365,8 +232,6 @@ fn ensure_creation_identity( if existing.kind != candidate.kind || existing.params != candidate.params || existing.creation_position != candidate.creation_position - || existing.parent_market != candidate.parent_market - || existing.outcome_side != candidate.outcome_side || existing.scripts != candidate.scripts || existing.assets != candidate.assets { @@ -413,82 +278,28 @@ fn interpret_contract( record: &ContractRecord, transaction: &Transaction, ) -> Result { - match (record.kind, &record.params, record.state) { - ( - ContractKind::BinaryMarketV1, - ContractParameters::BinaryMarket(params), - ContractState::BinaryMarket(before), - ) => { - let live = materialize_market_outputs(context, record)?; - let interpreted = interpret_binary_market_spend(*params, before, &live, transaction)?; - let spent_outpoints = interpreted.spent_outpoints.to_vec(); - ensure_complete_spend(record, &spent_outpoints)?; - let new_outpoints = interpreted - .continuations - .iter() - .map(|continuation| TrackedOutpoint { - role: continuation.slot as u8, - outpoint: continuation.output.outpoint, - }) - .collect(); - Ok(StateUpdate { - contract_id: record.contract_id, - old_state: record.state, - new_state: ContractState::BinaryMarket(interpreted.after), - spent_outpoints, - new_outpoints, - order_remaining_base: None, - transition: market_transition_record(interpreted.path, interpreted.transition), - }) - } - ( - ContractKind::MakerOrderV1, - ContractParameters::MakerOrder(params), - ContractState::MakerOrder(before), - ) => { - let live = materialize_maker_output(context, record)?; - let interpreted = interpret_maker_order_spend(*params, before, &live, transaction)?; - let spent_outpoints = vec![interpreted.spent_outpoint]; - ensure_complete_spend(record, &spent_outpoints)?; - let new_outpoints = interpreted - .continuation - .as_ref() - .map(|continuation| { - vec![TrackedOutpoint { - role: 0, - outpoint: continuation.outpoint, - }] - }) - .unwrap_or_default(); - let order_remaining_base = match interpreted.after { - MakerOrderState::Active { remaining_base, .. } => Some(remaining_base), - MakerOrderState::Consumed | MakerOrderState::Cancelled => None, - }; - Ok(StateUpdate { - contract_id: record.contract_id, - old_state: record.state, - new_state: ContractState::MakerOrder(interpreted.after), - spent_outpoints, - new_outpoints, - order_remaining_base, - transition: maker_transition_record(interpreted.kind), - }) - } - _ => Err(NodeInterpretError::ContractShape(record.contract_id)), - } -} - -fn materialize_maker_output( - context: &InterpretationContext<'_>, - record: &ContractRecord, -) -> Result { - let [tracked] = record.outpoints.as_slice() else { - return Err(NodeInterpretError::InvalidLiveSet(record.contract_id)); - }; - if tracked.role != 0 { - return Err(NodeInterpretError::InvalidLiveSet(record.contract_id)); - } - materialize_output(context, *tracked) + let ContractParameters::BinaryMarket(params) = &record.params; + let ContractState::BinaryMarket(before) = record.state; + let live = materialize_market_outputs(context, record)?; + let interpreted = interpret_binary_market_spend(*params, before, &live, transaction)?; + let spent_outpoints = interpreted.spent_outpoints.to_vec(); + ensure_complete_spend(record, &spent_outpoints)?; + let new_outpoints = interpreted + .continuations + .iter() + .map(|continuation| TrackedOutpoint { + role: continuation.slot as u8, + outpoint: continuation.output.outpoint, + }) + .collect(); + Ok(StateUpdate { + contract_id: record.contract_id, + old_state: record.state, + new_state: ContractState::BinaryMarket(interpreted.after), + spent_outpoints, + new_outpoints, + transition: market_transition_record(interpreted.path, interpreted.transition), + }) } fn materialize_market_outputs( @@ -631,26 +442,6 @@ fn contract_in_context( ensure_complete_spend(record, &update.spent_outpoints)?; record.state = update.new_state; record.outpoints.clone_from(&update.new_outpoints); - match (record.state, update.order_remaining_base) { - (ContractState::BinaryMarket(_), None) => {} - ( - ContractState::MakerOrder(MakerOrderState::Active { remaining_base, .. }), - Some(supplied), - ) if remaining_base == supplied => { - let order_book = record - .order_book - .as_mut() - .ok_or(NodeInterpretError::OverlayStateMismatch(contract_id))?; - order_book.remaining_base = supplied; - } - ( - ContractState::MakerOrder( - MakerOrderState::Consumed | MakerOrderState::Cancelled, - ), - None, - ) => record.order_book = None, - _ => return Err(NodeInterpretError::OverlayStateMismatch(contract_id)), - } } } Ok(current) @@ -726,34 +517,6 @@ fn market_transition_record( TransitionRecord { kind, payload } } -fn maker_transition_record(kind: MakerOrderSpendKind) -> TransitionRecord { - match kind { - MakerOrderSpendKind::Fill(fill) => { - let mut payload = Vec::with_capacity(25); - payload.extend_from_slice(&fill.filled_base.to_be_bytes()); - payload.extend_from_slice(&fill.maker_payment.to_be_bytes()); - match fill.remaining_locked { - Some(remaining) => { - payload.push(1); - payload.extend_from_slice(&remaining.to_be_bytes()); - } - None => { - payload.push(0); - payload.extend_from_slice(&0_u64.to_be_bytes()); - } - } - TransitionRecord { - kind: TRANSITION_V1_MAKER_FILLED, - payload, - } - } - MakerOrderSpendKind::Cancel => TransitionRecord { - kind: TRANSITION_V1_MAKER_CANCELLED, - payload: Vec::new(), - }, - } -} - const fn outcome_byte(outcome: BinaryOutcome) -> u8 { match outcome { BinaryOutcome::Yes => 0, @@ -779,8 +542,6 @@ pub enum NodeInterpretError { MissingOutput(OutPoint), #[error("tracked contract {0:?} has an invalid live-output set")] InvalidLiveSet(ContractId), - #[error("tracked contract {0:?} has inconsistent kind, parameters, and state")] - ContractShape(ContractId), #[error("contract {0:?} was created twice in one interpretation overlay")] DuplicateOverlayContract(ContractId), #[error("contract {0:?} has inconsistent same-block transition state")] diff --git a/crates/deadcat-node/src/interpreter/tests.rs b/crates/deadcat-node/src/interpreter/tests.rs index 8af5654..05b3d80 100644 --- a/crates/deadcat-node/src/interpreter/tests.rs +++ b/crates/deadcat-node/src/interpreter/tests.rs @@ -5,32 +5,25 @@ use deadcat_contracts::SimplicityNetwork; use deadcat_contracts::binary_market::{ BinaryMarketAction, BinaryMarketSlot, CompiledBinaryMarket, }; -use deadcat_contracts::maker_order::{CompiledMakerOrder, derive_instance_id}; use deadcat_contracts::market_crypto::derive_issuance_assets; -use deadcat_contracts::recovery::{ - MarketCollateral, MarketRecoveryHint, OrderRecoveryHint, ParentMarketRef, recovery_txout, -}; +use deadcat_contracts::recovery::{MarketCollateral, MarketRecoveryHint, recovery_txout}; use deadcat_contracts::rt::{RtLeg, RtSide, commitments, factors}; use deadcat_types::{ BinaryMarketParams, BinaryMarketState, ChainAnchor, ChainPosition, ContractSyncState, - MakerOrderParams, MakerOrderState, OrderDirection, OrderSide, }; -use elements::confidential::{Asset, Nonce, Value}; +use elements::confidential::{Nonce, Value}; use elements::hashes::Hash as _; use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTransaction}; -use elements::secp256k1_zkp::{Keypair, Secp256k1, ZERO_TWEAK}; +use elements::secp256k1_zkp::ZERO_TWEAK; use elements::{ - AssetIssuance, BlockHash, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, TxOutWitness, - Txid, + AssetIssuance, BlockHash, LockTime, OutPoint, Transaction, TxIn, TxOut, TxOutWitness, Txid, }; use tempfile::TempDir; use super::*; -use crate::registration::{verify_binary_market_creation, verify_maker_order_creation}; +use crate::registration::verify_binary_market_creation; use crate::store::{ - AssetBinding, AssetRelationKind, BlockDelta, ChainTxDelta, ContractParameters, ContractRecord, - ContractState, OrderBookEntry, RegistrationEvidence, ScriptBinding, Store, TrackedOutpoint, - TransitionRecord, + BlockDelta, ChainTxDelta, ContractRecord, ContractState, RegistrationEvidence, Store, }; const VALID_XONLY: [u8; 32] = [ @@ -56,129 +49,6 @@ fn empty_store() -> (TempDir, Store) { (directory, store) } -fn explicit_txout(asset_id: AssetId, value: u64, script_pubkey: Script) -> TxOut { - TxOut { - asset: Asset::Explicit(asset_id), - value: Value::Explicit(value), - nonce: Nonce::Null, - script_pubkey, - witness: TxOutWitness::default(), - } -} - -fn key(seed: u8) -> [u8; 32] { - Keypair::from_seckey_slice(&Secp256k1::new(), &[seed; 32]) - .expect("key") - .x_only_public_key() - .0 - .serialize() -} - -fn maker_params(seed: u8) -> MakerOrderParams { - MakerOrderParams { - base_asset_id: asset(0x11), - quote_asset_id: asset(0x22), - price: 7, - min_active_base: 3, - direction: OrderDirection::SellBase, - instance_id: [seed; 32], - maker_pubkey: key(seed), - } -} - -fn maker_creation( - params: &[MakerOrderParams], - position: ChainPosition, - synced_through: ChainAnchor, -) -> (Transaction, Vec) { - let outputs = params - .iter() - .map(|params| { - let compiled = CompiledMakerOrder::new(*params).expect("compile maker"); - explicit_txout(params.base_asset_id, 10, compiled.script_pubkey().clone()) - }) - .collect(); - let transaction = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: Vec::new(), - output: outputs, - }; - let records = params - .iter() - .enumerate() - .map(|(index, params)| { - let compiled = CompiledMakerOrder::new(*params).expect("compile maker"); - let output_index = u32::try_from(index).expect("vout"); - let contract_id = ContractId::new(OutPoint::new(transaction.txid(), output_index)); - let parent_market = - ContractId::new(OutPoint::new(Txid::from_byte_array([0x78; 32]), 0)); - ContractRecord { - contract_id, - kind: ContractKind::MakerOrderV1, - params: ContractParameters::MakerOrder(*params), - creation_position: position, - state: ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: 10, - total_filled_base: 0, - }), - sync_state: ContractSyncState::Ready { synced_through }, - parent_market: Some(parent_market), - outcome_side: Some(OrderSide::Yes), - scripts: vec![ScriptBinding { - role: 0, - script_pubkey: compiled.script_pubkey().as_bytes().to_vec(), - }], - assets: vec![ - AssetBinding { - asset_id: params.base_asset_id, - relation: AssetRelationKind::OrderBase, - role: 0, - }, - AssetBinding { - asset_id: params.quote_asset_id, - relation: AssetRelationKind::OrderQuote, - role: 1, - }, - ], - outpoints: vec![TrackedOutpoint { - role: 0, - outpoint: OutPoint::new(transaction.txid(), output_index), - }], - order_book: Some(OrderBookEntry { - market_id: parent_market, - side: OrderSide::Yes, - direction: params.direction, - price: params.price, - creation_position: position, - remaining_base: 10, - }), - } - }) - .collect(); - (transaction, records) -} - -fn cancellation(outpoints: impl IntoIterator) -> Transaction { - let input = outpoints - .into_iter() - .map(|outpoint| { - let mut input = TxIn { - previous_output: outpoint, - ..TxIn::default() - }; - input.witness.script_witness = vec![vec![1; 64]]; - input - }) - .collect(); - Transaction { - version: 2, - lock_time: LockTime::ZERO, - input, - output: Vec::new(), - } -} - fn prior_creation( transaction: Transaction, records: Vec, @@ -195,197 +65,19 @@ fn prior_creation( } } -#[test] -fn multi_contract_batch_is_atomic_and_fails_closed() { - let (_directory, store) = empty_store(); - let block = anchor(8, 0x81); - let position = ChainPosition { - block_height: 8, - tx_index: 0, - }; - let (creation, records) = - maker_creation(&[maker_params(0x31), maker_params(0x32)], position, block); - let prior = vec![prior_creation( - creation, - records.clone(), - position, - block.hash, - )]; - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, asset(0xaa)); - let spend = cancellation(records.iter().map(|record| record.outpoints[0].outpoint)); - let context = InterpretationContext { - store: &store, - anchor: block, - position: ChainPosition { - block_height: 8, - tx_index: 1, - }, - prior_transactions: &prior, - retained_declarations: &[], - mode: InterpretationMode::Canonical, - }; - - let interpreted = interpreter - .interpret_transaction(&context, &spend) - .expect("both cancellations"); - assert_eq!(interpreted.state_updates.len(), 2); - assert!(interpreted.state_updates.iter().all(|update| { - update.new_state == ContractState::MakerOrder(MakerOrderState::Cancelled) - && update.transition.kind == TRANSITION_V1_MAKER_CANCELLED - && update.transition.payload.is_empty() - })); - - let mut invalid = spend; - invalid.input[1].witness.script_witness.clear(); - assert!( - interpreter - .interpret_transaction(&context, &invalid) - .is_err() - ); -} - -#[test] -fn same_block_overlay_uses_latest_state_and_transaction_output() { - let (_directory, store) = empty_store(); - let block = anchor(9, 0x91); - let creation_position = ChainPosition { - block_height: 9, - tx_index: 0, - }; - let params = maker_params(0x33); - let (creation, records) = maker_creation(&[params], creation_position, block); - let record = records[0].clone(); - let compiled = CompiledMakerOrder::new(params).expect("compile maker"); - let move_tx = Transaction { +fn cancellation(outpoints: impl IntoIterator) -> Transaction { + Transaction { version: 2, lock_time: LockTime::ZERO, - input: Vec::new(), - output: vec![explicit_txout( - params.base_asset_id, - 6, - compiled.script_pubkey().clone(), - )], - }; - let moved = OutPoint::new(move_tx.txid(), 0); - let prior = vec![ - prior_creation( - creation, - vec![record.clone()], - creation_position, - block.hash, - ), - ChainTxDelta { - position: ChainPosition { - block_height: 9, - tx_index: 1, - }, - block_hash: block.hash, - txid: move_tx.txid(), - raw_tx: move_tx, - created_contracts: Vec::new(), - state_updates: vec![StateUpdate { - contract_id: record.contract_id, - old_state: record.state, - new_state: ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 4, - }), - spent_outpoints: vec![record.outpoints[0].outpoint], - new_outpoints: vec![TrackedOutpoint { - role: 0, - outpoint: moved, - }], - order_remaining_base: Some(6), - transition: TransitionRecord { - kind: TRANSITION_V1_MAKER_FILLED, - payload: Vec::new(), - }, - }], - }, - ]; - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, asset(0xaa)); - let context = InterpretationContext { - store: &store, - anchor: block, - position: ChainPosition { - block_height: 9, - tx_index: 2, - }, - prior_transactions: &prior, - retained_declarations: &[], - mode: InterpretationMode::Canonical, - }; - - let interpreted = interpreter - .interpret_transaction(&context, &cancellation([moved])) - .expect("overlay cancellation"); - assert_eq!(interpreted.state_updates.len(), 1); - assert_eq!( - interpreted.state_updates[0].old_state, - ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 4, - }) - ); -} - -fn seed_store_with_orders() -> (TempDir, Store, Vec) { - let (directory, store) = empty_store(); - let baseline = anchor(0, 0x01); - let current = anchor(1, 0x02); - store.initialize_tip(baseline).expect("tip"); - let position = ChainPosition { - block_height: 1, - tx_index: 0, - }; - let (creation, records) = - maker_creation(&[maker_params(0x34), maker_params(0x35)], position, current); - let creation_txid = creation.txid(); - store - .apply_block(&BlockDelta { - anchor: current, - prev_block_hash: baseline.hash, - ordered_txids: vec![creation_txid], - relevant_transactions: vec![prior_creation( - creation, - records.clone(), - position, - current.hash, - )], - recovery_hints: Vec::new(), - }) - .expect("seed block"); - (directory, store, records) -} - -#[test] -fn backfill_filters_non_targets_and_materializes_stored_outputs() { - let (_directory, store, records) = seed_store_with_orders(); - let target = records[0].contract_id; - let targets = [target]; - let context = InterpretationContext { - store: &store, - anchor: anchor(2, 0x03), - position: ChainPosition { - block_height: 2, - tx_index: 0, - }, - prior_transactions: &[], - retained_declarations: &[], - mode: InterpretationMode::Backfill { - contract_ids: &targets, - }, - }; - let spend = cancellation(records.iter().map(|record| record.outpoints[0].outpoint)); - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, asset(0xaa)); - - let interpreted = interpreter - .interpret_transaction(&context, &spend) - .expect("targeted backfill"); - assert!(interpreted.created_contracts.is_empty()); - assert!(interpreted.recovery_hints.is_empty()); - assert_eq!(interpreted.state_updates.len(), 1); - assert_eq!(interpreted.state_updates[0].contract_id, target); + input: outpoints + .into_iter() + .map(|outpoint| TxIn { + previous_output: outpoint, + ..TxIn::default() + }) + .collect(), + output: Vec::new(), + } } fn issuance_input(byte: u8, vout: u32) -> TxIn { @@ -600,9 +292,7 @@ fn composed_market_issuance( let mut pset = PartiallySignedTransaction::new_v2(); let mut pending = Vec::with_capacity(issuances.len()); for issuance in issuances { - let ContractState::BinaryMarket(before) = issuance.record.state else { - panic!("market fixture state"); - }; + let ContractState::BinaryMarket(before) = issuance.record.state; let live = market_live_inputs(issuance.record, issuance.source_transaction); let plan = BinaryMarketTransitionPlan::new( issuance.params, @@ -1121,356 +811,7 @@ fn canonical_hint_creates_ready_market_but_composed_shape_is_registration_only() } #[test] -fn canonical_order_hint_publicly_discovers_same_transaction_order() { - let (_directory, store) = empty_store(); - let policy = asset(0xa2); - let (mut transaction, market_params) = standalone_market_with_params(policy); - let creation_prevouts = transaction - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let params = MakerOrderParams { - base_asset_id: market_params.yes_token_asset_id, - quote_asset_id: market_params.collateral_asset_id, - price: 100, - min_active_base: 10, - direction: OrderDirection::SellQuote, - instance_id: derive_instance_id(&creation_prevouts, 3).expect("instance"), - maker_pubkey: VALID_XONLY, - }; - let compiled = CompiledMakerOrder::new(params).expect("compile order"); - transaction.output.push(explicit_txout( - params.quote_asset_id, - 2_000, - compiled.script_pubkey().clone(), - )); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 0x1234, - parent_market: ParentMarketRef::SameTransaction { vout: 0 }, - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - }; - transaction - .output - .push(recovery_txout(policy, &hint.encode()).expect("order hint")); - - let current = anchor(12, 0xc2); - let interpreted = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, policy) - .interpret_transaction( - &InterpretationContext { - store: &store, - anchor: current, - position: ChainPosition { - block_height: 12, - tx_index: 5, - }, - prior_transactions: &[], - retained_declarations: &[], - mode: InterpretationMode::Canonical, - }, - &transaction, - ) - .expect("publicly discover market and maker order"); - - let market_id = ContractId::new(OutPoint::new(transaction.txid(), 0)); - let order_id = ContractId::new(OutPoint::new(transaction.txid(), 3)); - assert_eq!( - interpreted - .created_contracts - .iter() - .map(|record| record.contract_id) - .collect::>(), - vec![market_id, order_id] - ); - assert_eq!( - interpreted - .recovery_hints - .iter() - .filter_map(|hint| hint.associated_contract) - .collect::>(), - vec![market_id, order_id] - ); -} - -#[test] -fn destructive_replay_revalidates_retained_market_and_canonical_same_tx_makers() { - let directory = tempfile::tempdir().expect("tempdir"); - let store = Store::open(directory.path().join("deadcat.redb")).expect("store"); - let genesis = anchor(0, 0x01); - let policy = asset(0xa2); - store - .initialize_chain( - crate::store::ChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash: genesis.hash, - policy_asset: policy, - }, - genesis, - ) - .expect("initialize chain"); - - let (mut creation, market_params) = standalone_market_with_params(policy); - // Move the canonical YES RT away from vout 0. The complete declaration - // remains verifiable, but fixed-shape market-hint discovery alone must not - // recover this composed market during rebuild. - creation.output.swap(0, 1); - let creation_prevouts = creation - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let order_params = |order_vout| MakerOrderParams { - base_asset_id: market_params.yes_token_asset_id, - quote_asset_id: market_params.collateral_asset_id, - price: 100, - min_active_base: 10, - direction: OrderDirection::SellQuote, - instance_id: derive_instance_id(&creation_prevouts, order_vout).expect("instance"), - maker_pubkey: VALID_XONLY, - }; - let first_params = order_params(3); - let second_params = order_params(5); - for params in [first_params, second_params] { - let compiled_order = CompiledMakerOrder::new(params).expect("compile order"); - creation.output.push(explicit_txout( - params.quote_asset_id, - 2_000, - compiled_order.script_pubkey().clone(), - )); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 0x1234, - parent_market: ParentMarketRef::SameTransaction { vout: 1 }, - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - }; - creation - .output - .push(recovery_txout(policy, &hint.encode()).expect("order hint")); - } - - let old_anchor = anchor(1, 0x02); - let old_position = ChainPosition { - block_height: 1, - tx_index: 0, - }; - store - .apply_block(&BlockDelta { - anchor: old_anchor, - prev_block_hash: genesis.hash, - ordered_txids: vec![creation.txid()], - relevant_transactions: Vec::new(), - recovery_hints: Vec::new(), - }) - .expect("index original creation block"); - let market_id = ContractId::new(OutPoint::new(creation.txid(), 1)); - let first_order_id = ContractId::new(OutPoint::new(creation.txid(), 3)); - let second_order_id = ContractId::new(OutPoint::new(creation.txid(), 5)); - let market = verify_binary_market_creation( - &creation, - old_position, - old_anchor, - LiquidNetwork::ElementsRegtest, - policy, - Some(market_params), - Some(market_id), - ) - .expect("verify market"); - let first_order = verify_maker_order_creation( - &creation, - old_position, - old_anchor, - policy, - first_order_id, - &market.record, - OrderSide::Yes, - first_params, - ) - .expect("verify first order"); - let second_order = verify_maker_order_creation( - &creation, - old_position, - old_anchor, - policy, - second_order_id, - &market.record, - OrderSide::Yes, - second_params, - ) - .expect("verify second order"); - let shared_creation = Arc::new(creation.clone()); - store - .register_contracts(&[ - ( - market.record, - RegistrationEvidence { - anchor: old_anchor, - transaction: Arc::clone(&shared_creation), - associated_hint: None, - }, - ), - ( - first_order.record, - RegistrationEvidence { - anchor: old_anchor, - transaction: Arc::clone(&shared_creation), - associated_hint: None, - }, - ), - ( - second_order.record, - RegistrationEvidence { - anchor: old_anchor, - transaction: shared_creation, - associated_hint: None, - }, - ), - ]) - .expect("retain composed declarations"); - assert_eq!( - store - .retained_declarations_for_txid(creation.txid()) - .expect("retained declarations") - .iter() - .map(|declaration| declaration.contract_id) - .collect::>(), - vec![market_id, first_order_id, second_order_id] - ); - - store.invalidate_for_rebuild().expect("invalidate"); - store.reset_for_rebuild().expect("activation rebuild reset"); - let replacement_one = anchor(1, 0x12); - let unrelated = Transaction { - version: 2, - lock_time: LockTime::from_consensus(0x12), - input: Vec::new(), - output: vec![TxOut::new_fee(1, policy)], - }; - store - .apply_block(&BlockDelta { - anchor: replacement_one, - prev_block_hash: genesis.hash, - ordered_txids: vec![unrelated.txid()], - relevant_transactions: Vec::new(), - recovery_hints: Vec::new(), - }) - .expect("replacement block one"); - - let replacement_two = anchor(2, 0x22); - let new_position = ChainPosition { - block_height: 2, - tx_index: 0, - }; - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, policy); - let retained = store - .retained_declarations_for_txid(creation.txid()) - .expect("retained declarations for replay"); - let maker_only = retained - .iter() - .copied() - .filter(|declaration| { - matches!( - declaration.descriptor, - deadcat_types::ContractDescriptor::MakerOrderV1 { .. } - ) - }) - .collect::>(); - let dormant = interpreter - .interpret_transaction( - &InterpretationContext { - store: &store, - anchor: replacement_two, - position: new_position, - prior_transactions: &[], - retained_declarations: &maker_only, - mode: InterpretationMode::Canonical, - }, - &creation, - ) - .expect("missing retained parent leaves maker declarations dormant"); - assert!(dormant.created_contracts.is_empty()); - - let interpreted = interpreter - .interpret_transaction( - &InterpretationContext { - store: &store, - anchor: replacement_two, - position: new_position, - prior_transactions: &[], - retained_declarations: &retained, - mode: InterpretationMode::Canonical, - }, - &creation, - ) - .expect("revalidate retained declarations"); - assert_eq!( - interpreted - .created_contracts - .iter() - .map(|record| record.contract_id) - .collect::>(), - vec![market_id, first_order_id, second_order_id] - ); - assert!( - interpreted - .created_contracts - .iter() - .all(|record| record.creation_position == new_position) - ); - - store - .apply_block(&BlockDelta { - anchor: replacement_two, - prev_block_hash: replacement_one.hash, - ordered_txids: vec![creation.txid()], - relevant_transactions: vec![prior_creation( - creation, - interpreted.created_contracts, - new_position, - replacement_two.hash, - )], - recovery_hints: Vec::new(), - }) - .expect("materialize replayed declarations"); - for contract_id in [market_id, first_order_id, second_order_id] { - let record = store - .contract(contract_id) - .expect("contract lookup") - .expect("replayed contract"); - assert_eq!(record.creation_position, new_position); - assert!(matches!( - record.sync_state, - ContractSyncState::Ready { synced_through } if synced_through == replacement_two - )); - } - - let assert_canonical_orders_are_indexed = |store: &Store| { - let mut actual = store - .ready_orders(market_id, None, None, None, 10) - .expect("ready orders") - .items - .into_iter() - .map(|order| order.contract.contract_id) - .collect::>(); - actual.sort_unstable(); - let mut expected = vec![first_order_id, second_order_id]; - expected.sort_unstable(); - assert_eq!(actual, expected); - }; - assert_canonical_orders_are_indexed(&store); - drop(store); - let reopened = Store::open(directory.path().join("deadcat.redb")).expect("reopen store"); - assert_canonical_orders_are_indexed(&reopened); -} - -#[test] -fn destructive_replay_revalidates_retained_market_without_maker_orders() { +fn destructive_replay_revalidates_two_retained_markets() { let directory = tempfile::tempdir().expect("tempdir"); let store = Store::open(directory.path().join("deadcat.redb")).expect("store"); let genesis = anchor(0, 0x01); @@ -1713,90 +1054,4 @@ fn transition_tags_and_payloads_are_byte_stable() { expected.extend_from_slice(&400_u64.to_be_bytes()); assert_eq!(issued.kind, TRANSITION_V1_MARKET_ISSUED); assert_eq!(issued.payload, expected); - - let filled = maker_transition_record(MakerOrderSpendKind::Fill( - deadcat_contracts::maker_order::MakerOrderFill { - filled_base: 4, - maker_payment: 28, - remaining_locked: Some(6), - next_state: MakerOrderState::Active { - remaining_base: 6, - total_filled_base: 4, - }, - }, - )); - let mut expected = Vec::new(); - expected.extend_from_slice(&4_u64.to_be_bytes()); - expected.extend_from_slice(&28_u64.to_be_bytes()); - expected.push(1); - expected.extend_from_slice(&6_u64.to_be_bytes()); - assert_eq!(filled.kind, TRANSITION_V1_MAKER_FILLED); - assert_eq!(filled.payload, expected); -} - -#[test] -fn prior_spends_and_invalid_witnesses_fail_closed() { - let (_directory, store) = empty_store(); - let block = anchor(15, 0xd1); - let position = ChainPosition { - block_height: 15, - tx_index: 0, - }; - let (creation, records) = maker_creation(&[maker_params(0x36)], position, block); - let record = records[0].clone(); - let first_spend = cancellation([record.outpoints[0].outpoint]); - let prior = vec![ - prior_creation(creation, vec![record.clone()], position, block.hash), - ChainTxDelta { - position: ChainPosition { - block_height: 15, - tx_index: 1, - }, - block_hash: block.hash, - txid: first_spend.txid(), - raw_tx: first_spend, - created_contracts: Vec::new(), - state_updates: vec![StateUpdate { - contract_id: record.contract_id, - old_state: record.state, - new_state: ContractState::MakerOrder(MakerOrderState::Cancelled), - spent_outpoints: vec![record.outpoints[0].outpoint], - new_outpoints: Vec::new(), - order_remaining_base: None, - transition: TransitionRecord { - kind: TRANSITION_V1_MAKER_CANCELLED, - payload: Vec::new(), - }, - }], - }, - ]; - let context = InterpretationContext { - store: &store, - anchor: block, - position: ChainPosition { - block_height: 15, - tx_index: 2, - }, - prior_transactions: &prior, - retained_declarations: &[], - mode: InterpretationMode::Canonical, - }; - let interpreter = DeadcatInterpreter::new(LiquidNetwork::ElementsRegtest, asset(0xaa)); - assert!(matches!( - interpreter.interpret_transaction(&context, &cancellation([record.outpoints[0].outpoint])), - Err(NodeInterpretError::SameBlockDoubleSpend { .. }) - )); - - let fresh_prior = &prior[..1]; - let fresh_context = InterpretationContext { - prior_transactions: fresh_prior, - ..context - }; - let mut invalid = cancellation([record.outpoints[0].outpoint]); - invalid.input[0].witness.script_witness = vec![vec![1; 12]]; - assert!( - interpreter - .interpret_transaction(&fresh_context, &invalid) - .is_err() - ); } diff --git a/crates/deadcat-node/src/registration.rs b/crates/deadcat-node/src/registration.rs index 8368f3f..dad054e 100644 --- a/crates/deadcat-node/src/registration.rs +++ b/crates/deadcat-node/src/registration.rs @@ -6,29 +6,26 @@ use std::str::FromStr as _; use std::sync::Arc; use deadcat_contracts::binary_market::{BinaryMarketSlot, CompiledBinaryMarket}; -use deadcat_contracts::maker_order::{ - CompiledMakerOrder, create, derive_instance_id, validate_against_market, -}; use deadcat_contracts::market_crypto::derive_issuance_assets; use deadcat_contracts::recovery::{ - MARKET_V1_TAG, MarketCollateral, MarketRecoveryHint, OrderRecoveryHint, validate_recovery_txout, + MARKET_V1_TAG, MarketCollateral, MarketRecoveryHint, validate_recovery_txout, }; use deadcat_contracts::rt::{RtLeg, RtSide, commitments, factors}; use deadcat_types::{ BinaryMarketParams, BinaryMarketState, CONTRACT_PACKAGE_FORMAT_VERSION, ChainAnchor, ChainPosition, ContractDeclaration, ContractDescriptor, ContractId, ContractKind, ContractPackage, ContractSyncState, LiquidNetwork, MAX_CONTRACT_PACKAGE_DECLARATIONS, - MAX_CONTRACT_PACKAGE_ROOTS, MakerOrderState, OrderDirection, RecoveryHintLocation, + MAX_CONTRACT_PACKAGE_ROOTS, RecoveryHintLocation, }; -use elements::confidential::{Asset, Nonce, Value}; +use elements::confidential::{Asset, Value}; use elements::secp256k1_zkp::ZERO_TWEAK; -use elements::{AssetId, BlockHash, OutPoint, Transaction, TxOutWitness, Txid}; +use elements::{AssetId, BlockHash, OutPoint, Transaction, Txid}; use thiserror::Error; use crate::chain::{ChainSource, ChainSourceError, TransactionStatus}; use crate::store::{ AssetBinding, AssetRelationKind, ContractParameters, ContractRecord, ContractState, - OrderBookEntry, RegistrationEvidence, ScriptBinding, Store, StoreError, TrackedOutpoint, + RegistrationEvidence, ScriptBinding, Store, StoreError, TrackedOutpoint, }; const LIQUID_MAINNET_USDT: &str = @@ -79,8 +76,7 @@ where } /// Verify every declaration from canonical chain evidence. Package order is - /// not trusted: dependencies are resolved before their children and each - /// creation transaction is fetched at most once. + /// not trusted and each creation transaction is fetched at most once. pub async fn verify_package( &self, package: &ContractPackage, @@ -90,14 +86,7 @@ where let mut evidence_bytes = 0_usize; let mut verified = BTreeMap::::new(); - // Markets have no dependencies and are verified first regardless of - // declaration order. - for declaration in declarations.values().filter(|declaration| { - matches!( - declaration.descriptor, - ContractDescriptor::BinaryMarketV1 { .. } - ) - }) { + for declaration in declarations.values() { let creation = self .creation_evidence( declaration.contract_id.txid(), @@ -105,9 +94,7 @@ where &mut evidence_bytes, ) .await?; - let ContractDescriptor::BinaryMarketV1 { params } = declaration.descriptor else { - unreachable!("filtered to market declarations") - }; + let ContractDescriptor::BinaryMarketV1 { params } = declaration.descriptor; let registration = verify_binary_market_creation_shared( Arc::clone(&creation.transaction), creation.position, @@ -120,57 +107,7 @@ where verified.insert(declaration.contract_id, registration); } - for declaration in declarations.values().filter(|declaration| { - matches!( - declaration.descriptor, - ContractDescriptor::MakerOrderV1 { .. } - ) - }) { - let ContractDescriptor::MakerOrderV1 { - parent_market, - side, - params, - } = declaration.descriptor - else { - unreachable!("filtered to maker-order declarations") - }; - let stored_parent; - let parent = if let Some(parent) = verified.get(&parent_market) { - &parent.record - } else { - stored_parent = self - .store - .contract(parent_market)? - .ok_or(RegistrationError::ParentMarketNotFound)?; - &stored_parent - }; - let creation = self - .creation_evidence( - declaration.contract_id.txid(), - &mut evidence, - &mut evidence_bytes, - ) - .await?; - if parent.creation_position > creation.position { - return Err(RegistrationError::InvalidPackage( - "maker order precedes its parent market".to_owned(), - )); - } - let registration = verify_maker_order_creation_shared( - Arc::clone(&creation.transaction), - creation.position, - creation.anchor, - self.policy_asset, - declaration.contract_id, - parent, - side, - params, - )?; - verified.insert(declaration.contract_id, registration); - } - - // Receipts and persistence inputs retain the sender's declaration - // order even though verification itself is dependency ordered. + // Receipts and persistence inputs retain the sender's declaration order. package .declarations .iter() @@ -267,11 +204,6 @@ where let mut declarations = BTreeMap::new(); for declaration in &package.declarations { - if declaration.descriptor.parent() == Some(declaration.contract_id) { - return Err(RegistrationError::InvalidPackage( - "contract declaration depends on itself".to_owned(), - )); - } if declarations .insert(declaration.contract_id, *declaration) .is_some() @@ -294,43 +226,12 @@ where )); } - let mut reachable = BTreeSet::new(); - let mut pending = package.roots.clone(); - while let Some(contract_id) = pending.pop() { - if !reachable.insert(contract_id) { - continue; - } - if let Some(parent) = declarations - .get(&contract_id) - .and_then(|declaration| declaration.descriptor.parent()) - && declarations.contains_key(&parent) - { - pending.push(parent); - } - } - if reachable.len() != declarations.len() { + if roots.len() != declarations.len() { return Err(RegistrationError::InvalidPackage( - "contract package contains declarations unreachable from its roots".to_owned(), + "every contract package declaration must be a root".to_owned(), )); } - for declaration in declarations.values() { - if let ContractDescriptor::MakerOrderV1 { parent_market, .. } = declaration.descriptor { - if let Some(parent) = declarations.get(&parent_market) { - if !matches!(parent.descriptor, ContractDescriptor::BinaryMarketV1 { .. }) { - return Err(RegistrationError::ParentIsNotMarket); - } - } else { - let parent = self - .store - .contract(parent_market)? - .ok_or(RegistrationError::ParentMarketNotFound)?; - if parent.kind != ContractKind::BinaryMarketV1 { - return Err(RegistrationError::ParentIsNotMarket); - } - } - } - } Ok(declarations) } @@ -566,8 +467,6 @@ pub(crate) fn verify_binary_market_creation_shared( sync_state: ContractSyncState::CatchingUp { synced_through: anchor, }, - parent_market: None, - outcome_side: None, scripts, assets: vec![ AssetBinding { @@ -606,7 +505,6 @@ pub(crate) fn verify_binary_market_creation_shared( outpoint: OutPoint::new(txid, no_output), }, ], - order_book: None, }; Ok(VerifiedRegistration { record, @@ -619,287 +517,6 @@ pub(crate) fn verify_binary_market_creation_shared( }) } -#[allow(clippy::too_many_arguments)] -pub fn verify_maker_order_creation( - transaction: &Transaction, - position: ChainPosition, - anchor: ChainAnchor, - policy_asset: AssetId, - contract_id: ContractId, - parent: &ContractRecord, - side: deadcat_types::OrderSide, - params: deadcat_types::MakerOrderParams, -) -> Result { - verify_maker_order_creation_shared( - Arc::new(transaction.clone()), - position, - anchor, - policy_asset, - contract_id, - parent, - side, - params, - ) -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn verify_maker_order_creation_shared( - creation_transaction: Arc, - position: ChainPosition, - anchor: ChainAnchor, - policy_asset: AssetId, - contract_id: ContractId, - parent: &ContractRecord, - side: deadcat_types::OrderSide, - params: deadcat_types::MakerOrderParams, -) -> Result { - let transaction = creation_transaction.as_ref(); - if contract_id.txid() != transaction.txid() { - return Err(RegistrationError::InvalidCreation( - "maker ContractId transaction does not match its creation transaction".to_owned(), - )); - } - let creation_prevouts = transaction - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let expected_instance_id = derive_instance_id(&creation_prevouts, contract_id.vout()) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?; - if params.instance_id != expected_instance_id { - return Err(RegistrationError::InvalidCreation( - "maker instance ID is not canonical for its creation inputs and vout".to_owned(), - )); - } - let ContractParameters::BinaryMarket(parent_params) = &parent.params else { - return Err(RegistrationError::ParentIsNotMarket); - }; - let expected_base = match side { - deadcat_types::OrderSide::Yes => parent_params.yes_token_asset_id, - deadcat_types::OrderSide::No => parent_params.no_token_asset_id, - }; - validate_against_market( - params, - expected_base, - parent_params.collateral_asset_id, - parent_params.collateral_per_pair().ok_or_else(|| { - RegistrationError::InvalidCreation("invalid parent payout".to_owned()) - })?, - ) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?; - let compiled = CompiledMakerOrder::new(params) - .map_err(|error| RegistrationError::Compilation(error.to_string()))?; - - let output = transaction - .output - .get(usize::try_from(contract_id.vout()).map_err(|_| { - RegistrationError::InvalidCreation("maker output index exceeds usize".to_owned()) - })?) - .ok_or_else(|| { - RegistrationError::InvalidCreation( - "maker ContractId output does not exist in the creation transaction".to_owned(), - ) - })?; - if output.script_pubkey != *compiled.script_pubkey() { - return Err(RegistrationError::InvalidCreation( - "maker ContractId output does not use the declared canonical order script".to_owned(), - )); - } - if output.nonce != Nonce::Null || output.witness != TxOutWitness::default() { - return Err(RegistrationError::InvalidCreation( - "canonical order output has a nonce or confidential proofs".to_owned(), - )); - } - let (asset, locked_amount) = match (output.asset, output.value) { - (Asset::Explicit(asset), Value::Explicit(amount)) => (asset, amount), - _ => { - return Err(RegistrationError::InvalidCreation( - "order output asset and value must be explicit".to_owned(), - )); - } - }; - let expected_asset = match params.direction { - OrderDirection::SellBase => params.base_asset_id, - OrderDirection::SellQuote => params.quote_asset_id, - }; - if asset != expected_asset { - return Err(RegistrationError::InvalidCreation( - "order output holds the wrong asset".to_owned(), - )); - } - let offered_base_capacity = match params.direction { - OrderDirection::SellBase => locked_amount, - OrderDirection::SellQuote => { - let price = u64::from(params.price); - if locked_amount % price != 0 { - return Err(RegistrationError::InvalidCreation( - "SellQuote locked amount is not an exact multiple of price".to_owned(), - )); - } - locked_amount / price - } - }; - let creation = create(params, offered_base_capacity) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?; - if creation.locked_amount != locked_amount { - return Err(RegistrationError::InvalidCreation( - "order locked amount is inconsistent with capacity".to_owned(), - )); - } - - let hint_output_index = contract_id.vout().checked_add(1).ok_or_else(|| { - RegistrationError::InvalidCreation("maker hint output index overflowed u32".to_owned()) - })?; - let hint_output = transaction - .output - .get(usize::try_from(hint_output_index).map_err(|_| { - RegistrationError::InvalidCreation("maker hint output index exceeds usize".to_owned()) - })?) - .ok_or_else(|| { - RegistrationError::InvalidCreation( - "canonical maker order must be followed by its recovery hint".to_owned(), - ) - })?; - let hint = OrderRecoveryHint::decode( - validate_recovery_txout(hint_output, policy_asset) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?, - ) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?; - if hint.parent_market.resolve(transaction.txid()) != parent.contract_id - || hint.side != side - || hint.direction != params.direction - || hint.price != params.price - || hint.min_active_base != params.min_active_base - || hint.maker_pubkey != params.maker_pubkey - { - return Err(RegistrationError::InvalidCreation( - "canonical maker hint does not match its order or parent market".to_owned(), - )); - } - - let record = ContractRecord { - contract_id, - kind: ContractKind::MakerOrderV1, - params: ContractParameters::MakerOrder(params), - creation_position: position, - state: ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: offered_base_capacity, - total_filled_base: 0, - }), - sync_state: ContractSyncState::CatchingUp { - synced_through: anchor, - }, - parent_market: Some(parent.contract_id), - outcome_side: Some(side), - scripts: vec![ScriptBinding { - role: 0, - script_pubkey: compiled.script_pubkey().as_bytes().to_vec(), - }], - assets: vec![ - AssetBinding { - asset_id: params.base_asset_id, - relation: AssetRelationKind::OrderBase, - role: 0, - }, - AssetBinding { - asset_id: params.quote_asset_id, - relation: AssetRelationKind::OrderQuote, - role: 1, - }, - ], - outpoints: vec![TrackedOutpoint { - role: 0, - outpoint: contract_id.creation_anchor(), - }], - order_book: Some(OrderBookEntry { - market_id: parent.contract_id, - side, - direction: params.direction, - price: params.price, - creation_position: position, - remaining_base: offered_base_capacity, - }), - }; - Ok(VerifiedRegistration { - record, - creation_block_anchor: anchor, - creation_transaction, - associated_hint: Some(RecoveryHintLocation { - position, - output_index: hint_output_index, - }), - }) -} - -pub(crate) fn verify_maker_order_hint_creation_shared( - creation_transaction: Arc, - position: ChainPosition, - anchor: ChainAnchor, - policy_asset: AssetId, - hint_output_index: u32, - parent: &ContractRecord, -) -> Result { - let transaction = creation_transaction.as_ref(); - let hint_output = transaction - .output - .get(usize::try_from(hint_output_index).map_err(|_| { - RegistrationError::InvalidCreation("maker hint output index exceeds usize".to_owned()) - })?) - .ok_or_else(|| { - RegistrationError::InvalidCreation( - "maker hint output does not exist in the creation transaction".to_owned(), - ) - })?; - let hint = OrderRecoveryHint::decode( - validate_recovery_txout(hint_output, policy_asset) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?, - ) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?; - if hint.parent_market.resolve(transaction.txid()) != parent.contract_id { - return Err(RegistrationError::InvalidCreation( - "maker hint names a different parent market".to_owned(), - )); - } - let order_output_index = hint_output_index.checked_sub(1).ok_or_else(|| { - RegistrationError::InvalidCreation( - "canonical maker hint must immediately follow its order output".to_owned(), - ) - })?; - let ContractParameters::BinaryMarket(parent_params) = &parent.params else { - return Err(RegistrationError::ParentIsNotMarket); - }; - let base_asset_id = match hint.side { - deadcat_types::OrderSide::Yes => parent_params.yes_token_asset_id, - deadcat_types::OrderSide::No => parent_params.no_token_asset_id, - }; - let creation_prevouts = transaction - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let params = deadcat_types::MakerOrderParams { - base_asset_id, - quote_asset_id: parent_params.collateral_asset_id, - price: hint.price, - min_active_base: hint.min_active_base, - direction: hint.direction, - instance_id: derive_instance_id(&creation_prevouts, order_output_index) - .map_err(|error| RegistrationError::InvalidCreation(error.to_string()))?, - maker_pubkey: hint.maker_pubkey, - }; - let contract_id = ContractId::new(OutPoint::new(transaction.txid(), order_output_index)); - verify_maker_order_creation_shared( - creation_transaction, - position, - anchor, - policy_asset, - contract_id, - parent, - hint.side, - params, - ) -} - fn unique_defining_input( transaction: &Transaction, expected_asset: AssetId, @@ -1026,10 +643,6 @@ pub enum RegistrationError { WrongChain, #[error("invalid contract package: {0}")] InvalidPackage(String), - #[error("parent market is not registered")] - ParentMarketNotFound, - #[error("parent contract is not a binary market")] - ParentIsNotMarket, #[error("contract compilation failed: {0}")] Compilation(String), #[error("invalid contract creation: {0}")] @@ -1040,9 +653,7 @@ pub enum RegistrationError { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use deadcat_contracts::maker_order::CompiledMakerOrder; - use deadcat_contracts::recovery::{OrderRecoveryHint, recovery_txout}; - use deadcat_types::{OrderDirection, OrderSide}; + use deadcat_contracts::recovery::recovery_txout; use elements::confidential::{Asset, Nonce, Value}; use elements::hashes::Hash as _; use elements::{ @@ -1425,142 +1036,6 @@ mod tests { ); } - #[tokio::test] - async fn reversed_same_transaction_package_registers_market_and_order_atomically() { - let policy_asset = asset(0x94); - let (mut transaction, market_params, position, creation_anchor) = - standalone_market(policy_asset); - let creation_prevouts = transaction - .input - .iter() - .map(|input| input.previous_output) - .collect::>(); - let order_params = |order_vout| deadcat_types::MakerOrderParams { - base_asset_id: market_params.yes_token_asset_id, - quote_asset_id: market_params.collateral_asset_id, - price: 100, - min_active_base: 10, - direction: OrderDirection::SellQuote, - instance_id: derive_instance_id(&creation_prevouts, order_vout).expect("instance"), - maker_pubkey: VALID_XONLY, - }; - let first_params = order_params(3); - let second_params = order_params(5); - for params in [first_params, second_params] { - let compiled_order = CompiledMakerOrder::new(params).expect("compile order"); - transaction.output.push(TxOut { - asset: Asset::Explicit(params.quote_asset_id), - value: Value::Explicit(2_000), - nonce: Nonce::Null, - script_pubkey: compiled_order.script_pubkey().clone(), - witness: TxOutWitness::default(), - }); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 0x1234, - parent_market: deadcat_contracts::recovery::ParentMarketRef::SameTransaction { - vout: 0, - }, - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - }; - transaction - .output - .push(recovery_txout(policy_asset, &hint.encode()).expect("canonical order hint")); - } - let market_id = ContractId::new(OutPoint::new(transaction.txid(), 0)); - let first_order_id = ContractId::new(OutPoint::new(transaction.txid(), 3)); - let second_order_id = ContractId::new(OutPoint::new(transaction.txid(), 5)); - let package = ContractPackage { - format_version: CONTRACT_PACKAGE_FORMAT_VERSION, - chain: deadcat_types::ChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash: BlockHash::all_zeros(), - }, - roots: vec![first_order_id, second_order_id], - // Deliberately child-first: package order is not dependency order. - declarations: vec![ - ContractDeclaration { - contract_id: first_order_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: OrderSide::Yes, - params: first_params, - }, - }, - ContractDeclaration { - contract_id: second_order_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: OrderSide::Yes, - params: second_params, - }, - }, - ContractDeclaration { - contract_id: market_id, - descriptor: ContractDescriptor::BinaryMarketV1 { - params: market_params, - }, - }, - ], - }; - let source = RegistrationSource::new( - transaction, - TransactionStatus::Confirmed { - anchor: creation_anchor, - tx_index: position.tx_index, - }, - ); - let directory = tempfile::tempdir().expect("tempdir"); - let store = Store::open(directory.path().join("package.redb")).expect("open store"); - store - .initialize_tip(creation_anchor) - .expect("initialize tip"); - let verifier = RegistrationVerifier::new( - &source, - &store, - LiquidNetwork::ElementsRegtest, - BlockHash::all_zeros(), - policy_asset, - ); - - let registrations = verifier - .verify_and_register_package(&package) - .await - .expect("register composed package"); - assert_eq!(source.transaction_calls.load(Ordering::Relaxed), 1); - assert_eq!(source.status_calls.load(Ordering::Relaxed), 1); - assert_eq!(registrations.len(), 3); - assert_eq!(registrations[0].0.record.contract_id, first_order_id); - assert_eq!(registrations[1].0.record.contract_id, second_order_id); - assert_eq!(registrations[2].0.record.contract_id, market_id); - assert!(Arc::ptr_eq( - ®istrations[0].0.creation_transaction, - ®istrations[1].0.creation_transaction, - )); - assert!(Arc::ptr_eq( - ®istrations[1].0.creation_transaction, - ®istrations[2].0.creation_transaction, - )); - assert!(registrations.iter().all(|(_, inserted)| *inserted)); - assert!(store.contract(market_id).expect("market lookup").is_some()); - assert!( - store - .contract(first_order_id) - .expect("first order lookup") - .is_some() - ); - assert!( - store - .contract(second_order_id) - .expect("second order lookup") - .is_some() - ); - assert_eq!(store.pending_backfills().expect("backfills").len(), 3); - } - #[tokio::test] async fn reversed_same_transaction_package_registers_two_markets_atomically() { let policy_asset = asset(0x90); @@ -1813,14 +1288,14 @@ mod tests { if message.contains("root must have a declaration") )); - let mut unreachable = package.clone(); - unreachable.declarations.push(ContractDeclaration { + let mut unrooted = package.clone(); + unrooted.declarations.push(ContractDeclaration { contract_id: ContractId::new(OutPoint::new(contract_id.txid(), 8)), descriptor: ContractDescriptor::BinaryMarketV1 { params }, }); assert!(matches!( - verifier.verify_package(&unreachable).await, - Err(RegistrationError::InvalidPackage(message)) if message.contains("unreachable") + verifier.verify_package(&unrooted).await, + Err(RegistrationError::InvalidPackage(message)) if message.contains("must be a root") )); let mut oversized = package; @@ -1934,121 +1409,4 @@ mod tests { Err(RegistrationError::InvalidCreation(message)) if message.contains("found 0") )); } - - #[test] - fn maker_order_registration_derives_capacity_and_parent_relation() { - let policy_asset = asset(0x97); - let (market_tx, _, market_position, market_anchor) = standalone_market(policy_asset); - let parent = verify_binary_market_creation( - &market_tx, - market_position, - market_anchor, - LiquidNetwork::ElementsRegtest, - policy_asset, - None, - None, - ) - .expect("parent") - .record; - let ContractParameters::BinaryMarket(parent_params) = parent.params else { - panic!("market params") - }; - let input = TxIn::default(); - let params = deadcat_types::MakerOrderParams { - base_asset_id: parent_params.yes_token_asset_id, - quote_asset_id: parent_params.collateral_asset_id, - price: 100, - min_active_base: 10, - direction: OrderDirection::SellQuote, - instance_id: derive_instance_id(&[input.previous_output], 0).expect("instance"), - maker_pubkey: VALID_XONLY, - }; - let compiled = CompiledMakerOrder::new(params).expect("order compile"); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 0x1234, - parent_market: parent.contract_id.into(), - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - } - .encode(); - let transaction = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: vec![input], - output: vec![ - TxOut { - asset: Asset::Explicit(params.quote_asset_id), - value: Value::Explicit(2_000), - nonce: Nonce::Null, - script_pubkey: compiled.script_pubkey().clone(), - witness: TxOutWitness::default(), - }, - recovery_txout(policy_asset, &hint).expect("hint output"), - ], - }; - let position = ChainPosition { - block_height: 101, - tx_index: 3, - }; - let verified = verify_maker_order_creation( - &transaction, - position, - anchor(101, 0x56), - policy_asset, - ContractId::new(OutPoint::new(transaction.txid(), 0)), - &parent, - OrderSide::Yes, - params, - ) - .expect("verify order"); - assert_eq!( - verified.record.state, - ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: 20, - total_filled_base: 0, - }) - ); - assert_eq!( - verified.associated_hint, - Some(RecoveryHintLocation { - position, - output_index: 1, - }) - ); - - // Copying an otherwise valid order output and hint to another vout is - // noncanonical: the second vout requires a different instance ID and - // therefore a different covenant/payment script pair. - let mut duplicated = transaction; - duplicated.output.extend_from_within(..2); - let first = verify_maker_order_creation( - &duplicated, - position, - anchor(101, 0x56), - policy_asset, - ContractId::new(OutPoint::new(duplicated.txid(), 0)), - &parent, - OrderSide::Yes, - params, - ) - .expect("first canonical order"); - assert_eq!(first.record.params, ContractParameters::MakerOrder(params)); - assert!(matches!( - verify_maker_order_creation( - &duplicated, - position, - anchor(101, 0x56), - policy_asset, - ContractId::new(OutPoint::new(duplicated.txid(), 2)), - &parent, - OrderSide::Yes, - params, - ), - Err(RegistrationError::InvalidCreation(message)) - if message.contains("instance ID") - )); - } } diff --git a/crates/deadcat-node/src/rpc_handler.rs b/crates/deadcat-node/src/rpc_handler.rs index 60405f0..38fcb27 100644 --- a/crates/deadcat-node/src/rpc_handler.rs +++ b/crates/deadcat-node/src/rpc_handler.rs @@ -1,6 +1,5 @@ //! Iroh RPC application handler over canonical store and chain evidence. -use std::cmp::Reverse; use std::sync::Arc; use std::time::Duration; @@ -8,10 +7,9 @@ use deadcat_iroh::{ClientId, RequestHandler, Subscription, SubscriptionItem}; use deadcat_rpc::{ AssetLookup, AssetRelation, AssetRelationKind, BackendKind, Capability, ContractHistoryPage, ContractPage, ContractView, Event, EventEnvelope, EventFilter, FeeRateEstimate, HistoryEntry, - MarketSnapshot, NodeInfo, OrderBookLevel, OrderBookSnapshot, PackageRegistrationReceipt, - PageRequest, RecoveryHintPage, RecoveryHintRecord, RegistrationReceipt, Request, Response, - RouteLeg, RouteSuggestion, RpcError, RpcErrorCode, SnapshotCursor, SnapshotMetadata, - SubscriptionEnd, TransactionEvidence, + MarketSnapshot, NodeInfo, PackageRegistrationReceipt, PageRequest, RecoveryHintPage, + RecoveryHintRecord, RegistrationReceipt, Request, Response, RpcError, RpcErrorCode, + SnapshotCursor, SnapshotMetadata, SubscriptionEnd, TransactionEvidence, }; use deadcat_types::{ ChainPosition, ContractKind, ContractSyncState, DiscoveryCoverage, DiscoveryMode, @@ -160,13 +158,6 @@ where } Request::ListMarkets { page } => self.list_markets(&page), Request::GetMarketSnapshot { market_id } => self.market_snapshot(market_id), - Request::ListOrders { - market_id, - side, - direction, - page, - } => self.list_orders(market_id, side, direction, &page), - Request::GetOrderBook { market_id } => self.order_book(market_id), Request::GetContractHistory { contract_id, after, @@ -349,13 +340,6 @@ where }, }) } - Request::SuggestRoute { - market_id, - side, - direction, - base_amount, - max_orders, - } => self.suggest_route(market_id, side, direction, base_amount, max_orders), Request::SubscribeEvents { .. } => Err(RpcError::new( RpcErrorCode::InvalidTransaction, "subscription request used on unary handler", @@ -390,7 +374,6 @@ where }; let mut capabilities = vec![ Capability::BinaryMarketV1, - Capability::MakerOrderV1, match self.config.backend { BackendKind::ElementsRpc => Capability::ElementsRpc, BackendKind::Esplora => Capability::Esplora, @@ -399,7 +382,6 @@ where Capability::BroadcastSignedTransaction, Capability::EvidenceQueries, Capability::DurableSubscriptions, - Capability::AdvisoryRouting, ]; if discovery.mode == DiscoveryMode::FullHintScan { capabilities.push(Capability::FullHintScan); @@ -456,24 +438,9 @@ where "market registration is still catching up", )); } - let params = match &record.params { - ContractParameters::BinaryMarket(params) => *params, - ContractParameters::MakerOrder(_) => { - return Err(RpcError::new( - RpcErrorCode::BackendUnavailable, - "stored market parameters are corrupt", - )); - } - }; - let state = match record.state { - ContractState::BinaryMarket(state) => state, - ContractState::MakerOrder(_) => { - return Err(RpcError::new( - RpcErrorCode::BackendUnavailable, - "stored market state is corrupt", - )); - } - }; + let ContractParameters::BinaryMarket(params) = &record.params; + let params = *params; + let ContractState::BinaryMarket(state) = record.state; let live_outpoints = record .outpoints .iter() @@ -493,205 +460,6 @@ where }) } - fn list_orders( - &self, - market_id: deadcat_types::ContractId, - side: Option, - direction: Option, - page: &PageRequest, - ) -> Result { - validate_limit(page.limit)?; - let cursor = page.cursor.as_ref().map(store_snapshot_cursor); - let page = self - .store - .ready_orders( - market_id, - side, - direction, - cursor.as_ref(), - usize::from(page.limit), - ) - .map_err(store_error)?; - Ok(Response::Orders { - page: ContractPage { - snapshot: snapshot_metadata(page.snapshot), - contracts: page - .items - .into_iter() - .map(|order| contract_view(order.contract)) - .collect(), - next: page.next.map(rpc_snapshot_cursor), - }, - }) - } - - fn order_book(&self, market_id: deadcat_types::ContractId) -> Result { - let (snapshot, market, orders) = self - .store - .order_book_entries(market_id) - .map_err(store_error)?; - let market = - market.ok_or_else(|| RpcError::new(RpcErrorCode::NotFound, "market not found"))?; - if market.kind != ContractKind::BinaryMarketV1 { - return Err(RpcError::new( - RpcErrorCode::InvalidTransaction, - "contract is not a binary market", - )); - } - if !matches!(market.sync_state, ContractSyncState::Ready { .. }) { - return Err(RpcError::new( - RpcErrorCode::NotSynced, - "market registration is still catching up", - )); - } - let mut asks = Vec::new(); - let mut bids = Vec::new(); - for order in orders { - let level = OrderBookLevel { - contract_id: order.contract.contract_id, - side: order.entry.side, - direction: order.entry.direction, - price: order.entry.price, - remaining_base: order.entry.remaining_base, - creation_position: order.entry.creation_position, - }; - match order.entry.direction { - deadcat_types::OrderDirection::SellBase => asks.push(level), - deadcat_types::OrderDirection::SellQuote => bids.push(level), - } - } - asks.sort_by_key(order_book_ask_key); - bids.sort_by_key(order_book_bid_key); - Ok(Response::OrderBook { - book: OrderBookSnapshot { - snapshot: snapshot_metadata(snapshot), - market_id, - asks, - bids, - }, - }) - } - - fn suggest_route( - &self, - market_id: deadcat_types::ContractId, - side: deadcat_types::OrderSide, - direction: deadcat_types::OrderDirection, - base_amount: u64, - max_orders: u16, - ) -> Result { - if base_amount == 0 || max_orders == 0 || max_orders > 1_000 { - return Err(RpcError::new( - RpcErrorCode::InvalidTransaction, - "base amount must be nonzero and max_orders must be in 1..=1000", - )); - } - let (snapshot, market, mut orders) = self - .store - .order_book_entries(market_id) - .map_err(store_error)?; - let market = - market.ok_or_else(|| RpcError::new(RpcErrorCode::NotFound, "market not found"))?; - if market.kind != ContractKind::BinaryMarketV1 { - return Err(RpcError::new( - RpcErrorCode::InvalidTransaction, - "contract is not a binary market", - )); - } - if !matches!(market.sync_state, ContractSyncState::Ready { .. }) { - return Err(RpcError::new( - RpcErrorCode::NotSynced, - "market registration is still catching up", - )); - } - if !matches!( - market.state, - ContractState::BinaryMarket(deadcat_types::BinaryMarketState::Trading { .. }) - ) { - return Err(RpcError::new( - RpcErrorCode::CovenantInvariantViolation, - "official routing stops after the parent market terminates", - )); - } - orders.retain(|order| order.entry.side == side && order.entry.direction == direction); - orders.sort_by(|left, right| { - let price = match direction { - deadcat_types::OrderDirection::SellBase => left.entry.price.cmp(&right.entry.price), - deadcat_types::OrderDirection::SellQuote => { - right.entry.price.cmp(&left.entry.price) - } - }; - price - .then_with(|| { - left.entry - .creation_position - .cmp(&right.entry.creation_position) - }) - .then_with(|| { - left.contract - .contract_id - .to_fixed_key() - .cmp(&right.contract.contract_id.to_fixed_key()) - }) - }); - - let mut remaining = base_amount; - let mut total_base = 0_u64; - let mut total_quote = 0_u64; - let mut legs = Vec::new(); - for order in orders { - if remaining == 0 || legs.len() == usize::from(max_orders) { - break; - } - let ContractParameters::MakerOrder(params) = order.contract.params else { - return Err(RpcError::new( - RpcErrorCode::BackendUnavailable, - "order-book index points to non-order parameters", - )); - }; - let minimum = u64::from(params.min_active_base); - let Some(fill) = feasible_route_fill(order.entry.remaining_base, minimum, remaining) - else { - continue; - }; - let quote = fill - .checked_mul(u64::from(order.entry.price)) - .ok_or_else(|| { - RpcError::new( - RpcErrorCode::InvalidTransaction, - "route quote amount overflows u64", - ) - })?; - total_base = total_base.checked_add(fill).ok_or_else(|| { - RpcError::new( - RpcErrorCode::InvalidTransaction, - "route base amount overflows u64", - ) - })?; - total_quote = total_quote.checked_add(quote).ok_or_else(|| { - RpcError::new( - RpcErrorCode::InvalidTransaction, - "route quote amount overflows u64", - ) - })?; - remaining -= fill; - legs.push(RouteLeg { - order_id: order.contract.contract_id, - base_amount: fill, - quote_amount: quote, - }); - } - Ok(Response::Route { - route: RouteSuggestion { - snapshot: snapshot_metadata(snapshot), - market_id, - legs, - total_base, - total_quote, - }, - }) - } - fn authorize_registration(&self, supplied: Option<&str>) -> Result<(), RpcError> { if let Some(expected) = self.config.registration_bearer_token.as_deref() && supplied != Some(expected) @@ -800,7 +568,7 @@ where break; } cursor = Some(stored.cursor); - if event_matches(&stored, &filter, &store) + if event_matches(&stored, &filter) && sender .send(SubscriptionItem::Event(EventEnvelope { cursor: stored.cursor, @@ -896,72 +664,14 @@ const fn asset_relation_kind(kind: StoreAssetRelationKind) -> AssetRelationKind StoreAssetRelationKind::NoToken => AssetRelationKind::NoToken, StoreAssetRelationKind::YesReissuanceToken => AssetRelationKind::YesReissuanceToken, StoreAssetRelationKind::NoReissuanceToken => AssetRelationKind::NoReissuanceToken, - StoreAssetRelationKind::OrderBase => AssetRelationKind::OrderBase, - StoreAssetRelationKind::OrderQuote => AssetRelationKind::OrderQuote, - } -} - -fn order_book_ask_key(level: &OrderBookLevel) -> (u8, u32, ChainPosition, [u8; 36]) { - ( - side_byte(level.side), - level.price, - level.creation_position, - level.contract_id.to_fixed_key(), - ) -} - -fn order_book_bid_key(level: &OrderBookLevel) -> (u8, Reverse, ChainPosition, [u8; 36]) { - ( - side_byte(level.side), - Reverse(level.price), - level.creation_position, - level.contract_id.to_fixed_key(), - ) -} - -const fn side_byte(side: deadcat_types::OrderSide) -> u8 { - match side { - deadcat_types::OrderSide::Yes => 0, - deadcat_types::OrderSide::No => 1, - } -} - -/// Choose a covenant-valid greedy fill no larger than the caller's remaining -/// request. A route may be partial; `total_base` makes that explicit. -const fn feasible_route_fill(capacity: u64, minimum: u64, requested: u64) -> Option { - if capacity < minimum || requested < minimum { - return None; - } - if requested >= capacity { - return Some(capacity); - } - let remainder = capacity - requested; - if remainder >= minimum { - return Some(requested); - } - let largest_partial = capacity - minimum; - if largest_partial >= minimum && largest_partial <= requested { - Some(largest_partial) - } else { - None } } fn contract_view(record: ContractRecord) -> ContractView { - let parameters = match record.params { - ContractParameters::BinaryMarket(params) => { - deadcat_rpc::ContractParametersView::BinaryMarket { params } - } - ContractParameters::MakerOrder(params) => { - deadcat_rpc::ContractParametersView::MakerOrder { params } - } - }; - let state = match record.state { - ContractState::BinaryMarket(state) => { - deadcat_rpc::ContractStateView::BinaryMarket { state } - } - ContractState::MakerOrder(state) => deadcat_rpc::ContractStateView::MakerOrder { state }, - }; + let ContractParameters::BinaryMarket(params) = record.params; + let parameters = deadcat_rpc::ContractParametersView::BinaryMarket { params }; + let ContractState::BinaryMarket(state) = record.state; + let state = deadcat_rpc::ContractStateView::BinaryMarket { state }; ContractView { contract_id: record.contract_id, kind: record.kind, @@ -969,8 +679,6 @@ fn contract_view(record: ContractRecord) -> ContractView { creation_position: record.creation_position, parameters, state, - parent_market: record.parent_market, - outcome_side: record.outcome_side, live_outpoints: record .outpoints .into_iter() @@ -982,7 +690,7 @@ fn contract_view(record: ContractRecord) -> ContractView { } } -fn event_matches(event: &StoredEventEnvelope, filter: &EventFilter, store: &Store) -> bool { +fn event_matches(event: &StoredEventEnvelope, filter: &EventFilter) -> bool { match filter { EventFilter::All => true, EventFilter::Contracts { contract_ids } => match &event.event { @@ -1003,26 +711,6 @@ fn event_matches(event: &StoredEventEnvelope, filter: &EventFilter, store: &Stor .any(|contract_id| contract_ids.contains(contract_id)), StoredEvent::SyncStatusChanged { .. } => true, }, - EventFilter::MarketTree { market_id } => match &event.event { - StoredEvent::ContractRegistered { contract_id } - | StoredEvent::ContractReady { contract_id, .. } - | StoredEvent::BackfillApplied { contract_id, .. } => store - .contract(*contract_id) - .ok() - .flatten() - .is_some_and(|record| { - record.contract_id == *market_id || record.parent_market == Some(*market_id) - }), - StoredEvent::TransactionApplied { - affected_market_ids, - .. - } - | StoredEvent::ChainRolledBack { - affected_market_ids, - .. - } => affected_market_ids.contains(market_id), - StoredEvent::SyncStatusChanged { .. } => true, - }, } } @@ -1036,13 +724,11 @@ fn event_from_store(event: StoredEvent) -> Event { txid, position, affected_contract_ids, - affected_market_ids, } => Event::TransactionApplied { anchor, txid, position, affected_contract_ids, - affected_market_ids, }, StoredEvent::BackfillApplied { contract_id, @@ -1065,13 +751,11 @@ fn event_from_store(event: StoredEvent) -> Event { new_tip, orphaned_positions, affected_contract_ids, - affected_market_ids, } => Event::ChainRolledBack { old_tip, new_tip, orphaned_positions, affected_contract_ids, - affected_market_ids, }, StoredEvent::SyncStatusChanged { status } => Event::SyncStatusChanged { status }, } @@ -1118,11 +802,7 @@ fn store_error(error: StoreError) -> RpcError { StoreError::StaleSnapshotCursor { .. } | StoreError::InvalidSnapshotKey { .. } | StoreError::SnapshotScopeMismatch { .. } => RpcErrorCode::SnapshotInvalidated, - StoreError::ContractNotFound(_) | StoreError::MaterializedMarketNotFound(_) => { - RpcErrorCode::NotFound - } - StoreError::MaterializedContractIsNotMarket(_) => RpcErrorCode::InvalidTransaction, - StoreError::MaterializedMarketNotReady(_) => RpcErrorCode::NotSynced, + StoreError::ContractNotFound(_) => RpcErrorCode::NotFound, StoreError::ForkConflict { .. } => RpcErrorCode::ForkConflict, _ => RpcErrorCode::BackendUnavailable, }; @@ -1155,12 +835,10 @@ fn registration_error(error: RegistrationError) -> RpcError { | StoreError::OutpointAlreadyOwned { .. } => RpcErrorCode::InvalidRegistration, _ => RpcErrorCode::BackendUnavailable, }, - RegistrationError::ParentMarketNotFound => RpcErrorCode::NotFound, RegistrationError::UnconfirmedCreation | RegistrationError::PreActivationCreation { .. } | RegistrationError::WrongChain | RegistrationError::InvalidPackage(_) - | RegistrationError::ParentIsNotMarket | RegistrationError::Compilation(_) | RegistrationError::InvalidCreation(_) => RpcErrorCode::InvalidRegistration, }; @@ -1171,41 +849,6 @@ fn registration_error(error: RegistrationError) -> RpcError { mod tests { use super::*; - #[test] - fn route_fill_respects_fill_and_remainder_minimums() { - assert_eq!(feasible_route_fill(10, 3, 10), Some(10)); - assert_eq!(feasible_route_fill(10, 3, 4), Some(4)); - // Filling eight would leave dust, so the largest valid partial leaves - // the three-unit minimum active remainder. - assert_eq!(feasible_route_fill(10, 3, 8), Some(7)); - assert_eq!(feasible_route_fill(5, 3, 4), None); - assert_eq!(feasible_route_fill(10, 3, 2), None); - } - - #[test] - fn order_book_keys_put_best_prices_first_per_side() { - use elements::hashes::Hash as _; - - let level = |side, price, tx_byte| OrderBookLevel { - contract_id: deadcat_types::ContractId::new(elements::OutPoint::new( - elements::Txid::from_byte_array([tx_byte; 32]), - u32::from(tx_byte), - )), - side, - direction: deadcat_types::OrderDirection::SellBase, - price, - remaining_base: 1, - creation_position: ChainPosition { - block_height: 1, - tx_index: u32::from(tx_byte), - }, - }; - let low = level(deadcat_types::OrderSide::Yes, 4, 1); - let high = level(deadcat_types::OrderSide::Yes, 9, 2); - assert!(order_book_ask_key(&low) < order_book_ask_key(&high)); - assert!(order_book_bid_key(&high) < order_book_bid_key(&low)); - } - #[test] fn registration_store_conflicts_are_reported_as_invalid_registration() { let error = registration_error(RegistrationError::Store( diff --git a/crates/deadcat-node/src/store.rs b/crates/deadcat-node/src/store.rs index d7bf226..5e9c6b6 100644 --- a/crates/deadcat-node/src/store.rs +++ b/crates/deadcat-node/src/store.rs @@ -13,7 +13,7 @@ use deadcat_rpc::{RecoveryFamily, SnapshotScope, SyncStatus}; use deadcat_types::{ BinaryMarketParams, BinaryMarketState, ChainAnchor, ChainPosition, ContractDeclaration, ContractDescriptor, ContractId, ContractKind, ContractSyncState, EventCursor, LiquidNetwork, - MakerOrderParams, MakerOrderState, OrderDirection, OrderSide, RecoveryHintLocation, + RecoveryHintLocation, }; use elements::hashes::Hash as _; use elements::{AssetId, BlockHash, OutPoint, Transaction, TxOut, Txid, encode}; @@ -49,8 +49,6 @@ const CONTRACT_OUTPOINTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("contract_outpoints"); const SCRIPT_INDEX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("script_index"); const ASSET_RELATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("asset_relations"); -const MARKET_CHILDREN: TableDefinition<&[u8], &[u8]> = TableDefinition::new("market_children"); -const ORDER_BOOK: TableDefinition<&[u8], &[u8]> = TableDefinition::new("order_book"); const RECOVERY_HINTS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("recovery_hints"); const CONTRACT_HISTORY: TableDefinition<&[u8], &[u8]> = TableDefinition::new("contract_history"); const BACKFILL_PROGRESS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("backfill_progress"); @@ -161,13 +159,11 @@ pub struct ChainIdentity { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ContractParameters { BinaryMarket(BinaryMarketParams), - MakerOrder(MakerOrderParams), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ContractState { BinaryMarket(BinaryMarketState), - MakerOrder(MakerOrderState), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -177,8 +173,6 @@ pub enum AssetRelationKind { NoToken, YesReissuanceToken, NoReissuanceToken, - OrderBase, - OrderQuote, } impl AssetRelationKind { @@ -189,8 +183,6 @@ impl AssetRelationKind { Self::NoToken => 2, Self::YesReissuanceToken => 3, Self::NoReissuanceToken => 4, - Self::OrderBase => 5, - Self::OrderQuote => 6, } } } @@ -214,16 +206,6 @@ pub struct TrackedOutpoint { pub outpoint: OutPoint, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct OrderBookEntry { - pub market_id: ContractId, - pub side: OrderSide, - pub direction: OrderDirection, - pub price: u32, - pub creation_position: ChainPosition, - pub remaining_base: u64, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ContractRecord { pub contract_id: ContractId, @@ -232,12 +214,9 @@ pub struct ContractRecord { pub creation_position: ChainPosition, pub state: ContractState, pub sync_state: ContractSyncState, - pub parent_market: Option, - pub outcome_side: Option, pub scripts: Vec, pub assets: Vec, pub outpoints: Vec, - pub order_book: Option, } impl ContractRecord { @@ -248,10 +227,6 @@ impl ContractRecord { ContractParameters::BinaryMarket(_), ContractState::BinaryMarket(_), ContractKind::BinaryMarketV1 - ) | ( - ContractParameters::MakerOrder(_), - ContractState::MakerOrder(_), - ContractKind::MakerOrderV1 ) ); if !shape_valid { @@ -279,53 +254,6 @@ impl ContractRecord { )); } } - match ( - self.kind, - self.parent_market, - self.outcome_side, - self.order_book, - self.state, - &self.params, - ) { - ( - ContractKind::BinaryMarketV1, - None, - None, - None, - ContractState::BinaryMarket(_), - ContractParameters::BinaryMarket(_), - ) => {} - ( - ContractKind::MakerOrderV1, - Some(parent), - Some(side), - Some(book), - ContractState::MakerOrder(MakerOrderState::Active { remaining_base, .. }), - ContractParameters::MakerOrder(params), - ) if parent == book.market_id - && side == book.side - && remaining_base == book.remaining_base - && params.price == book.price - && params.direction == book.direction => {} - ( - ContractKind::MakerOrderV1, - Some(_), - Some(_), - None, - ContractState::MakerOrder(MakerOrderState::Consumed | MakerOrderState::Cancelled), - ContractParameters::MakerOrder(_), - ) => {} - (ContractKind::LmsrV1Reserved, _, _, _, _, _) => { - return Err(StoreError::InvalidContract( - "reserved LMSR contracts cannot be stored in v1".to_owned(), - )); - } - _ => { - return Err(StoreError::InvalidContract( - "parent/order-book metadata disagrees with contract state".to_owned(), - )); - } - } Ok(()) } } @@ -345,8 +273,6 @@ pub struct StateUpdate { pub new_state: ContractState, pub spent_outpoints: Vec, pub new_outpoints: Vec, - /// Required for an active order, absent for markets and terminal orders. - pub order_remaining_base: Option, pub transition: TransitionRecord, } @@ -515,12 +441,6 @@ pub struct MaterializedPage { pub next: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MaterializedOrder { - pub contract: ContractRecord, - pub entry: OrderBookEntry, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AssetRelationRecord { pub contract_id: ContractId, @@ -543,7 +463,6 @@ pub enum StoredEvent { txid: Txid, position: ChainPosition, affected_contract_ids: Vec, - affected_market_ids: Vec, }, BackfillApplied { contract_id: ContractId, @@ -559,7 +478,6 @@ pub enum StoredEvent { new_tip: ChainAnchor, orphaned_positions: Vec, affected_contract_ids: Vec, - affected_market_ids: Vec, }, SyncStatusChanged { status: SyncStatus, @@ -1101,124 +1019,6 @@ impl Store { materialized_page(snapshot, scope, rows, limit) } - /// Page active ready maker orders in exact order-book key order. - pub fn ready_orders( - &self, - market_id: ContractId, - side: Option, - direction: Option, - cursor: Option<&StoreSnapshotCursor>, - limit: usize, - ) -> Result, StoreError> { - validate_query_limit(limit)?; - let read = self.database.begin_read()?; - let snapshot = snapshot_from_read(&read)?; - let scope = SnapshotScope::Orders { - market_id, - side, - direction, - }; - validate_snapshot_cursor(cursor, snapshot, scope, 86)?; - let after = cursor.map(|cursor| cursor.after_key.as_slice()); - let contracts = read.open_table(CONTRACTS)?; - let mut market = contracts - .get(market_id.to_fixed_key().as_slice())? - .map(|value| decode_record::(value.value())) - .transpose()? - .ok_or(StoreError::MaterializedMarketNotFound(market_id))?; - if market.kind != ContractKind::BinaryMarketV1 { - return Err(StoreError::MaterializedContractIsNotMarket(market_id)); - } - if !matches!(market.sync_state, ContractSyncState::Ready { .. }) { - return Err(StoreError::MaterializedMarketNotReady(market_id)); - } - normalize_ready_anchor(&mut market, snapshot.as_of); - let table = read.open_table(ORDER_BOOK)?; - let mut rows = Vec::new(); - for entry in table.iter()? { - let (key, value) = entry?; - let key = key.value(); - if after.is_some_and(|after| key <= after) { - continue; - } - let entry: OrderBookEntry = decode_record(value.value())?; - if entry.market_id != market_id - || side.is_some_and(|side| entry.side != side) - || direction.is_some_and(|direction| entry.direction != direction) - { - continue; - } - let key_array: [u8; 86] = key - .try_into() - .map_err(|_| StoreError::CorruptIndexKey("order_book"))?; - let contract_id = decode_contract_key(&key_array[50..])?; - let Some(contract) = contracts.get(contract_id.to_fixed_key().as_slice())? else { - return Err(StoreError::CorruptMaterializedIndex("order_book")); - }; - let mut contract: ContractRecord = decode_record(contract.value())?; - if !matches!(contract.sync_state, ContractSyncState::Ready { .. }) { - continue; - } - normalize_ready_anchor(&mut contract, snapshot.as_of); - rows.push((key.to_vec(), MaterializedOrder { contract, entry })); - if rows.len() > limit { - break; - } - } - materialized_page(snapshot, scope, rows, limit) - } - - /// Return the complete ready book in deterministic key order. Callers can - /// split asks/bids without rebuilding contract state. - pub fn order_book_entries( - &self, - market_id: ContractId, - ) -> Result< - ( - StoreSnapshotMetadata, - Option, - Vec, - ), - StoreError, - > { - let read = self.database.begin_read()?; - let snapshot = snapshot_from_read(&read)?; - let contracts = read.open_table(CONTRACTS)?; - let mut market = contracts - .get(market_id.to_fixed_key().as_slice())? - .map(|value| decode_record(value.value())) - .transpose()?; - if let Some(market) = market.as_mut() { - normalize_ready_anchor(market, snapshot.as_of); - } - let table = read.open_table(ORDER_BOOK)?; - let mut orders = Vec::new(); - for entry in table.iter()? { - let (key, value) = entry?; - let book: OrderBookEntry = decode_record(value.value())?; - if book.market_id != market_id { - continue; - } - let key: [u8; 86] = key - .value() - .try_into() - .map_err(|_| StoreError::CorruptIndexKey("order_book"))?; - let contract_id = decode_contract_key(&key[50..])?; - let Some(contract) = contracts.get(contract_id.to_fixed_key().as_slice())? else { - return Err(StoreError::CorruptMaterializedIndex("order_book")); - }; - let mut contract: ContractRecord = decode_record(contract.value())?; - if matches!(contract.sync_state, ContractSyncState::Ready { .. }) { - normalize_ready_anchor(&mut contract, snapshot.as_of); - orders.push(MaterializedOrder { - contract, - entry: book, - }); - } - } - Ok((snapshot, market, orders)) - } - /// Page public recovery hints by canonical chain/output location. A /// continuation is valid only while both the exact indexed tip and durable /// event watermark still match the snapshot that produced it. @@ -2028,7 +1828,6 @@ impl Store { let mut current_tip = old_tip; let mut orphaned_positions = Vec::new(); let mut affected_contract_ids = Vec::new(); - let mut affected_market_ids = Vec::new(); for height in (ancestor.height + 1..=old_tip.height).rev() { let undo: UndoBlock = read_fixed_from_write(&write, UNDO_BLOCKS, &height.to_be_bytes())? @@ -2036,7 +1835,6 @@ impl Store { for change in undo.contract_changes.iter().rev() { if let Some(current) = read_contract_from_write(&write, change.contract_id)? { affected_contract_ids.push(change.contract_id); - collect_market_id(¤t, &mut affected_market_ids); remove_contract(&write, ¤t)?; } if let Some(before) = &change.before { @@ -2088,7 +1886,6 @@ impl Store { }); } sort_dedup_contracts(&mut affected_contract_ids); - sort_dedup_contracts(&mut affected_market_ids); orphaned_positions.sort(); write_tip(&write, ancestor)?; #[cfg(test)] @@ -2101,7 +1898,6 @@ impl Store { new_tip: ancestor, orphaned_positions: orphaned_positions.clone(), affected_contract_ids, - affected_market_ids, }; let high = append_event(&write, event)?; #[cfg(test)] @@ -2406,11 +2202,7 @@ fn associate_registration_hint( else { return Ok(()); }; - let expected_family = match record.kind { - ContractKind::BinaryMarketV1 => RecoveryFamily::BinaryMarketV1, - ContractKind::MakerOrderV1 => RecoveryFamily::MakerOrderV1, - ContractKind::LmsrV1Reserved => return Ok(()), - }; + let expected_family = RecoveryFamily::BinaryMarketV1; if hint.location == location && hint.creation_txid == record.contract_id.txid() && hint.family == expected_family @@ -2423,31 +2215,8 @@ fn associate_registration_hint( } fn declaration_from_record(record: &ContractRecord) -> Result { - let descriptor = match ( - record.kind, - &record.params, - record.parent_market, - record.outcome_side, - ) { - (ContractKind::BinaryMarketV1, ContractParameters::BinaryMarket(params), None, None) => { - ContractDescriptor::BinaryMarketV1 { params: *params } - } - ( - ContractKind::MakerOrderV1, - ContractParameters::MakerOrder(params), - Some(parent_market), - Some(side), - ) => ContractDescriptor::MakerOrderV1 { - parent_market, - side, - params: *params, - }, - _ => { - return Err(StoreError::InvalidContract( - "contract cannot be normalized into a retained declaration".to_owned(), - )); - } - }; + let ContractParameters::BinaryMarket(params) = &record.params; + let descriptor = ContractDescriptor::BinaryMarketV1 { params: *params }; Ok(ContractDeclaration { contract_id: record.contract_id, descriptor, @@ -2459,8 +2228,6 @@ fn registration_identity_matches(existing: &ContractRecord, registration: &Contr && existing.kind == registration.kind && existing.params == registration.params && existing.creation_position == registration.creation_position - && existing.parent_market == registration.parent_market - && existing.outcome_side == registration.outcome_side && existing.scripts == registration.scripts && existing.assets == registration.assets } @@ -2667,7 +2434,6 @@ fn apply_backfill_transaction( let mut after = before; after.state = update.new_state; after.outpoints.clone_from(&update.new_outpoints); - update_order_book(&mut after, update.order_remaining_base)?; after.validate()?; insert_contract(write, &after)?; @@ -2801,10 +2567,6 @@ fn apply_chain_transaction( .iter() .map(|contract| contract.contract_id) .collect::>(); - let mut markets = Vec::new(); - for contract in &delta.created_contracts { - collect_market_id(contract, &mut markets); - } for update in &delta.state_updates { let before = read_contract_from_write(write, update.contract_id)? .ok_or(StoreError::ContractNotFound(update.contract_id))?; @@ -2823,7 +2585,6 @@ fn apply_chain_transaction( let mut after = before.clone(); after.state = update.new_state; after.outpoints.clone_from(&update.new_outpoints); - update_order_book(&mut after, update.order_remaining_base)?; after.validate()?; insert_contract(write, &after)?; let history = StoredHistoryEntry { @@ -2845,11 +2606,9 @@ fn apply_chain_transaction( write_fixed(write, CONTRACT_HISTORY, &history_key, &history)?; undo.history_keys.push((update.contract_id, delta.position)); affected.push(update.contract_id); - collect_market_id(&after, &mut markets); } sort_dedup_contracts(&mut affected); - sort_dedup_contracts(&mut markets); let stored = StoredTransaction { position: delta.position, block_hash: delta.block_hash, @@ -2884,7 +2643,6 @@ fn apply_chain_transaction( txid: delta.txid, position: delta.position, affected_contract_ids: affected, - affected_market_ids: markets, }, )?; Ok(()) @@ -3079,35 +2837,6 @@ fn validate_new_outpoints( Ok(()) } -fn update_order_book( - record: &mut ContractRecord, - remaining: Option, -) -> Result<(), StoreError> { - match (record.state, remaining) { - (ContractState::BinaryMarket(_), None) if record.order_book.is_none() => Ok(()), - ( - ContractState::MakerOrder(MakerOrderState::Active { remaining_base, .. }), - Some(supplied), - ) if remaining_base == supplied => { - let book = record.order_book.as_mut().ok_or_else(|| { - StoreError::InvalidTransition("active order has no order-book metadata".to_owned()) - })?; - book.remaining_base = supplied; - Ok(()) - } - ( - ContractState::MakerOrder(MakerOrderState::Consumed | MakerOrderState::Cancelled), - None, - ) => { - record.order_book = None; - Ok(()) - } - _ => Err(StoreError::InvalidTransition( - "order-book capacity disagrees with new contract state".to_owned(), - )), - } -} - fn insert_recovery_hint( write: &WriteTransaction, hint: &RecoveryHintDelta, @@ -3176,26 +2905,6 @@ fn insert_contract(write: &WriteTransaction, record: &ContractRecord) -> Result< asset, )?; } - if let Some(parent) = record.parent_market { - write_fixed( - write, - MARKET_CHILDREN, - &market_child_key( - parent, - record.contract_id, - record.outcome_side.expect("validated maker order side"), - ), - &record.contract_id, - )?; - } - if let Some(order) = record.order_book { - write_fixed( - write, - ORDER_BOOK, - &order_key(record.contract_id, order), - &order, - )?; - } write_fixed(write, CONTRACTS, &contract_key, record) } @@ -3224,35 +2933,9 @@ fn remove_contract(write: &WriteTransaction, record: &ContractRecord) -> Result< &asset_key(record.contract_id, *asset), )?; } - if let Some(parent) = record.parent_market { - remove_fixed( - write, - MARKET_CHILDREN, - &market_child_key( - parent, - record.contract_id, - record.outcome_side.expect("validated maker order side"), - ), - )?; - } - if let Some(order) = record.order_book { - remove_fixed(write, ORDER_BOOK, &order_key(record.contract_id, order))?; - } remove_fixed(write, CONTRACTS, &record.contract_id.to_fixed_key()) } -fn collect_market_id(record: &ContractRecord, output: &mut Vec) { - match record.kind { - ContractKind::BinaryMarketV1 => output.push(record.contract_id), - ContractKind::MakerOrderV1 => { - if let Some(parent) = record.parent_market { - output.push(parent); - } - } - ContractKind::LmsrV1Reserved => {} - } -} - fn contract_is_live(state: ContractState) -> bool { match state { ContractState::BinaryMarket(BinaryMarketState::Trading { .. }) => true, @@ -3267,8 +2950,6 @@ fn contract_is_live(state: ContractState) -> bool { collateral_unredeemed, }, ) => collateral_unredeemed != 0, - ContractState::MakerOrder(MakerOrderState::Active { .. }) => true, - ContractState::MakerOrder(MakerOrderState::Consumed | MakerOrderState::Cancelled) => false, } } @@ -3402,8 +3083,6 @@ fn create_tables(write: &WriteTransaction) -> Result<(), StoreError> { drop(write.open_table(CONTRACT_OUTPOINTS)?); drop(write.open_table(SCRIPT_INDEX)?); drop(write.open_table(ASSET_RELATIONS)?); - drop(write.open_table(MARKET_CHILDREN)?); - drop(write.open_table(ORDER_BOOK)?); drop(write.open_table(RECOVERY_HINTS)?); drop(write.open_table(CONTRACT_HISTORY)?); drop(write.open_table(BACKFILL_PROGRESS)?); @@ -3421,8 +3100,6 @@ fn clear_chain_tables(write: &WriteTransaction) -> Result<(), StoreError> { write.open_table(CONTRACT_OUTPOINTS)?.retain(|_, _| false)?; write.open_table(SCRIPT_INDEX)?.retain(|_, _| false)?; write.open_table(ASSET_RELATIONS)?.retain(|_, _| false)?; - write.open_table(MARKET_CHILDREN)?.retain(|_, _| false)?; - write.open_table(ORDER_BOOK)?.retain(|_, _| false)?; write.open_table(RECOVERY_HINTS)?.retain(|_, _| false)?; write.open_table(CONTRACT_HISTORY)?.retain(|_, _| false)?; write.open_table(BACKFILL_PROGRESS)?.retain(|_, _| false)?; @@ -3697,7 +3374,11 @@ fn decode_record(bytes: &[u8]) -> Result { actual: version, }); } - Ok(postcard::from_bytes(payload)?) + let (value, trailing) = postcard::take_from_bytes(payload)?; + if !trailing.is_empty() { + return Err(StoreError::TrailingRecordBytes(trailing.len())); + } + Ok(value) } fn digest(value: &T) -> Result<[u8; 32], StoreError> { @@ -3780,32 +3461,6 @@ fn asset_key(contract_id: ContractId, binding: AssetBinding) -> [u8; 70] { key } -fn market_child_key(parent: ContractId, child: ContractId, side: OrderSide) -> [u8; 74] { - let mut key = [0_u8; 74]; - key[..36].copy_from_slice(&parent.to_fixed_key()); - key[36] = match side { - OrderSide::Yes => 0, - OrderSide::No => 1, - }; - key[37] = 0; // v1 child kind: maker order - key[38..].copy_from_slice(&child.to_fixed_key()); - key -} - -fn order_key(contract_id: ContractId, order: OrderBookEntry) -> [u8; 86] { - let mut key = [0_u8; 86]; - key[..36].copy_from_slice(&order.market_id.to_fixed_key()); - key[36] = match order.side { - OrderSide::Yes => 0, - OrderSide::No => 1, - }; - key[37] = order.direction.protocol_byte(); - key[38..42].copy_from_slice(&order.price.to_be_bytes()); - key[42..50].copy_from_slice(&order.creation_position.to_fixed_key()); - key[50..].copy_from_slice(&contract_id.to_fixed_key()); - key -} - #[derive(Debug, Error)] pub enum StoreError { #[cfg(test)] @@ -3839,18 +3494,12 @@ pub enum StoreError { CorruptMetadata(&'static str), #[error("persisted index key has an invalid encoding: {0}")] CorruptIndexKey(&'static str), - #[error("materialized index points to missing state: {0}")] - CorruptMaterializedIndex(&'static str), - #[error("materialized market was not found: {0:?}")] - MaterializedMarketNotFound(ContractId), - #[error("materialized contract is not a binary market: {0:?}")] - MaterializedContractIsNotMarket(ContractId), - #[error("materialized market is not ready: {0:?}")] - MaterializedMarketNotReady(ContractId), #[error("persisted record is empty")] EmptyRecord, #[error("record version mismatch: expected {expected}, found {actual}")] RecordVersionMismatch { expected: u8, actual: u8 }, + #[error("persisted record has {0} trailing bytes from an incompatible shape")] + TrailingRecordBytes(usize), #[error("network policy asset validation failed: {0}")] NetworkPolicy(#[from] PolicyAssetError), #[error("chain identity mismatch: database has {expected:?}, requested {actual:?}")] @@ -4000,6 +3649,22 @@ mod tests { use super::*; use elements::{LockTime, OutPoint, TxIn}; + #[derive(Serialize)] + struct LegacyMarketRecord { + contract_id: ContractId, + kind: ContractKind, + params: ContractParameters, + creation_position: ChainPosition, + state: ContractState, + sync_state: ContractSyncState, + parent_market: Option, + outcome_side: Option<()>, + scripts: Vec, + assets: Vec, + outpoints: Vec, + order_book: Option<()>, + } + fn block_hash(byte: u8) -> BlockHash { BlockHash::from_byte_array([byte; 32]) } @@ -4062,8 +3727,6 @@ mod tests { creation_position, state: ContractState::BinaryMarket(BinaryMarketState::Trading { outstanding_pairs }), sync_state: ContractSyncState::Ready { synced_through }, - parent_market: None, - outcome_side: None, scripts: vec![ScriptBinding { role: 0, script_pubkey: vec![marker, 0x51], @@ -4077,7 +3740,6 @@ mod tests { role: 0, outpoint: OutPoint::new(txid, vout), }], - order_book: None, } } @@ -4102,7 +3764,6 @@ mod tests { role: 0, outpoint: OutPoint::new(spending_txid, vout), }], - order_remaining_base: None, transition: TransitionRecord { kind: 1, payload: new_pairs.to_be_bytes().to_vec(), @@ -4144,6 +3805,41 @@ mod tests { (dir, path, store) } + #[test] + fn record_decoder_rejects_the_trailing_fields_from_alpha_market_records() { + let current = market_record( + 0x33, + ChainPosition { + block_height: 1, + tx_index: 0, + }, + Txid::from_byte_array([0x44; 32]), + 0, + 0, + anchor(1), + ); + let legacy = LegacyMarketRecord { + contract_id: current.contract_id, + kind: current.kind, + params: current.params, + creation_position: current.creation_position, + state: current.state, + sync_state: current.sync_state, + parent_market: None, + outcome_side: None, + scripts: Vec::new(), + assets: Vec::new(), + outpoints: Vec::new(), + order_book: None, + }; + + let encoded = encode_record(&legacy).expect("legacy record"); + assert!(matches!( + decode_record::(&encoded), + Err(StoreError::TrailingRecordBytes(_)) + )); + } + #[test] fn schema_chain_identity_epoch_and_state_survive_reopen() { let (dir, path, store) = initialized_store(); @@ -5134,12 +4830,12 @@ mod tests { recovery_hints: vec![RecoveryHintDelta { location: mismatched_location, creation_txid: mismatched_tx.txid(), - family: RecoveryFamily::MakerOrderV1, + family: RecoveryFamily::BinaryMarketV1, payload: vec![0x20, 0x01], associated_contract: None, }], }) - .expect("index mismatched-family hint"); + .expect("index mismatched-transaction hint"); let mut missing = market_record(0x69, missing_position, missing_tx.txid(), 0, 0, anchor(1)); missing.sync_state = ContractSyncState::CatchingUp { @@ -5172,7 +4868,7 @@ mod tests { RegistrationEvidence { anchor: anchor(1), transaction: Arc::new(mismatched_tx), - associated_hint: Some(mismatched_location), + associated_hint: Some(missing_location), }, ), ]) @@ -5271,10 +4967,8 @@ mod tests { synced_through: anchor(2), }; assert_eq!(contract, Some(expected_market)); - let collateral = match market_a.params { - ContractParameters::BinaryMarket(params) => params.collateral_asset_id, - ContractParameters::MakerOrder(_) => unreachable!(), - }; + let ContractParameters::BinaryMarket(params) = market_a.params; + let collateral = params.collateral_asset_id; assert!( store .asset_relations(collateral) diff --git a/crates/deadcat-node/src/store/assurance_tests.rs b/crates/deadcat-node/src/store/assurance_tests.rs index 0393e6d..bdbcd8e 100644 --- a/crates/deadcat-node/src/store/assurance_tests.rs +++ b/crates/deadcat-node/src/store/assurance_tests.rs @@ -6,7 +6,7 @@ //! generated operation and after process-style reopen boundaries. //! //! The seeded generator is intentionally bounded to ready, live binary-market -//! trading state in the canonical block path. Maker-order, terminal-state, +//! trading state in the canonical block path. Terminal-state, //! retained-registration, and backfill semantics have targeted tests elsewhere; //! the failpoint fixtures below touch recovery, retained, and backfill rows only //! where needed to exercise a real mutation boundary. @@ -138,8 +138,6 @@ fn market_record( outstanding_pairs: 0, }), sync_state: ContractSyncState::Ready { synced_through }, - parent_market: None, - outcome_side: None, scripts: vec![ScriptBinding { role: 0, script_pubkey: vec![marker, 0x51], @@ -153,7 +151,6 @@ fn market_record( role: 0, outpoint: OutPoint::new(txid, vout), }], - order_book: None, } } @@ -197,8 +194,6 @@ struct DatabaseInventory { contract_outpoints: Vec, script_index: Vec, asset_relations: Vec, - market_children: Vec, - order_book: Vec, recovery_hints: Vec, contract_history: Vec, backfill_progress: Vec, @@ -244,8 +239,6 @@ fn database_inventory(store: &Store) -> DatabaseInventory { contract_outpoints: byte_rows(&read, CONTRACT_OUTPOINTS), script_index: byte_rows(&read, SCRIPT_INDEX), asset_relations: byte_rows(&read, ASSET_RELATIONS), - market_children: byte_rows(&read, MARKET_CHILDREN), - order_book: byte_rows(&read, ORDER_BOOK), recovery_hints: byte_rows(&read, RECOVERY_HINTS), contract_history: byte_rows(&read, CONTRACT_HISTORY), backfill_progress: byte_rows(&read, BACKFILL_PROGRESS), @@ -399,8 +392,7 @@ impl ReferenceModel { anchor: delta.anchor, txid: transaction.txid, position: transaction.position, - affected_contract_ids: affected.clone(), - affected_market_ids: affected, + affected_contract_ids: affected, }); } assert!( @@ -463,8 +455,7 @@ impl ReferenceModel { old_tip, new_tip: self.tip, orphaned_positions, - affected_contract_ids: affected_contract_ids.clone(), - affected_market_ids: affected_contract_ids, + affected_contract_ids, }); self.tip } @@ -569,8 +560,6 @@ fn model_asset_relation_tag(relation: AssetRelationKind) -> u8 { AssetRelationKind::NoToken => 2, AssetRelationKind::YesReissuanceToken => 3, AssetRelationKind::NoReissuanceToken => 4, - AssetRelationKind::OrderBase => 5, - AssetRelationKind::OrderQuote => 6, } } @@ -742,8 +731,6 @@ fn model_inventory(model: &ReferenceModel) -> DatabaseInventory { contract_outpoints: sorted(contract_outpoints), script_index: sorted(script_index), asset_relations: sorted(asset_relations), - market_children: Vec::new(), - order_book: Vec::new(), recovery_hints: Vec::new(), contract_history: sorted(contract_history), backfill_progress: Vec::new(), @@ -957,7 +944,6 @@ fn generated_block( u32::try_from(vout).expect("small generated vout"), ), }], - order_remaining_base: None, transition: TransitionRecord { kind: 1, payload: new_pairs.to_be_bytes().to_vec(), @@ -1307,7 +1293,6 @@ fn composed_transition_block(previous: &BlockDelta) -> BlockDelta { role: 0, outpoint: OutPoint::new(txid, u32::try_from(vout).expect("small vout")), }], - order_remaining_base: None, transition: TransitionRecord { kind: 1, payload: vec![u8::try_from(vout).expect("small transition")], diff --git a/crates/deadcat-node/src/sync.rs b/crates/deadcat-node/src/sync.rs index ae364d3..6cae9e2 100644 --- a/crates/deadcat-node/src/sync.rs +++ b/crates/deadcat-node/src/sync.rs @@ -1484,8 +1484,6 @@ mod tests { outstanding_pairs: 0, }), sync_state, - parent_market: None, - outcome_side: None, scripts: vec![ScriptBinding { role: 0, script_pubkey: vec![marker, 0x51], @@ -1499,7 +1497,6 @@ mod tests { role: 0, outpoint: OutPoint::new(txid, 0), }], - order_book: None, } } @@ -1523,7 +1520,6 @@ mod tests { role: 0, outpoint: OutPoint::new(spending_txid, 0), }], - order_remaining_base: None, transition: TransitionRecord { kind: 1, payload: outstanding_pairs.to_be_bytes().to_vec(), diff --git a/crates/deadcat-node/tests/market_persistence_reorg.rs b/crates/deadcat-node/tests/market_persistence_reorg.rs index d09c584..54127cb 100644 --- a/crates/deadcat-node/tests/market_persistence_reorg.rs +++ b/crates/deadcat-node/tests/market_persistence_reorg.rs @@ -295,9 +295,7 @@ fn assert_markets_issued( .output(OutPoint::new(issuance.txid(), output_base + 1)) .expect("read NO output") .expect("stored NO output"); - let deadcat_node::store::ContractParameters::BinaryMarket(params) = record.params else { - unreachable!(); - }; + let deadcat_node::store::ContractParameters::BinaryMarket(params) = record.params; assert_eq!( infer_side( RtLeg::Yes, diff --git a/crates/deadcat-node/tests/rpc_handler.rs b/crates/deadcat-node/tests/rpc_handler.rs index e61edf1..db9268a 100644 --- a/crates/deadcat-node/tests/rpc_handler.rs +++ b/crates/deadcat-node/tests/rpc_handler.rs @@ -2,16 +2,13 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use deadcat_contracts::maker_order::{CompiledMakerOrder, derive_instance_id}; -use deadcat_contracts::recovery::{OrderRecoveryHint, recovery_txout}; use deadcat_iroh::{RequestHandler as _, SubscriptionItem}; use deadcat_node::chain::{ChainSource, ChainSourceError, Outspend, TransactionStatus}; use deadcat_node::rpc_handler::{NodeRpcHandler, RpcHandlerConfig}; use deadcat_node::store::{ AssetBinding, AssetRelationKind as StoreAssetRelationKind, BlockDelta, ChainIdentity as StoreChainIdentity, ChainTxDelta, ContractParameters, ContractRecord, - ContractState, OrderBookEntry, RecoveryHintDelta, RegistrationEvidence, ScriptBinding, - StateUpdate, Store, TrackedOutpoint, TransitionRecord, + ContractState, RecoveryHintDelta, ScriptBinding, Store, TrackedOutpoint, }; use deadcat_rpc::{ AssetRelationKind, BackendKind, Capability, Event, EventFilter, PageRequest, RecoveryFamily, @@ -21,14 +18,11 @@ use deadcat_types::{ BinaryMarketParams, BinaryMarketState, CONTRACT_PACKAGE_FORMAT_VERSION, ChainAnchor, ChainIdentity, ChainPosition, ContractDeclaration, ContractDescriptor, ContractId, ContractKind, ContractPackage, ContractSyncState, DiscoveryCoverage, DiscoveryMode, - LiquidNetwork, MakerOrderParams, MakerOrderState, OrderDirection, OrderSide, - RecoveryHintLocation, + LiquidNetwork, RecoveryHintLocation, }; -use elements::confidential::{Asset, Nonce, Value}; use elements::hashes::Hash as _; use elements::{ - AssetId, Block, BlockHash, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, TxOutWitness, - Txid, + AssetId, Block, BlockHash, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, Txid, }; #[derive(Clone, Copy)] @@ -84,66 +78,6 @@ impl ChainSource for MockSource { } } -#[derive(Clone)] -struct RegistrationSource { - transaction: Transaction, - status: TransactionStatus, -} - -#[async_trait] -impl ChainSource for RegistrationSource { - async fn tip(&self) -> Result { - Ok(anchor(1)) - } - - async fn block_hash(&self, _height: u32) -> Result { - Err(unused_backend_call()) - } - - async fn block(&self, _hash: BlockHash) -> Result { - Err(unused_backend_call()) - } - - async fn transaction(&self, txid: Txid) -> Result { - if txid == self.transaction.txid() { - Ok(self.transaction.clone()) - } else { - Err(ChainSourceError::NotFound(txid.to_string())) - } - } - - async fn transaction_status(&self, txid: Txid) -> Result { - if txid == self.transaction.txid() { - Ok(self.status) - } else { - Err(ChainSourceError::NotFound(txid.to_string())) - } - } - - async fn outspend(&self, _outpoint: OutPoint) -> Result, ChainSourceError> { - Err(unused_backend_call()) - } - - async fn script_history(&self, _script: &Script) -> Result, ChainSourceError> { - Err(unused_backend_call()) - } - - async fn issuance_transaction( - &self, - _asset_id: AssetId, - ) -> Result, ChainSourceError> { - Err(unused_backend_call()) - } - - async fn estimate_fee_rate(&self, _target_blocks: u16) -> Result { - Err(unused_backend_call()) - } - - async fn broadcast(&self, _transaction: &Transaction) -> Result { - Err(unused_backend_call()) - } -} - fn unused_backend_call() -> ChainSourceError { ChainSourceError::Unavailable("unexpected mock backend call".to_owned()) } @@ -154,7 +88,6 @@ struct Fixture { handler: NodeRpcHandler, market: ContractRecord, other_market: ContractRecord, - orders: Vec, transactions: Vec, collateral: AssetId, } @@ -210,8 +143,6 @@ fn market_record(marker: u8, transaction: &Transaction, tx_index: u32) -> Contra sync_state: ContractSyncState::Ready { synced_through: anchor(1), }, - parent_market: None, - outcome_side: None, scripts: vec![ScriptBinding { role: 0, script_pubkey: vec![0x51, marker], @@ -232,89 +163,6 @@ fn market_record(marker: u8, transaction: &Transaction, tx_index: u32) -> Contra role: 0, outpoint: OutPoint::new(transaction.txid(), 0), }], - order_book: None, - } -} - -#[derive(Clone, Copy)] -struct OrderSpec { - marker: u8, - price: u32, - minimum: u32, - remaining: u64, - direction: OrderDirection, - side: OrderSide, -} - -fn order_record( - spec: OrderSpec, - transaction: &Transaction, - tx_index: u32, - market: &ContractRecord, -) -> ContractRecord { - let ContractParameters::BinaryMarket(market_params) = market.params else { - unreachable!("market fixture") - }; - let base_asset_id = match spec.side { - OrderSide::Yes => market_params.yes_token_asset_id, - OrderSide::No => market_params.no_token_asset_id, - }; - let params = MakerOrderParams { - base_asset_id, - quote_asset_id: market_params.collateral_asset_id, - price: spec.price, - min_active_base: spec.minimum, - direction: spec.direction, - instance_id: [spec.marker.wrapping_add(1); 32], - maker_pubkey: [spec.marker.wrapping_add(2); 32], - }; - let creation_position = ChainPosition { - block_height: 1, - tx_index, - }; - let contract_id = ContractId::new(OutPoint::new(transaction.txid(), 0)); - ContractRecord { - contract_id, - kind: ContractKind::MakerOrderV1, - params: ContractParameters::MakerOrder(params), - creation_position, - state: ContractState::MakerOrder(MakerOrderState::Active { - remaining_base: spec.remaining, - total_filled_base: 0, - }), - sync_state: ContractSyncState::Ready { - synced_through: anchor(1), - }, - parent_market: Some(market.contract_id), - outcome_side: Some(spec.side), - scripts: vec![ScriptBinding { - role: 0, - script_pubkey: vec![0x52, spec.marker], - }], - assets: vec![ - AssetBinding { - asset_id: params.base_asset_id, - relation: StoreAssetRelationKind::OrderBase, - role: 0, - }, - AssetBinding { - asset_id: params.quote_asset_id, - relation: StoreAssetRelationKind::OrderQuote, - role: 1, - }, - ], - outpoints: vec![TrackedOutpoint { - role: 0, - outpoint: OutPoint::new(transaction.txid(), 0), - }], - order_book: Some(OrderBookEntry { - market_id: market.contract_id, - side: spec.side, - direction: spec.direction, - price: spec.price, - creation_position, - remaining_base: spec.remaining, - }), } } @@ -350,64 +198,10 @@ fn new_store() -> (tempfile::TempDir, Arc) { fn fixture() -> Fixture { let (directory, store) = new_store(); - let transactions = (1..=6).map(transaction).collect::>(); + let transactions = (1..=2).map(transaction).collect::>(); let market = market_record(0x31, &transactions[0], 0); let other_market = market_record(0x32, &transactions[1], 1); - let orders = vec![ - order_record( - OrderSpec { - marker: 0x41, - price: 7, - minimum: 3, - remaining: 6, - direction: OrderDirection::SellBase, - side: OrderSide::Yes, - }, - &transactions[2], - 2, - &market, - ), - order_record( - OrderSpec { - marker: 0x42, - price: 3, - minimum: 3, - remaining: 10, - direction: OrderDirection::SellBase, - side: OrderSide::Yes, - }, - &transactions[3], - 3, - &market, - ), - order_record( - OrderSpec { - marker: 0x43, - price: 4, - minimum: 2, - remaining: 5, - direction: OrderDirection::SellQuote, - side: OrderSide::Yes, - }, - &transactions[4], - 4, - &market, - ), - order_record( - OrderSpec { - marker: 0x44, - price: 8, - minimum: 2, - remaining: 7, - direction: OrderDirection::SellQuote, - side: OrderSide::Yes, - }, - &transactions[5], - 5, - &market, - ), - ]; - let records = [vec![market.clone(), other_market.clone()], orders.clone()].concat(); + let records = vec![market.clone(), other_market.clone()]; let relevant_transactions = transactions .iter() .zip(records) @@ -430,34 +224,19 @@ fn fixture() -> Fixture { prev_block_hash: anchor(0).hash, ordered_txids: transactions.iter().map(Transaction::txid).collect(), relevant_transactions, - recovery_hints: vec![ - RecoveryHintDelta { - location: RecoveryHintLocation { - position: ChainPosition { - block_height: 1, - tx_index: 0, - }, - output_index: 0, - }, - creation_txid: transactions[0].txid(), - family: RecoveryFamily::BinaryMarketV1, - payload: vec![0xdc, 1], - associated_contract: Some(market.contract_id), - }, - RecoveryHintDelta { - location: RecoveryHintLocation { - position: ChainPosition { - block_height: 1, - tx_index: 2, - }, - output_index: 0, + recovery_hints: vec![RecoveryHintDelta { + location: RecoveryHintLocation { + position: ChainPosition { + block_height: 1, + tx_index: 0, }, - creation_txid: transactions[2].txid(), - family: RecoveryFamily::MakerOrderV1, - payload: vec![0xdc, 2], - associated_contract: None, + output_index: 0, }, - ], + creation_txid: transactions[0].txid(), + family: RecoveryFamily::BinaryMarketV1, + payload: vec![0xdc, 1], + associated_contract: Some(market.contract_id), + }], }) .expect("apply fixture block"); @@ -482,7 +261,6 @@ fn fixture() -> Fixture { handler, market, other_market, - orders, transactions, collateral: asset(0x20), } @@ -563,113 +341,15 @@ async fn market_pages_are_atomic_and_invalidate_after_snapshot_changes() { } #[tokio::test] -async fn materialized_list_book_hint_and_asset_queries_match_the_canonical_store() { +async fn materialized_hint_and_asset_queries_match_the_canonical_store() { let fixture = fixture(); - let Response::Orders { page } = request( - &fixture.handler, - Request::ListOrders { - market_id: fixture.market.contract_id, - side: Some(OrderSide::Yes), - direction: Some(OrderDirection::SellBase), - page: PageRequest { - cursor: None, - limit: 10, - }, - }, - ) - .await - .expect("orders") else { - panic!("unexpected response") - }; - assert_eq!(page.contracts.len(), 2); - assert!(page.contracts.iter().all(|order| { - order.parent_market == Some(fixture.market.contract_id) - && order.outcome_side == Some(OrderSide::Yes) - })); - - let Response::Orders { page: first } = request( - &fixture.handler, - Request::ListOrders { - market_id: fixture.market.contract_id, - side: Some(OrderSide::Yes), - direction: Some(OrderDirection::SellBase), - page: PageRequest { - cursor: None, - limit: 1, - }, - }, - ) - .await - .expect("first order page") else { - panic!("unexpected response") - }; - let next = first.next.expect("second order cursor"); - let error = request( - &fixture.handler, - Request::ListOrders { - market_id: fixture.market.contract_id, - side: Some(OrderSide::Yes), - direction: Some(OrderDirection::SellQuote), - page: PageRequest { - cursor: Some(next.clone()), - limit: 1, - }, - }, - ) - .await - .expect_err("changing a cursor's order filter"); - assert_eq!(error.code, RpcErrorCode::SnapshotInvalidated); - let Response::Orders { page: second } = request( - &fixture.handler, - Request::ListOrders { - market_id: fixture.market.contract_id, - side: Some(OrderSide::Yes), - direction: Some(OrderDirection::SellBase), - page: PageRequest { - cursor: Some(next), - limit: 1, - }, - }, - ) - .await - .expect("second order page") else { - panic!("unexpected response") - }; - assert_eq!(second.contracts.len(), 1); - assert!(second.next.is_none()); - - let Response::OrderBook { book } = request( - &fixture.handler, - Request::GetOrderBook { - market_id: fixture.market.contract_id, - }, - ) - .await - .expect("order book") else { - panic!("unexpected response") - }; - assert_eq!( - book.asks - .iter() - .map(|level| level.price) - .collect::>(), - vec![3, 7] - ); - assert_eq!( - book.bids - .iter() - .map(|level| level.price) - .collect::>(), - vec![8, 4] - ); - let Response::RecoveryHints { page: hints } = request( &fixture.handler, Request::ListRecoveryHints { family: None, page: PageRequest { cursor: None, - limit: 1, + limit: 10, }, }, ) @@ -679,38 +359,12 @@ async fn materialized_list_book_hint_and_asset_queries_match_the_canonical_store }; assert_eq!(hints.snapshot.as_of, anchor(1)); assert_eq!(hints.hints.len(), 1); - let next = hints.next.expect("second hint cursor"); - let error = request( - &fixture.handler, - Request::ListRecoveryHints { - family: Some(RecoveryFamily::MakerOrderV1), - page: PageRequest { - cursor: Some(next.clone()), - limit: 10, - }, - }, - ) - .await - .expect_err("changing a cursor's recovery-hint filter"); - assert_eq!(error.code, RpcErrorCode::SnapshotInvalidated); - - let Response::RecoveryHints { page: hints } = request( - &fixture.handler, - Request::ListRecoveryHints { - family: None, - page: PageRequest { - cursor: Some(next), - limit: 10, - }, - }, - ) - .await - .expect("continued recovery hints") else { - panic!("unexpected response") - }; - assert_eq!(hints.hints.len(), 1); - assert_eq!(hints.hints[0].family, RecoveryFamily::MakerOrderV1); - assert_eq!(hints.hints[0].associated_contract, None); + assert!(hints.next.is_none()); + assert_eq!(hints.hints[0].family, RecoveryFamily::BinaryMarketV1); + assert_eq!( + hints.hints[0].associated_contract, + Some(fixture.market.contract_id) + ); let Response::Asset { lookup } = request( &fixture.handler, @@ -726,155 +380,11 @@ async fn materialized_list_book_hint_and_asset_queries_match_the_canonical_store relation.contract_id == fixture.market.contract_id && relation.kind == AssetRelationKind::Collateral })); - assert_eq!( - lookup - .relations - .iter() - .filter(|relation| relation.kind == AssetRelationKind::OrderQuote) - .count(), - fixture.orders.len() - ); -} - -#[tokio::test] -async fn advisory_routes_use_best_prices_respect_minimums_and_stop_after_resolution() { - let fixture = fixture(); - let best_ask = fixture.orders[1].contract_id; - let worse_ask = fixture.orders[0].contract_id; - let Response::Route { route } = request( - &fixture.handler, - Request::SuggestRoute { - market_id: fixture.market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 13, - max_orders: 10, - }, - ) - .await - .expect("route") else { - panic!("unexpected response") - }; - assert_eq!( - route - .legs - .iter() - .map(|leg| (leg.order_id, leg.base_amount, leg.quote_amount)) - .collect::>(), - vec![(best_ask, 10, 30), (worse_ask, 3, 21)] - ); - assert_eq!(route.total_base, 13); - assert_eq!(route.total_quote, 51); - - let Response::Route { route } = request( - &fixture.handler, - Request::SuggestRoute { - market_id: fixture.market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellQuote, - base_amount: 9, - max_orders: 10, - }, - ) - .await - .expect("best-bid route") else { - panic!("unexpected response") - }; - assert_eq!( - route - .legs - .iter() - .map(|leg| (leg.order_id, leg.base_amount, leg.quote_amount)) - .collect::>(), - vec![ - (fixture.orders[3].contract_id, 7, 56), - (fixture.orders[2].contract_id, 2, 8), - ] - ); - assert_eq!(route.total_base, 9); - assert_eq!(route.total_quote, 64); - - let Response::Route { route } = request( - &fixture.handler, - Request::SuggestRoute { - market_id: fixture.market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 8, - max_orders: 1, - }, - ) - .await - .expect("minimum-aware route") else { - panic!("unexpected response") - }; - assert_eq!(route.legs[0].base_amount, 7); - assert_eq!(route.total_base, 7); - - resolve_market(&fixture); - let error = request( - &fixture.handler, - Request::SuggestRoute { - market_id: fixture.market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 3, - max_orders: 1, - }, - ) - .await - .expect_err("resolved market must not be officially routed"); - assert_eq!(error.code, RpcErrorCode::CovenantInvariantViolation); -} - -fn resolve_market(fixture: &Fixture) { - let mut resolution = transaction(100); - resolution.input = vec![TxIn { - previous_output: fixture.market.outpoints[0].outpoint, - ..TxIn::default() - }]; - let position = ChainPosition { - block_height: 2, - tx_index: 0, - }; - fixture - .store - .apply_block(&BlockDelta { - anchor: anchor(2), - prev_block_hash: anchor(1).hash, - ordered_txids: vec![resolution.txid()], - relevant_transactions: vec![ChainTxDelta { - position, - block_hash: anchor(2).hash, - txid: resolution.txid(), - raw_tx: resolution.clone(), - created_contracts: Vec::new(), - state_updates: vec![StateUpdate { - contract_id: fixture.market.contract_id, - old_state: fixture.market.state, - new_state: ContractState::BinaryMarket(BinaryMarketState::ResolvedYes { - collateral_unredeemed: 2_000, - }), - spent_outpoints: fixture - .market - .outpoints - .iter() - .map(|tracked| tracked.outpoint) - .collect(), - new_outpoints: vec![TrackedOutpoint { - role: 0, - outpoint: OutPoint::new(resolution.txid(), 0), - }], - order_remaining_base: None, - transition: TransitionRecord { - kind: 9, - payload: vec![1], - }, - }], - }], - recovery_hints: Vec::new(), - }) - .expect("resolve market"); + assert!(lookup.relations.iter().all(|relation| { + relation.kind == AssetRelationKind::Collateral + && [fixture.market.contract_id, fixture.other_market.contract_id] + .contains(&relation.contract_id) + })); } #[tokio::test] @@ -970,9 +480,7 @@ async fn rescan_required_blocks_every_chain_derived_rpc_before_dispatch() { .invalidate_for_rebuild() .expect("invalidate store"); - let ContractParameters::BinaryMarket(params) = fixture.market.params else { - panic!("fixture market parameters") - }; + let ContractParameters::BinaryMarket(params) = fixture.market.params; let package = ContractPackage { format_version: CONTRACT_PACKAGE_FORMAT_VERSION, chain: ChainIdentity { @@ -1001,15 +509,6 @@ async fn rescan_required_blocks_every_chain_derived_rpc_before_dispatch() { Request::GetMarketSnapshot { market_id: fixture.market.contract_id, }, - Request::ListOrders { - market_id: fixture.market.contract_id, - side: None, - direction: None, - page: page.clone(), - }, - Request::GetOrderBook { - market_id: fixture.market.contract_id, - }, Request::ListRecoveryHints { family: None, page: page.clone(), @@ -1028,13 +527,6 @@ async fn rescan_required_blocks_every_chain_derived_rpc_before_dispatch() { Request::LookupAsset { asset_id: fixture.collateral, }, - Request::SuggestRoute { - market_id: fixture.market.contract_id, - side: OrderSide::Yes, - direction: OrderDirection::SellBase, - base_amount: 1, - max_orders: 1, - }, ]; for request_value in blocked { let error = request(&fixture.handler, request_value) @@ -1122,169 +614,12 @@ async fn registration_auth_is_checked_before_touching_the_backend() { assert_eq!(error.code, RpcErrorCode::InvalidRegistration); } -#[tokio::test] -async fn registration_package_rpc_returns_ordered_idempotent_receipts() { - const VALID_XONLY: [u8; 32] = [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, - 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, - 0x3a, 0xc0, - ]; - - let directory = tempfile::tempdir().expect("temporary directory"); - let store = Arc::new( - Store::open(directory.path().join("registration.redb")).expect("open registration store"), - ); - // Seed an already verified parent market without an interpreted creation - // row so the RPC test can focus on the package boundary and exact maker anchor. - let parent_transaction = transaction(90); - store - .initialize_chain( - StoreChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash: block_hash(0), - policy_asset: asset(0x20), - }, - anchor(0), - ) - .expect("initialize chain"); - let mut parent = market_record(0x31, &parent_transaction, 0); - parent.sync_state = ContractSyncState::CatchingUp { - synced_through: anchor(1), - }; - let ContractParameters::BinaryMarket(parent_params) = parent.params else { - panic!("parent market params") - }; - - let creation_input = TxIn::default(); - let params = MakerOrderParams { - base_asset_id: parent_params.yes_token_asset_id, - quote_asset_id: parent_params.collateral_asset_id, - price: 5, - min_active_base: 3, - direction: OrderDirection::SellBase, - instance_id: derive_instance_id(&[creation_input.previous_output], 0).expect("instance"), - maker_pubkey: VALID_XONLY, - }; - let compiled = CompiledMakerOrder::new(params).expect("compile maker order"); - let hint = OrderRecoveryHint { - side: OrderSide::Yes, - direction: params.direction, - masked_order_index: 0x1234, - parent_market: parent.contract_id.into(), - price: params.price, - min_active_base: params.min_active_base, - maker_pubkey: params.maker_pubkey, - }; - let creation = Transaction { - version: 2, - lock_time: LockTime::ZERO, - input: vec![creation_input], - output: vec![ - TxOut { - asset: Asset::Explicit(params.base_asset_id), - value: Value::Explicit(10), - nonce: Nonce::Null, - script_pubkey: compiled.script_pubkey().clone(), - witness: TxOutWitness::default(), - }, - recovery_txout(asset(0x20), &hint.encode()).expect("order hint"), - ], - }; - store - .apply_block(&BlockDelta { - anchor: anchor(1), - prev_block_hash: anchor(0).hash, - ordered_txids: vec![parent_transaction.txid(), creation.txid()], - relevant_transactions: Vec::new(), - recovery_hints: Vec::new(), - }) - .expect("index shared creation block"); - store - .register_contract( - &parent, - &RegistrationEvidence { - anchor: anchor(1), - transaction: Arc::new(parent_transaction), - associated_hint: None, - }, - ) - .expect("register parent fixture"); - let contract_id = ContractId::new(OutPoint::new(creation.txid(), 0)); - let package = ContractPackage { - format_version: CONTRACT_PACKAGE_FORMAT_VERSION, - chain: ChainIdentity { - network: LiquidNetwork::ElementsRegtest, - genesis_hash: block_hash(0), - }, - roots: vec![contract_id], - declarations: vec![ContractDeclaration { - contract_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: parent.contract_id, - side: OrderSide::Yes, - params, - }, - }], - }; - let source = RegistrationSource { - transaction: creation, - status: TransactionStatus::Confirmed { - anchor: anchor(1), - tx_index: 1, - }, - }; - let discovery = DiscoveryCoverage { - mode: DiscoveryMode::AdvisoryOnly, - from: anchor(1), - scanned_through: anchor(1), - target_tip: anchor(1), - canonical_market_complete: false, - }; - let handler = NodeRpcHandler::new(Arc::new(source), Arc::clone(&store), rpc_config(discovery)) - .expect("registration handler"); - - for already_registered in [false, true] { - let response = handler - .handle( - [0x55; 32], - Request::RegisterContractPackage { - package: package.clone(), - bearer_token: Some("registration-secret".to_owned()), - }, - ) - .await - .expect("package registration RPC"); - let Response::RegistrationAccepted { registration } = response else { - panic!("unexpected registration response") - }; - assert_eq!(registration.roots, vec![contract_id]); - assert_eq!(registration.contracts.len(), 1); - assert_eq!(registration.contracts[0].contract_id, contract_id); - assert_eq!( - registration.contracts[0].sync_state, - ContractSyncState::CatchingUp { - synced_through: anchor(1), - } - ); - assert_eq!( - registration.contracts[0].already_registered, - already_registered - ); - } - assert!( - store - .contract(contract_id) - .expect("registered order lookup") - .is_some() - ); -} - #[tokio::test] async fn transaction_evidence_consensus_decodes_the_persisted_transaction() { let fixture = fixture(); let position = ChainPosition { block_height: 1, - tx_index: 3, + tx_index: 1, }; let Response::Transaction { evidence: Some(evidence), @@ -1295,11 +630,11 @@ async fn transaction_evidence_consensus_decodes_the_persisted_transaction() { panic!("unexpected response") }; assert_eq!(evidence.position, position); - assert_eq!(evidence.transaction, fixture.transactions[3]); - assert_eq!(evidence.txid, fixture.transactions[3].txid()); + assert_eq!(evidence.transaction, fixture.transactions[1]); + assert_eq!(evidence.txid, fixture.transactions[1].txid()); assert_eq!( evidence.affected_contract_ids, - vec![fixture.orders[1].contract_id] + vec![fixture.other_market.contract_id] ); } diff --git a/crates/deadcat-rpc/src/lib.rs b/crates/deadcat-rpc/src/lib.rs index 80d4e3a..888eda3 100644 --- a/crates/deadcat-rpc/src/lib.rs +++ b/crates/deadcat-rpc/src/lib.rs @@ -3,7 +3,7 @@ use deadcat_types::{ BinaryMarketParams, BinaryMarketState, ChainAnchor, ChainPosition, ContractId, ContractKind, ContractPackage, ContractSyncState, DiscoveryCoverage, EventCursor, LiquidNetwork, - MakerOrderParams, MakerOrderState, OrderDirection, OrderSide, RecoveryHintLocation, + RecoveryHintLocation, }; use elements::{AssetId, BlockHash, OutPoint, Transaction, Txid}; use serde::{Deserialize, Serialize}; @@ -63,15 +63,6 @@ pub enum Request { GetMarketSnapshot { market_id: ContractId, }, - ListOrders { - market_id: ContractId, - side: Option, - direction: Option, - page: PageRequest, - }, - GetOrderBook { - market_id: ContractId, - }, ListRecoveryHints { family: Option, page: PageRequest, @@ -93,14 +84,6 @@ pub enum Request { EstimateFeerate { target_blocks: u16, }, - SuggestRoute { - market_id: ContractId, - side: OrderSide, - direction: OrderDirection, - #[serde(with = "deadcat_types::serde_u64_string")] - base_amount: u64, - max_orders: u16, - }, BroadcastSignedTransaction { transaction: Transaction, }, @@ -114,7 +97,6 @@ pub enum Request { #[serde(rename_all = "snake_case")] pub enum RecoveryFamily { BinaryMarketV1, - MakerOrderV1, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -155,12 +137,6 @@ pub enum Response { MarketSnapshot { snapshot: MarketSnapshot, }, - Orders { - page: ContractPage, - }, - OrderBook { - book: OrderBookSnapshot, - }, RecoveryHints { page: RecoveryHintPage, }, @@ -179,9 +155,6 @@ pub enum Response { Feerate { estimate: FeeRateEstimate, }, - Route { - route: RouteSuggestion, - }, BroadcastAccepted { txid: Txid, }, @@ -229,7 +202,6 @@ pub enum SyncStatus { #[serde(rename_all = "snake_case")] pub enum Capability { BinaryMarketV1, - MakerOrderV1, ElementsRpc, Esplora, FullHintScan, @@ -237,7 +209,6 @@ pub enum Capability { BroadcastSignedTransaction, EvidenceQueries, DurableSubscriptions, - AdvisoryRouting, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -264,8 +235,6 @@ pub struct ContractView { pub creation_position: ChainPosition, pub parameters: ContractParametersView, pub state: ContractStateView, - pub parent_market: Option, - pub outcome_side: Option, pub live_outpoints: Vec, } @@ -292,14 +261,7 @@ pub struct SnapshotCursor { #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum SnapshotScope { Markets, - Orders { - market_id: ContractId, - side: Option, - direction: Option, - }, - RecoveryHints { - family: Option, - }, + RecoveryHints { family: Option }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -321,14 +283,12 @@ pub struct ContractPage { #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum ContractParametersView { BinaryMarket { params: BinaryMarketParams }, - MakerOrder { params: MakerOrderParams }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum ContractStateView { BinaryMarket { state: BinaryMarketState }, - MakerOrder { state: MakerOrderState }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -349,27 +309,6 @@ pub struct MarketSnapshot { pub live_outpoints: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OrderBookSnapshot { - pub snapshot: SnapshotMetadata, - pub market_id: ContractId, - pub asks: Vec, - pub bids: Vec, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OrderBookLevel { - pub contract_id: ContractId, - pub side: OrderSide, - pub direction: OrderDirection, - pub price: u32, - #[serde(with = "deadcat_types::serde_u64_string")] - pub remaining_base: u64, - pub creation_position: ChainPosition, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RecoveryHintPage { @@ -439,8 +378,6 @@ pub enum AssetRelationKind { NoToken, YesReissuanceToken, NoReissuanceToken, - OrderBase, - OrderQuote, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -459,28 +396,6 @@ pub struct FeeRateEstimate { pub sats_per_kvb: u64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RouteSuggestion { - pub snapshot: SnapshotMetadata, - pub market_id: ContractId, - pub legs: Vec, - #[serde(with = "deadcat_types::serde_u64_string")] - pub total_base: u64, - #[serde(with = "deadcat_types::serde_u64_string")] - pub total_quote: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RouteLeg { - pub order_id: ContractId, - #[serde(with = "deadcat_types::serde_u64_string")] - pub base_amount: u64, - #[serde(with = "deadcat_types::serde_u64_string")] - pub quote_amount: u64, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RecoveryHintRecord { @@ -497,7 +412,6 @@ pub struct RecoveryHintRecord { pub enum EventFilter { All, Contracts { contract_ids: Vec }, - MarketTree { market_id: ContractId }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -522,7 +436,6 @@ pub enum Event { txid: Txid, position: ChainPosition, affected_contract_ids: Vec, - affected_market_ids: Vec, }, BackfillApplied { contract_id: ContractId, @@ -534,7 +447,6 @@ pub enum Event { new_tip: ChainAnchor, orphaned_positions: Vec, affected_contract_ids: Vec, - affected_market_ids: Vec, }, SyncStatusChanged { status: SyncStatus, @@ -601,11 +513,11 @@ mod tests { const PACKAGE_TXID: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; fn package_fixture() -> ContractPackage { - let market_id = ContractId::new(OutPoint::new( + let first_market_id = ContractId::new(OutPoint::new( Txid::from_str(PACKAGE_TXID).expect("txid"), 2, )); - let order_id = ContractId::new(OutPoint::new( + let second_market_id = ContractId::new(OutPoint::new( Txid::from_str(PACKAGE_TXID).expect("txid"), 9, )); @@ -618,29 +530,27 @@ mod tests { network: LiquidNetwork::ElementsRegtest, genesis_hash: BlockHash::from_str(&"aa".repeat(32)).expect("genesis hash"), }, - roots: vec![order_id], - // Dependency order is intentionally reversed. The wire contract - // preserves declaration order even though verification topologically - // evaluates the market before its order. + roots: vec![second_market_id, first_market_id], + // The wire contract and registration receipt preserve caller order + // for independent markets sharing one atomic package. declarations: vec![ ContractDeclaration { - contract_id: order_id, - descriptor: ContractDescriptor::MakerOrderV1 { - parent_market: market_id, - side: OrderSide::Yes, - params: MakerOrderParams { - base_asset_id: asset("22"), - quote_asset_id: asset("11"), - price: 2_500, - min_active_base: 10, - direction: OrderDirection::SellQuote, - instance_id: [0x66; 32], - maker_pubkey: [0x77; 32], + contract_id: second_market_id, + descriptor: ContractDescriptor::BinaryMarketV1 { + params: BinaryMarketParams { + oracle_public_key: [0x03; 32], + collateral_asset_id: asset("66"), + yes_token_asset_id: asset("77"), + no_token_asset_id: asset("88"), + yes_reissuance_token_id: asset("99"), + no_reissuance_token_id: asset("aa"), + base_payout: 50_000_000, + expiry_height: 2_345, }, }, }, ContractDeclaration { - contract_id: market_id, + contract_id: first_market_id, descriptor: ContractDescriptor::BinaryMarketV1 { params: BinaryMarketParams { oracle_public_key: [0x02; 32], @@ -757,7 +667,7 @@ mod tests { schema_version: SCHEMA_VERSION, request_id: RequestId(8), request: Request::ListRecoveryHints { - family: Some(RecoveryFamily::MakerOrderV1), + family: Some(RecoveryFamily::BinaryMarketV1), page: PageRequest { cursor: None, limit: 2, @@ -800,17 +710,11 @@ mod tests { }, output_index: 4, }; - let mut payload = vec![0x40, 0x00, 0x01]; - payload.extend_from_slice(&contract_id.txid().to_byte_array()); - payload.extend_from_slice(&contract_id.vout().to_be_bytes()); - payload.extend_from_slice(&2_500_u32.to_be_bytes()); - payload.extend_from_slice(&10_u32.to_be_bytes()); - payload.extend_from_slice(&[ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, - 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, - 0xce, 0x80, 0x3a, 0xc0, - ]); - assert_eq!(payload.len(), 79); + let mut payload = vec![0x10]; + payload.extend_from_slice(&[0x02; 32]); + payload.push(0); + payload.extend_from_slice(&1_234_u32.to_be_bytes()); + assert_eq!(payload.len(), 38); let envelope = ServerEnvelope { schema_version: SCHEMA_VERSION, request_id: RequestId(8), @@ -822,7 +726,7 @@ mod tests { hints: vec![RecoveryHintRecord { location, creation_txid: contract_id.txid(), - family: RecoveryFamily::MakerOrderV1, + family: RecoveryFamily::BinaryMarketV1, payload, associated_contract: None, }], @@ -830,7 +734,7 @@ mod tests { as_of, event_high_watermark, scope: SnapshotScope::RecoveryHints { - family: Some(RecoveryFamily::MakerOrderV1), + family: Some(RecoveryFamily::BinaryMarketV1), }, after_key: vec![0, 0, 0, 42, 0, 0, 0, 3, 0, 0, 0, 4], }), @@ -880,6 +784,95 @@ mod tests { assert!(serde_json::from_str::(json).is_err()); } + #[test] + fn removed_maker_wire_variants_are_rejected() { + let contract_id = format!("{PACKAGE_TXID}:2"); + let requests = [ + serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "request_id": "1", + "request": { + "list_orders": { + "market_id": contract_id, + "side": null, + "direction": null, + "page": { "cursor": null, "limit": 1 } + } + } + }), + serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "request_id": "1", + "request": { "get_order_book": { "market_id": contract_id } } + }), + serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "request_id": "1", + "request": { + "suggest_route": { + "market_id": contract_id, + "side": "yes", + "direction": "sell_base", + "base_amount": "1", + "max_orders": 1 + } + } + }), + ]; + for request in requests { + assert!(serde_json::from_value::(request).is_err()); + } + assert!(serde_json::from_str::(r#""maker_order_v1""#).is_err()); + assert!(serde_json::from_str::(r#""advisory_routing""#).is_err()); + assert!(serde_json::from_str::(r#""maker_order_v1""#).is_err()); + + let market_id = package_fixture().roots[0]; + let removed_market_tree = serde_json::json!({ + "market_tree": { "market_id": market_id } + }); + assert!(serde_json::from_value::(removed_market_tree).is_err()); + + let event = Event::TransactionApplied { + anchor: ChainAnchor { + height: 42, + hash: BlockHash::from_str(&"bb".repeat(32)).expect("block hash"), + }, + txid: Txid::from_str(PACKAGE_TXID).expect("txid"), + position: ChainPosition { + block_height: 42, + tx_index: 3, + }, + affected_contract_ids: vec![market_id], + }; + let mut old_event = serde_json::to_value(event).expect("serialize event"); + old_event["transaction_applied"] + .as_object_mut() + .expect("transaction applied object") + .insert( + "affected_market_ids".to_owned(), + serde_json::json!([market_id]), + ); + assert!(serde_json::from_value::(old_event).is_err()); + + let mut package = serde_json::to_value(package_fixture()).expect("serialize package"); + package["declarations"][0]["descriptor"] = serde_json::json!({ + "maker_order_v1": { + "parent_market": contract_id, + "side": "yes", + "params": { + "base_asset_id": "22".repeat(32), + "quote_asset_id": "11".repeat(32), + "price": 2500, + "min_active_base": 10, + "direction": "sell_quote", + "instance_id": "66".repeat(32), + "maker_pubkey": "77".repeat(32) + } + } + }); + assert!(serde_json::from_value::(package).is_err()); + } + #[test] fn unsupported_version_is_typed() { let request = RequestEnvelope { diff --git a/crates/deadcat-types/src/lib.rs b/crates/deadcat-types/src/lib.rs index 45e1a01..45eed9c 100644 --- a/crates/deadcat-types/src/lib.rs +++ b/crates/deadcat-types/src/lib.rs @@ -178,9 +178,6 @@ pub enum LiquidNetwork { #[serde(rename_all = "snake_case")] pub enum ContractKind { BinaryMarketV1, - MakerOrderV1, - /// Reserved for capability negotiation; registration is unsupported in v1. - LmsrV1Reserved, } /// Exact chain on which the declarations in a contract package must exist. @@ -214,65 +211,12 @@ impl BinaryMarketParams { } } -/// Which outcome token an order trades against collateral. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OrderSide { - Yes, - No, -} - -/// Asset held by an active maker order. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OrderDirection { - SellBase, - SellQuote, -} - -impl OrderDirection { - #[must_use] - pub const fn protocol_byte(self) -> u8 { - match self { - Self::SellBase => 0, - Self::SellQuote => 1, - } - } -} - -/// Public parameters needed to compile and validate a maker order. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MakerOrderParams { - pub base_asset_id: AssetId, - pub quote_asset_id: AssetId, - pub price: u32, - pub min_active_base: u32, - pub direction: OrderDirection, - /// Stable order instance commitment. The covenant accepts any value; - /// canonical creation derives it from all creation input prevouts and the - /// initial order output index. - #[serde(with = "hex::serde")] - pub instance_id: [u8; 32], - /// Per-order base key. The canonical covenant derives independent - /// cancellation and payment keys from this key and `instance_id`. - #[serde(with = "hex::serde")] - pub maker_pubkey: [u8; 32], -} - /// Complete public semantics needed to compile and independently verify one /// supported contract family. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum ContractDescriptor { - BinaryMarketV1 { - params: BinaryMarketParams, - }, - MakerOrderV1 { - parent_market: ContractId, - side: OrderSide, - params: MakerOrderParams, - }, + BinaryMarketV1 { params: BinaryMarketParams }, } impl ContractDescriptor { @@ -280,15 +224,6 @@ impl ContractDescriptor { pub const fn kind(self) -> ContractKind { match self { Self::BinaryMarketV1 { .. } => ContractKind::BinaryMarketV1, - Self::MakerOrderV1 { .. } => ContractKind::MakerOrderV1, - } - } - - #[must_use] - pub const fn parent(self) -> Option { - match self { - Self::BinaryMarketV1 { .. } => None, - Self::MakerOrderV1 { parent_market, .. } => Some(parent_market), } } } @@ -304,7 +239,7 @@ pub struct ContractDeclaration { } /// Portable ingestion unit. Roots identify the contracts requested by the -/// sender; declarations may additionally carry their dependency closure. +/// sender. With the market-only contract set, every declaration is a root. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ContractPackage { @@ -336,20 +271,6 @@ pub enum BinaryMarketState { }, } -/// Confirmed maker-order materialized state. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MakerOrderState { - Active { - #[serde(with = "serde_u64_string")] - remaining_base: u64, - #[serde(with = "serde_u64_string")] - total_filled_base: u64, - }, - Consumed, - Cancelled, -} - /// Whether a verified registration has replayed through the indexed tip. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/docs/acceptance/maker-orders-v1.md b/docs/acceptance/maker-orders-v1.md index 4fd1228..94aa57c 100644 --- a/docs/acceptance/maker-orders-v1.md +++ b/docs/acceptance/maker-orders-v1.md @@ -1,7 +1,14 @@ # Maker-order v1 live acceptance packet -Status: Complete for the canonical instance/public-discovery local Elements -protocol gate. +> **Historical alpha record.** The maker-order contract and this live gate were +> removed before any testnet or mainnet deployment. The accepted implementation +> and test are preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). +> Test names and commands below are non-runnable in the current checkout and +> apply only to that pinned revision. + +Status: Retired; previously complete for the canonical +instance/public-discovery local Elements protocol gate. This packet records the production-shaped boundary for the v1 maker-order covenant. It complements the pure Rust, generated Simplicity, interpreter, @@ -82,23 +89,24 @@ The harness then creates genuine alternate-hash branches with the regtest The final state and independently replayed evidence reference only the canonical replacement branch. -## Required commands +## Historical commands -Run the focused gate: +At the pinned revision, the focused gate was: ```sh nix develop .#default --command just regtest-maker-orders ``` -Run every required local/CI gate: +The complete local/CI gate at that revision was: ```sh nix develop .#default --command just ci ``` -`.github/workflows/ci.yml` invokes `just regtest`, which includes the -binary-market A/B, maker-order, and multi-contract live suites. The test is -ignored only for ordinary `cargo test`; it is mandatory in CI. +At that revision, `.github/workflows/ci.yml` invoked `just regtest`, including +the binary-market A/B, maker-order, and heterogeneous multi-contract live +suites. The maker test was ignored only by ordinary `cargo test` and was +mandatory in that revision's CI. ## Deliberate boundaries diff --git a/docs/acceptance/multi-contract-v1.md b/docs/acceptance/multi-contract-v1.md index e764822..e148c4b 100644 --- a/docs/acceptance/multi-contract-v1.md +++ b/docs/acceptance/multi-contract-v1.md @@ -1,6 +1,13 @@ # Multi-contract v1 live acceptance packet -Status: Complete for the local Elements protocol gate; mandatory in CI. +> **Historical alpha record.** This heterogeneous market-plus-maker gate was +> replaced by the market-only multi-instance gate before any testnet or mainnet +> deployment. Its final implementation is preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). +> Test names and commands below are non-runnable in the current checkout and +> apply only to that pinned revision. + +Status: Retired; previously complete and mandatory in CI. This packet records the production-shaped acceptance boundary for one transaction advancing heterogeneous Deadcat contracts. It supplements the @@ -8,7 +15,7 @@ deterministic interpreter and redb atomicity fixtures with a real transaction accepted and mined by an isolated `elementsd` liquidregtest chain. It introduces no new market or maker-order consensus rule. -The gate is implemented by +The retired gate was implemented by `multi_contract_transaction_is_accepted_and_indexed_by_elementsd` in `crates/deadcat-client/tests/market_regtest.rs`. @@ -106,23 +113,24 @@ tip. All three contracts, order-book rows, histories, shared raw-transaction evidence, and independent client replays then match the replacement branch; the pre-rebuild event cursor remains stale. -## Required commands +## Historical commands -Run the focused gate: +At the pinned revision, the focused gate was: ```sh nix develop .#default --command just regtest-multi-contract ``` -Run every required local and CI gate: +The complete local/CI gate at that revision was: ```sh nix develop .#default --command just ci ``` -`.github/workflows/ci.yml` invokes `just regtest`, which includes the -binary-market A/B, maker-order, and multi-contract live suites. This test is -ignored only for ordinary `cargo test`; it is mandatory in CI. +At that revision, `.github/workflows/ci.yml` invoked `just regtest`, including +the binary-market A/B, maker-order, and heterogeneous multi-contract live +suites. This test was ignored only by ordinary `cargo test` and was mandatory +in that revision's CI. ## Deliberate boundaries diff --git a/docs/adr/0001-authority-and-shared-node.md b/docs/adr/0001-authority-and-shared-node.md index d69a7ea..df2b823 100644 --- a/docs/adr/0001-authority-and-shared-node.md +++ b/docs/adr/0001-authority-and-shared-node.md @@ -65,7 +65,7 @@ that a single server supplied a complete or current view of the chain. A hosted node can still: - serve an old but internally valid tip; -- omit a resolution, cancellation, fill, order, or public liquidity source; +- omit a market transition or public liquidity source; - provide an inflated fee estimate; - withhold transaction relay; or - learn which markets a client queries and when it broadcasts. @@ -95,13 +95,14 @@ compliance suite. ## Discovery and transport Manual registration and read-only Nostr ingestion supply chain-bound -`ContractPackage` values: requested roots plus complete public declarations and -their parent dependencies. Neither source is authoritative. A `ContractId` is -only the exact creation-anchor outpoint, while its declaration supplies the -claimed contract semantics. The node fetches canonical creation data from its -own chain source, compiles each canonical contract version, verifies the exact -anchor and creation invariant, and registers the whole package atomically only -after every declaration succeeds. +`ContractPackage` values: requested roots plus complete public market +declarations. In the market-only package shape, every declaration is a root. +Neither source is authoritative. A `ContractId` is only the exact +creation-anchor outpoint, while its declaration supplies the claimed contract +semantics. The node fetches canonical creation data from its own chain source, +compiles each supported contract version, verifies the exact anchor and +creation invariant, and registers the whole package atomically only after every +declaration succeeds. Iroh is the only v1 application transport. It uses a versioned ALPN, bounded length-delimited messages, a stable server identity, connection and stream diff --git a/docs/adr/0002-v1-contract-scope.md b/docs/adr/0002-v1-contract-scope.md index ea9e3de..983e07a 100644 --- a/docs/adr/0002-v1-contract-scope.md +++ b/docs/adr/0002-v1-contract-scope.md @@ -1,8 +1,15 @@ # ADR 0002: V1 contract scope and lifecycle -- Status: Superseded by [ADR 0006](0006-rfq-first-liquidity-scope.md) +- Status: Release scope superseded by + [ADR 0006](0006-rfq-first-liquidity-scope.md); binary-market decisions retained - Date: 2026-07-12 +> **Historical alpha scope.** This ADR records the original two-contract +> decision. ADR 0006 removed the maker order and LMSR reservation before +> deployment while retaining the binary-market lifecycle decisions below. The +> final pre-removal implementation is preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). + ## Decision V1 implements two fresh, incompatible-with-legacy contract families: diff --git a/docs/adr/0003-order-economics.md b/docs/adr/0003-order-economics.md index 1b7e938..843fbf6 100644 --- a/docs/adr/0003-order-economics.md +++ b/docs/adr/0003-order-economics.md @@ -4,6 +4,11 @@ [ADR 0006](0006-rfq-first-liquidity-scope.md) - Date: 2026-07-12 +> **Historical alpha record.** No maker contract was deployed to testnet or +> mainnet. The final implementation governed by this ADR is preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). +> Future RFQ, DLOB, or AMM pricing must make a new explicit economics decision. + ## Context V1 needs an order price that is exactly enforceable in indivisible Liquid asset diff --git a/docs/adr/0005-rt-blinding-schedule.md b/docs/adr/0005-rt-blinding-schedule.md index 774bfe2..ed11423 100644 --- a/docs/adr/0005-rt-blinding-schedule.md +++ b/docs/adr/0005-rt-blinding-schedule.md @@ -266,8 +266,7 @@ that do not depend on accepting the A/B candidate: too small for Elements' Simplicity cost-budget rule. Client finalization now appends the padding annex returned by the Simplicity cost bound when needed. An exhaustive regression covers every market lifecycle path, every sibling - input, both resolution outcomes, both redemption shapes, and all maker fill - shapes. + input, both resolution outcomes, and both redemption shapes. The first hardening commit preserved the rolling covenant, CMR, and source-level witness ABI. It established a stronger baseline before the @@ -334,9 +333,9 @@ Concrete-block sync tests start with no registered contract and exercise OP_RETURN recovery through `SyncCoordinator + DeadcatInterpreter`, redb reopen, idempotent replay, and coordinator-driven one-/two-block branch replacement. Deterministic fixtures prove two-market atomic interpreter/store orchestration; -the mandatory [multi-contract live gate](../acceptance/multi-contract-v1.md) -adds a real composed covenant transaction accepted by Elements and processed -as one atomic interpreter/store batch. Exact full-market and live +the mandatory `multi_market_transaction_is_accepted_and_indexed_by_elementsd` +gate adds a real transaction advancing two independent markets, accepted by +Elements and processed as one atomic interpreter/store batch. Exact full-market and live measurements are preserved in [`../measurements/binary-market-ab-v1.json`](../measurements/binary-market-ab-v1.json) and the acceptance packet. @@ -408,10 +407,10 @@ full checklist and evidence locations live in direct and coordinator-driven one-/two-block reorg replay, live wallet composition, and deterministic two-market atomic indexing pass the candidate corpus. -6. **Complete — live composed orchestration:** one real transaction advances a - market and two maker orders through Elements acceptance, +6. **Complete — live composed orchestration:** one real transaction advances + two independent markets through Elements acceptance, transaction-atomic indexing, restart, reorg replay, and independent client - validation in the mandatory multi-contract gate. + validation in the mandatory multi-market gate. 7. **Partially complete — review and approval:** Tommy Volk approved the protocol-owner checklist on 2026-07-14 against `7ed20b8b81306eaf81ee49b80b4ea65b49804871`. The constants, scalar byte diff --git a/docs/adr/0006-rfq-first-liquidity-scope.md b/docs/adr/0006-rfq-first-liquidity-scope.md index 0d8277a..8a98948 100644 --- a/docs/adr/0006-rfq-first-liquidity-scope.md +++ b/docs/adr/0006-rfq-first-liquidity-scope.md @@ -5,13 +5,14 @@ - Supersedes: ADR 0002's release-scope decision - Retires as historical: ADR 0003 - Amends: ADR 0001's node-side advisory-routing responsibility +- Implementation status updated: 2026-07-30 ## Context -The current clean-slate alpha implements both `BinaryMarketV1` and -`MakerOrderV1`. The maker contract supplied a useful on-chain limit-order -experiment and produced valuable contract-composition, recovery, indexing, and -acceptance evidence. +At the time of this decision, the clean-slate alpha implemented both +`BinaryMarketV1` and `MakerOrderV1`. The maker contract supplied a useful +on-chain limit-order experiment and produced valuable contract-composition, +recovery, indexing, and acceptance evidence. It is not the desired first public trading experience. A standing limit order is an awkward primary interface for a binary prediction market, while carrying @@ -98,10 +99,12 @@ links apply only to that revision. - ADR 0001's keyless shared-node trust boundary remains accepted, but the node no longer suggests trading routes. Venue discovery and routing are client-local responsibilities. -- Maker-specific code and tests can be deleted rather than deprecated. +- Maker-specific code and tests were deleted rather than deprecated in + [PR #16](https://github.com/Resolvr-io/deadcat-node/pull/16). - Generic atomic indexing, restart, reorg, rebuild, and composition guarantees - currently exercised by maker fixtures must receive market-only replacements - before those fixtures are removed. + received market-only replacements in + [PR #15](https://github.com/Resolvr-io/deadcat-node/pull/15) before the maker + fixtures were removed. - Removing maker code does not relax the requirement that binary-market replay exactly match the covenant's expiry semantics, including transaction-global locktime activation and height-versus-time classification. @@ -110,10 +113,11 @@ links apply only to that revision. ## Follow-up -1. Replace maker-dependent generic assurance fixtures with market-only - equivalents. -2. Remove `MakerOrderV1` through every active code, storage, wire, CLI, fixture, - test, and normative-document surface without changing version constants. +1. **Completed in PR #15:** replace maker-dependent generic assurance fixtures + with market-only equivalents. +2. **Completed in PR #16:** remove `MakerOrderV1` through every active code, + storage, wire, CLI, fixture, test, and normative-document surface without + changing version constants. 3. Prove a two-wallet confidential RFQ settlement on liquidregtest before freezing a remote RFQ protocol. 4. Add the smallest client-local exact-in/exact-out venue adapter and diff --git a/docs/adr/README.md b/docs/adr/README.md index 43ab83f..fc620a9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,7 +6,7 @@ change after covenant CMRs or public wire formats exist. | ADR | Decision | |---|---| | [0001](0001-authority-and-shared-node.md) | This repository is authoritative; the node is shared-safe and keyless; routing responsibility is amended by ADR 0006 | -| [0002](0002-v1-contract-scope.md) | **Superseded:** the alpha contained the binary market and maker order | +| [0002](0002-v1-contract-scope.md) | **Partially superseded:** historical two-contract alpha scope; binary-market decisions retained by ADR 0006 | | [0003](0003-order-economics.md) | **Historical:** the removed maker experiment used exact integer prices and one minimum active amount | | [0004](0004-chain-state-and-reorgs.md) | Chain transactions apply atomically; confirmed-tip state rolls back two blocks | | [0005](0005-rt-blinding-schedule.md) | **Proposed:** complementary A/B RT engineering evidence and protocol-owner approval are complete; focused external review remains | diff --git a/docs/architecture.md b/docs/architecture.md index 8088364..3ed941c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,44 +2,38 @@ ## Purpose -`deadcat-node` is a shared-safe Deadcat chain index and evidence service. It is -the authoritative source repository for canonical contract implementations, but -runtime clients do not trust a remote node with keys, wallet state, transaction -construction, or contract semantics. +`deadcat-node` is a shared-safe binary-market chain index and evidence service. +This repository is authoritative for the canonical SimplicityHL market +implementation, but runtime clients do not trust a remote node with keys, +wallet state, transaction construction, contract semantics, or venue selection. -The first release tracks binary markets and maker orders. LMSR types are -reserved in versioned enums and capability negotiation without an implementation. +Trading venues are separate systems. The initial direction is a noncustodial +RFQ service; future AMM and DLOB venues can sit behind the same client-side +router boundary without becoming node RPC methods. ## Workspace shape -The intended workspace is: - ```text -deadcat-types internal canonical IDs, domain types, and fixed codecs -deadcat-contracts canonical .simf, build-generated bindings, - parameters, interpreters, builders, and committed vectors -deadcat-client local evidence replay, routing, PSET construction, - native/WASM-facing API +deadcat-types canonical IDs, domain types, and fixed codecs +deadcat-contracts canonical .simf, generated bindings, economics, + interpretation, recovery, and committed vectors +deadcat-client local evidence replay and market PSET construction deadcat-rpc transport-independent versioned DTOs and cursors deadcat-iroh bounded Iroh client/server framing -deadcat-node chain coordinator, redb, discovery, backends, - and advisory route suggestions +deadcat-node chain coordinator, redb, discovery, and backends deadcat-cli operator and end-to-end client workflows ``` -The contracts and client crates are internal implementation boundaries and may -be marked `publish = false`. There is no commitment to a generic public -`deadcat-core` library. +The package and multi-instance machinery is generic even though +`BinaryMarketV1` is currently the only supported contract family. ## Contract generation Canonical `.simf` sources, `Simplex.toml`, `build.rs`, lockfiles, and golden -CMR/script/recovery vectors are committed. Generated Rust bindings live in a -crate-local ignored directory and are recreated by `simplex build` during a -clean Cargo build. Nix supplies the compiler, and CI verifies that its exact -smplx release/revision matches the Rust smplx libraries before generation. -Generated bindings are never hand-edited and shipped binaries do not need the -compiler at runtime. +CMR/script/recovery vectors are committed. Generated Rust bindings are +crate-local ignored build outputs recreated by `simplex build`. Nix supplies +the compiler, and CI verifies that the exact smplx release matches the Rust +libraries before generation. Shipped binaries do not need the compiler. ## Runtime boundaries @@ -47,39 +41,37 @@ compiler at runtime. The client owns: -- canonical contract templates pinned to a protocol release; +- canonical market templates pinned to a protocol release; - verification of creation parameters, CMRs, scripts, and asset relationships; -- replay of raw transition evidence; -- parent-market freshness preflight for every trade; -- local order-book routing and verification of advisory route suggestions; +- replay of raw creation and transition evidence; - wallet discovery, coin selection, and fee bounds; -- PSET construction, deterministic Deadcat pre-blinding, wallet-output - blinding, inspection, and signing; and +- market PSET construction, deterministic reissuance-token blinding, + wallet-output blinding, inspection, and signing; +- RFQ/AMM/DLOB venue queries and route selection; and - choice of one or more broadcasters. The official client never compiles arbitrary SimplicityHL supplied by a node. +Venue code must not treat node-derived state as quote authorization. ### Node The node owns: -- hint discovery plus ingestion of untrusted declarations and packages from - read-only Nostr and manual registration; -- canonical contract recompilation and chain verification; +- public market-hint discovery plus ingestion of untrusted packages; +- canonical market recompilation and chain verification; - complete-block, transaction-ordered ingestion; -- materialized current state, indexes, raw transition evidence, and history; +- materialized state, indexes, raw evidence, and history; - durable cursored subscriptions; -- sync/readiness reporting; and -- optional fee estimation, route suggestions, and signed-transaction relay. +- synchronization and readiness reporting; and +- optional fee estimation and signed-transaction relay. -The node has no wallet RPC. In particular it does not accept wallet -descriptors, wallet scripts, unblinded wallet inputs, blinding factors, or -unsigned PSET construction requests. A Deadcat `ContractDescriptor` is public -contract semantics for chain verification, not a wallet descriptor. +The node has no wallet RPC and no trading API. It does not accept wallet +descriptors, wallet scripts, unblinded inputs, blinding factors, quote +requests, routes, or unsigned-PSET construction requests. ## Evidence flow -Every derived state response is anchored to an exact chain and index position: +Every derived response is anchored to an exact chain and index position: ```text network + genesis hash @@ -87,153 +79,84 @@ source tip { height, hash } indexed tip { height, hash } sync status contract synced_to -state hash raw creation/transition references ``` -A client can re-fetch raw transactions, compile the relevant canonical -contract, and replay the transition sequence. This detects fabricated derived -state and contract-inconsistent evidence. Against a single remote node it does -not establish that the supplied chain view is canonical, current, or complete, -so clients may compare another node or a local Elements backend. That -independent comparison authenticates the complete consensus transaction at its -reported block position, including input and output witnesses: an Elements -`txid` excludes witness data, while Deadcat transition interpretation depends on -the Simplicity/Taproot witness. +A client can fetch raw transactions, compile the canonical market, and replay +the transition sequence. This detects fabricated derived state and +contract-inconsistent evidence. Against one remote node it does not establish +that the supplied chain view is current, canonical, or complete, so a client +may compare another node or local Elements backend. The independent check must +authenticate the complete consensus transaction at its reported block +position, including witnesses: an Elements `txid` does not commit to witness +data, while Deadcat interpretation does. ## Identity and portable ingestion -These types have intentionally separate responsibilities: - ```text -ContractId exact creation-anchor outpoint; stable instance identity -ContractDescriptor complete public semantics needed to compile a family +ContractId exact market creation-anchor outpoint +ContractDescriptor complete public semantics needed to compile a market ContractDeclaration untrusted ContractId-plus-descriptor claim -ContractPackage chain-bound roots plus declarations/dependencies +ContractPackage chain-bound roots plus declarations ``` -`ContractId` is a nominal newtype around `elements::OutPoint`, not a semantic -hash. A market uses its initial dormant YES RT output as its anchor; a maker -order uses its initial order output. Canonical maker creation derives a stable -instance ID from the creation input-prevout set and that output index, so -multiple orders in one transaction compile to distinct covenant and receive -scripts. A CMR identifies a Simplicity program commitment, not a stable live -outpoint. Conversely, an anchor alone says nothing about the alleged contract -semantics. The declaration supplies those semantics, and canonical chain -verification proves whether the claim is true. - -Ordinary UTXO references use `elements::OutPoint` throughout the internal chain -and transaction APIs. `ContractId` exists only to prevent confusing an -arbitrary current/wallet outpoint with a canonical creation anchor. Explicit -wire and redb codecs preserve protocol stability without introducing a second -generic project-specific outpoint, converting through Bitcoin's distinct txid -type, or coupling the protocol to LWK. - -Package format v1 binds declarations to the exact Liquid network and genesis, -names one through 16 declared roots, and carries at most 64 declarations. It -rejects duplicate roots/IDs, self-dependencies, missing root declarations, and -declarations unrelated to a root. A maker's parent market must be included in -the dependency closure or already verified by the node; package order is not -trusted. These bounds apply before expensive chain work. - -## Chain sources - -One internal `ChainSource` abstraction is implemented by: - -- `ElementsRpcChainSource`, using a locally validating `elementsd`; -- `EsploraChainSource`, using a public, private, or OAuth-authenticated - Esplora endpoint. - -The interface must provide the current tip, block hashes and complete ordered -blocks, raw transactions, outspends, issuance-origin lookup, script discovery, -fee estimates, and optional broadcast. Both implementations pass the same -backend compliance suite. - -The hosted production service should use its own Elements Core backend. Esplora -is the low-operations option for ordinary self-hosting and development. - -`ChainIdentity` stores the selected network, genesis hash, and native policy -asset. Liquid and Liquid testnet policy assets are compiled network constants, -not operator-selected configuration. The CLI derives them when omitted and -rejects a conflicting override before database creation; Elements regtest -requires an explicit asset because its chain parameters are dynamic. The store -rechecks this invariant before opening its initialization write transaction, -and the RPC handler rechecks persisted identity before exposing any capability. -Consequently an embedder or malformed legacy database cannot advertise -`FullHintScan` while interpreting contracts against the wrong production -collateral asset. - -Full public market recovery is a separate backend capability. With archival -Elements Core, the node scans complete blocks strictly after the exclusive v1 -activation anchor, -parses market hints, derives issuance assets, recompiles the market, verifies -the dormant RT outputs, and follows the resulting lineage. Esplora can provide -the same result only when raw historical blocks are available; standard Esplora -has no query for all OP_RETURNs matching a prefix, so a global scan is expensive. - -## Discovery - -Nostr events and manual RPC calls carry untrusted `ContractPackage` values. -Registration performs: - -1. package format, bounds, exact network/genesis, activation-height boundary, - roots, and dependency-graph validation; -2. confirmed raw creation-transaction retrieval from the node's own chain - source, with each shared transaction fetched at most once; -3. parent-market verification before child contracts, independent of supplied - declaration order; -4. canonical contract compilation and exact anchor/script matching; -5. asset, issuance, value, and family-specific creation validation; and -6. after all declarations pass, one atomic store transaction for every - contract's metadata, scripts, indexes, evidence, starting outpoints, and - normalized durable declaration. - -The sender is not an attesting authority. If any declaration fails, none of the -package is registered; an identical retry is idempotent. Package roots identify -the requested contracts, while included non-roots supply their dependency -closure. A dependency omitted from the package must already be verified in the -same node. - -Idempotence here describes state, not a zero-cost request. The alpha verifier -still retrieves and checks evidence before recognizing an identical retry. -Hosted public operators must enable the registration bearer token or enforce an -equivalent edge rate limit. Per-peer admission, a process-wide weighted -evidence budget, and a canonical stored-evidence fast path remain explicit -availability hardening before a production release; this does not weaken the -atomic or chain-verifiable registration boundary. - -Normalized declarations form a non-chain-derived watch registry. Materialized -records and their live outpoints are still discarded on a destructive rebuild; -the registry survives so replay from the persisted immutable v1 activation -checkpoint can verify retained markets before their retained maker children -against the exact replacement-branch transactions. Registration rejects -creation at or before that checkpoint, so the replay boundary cannot omit a -supported v1 contract. A declaration that is absent or invalid on the -replacement branch remains dormant and cannot prevent unrelated canonical -synchronization. - -For a binary market, step 5 is a critical solvency check. The node and client -independently require each uniquely derived issuance to have a null initial -outcome-token amount and a one-unit RT amount fully accounted for by the exact -one-unit side-A commitment locked at its compiled dormant script. On a -consensus-valid Elements transaction, this proves that no creator-retained -spendable RT authority exists; accepting a script match without that creation -proof could admit outcome tokens reissued outside the collateral covenant. The -protocol specification defines the complete creation invariant. - -That proof is relative to the supplied canonical chain evidence. Elements Core -mode validates the chain locally; an Esplora-backed node and a client using a -remote node retain the stale, incomplete, or false-chain-view risks described -in [ADR 0001](adr/0001-authority-and-shared-node.md). Contract-semantic replay -does not by itself prove that one remote source supplied the current canonical -Liquid chain. - -Market and canonical order recovery hints are publicly reconstructible. An -order hint supplies its parent reference, economics, side/direction, and maker -base key; adjacency plus the creation inputs supplies its instance ID. A node -therefore compiles and globally discovers an order from chain data alone. The -masked derivation index remains mnemonic-private, so a client can test ownership -locally without revealing its mnemonic or derived keys. +`ContractId` is a nominal newtype around `elements::OutPoint`. A market uses +its initial dormant YES reissuance-token output as its anchor. An anchor alone +says nothing about alleged semantics; the declaration supplies semantics and +canonical chain verification proves or rejects the claim. + +Package format v1 binds one to 16 roots and at most 64 declarations to an exact +Liquid network and genesis. Duplicate roots or IDs, missing root declarations, +and declarations not named as roots are rejected before expensive chain work. +Independent market declarations may share one creation transaction and are +still verified and committed atomically. + +Registration: + +1. validates package bounds, network/genesis, activation boundary, and roots; +2. fetches each shared confirmed creation transaction at most once; +3. compiles every declared market and verifies its exact anchor; +4. validates issuance, commitments, scripts, values, and recovery data; and +5. commits all declarations, evidence, indexes, and starting outpoints once. + +If any declaration fails, none is registered. An identical retry is +idempotent. Normalized declarations form a non-chain-derived watch registry +that survives destructive rebuild so retained markets can be replayed on the +replacement branch. + +## Chain sources and discovery + +The internal `ChainSource` abstraction has two production implementations: + +- `ElementsRpcChainSource`, backed by a locally validating `elementsd`; +- `EsploraChainSource`, backed by a public or private Esplora endpoint. + +Both provide tip and block data, raw transactions, outspends, issuance-origin +lookup, script history, fee estimates, and optional broadcast, and both pass +the same backend-equivalence gate. + +With archival Elements Core, the node scans complete blocks strictly after the +exclusive v1 activation anchor, parses public market hints, derives issuance +assets, recompiles markets, verifies dormant outputs, and follows their +lineages. Standard Esplora cannot globally query every matching OP_RETURN, so +Esplora-backed discovery relies on portable package registration unless the +deployment provides equivalent historical scanning. + +The critical creation proof requires each defining issuance to create no +outcome-token amount and exactly one reissuance token, fully accounted for by +the expected side-A commitment at the compiled dormant script. A script match +without this proof could admit outcome tokens reissued outside the collateral +covenant. + +## Persistence and reorgs + +Blocks are interpreted in transaction order and committed atomically. All +markets affected by one transaction share one evidence record and either +advance together or not at all. The store retains enough undo data for the +supported shallow reorg window. A deeper fork enters sticky `RescanRequired`; +chain-derived reads fail closed until the operator verifies chain identity, +resets derived materialization, and replays from the immutable activation +checkpoint. ## Transport and operations @@ -241,48 +164,24 @@ Iroh is the only v1 application transport: - ALPN `deadcat/1`; - UTF-8 JSON frames encoded as `[u32 little-endian length][JSON bytes]`; -- explicit `{ schema_version: u32, request_id: u64, ... }` request and - response envelopes, with the response echoing the request ID; -- one bidirectional QUIC stream per request: unary replies send one response - and finish, while subscriptions send an acknowledgement followed by durable - event envelopes; -- hard failure for unknown versions, variants, and fields rather than partial - interpretation; -- a 16 MiB per-frame limit, incremental reads, and a 32 MiB process-wide - inbound-byte semaphore so concurrent length prefixes cannot multiply memory - without bound; -- stable server EndpointId and authenticated encryption; -- connection, stream, request, and idle limits; +- strict versioned request/response envelopes; +- one bidirectional QUIC stream per request; +- hard failure for unknown variants and fields; +- bounded per-frame and process-wide inbound memory; +- stable authenticated endpoint identity; - pagination for growing collections; and -- graceful shutdown with completed-task reaping. - -The first scaffold includes a browser/WASM connection spike using the exact -pinned Iroh version. A transport failure requires a new decision record rather -than silently adding a public HTTP API. +- graceful shutdown with task reaping. -Shared deployments expose public reads. Registration, expensive historical -work, and broadcast receive method-specific quotas and optional capabilities. -Iroh client identities are not treated as durable anti-Sybil identities. - -Structured tracing includes chain-source latency, indexed lag, redb commit -latency, transitions by kind, RPC latency/errors, and subscription backpressure. -`GetInfo` is the protocol health and readiness endpoint. +Shared deployments expose public reads. Registration, historical evidence, and +broadcast receive method-specific bounds and optional authorization. ## Security consequences -A malicious hosted node cannot make the official client sign a transaction that -violates its locally displayed and validated spend intent. It can still omit -data, show stale-but-valid state, reduce best execution, censor relay, and learn -query timing. Self-hosting and independent cross-checks reduce that residual -trust. - -Post-resolution order risk is especially important: an independent order -remains covenant-fillable after its market terminates. Every trade snapshot -therefore includes the parent market even when the transaction will not spend -it. This is a client safety preflight, not a consensus guarantee. - -Canonical creation is the output-aliasing security boundary. Official builders -and nodes require the input-set-plus-vout instance derivation and adjacent -public hint. The covenant primitive accepts an arbitrary instance ID, so a -custom creator can intentionally make colliding, byte-identical scripts; such -orders are unsupported foreign contracts and must not enter official routing. +A malicious hosted node cannot make the official client sign a transaction +that violates locally reconstructed market semantics and spend intent. It can +still omit data, show stale-but-valid state, censor relay, and learn query +timing. Self-hosting and independent cross-checks reduce that residual trust. + +RFQ providers and future venues have a separate trust boundary: their quotes +may expire or become unfillable, but settlement remains noncustodial and the +client must verify the final atomic Liquid transaction before signing. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 3cdcdbc..6d015f3 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,7 +1,15 @@ -# V1 implementation plan - -Every phase has a test gate. A later phase does not compensate for an ambiguous -or untested covenant invariant in an earlier one. +# V1 alpha implementation record + +> **Completed historical record.** This plan describes the first alpha, +> including the subsequently removed maker-order experiment. No maker contract +> was deployed to testnet or mainnet. The final pre-removal implementation is +> preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). +> Current work is market-only and RFQ-first; this file is not the active +> roadmap. + +Every phase below had a test gate. A later phase did not compensate for an +ambiguous or untested covenant invariant in an earlier one. ## Phase 0: Protocol lock and workspace scaffold diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index 740e3a7..aaeb064 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -17,9 +17,8 @@ market-only production scope, RFQ-first liquidity boundary, complete future interfaces and phases in this roadmap remain non-normative until their own specifications and acceptance work exist. -In particular, this roadmap does not by itself: +In particular, this roadmap is not an implementation mechanism and does not: -- remove or disable `MakerOrderV1`; - change any deployed contract's semantics; - supersede an accepted architecture decision record; - define a stable RFQ, AMM, DLOB, or router API; or @@ -30,7 +29,9 @@ updates. ADR 0006 records that no maker contract reached Liquid testnet, mainnet, or production and that incompatible local alpha data is disposable. `MakerOrderV1` therefore has no compatibility, migration, recovery, indexing, or versioning period; any later output using the published alpha covenant is an -unsupported foreign contract. +unsupported foreign contract. The market-only implementation work was +completed by [PR #15](https://github.com/Resolvr-io/deadcat-node/pull/15) and +[PR #16](https://github.com/Resolvr-io/deadcat-node/pull/16). The roadmap should be archived or split into normative specifications once every phase has either shipped or been explicitly rejected and its surviving @@ -42,7 +43,7 @@ the current alpha implementation or erase its acceptance history: | Horizon | Supported liquidity scope | |---|---| -| Current alpha | `BinaryMarketV1` and `MakerOrderV1` are implemented and tested | +| Current alpha | `BinaryMarketV1` only; the maker experiment is retained solely as historical evidence | | First public production release | Binary-market lifecycle plus a separate noncustodial RFQ service | | Future scope | Permissionless AMM and/or DLOB venues, followed by bounded atomic split routing | @@ -224,14 +225,16 @@ ADR 0006 completes the scope and compatibility decision: | CI and acceptance evidence | Replace maker-dependent generic gates; retain dated packets as historical evidence | | Existing alpha data | Delete or rebuild; no migration or compatibility decoding | -The remaining Phase 0 implementation work is: +Phase 0 is complete: -- Make the supported production creation and routing scope consistently - market-only across the protocol, README, architecture, RPC, and acceptance - docs. -- Preserve the maker-order audit and acceptance work as dated historical +- [PR #15](https://github.com/Resolvr-io/deadcat-node/pull/15) replaced + maker-dependent generic assurance with atomic market-only coverage. +- [PR #16](https://github.com/Resolvr-io/deadcat-node/pull/16) made the active + contract, client, node, storage, wire, CLI, fixture, CI, and normative + documentation surfaces market-only. +- The maker-order audit and acceptance work remains as dated historical evidence with immutable source references. -- Define the RFQ process as separate from `deadcat-node`. +- The RFQ process is explicitly separate from `deadcat-node`. ### Phase 1: one noncustodial RFQ provider @@ -449,9 +452,10 @@ Time validity, state validity, and client freshness policy are distinct. Parent-market trading state is a global client pre-sign policy for every venue unless a venue covenant explicitly consumes or authenticates live market state. -`MakerOrderV1`, for example, does not natively close its fill path when its -parent market terminates. A route can therefore be invalid under client policy -even while each selected venue input remains consensus-spendable. +The retired alpha `MakerOrderV1`, for example, did not natively close its fill +path when its parent market terminated. A future venue can likewise be invalid +under client policy even while each selected venue input remains +consensus-spendable. An illustrative validity model is: @@ -545,27 +549,31 @@ bind the user's aggregate result. Every leg confirms together or none does. The existing [multi-contract acceptance packet](acceptance/multi-contract-v1.md) is -historical proof that the current toolchain, interpreter, store, and Elements -boundary can process one transaction advancing heterogeneous contracts. It does -not by itself prove that future RFQ, AMM, and DLOB layouts will compose safely. +historical proof that the pre-removal toolchain, interpreter, store, and +Elements boundary processed one transaction advancing heterogeneous contracts. +The active +[multi-market gate](../crates/deadcat-client/tests/market_regtest.rs) proves the +current market-only stack still indexes, replays, rolls back, and rebuilds one +transaction advancing multiple contracts atomically. Neither result proves +that future RFQ, AMM, and DLOB layouts will compose safely. ### Current implementation footholds -The current client already contains useful shapes, but none is the stable venue +The current client contains useful shapes, but none is the stable venue interface proposed here: - [`BinaryMarketTransitionPlan`](../crates/deadcat-client/src/market_builder.rs) exposes mandatory outputs at a caller-chosen base and finalizes only against the composed PSET. -- [`MakerFillPlan`](../crates/deadcat-client/src/maker_builder.rs) accepts - caller-selected payment and remainder indices and rejects an aliased output. - That is useful historical composition evidence, not a reason to keep its - economics or contract in production scope. - [Client validation](../crates/deadcat-client/src/validation.rs) is already a separate authority boundary from node indexing. -- The - [live multi-contract fixture](../crates/deadcat-client/tests/market_regtest.rs) - constructs and confirms one six-input, ten-output heterogeneous transaction. +- The [live multi-market fixture](../crates/deadcat-client/tests/market_regtest.rs) + composes two market transitions and proves transaction-atomic behavior. +- The retired + [`MakerFillPlan`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/maker_builder.rs) + and + [heterogeneous live fixture](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/tests/market_regtest.rs) + remain historical composition evidence, not production interfaces. Phase 1 should extract and test the smallest generic plan/composer seam from these patterns instead of making the router depend on maker-specific types. @@ -917,17 +925,16 @@ three independently designed fragment layouts compose safely. ADR 0006 accepted the direction, superseded the release-scope decision in [ADR 0002](adr/0002-v1-contract-scope.md), and retired -[ADR 0003](adr/0003-order-economics.md) as historical. Before production scope -changes ship: +[ADR 0003](adr/0003-order-economics.md) as historical. The Phase 0 documentation +work is complete: -1. update the [README](../README.md) and - [architecture](architecture.md) to distinguish the keyless node from the - inventory-bearing RFQ service; -2. mark the [existing implementation plan](implementation-plan.md) as a +1. the [README](../README.md) and [architecture](architecture.md) distinguish + the keyless node from the inventory-bearing RFQ service; +2. the [existing implementation plan](implementation-plan.md) is a completed alpha record; -3. update the protocol and storage/RPC specifications alongside actual code - removal or capability changes; -4. preserve dated maker-order audits and acceptance packets with immutable +3. the protocol and storage/RPC specifications match the market-only code and + capability surface; +4. dated maker-order audits and acceptance packets are preserved with immutable source references; and -5. retain the heterogeneous multi-contract acceptance result as evidence for - future atomic composition while replacing its active maker-dependent gate. +5. the heterogeneous multi-contract result remains historical evidence while + the active gate uses two independent markets. diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 4f215ea..05636f4 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -58,27 +58,19 @@ convention above. Its strict human-readable wire encoding is the object `{"txid": "...", "vout": n}`; the ordinary `elements::OutPoint` string serde is not inherited. -The family-specific creation anchor is: - -- binary market: the initial dormant YES RT output, whose exact side-A - commitment and compiled `DormantYesRt` script are verified along with the NO - leg and the complete creation invariant; and -- maker order: the initial output holding the order at its compiled maker-order - script. +The creation anchor is the binary market's initial dormant YES RT output. Its +exact side-A commitment and compiled `DormantYesRt` script are verified along +with the NO leg and the complete creation invariant. For the official standalone market layout the market anchor is vout 0. A -validated custom composition may place it elsewhere. The maker anchor is the -exact canonical order output index. One transaction can create multiple orders, -but each canonical order has an instance-specific script; byte-identical -noncanonical clones are deliberately outside the supported security envelope. +validated custom composition may place it elsewhere, and one transaction may +create multiple independently anchored markets. The ID remains stable as later transactions move or terminate the contract. It does not commit to the descriptor and does not certify that the output exists or is a valid Deadcat contract. That proof belongs to declaration ingestion and chain evidence. Simplicity CMRs are deterministically derived from the stored -parameters. The maker CMR varies with its instance-derived receive-script hash, -but the stable protocol identity is still the verified creation anchor rather -than the CMR. +parameters, while stable protocol identity is the verified creation anchor. Ordinary transaction inputs, outputs, and live contract state use `elements::OutPoint` directly. The protocol does not define a duplicate generic @@ -100,11 +92,6 @@ pub enum ContractDescriptor { BinaryMarketV1 { params: BinaryMarketParams, }, - MakerOrderV1 { - parent_market: ContractId, - side: OrderSide, - params: MakerOrderParams, - }, } pub struct ContractDeclaration { @@ -121,11 +108,9 @@ pub struct ContractPackage { ``` A descriptor contains the complete public semantics required to compile one -supported contract. A declaration is an untrusted claim that its descriptor is -instantiated at its `ContractId`. A package is the portable registration unit: -roots name what the sender wants ingested, while additional declarations may -carry the roots' parent dependency closure. None of these objects attests to -chain inclusion or validity. +market. A declaration is an untrusted claim that its descriptor is instantiated +at its `ContractId`. A package is the portable atomic registration unit. None +of these objects attests to chain inclusion or validity. Package format v1 has these structural rules: @@ -134,40 +119,28 @@ Package format v1 has these structural rules: node; - there are 1 through 16 unique roots and 1 through 64 unique declarations, with every root declared; -- a declaration cannot depend on itself, and every included declaration must - be reachable from a root through included parent edges, so unrelated payload - padding is rejected; and -- a referenced parent must either be included in the package or already be a - verified contract in the receiving node. An included maker parent must be a - binary market. +- every included declaration is named as a root, so unrelated payload padding + is rejected. Declaration order has no authority over verification. The verifier resolves -parents before children, fetches each shared creation transaction at most once, -and checks that a child was not created before its parent. Registration receipts -nevertheless preserve the sender's order: `roots` matches package root order and -`contracts` matches declaration order. It retrieves confirmed creation -transactions and status from its own configured chain source. The cumulative -consensus-encoded size of unique creation transactions is limited to 16 MiB per -package, matching the Iroh RPC frame ceiling and bounding server-side evidence -work that is not present in the inbound request. The verifier recompiles the -canonical family locally, checks the nominated anchor and all family-specific -creation invariants, and registers verified contracts as catching up. The node -then replays their lineage to its indexed tip. Supplied current outpoints or raw -transactions, if supported later as acceleration hints, never replace that -retrieval and verification. +each shared creation transaction at most once. Registration receipts preserve +sender order: `roots` matches package root order and `contracts` matches +declaration order. The verifier retrieves confirmed creation transactions and +status from its own chain source, recompiles each market, checks its nominated +anchor and creation invariants, and registers all markets as catching up. The +node then replays each lineage to its indexed tip. Only after every declaration succeeds does one redb write transaction register -the complete package. One invalid declaration, dependency, chain identity, or -conflicting existing record rejects the package without partial insertion. An -identical retry is idempotent. The same transaction also retains a normalized -copy of every verified declaration as explicit watch intent. Chain-derived -state is still disposable: after a destructive rebuild, replay immediately -after the immutable network activation checkpoint matches those declarations -by creation transaction, verifies markets before maker children against the -exact canonical transactions, and rematerializes only the claims that remain -valid. Registration rejects v1 creation at or before that checkpoint, making -the replay boundary complete. Missing or invalid claims stay dormant rather -than blocking unrelated synchronization. +the complete package. One invalid declaration, chain identity, or conflicting +existing record rejects the package without partial insertion. An identical +retry is idempotent. The same transaction also retains a normalized copy of +every verified declaration as explicit watch intent. Chain-derived state is +still disposable: after a destructive rebuild, replay immediately after the +immutable network activation checkpoint matches those declarations by creation +transaction and rematerializes only the claims that remain valid. Registration +rejects v1 creation at or before that checkpoint, making the replay boundary +complete. Missing or invalid claims stay dormant rather than +blocking unrelated synchronization. ### Recovery outputs @@ -604,318 +577,18 @@ still rediscover a market creation transaction it funded, while a token holder can locate the same transaction through first-issuance lookup for an unknown YES/NO asset. -## 3. Maker limit order - -### Parameters - -```rust -pub enum OrderDirection { - SellBase, - SellQuote, -} - -pub struct MakerOrderParams { - pub base_asset_id: AssetId, - pub quote_asset_id: AssetId, - pub price: u32, - pub min_active_base: u32, - pub direction: OrderDirection, - pub instance_id: [u8; 32], - pub maker_pubkey: XOnlyPublicKey, -} -``` - -BASE is one parent-market outcome token. QUOTE is exactly the parent collateral -asset. Canonical validity is: - -```text -1 <= price <= parent_market.cp -1 <= min_active_base -``` - -The order input and every covenant-constrained output use explicit asset and -value. Taker wallet outputs may be confidential. - -`maker_pubkey` is a per-wallet-index base key. `instance_id` derives independent -cancellation and receive keys. The derived cancellation key is the Taproot -internal key, and key-spend is the sole cancellation mechanism. The Simplicity -leaf contains only the permissionless fill path; it has no cosigner and no -script-cancel branch. - -### Creation - -The public builder accepts `offered_base_capacity: u64` for both directions and -requires it be at least `min_active_base`. - -```text -SellBase locks offered_base_capacity BASE atoms -SellQuote locks offered_base_capacity * price QUOTE atoms -``` - -The SellQuote rule makes every canonical live remainder an exact multiple of -price. A non-multiple foreign creation is not a canonical v1 order. - -Canonical creation additionally requires: - -```text -sorted_prevouts = - lexicographically sort each (txid_internal_bytes || vout_be_u32) - -inputs_commitment = tagged_hash( - "deadcat/order-inputs/v1", - input_count_be_u32 || concat(sorted_prevouts) -) - -instance_id = tagged_hash( - "deadcat/order-instance/v1", - inputs_commitment || order_creation_vout_be_u32 -) -``` - -Every creation input prevout and the reserved order vout are known before the -outputs are constructed, so this has no txid circularity. Input ordering does -not affect identity. `instance_id` remains unchanged through partial fills even -when the continuation moves to another vout. - -The covenant compiler accepts any 32-byte `instance_id`; that property keeps -the contract primitive composable. Official builders, registration, and public -discovery require the derivation above. A noncanonical creator can deliberately -reuse an instance ID and produce byte-identical order scripts, reintroducing -output aliasing for software that elects to treat those outputs as orders. -Noncanonical orders are therefore unsupported and must be handled as unsafe -foreign contracts. - -The canonical order output is immediately followed by its recovery/discovery -hint. That adjacency lets a scanner determine the creation vout without placing -`instance_id` in the hint. - -### Fill layout - -The script witness selects a maker-payment output `p`, an explicit -`is_partial` branch, and a remainder output `r`. For an order input at any index: - -- the maker payment at `p` must use the instance-derived receive script; -- `p != r` always, keeping the two witness fields unambiguous; a full fill does - not otherwise inspect `r`; -- the remainder output must reproduce the exact covenant script; and -- output indices and products are bounds/overflow checked. - -Neither output index is coupled to the order input index. Canonical instance -uniqueness makes both the receive script and continuation script order-specific, -so independently selected outputs cannot satisfy another canonical order. -Client fill plans additionally bind the exact live input outpoint, preventing a -composer from silently substituting a same-script foreign output. - -Let `I` be order input amount, `M` maker output amount, `R` nonzero remainder, -`P` price, `A` minimum, and `F` filled BASE atoms. - -#### SellBase - -```text -input asset = BASE -maker asset = QUOTE - -full: - F = I - M = F * P - F >= A - no covenant continuation - -partial: - 0 < R < I - remainder asset = BASE - F = I - R - M = F * P - F >= A - R >= A -``` - -#### SellQuote - -```text -input asset = QUOTE -maker asset = BASE -F = M - -full: - I = F * P - F >= A - no covenant continuation - -partial: - R > 0 - remainder asset = QUOTE - I = F * P + R - F >= A - R >= A * P -``` - -Every maker payment is exact. Overpayment does not substitute for an equality. - -### Cancellation and transition detection - -After stripping an optional Taproot annex: - -- key-spend of the tracked outpoint is cancellation, regardless of unrelated - outputs; -- script-spend is a fill; -- a partial fill adopts only the witness-selected remainder output; and -- a full fill has no tracked continuation even if the transaction contains an - unrelated decoy output with the same script. - -The interpreter validates the exact direction-specific equation before updating -state. - -Canonical state is: - -```rust -pub enum MakerOrderState { - Active { - remaining_base: u64, - total_filled_base: u64, - }, - Consumed, - Cancelled, -} -``` - -For SellQuote, `remaining_base = explicit_quote_amount / price`; canonical -creation and transitions make the division exact. - -### Parent-market terminal state - -The covenant does not inspect or co-spend the parent market. It remains -consensus-fillable after resolution or expiry until consumed or cancelled. -Official routing stops as soon as the parent is observed outside Trading, and -every preflight carries a fresh parent snapshot. This does not prevent an -adversarial custom fill. - -### Key derivation and recovery - -The mnemonic derives the Deadcat root: - -```text -m/86'/1145258324' -``` - -Numeric hardened children are fixed as: - -```text -m/86'/1145258324'/0' deadcat_secret_key -m/86'/1145258324'/1'/i' maker key at u16 order index i -m/86'/1145258324'/2'/i' reserved future pool admin key -``` - -```text -cancel_tweak = hash_to_scalar("deadcat/order-cancel/v1", instance_id) -receive_tweak = hash_to_scalar("deadcat/order-receive/v1", instance_id) - -P_cancel = xonly_add_tweak(maker_pubkey, cancel_tweak) -P_receive = xonly_add_tweak(maker_pubkey, receive_tweak) - -maker_receive_spk = OP_1 PUSH32 P_receive -maker_receive_spk_hash = SHA256(maker_receive_spk) -``` - -`maker_pubkey` is the BIP-340 x-only serialization of the even-Y lift of the -derived maker key. `xonly_add_tweak` uses the standard secp256k1 x-only tweak -operation, records the resulting parity needed to derive the corresponding -private spend key, and returns the result's 32-byte x-only serialization. -`P_cancel` is the Taproot internal key for the covenant tree; `P_receive` is the -internal key of the maker's key-path receive output. An infinity result makes -that instance unusable rather than selecting a different unstated derivation. - -`direction_byte` is zero for SellBase and one for SellQuote, matching the order -type-tag direction bit and mask context. - -Order-mask context is: - -```text -parent_market_reference 36 bytes -price 4 bytes, BE -side 1 byte, YES=0, NO=1 -direction 1 byte, SellBase=0, SellQuote=1 -min_active_base 4 bytes, BE -maker_pubkey 32 bytes -``` - -```text -mask_bytes = HMAC-SHA256( - deadcat_secret_key, - "deadcat/order_mask" || context -)[0..2] - -mask_u16 = big_endian_u16(mask_bytes) -masked_order_index = order_index XOR mask_u16 -``` - -The masked index remains an owner mnemonic-recovery aid, but the rest of the -hint is intentionally public. A node does not need to unmask the index: -adjacency supplies the order vout, the creation inputs derive `instance_id`, and -the hint plus verified parent supplies every remaining compile parameter. - -For chain-only owner recovery, the client scans order hints, unmasks a candidate -index, derives the base/cancellation/receive keys using the chain-derived -`instance_id`, and accepts ownership only if the base maker key and compiled -script match the hint and adjacent creation output. XOR unmasking produces some -`u16` for every foreign hint; the public-key and script matches are therefore -the ownership test. The mnemonic or derived xprv is never sent to the node. - -Public registration supplies a `ContractPackage`. A maker-order descriptor -contains the full parent `ContractId`, side, direction, price, minimum, maker -base public key, and canonical instance ID; its declaration nominates the exact -creation output. The node re-derives the instance ID, requires the adjacent -matching hint, and verifies the explicit state, script, asset, amount, and -economics. The node derives its current outpoint only by replaying that anchor's -complete spend lineage. - -### Recovery hint - -V1 order tags are: - -```text -0x40 YES / SellBase -0x44 YES / SellQuote -0x48 NO / SellBase -0x4c NO / SellQuote -``` - -Payload, 79 bytes: - -```text -Byte 0 complete order type tag -Bytes 1-2 masked_order_index, u16 big-endian -Bytes 3-34 parent market txid, internal byte order -Bytes 35-38 parent market vout, u32 big-endian -Bytes 39-42 price, u32 big-endian -Bytes 43-46 min_active_base, u32 big-endian -Bytes 47-78 maker base x-only public key -``` - -The complete canonical script is `OP_RETURN OP_PUSHDATA1 0x4f ` (82 -bytes). No trailing bytes are accepted. For an already-created parent, bytes -3-38 are its complete `ContractId`. For a parent market created in the same -transaction, an all-zero parent txid is the reserved “this transaction” -sentinel and the vout remains explicit. This avoids a txid self-reference while -retaining atomic market-plus-order creation. - -The node globally associates the hint only after it resolves the parent, -derives the adjacent order's instance ID, compiles the covenant, and verifies -the exact output. A copied or malformed hint is only a rejected candidate. - -## 4. Confidentiality matrix +## 3. Confidentiality matrix | Output/input role | Asset/value visibility | |---|---| | Market collateral state | explicit | -| Order input, maker payment, remainder | explicit | | YES/NO cancellation/redemption burns | explicit | | RT state and RT terminal burns | confidential, covenant verified | | User token destination | explicit or confidential | | User collateral payout/change | explicit or confidential | | Fee output | standard explicit policy-asset fee | -## 5. Required golden vectors +## 4. Required golden vectors Machine-readable fixtures are committed before a contract is considered stable: @@ -932,32 +605,20 @@ Machine-readable fixtures are committed before a contract is considered stable: 7. expiry lock-height boundary fixtures (`nLockTime = H - 1` rejected by the covenant, block `H` not final, block `H + 1` accepted) plus valid late oracle resolution races and the `500_000_000` type boundary; -8. mnemonic to numeric paths, secret, base order key, canonical instance ID, - mask, cancellation/receive tweaks and parity, receive private key, and - receive script/hash; -9. all four order hint tags; -10. both directions' full/partial fills at minimum and overflow boundaries; -11. decoy-output and shifted-window transactions proving witness-grounded +8. decoy-output and shifted-window transactions proving witness-grounded interpretation; -12. key-spend cancellation with and without annex; -13. a custom composed market-plus-multiple-orders transaction and its atomic - transition batch; -14. anchor-based ContractId/wire/redb key encodings, multi-contract same-tx +9. a custom transaction advancing multiple independent markets and its atomic + transition batch; +10. anchor-based ContractId/wire/redb key encodings, multi-market same-tx identity, package validation/atomicity, and apply/rollback fixtures; and -15. `hash_to_scalar` modular-reduction cases generated from fixed artificial +11. `hash_to_scalar` modular-reduction cases generated from fixed artificial inputs. -## 6. Superseded historical choices +## 5. Superseded historical choices V1 deliberately supersedes these older proposals: - rounded `expiry_time / 60` stored as u24; -- u24/u64 canonical order price; -- independent u8 fill/remainder minimums; -- rational price with maker-favoring ceiling rounding; -- maker fill cosigner; -- Simplicity script cancellation; -- fixed `current_index + 1` order remainder; - first-matching-script transition detection; - state models that store `outstanding_pairs` after asymmetric expiry redemption; diff --git a/docs/simplicity-contract-audit-2026-07-24.md b/docs/simplicity-contract-audit-2026-07-24.md index e3e8140..cf84f89 100644 --- a/docs/simplicity-contract-audit-2026-07-24.md +++ b/docs/simplicity-contract-audit-2026-07-24.md @@ -1,12 +1,19 @@ # Simplicity Contract Audit — 2026-07-24 +> **Historical alpha record.** The maker-order contract audited here was +> removed before any testnet or mainnet deployment. Its final pre-removal +> implementation is preserved at +> [`d7be35b27a020a61333e471b2ded5f59e3a0a039`](https://github.com/Resolvr-io/deadcat-node/tree/d7be35b27a020a61333e471b2ded5f59e3a0a039). +> Maker findings and source links below describe that revision, not the current +> market-only codebase. + ## Status and scope This document records an internal source-level audit of the two SimplicityHL contracts in this repository: -- [`binary_market.simf`](../crates/deadcat-contracts/simplicityhl/binary_market.simf) -- [`maker_order.simf`](../crates/deadcat-contracts/simplicityhl/maker_order.simf) +- [`binary_market.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/binary_market.simf) +- [`maker_order.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/maker_order.simf) The audited revision was `3efcc11` on branch `codex/canonical-maker-orders`. @@ -30,12 +37,16 @@ environment, and the daemon-free test suites listed under ### Document lifecycle -**Status:** Active audit record. +**Status:** Historical audit record; not yet superseded. This report describes revision `3efcc11`; it is a historical security record, not the normative protocol specification. Findings should be updated with links to their fix, accepted-risk decision, or other disposition. +H-1 was resolved by merged PR #11. H-2, M-1, M-2, L-1 through L-4, and L-6 +were retired with the maker-order subsystem before deployment; they remain here +as design evidence. L-5 still applies to the market-only build. + Mark this report **Superseded** when: - every finding has a documented disposition; @@ -93,18 +104,17 @@ outputs if the creation-time reissuance-token supply checks ever regressed. **Severity:** High -**Disposition:** Remediation is proposed in -[PR #11](https://github.com/Resolvr-io/deadcat-node/pull/11). It makes the +**Disposition:** Resolved by merged +[PR #11](https://github.com/Resolvr-io/deadcat-node/pull/11), which makes the shared interpreter reproduce the transaction-global `check_lock_height` predicate and retains covenant/interpreter regressions for a non-final -follower, all-final inputs, and a time-typed lock. Mark this finding resolved -when that PR merges. +follower, all-final inputs, and a time-typed lock. **Affected components:** -- [`binary_market.simf`](../crates/deadcat-contracts/simplicityhl/binary_market.simf#L574-L616) -- [`interpret/binary_market.rs`](../crates/deadcat-contracts/src/interpret/binary_market.rs#L984-L996) -- [`sync.rs`](../crates/deadcat-node/src/sync.rs#L473-L476) +- [`binary_market.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/binary_market.simf#L574-L616) +- [`interpret/binary_market.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/src/interpret/binary_market.rs#L984-L996) +- [`sync.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/sync.rs#L473-L476) - Independent client history replay, which uses the same interpreter #### Contract behavior @@ -175,9 +185,9 @@ This is an inexpensive contract-specific indexing and availability failure, not a direct collateral theft. The official builder does not expose the issue because -[`prepare_expiry`](../crates/deadcat-client/src/market_builder.rs#L484-L508) +[`prepare_expiry`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/market_builder.rs#L484-L508) sets every contract input to a non-final sequence, and -[`verify_expiry`](../crates/deadcat-client/src/market_builder.rs#L769-L799) +[`verify_expiry`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/market_builder.rs#L769-L799) requires all contract inputs to remain non-final. A custom transaction is not bound by that stricter builder policy. @@ -204,7 +214,7 @@ covenant-valid arrangement. #### Recommended regression The narrowest red-to-green regression belongs beside the active-expiry tests in -[`tests/interpret.rs`](../crates/deadcat-contracts/tests/interpret.rs#L478-L645): +[`tests/interpret.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/tests/interpret.rs#L478-L645): 1. Refactor `finalized_active_expiry` to accept three input sequences. 2. Build the mixed sequence array @@ -228,8 +238,8 @@ and require both covenant execution and interpretation to reject the expiry. **Affected components:** -- [`validation.rs::replay_contract_history`](../crates/deadcat-client/src/validation.rs#L398-L480) -- [`validation.rs::replay_maker`](../crates/deadcat-client/src/validation.rs#L517-L616) +- [`validation.rs::replay_contract_history`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/validation.rs#L398-L480) +- [`validation.rs::replay_maker`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/validation.rs#L517-L616) #### Current behavior @@ -250,17 +260,17 @@ It does not: - enforce the network activation checkpoint used by registration. `replay_contract_history` accepts `Option<&ContractView>` for the parent. -[`validate_order_against_parent`](../crates/deadcat-client/src/validation.rs#L155-L201) +[`validate_order_against_parent`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/validation.rs#L155-L201) checks structural and economic consistency, but a raw `ContractView` is not evidence that the parent was canonically created or replayed. Node registration correctly rederives the instance identity at -[`registration.rs`](../crates/deadcat-node/src/registration.rs#L662-L673) and +[`registration.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/registration.rs#L662-L673) and checks the parent and recovery hint at -[`registration.rs`](../crates/deadcat-node/src/registration.rs#L674-L778). +[`registration.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/registration.rs#L674-L778). The existing test -[`maker_creation_replay_uses_the_exact_nominated_output`](../crates/deadcat-client/src/validation.rs#L1791-L1865) +[`maker_creation_replay_uses_the_exact_nominated_output`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/validation.rs#L1791-L1865) constructs a zero-input transaction containing two identical order outputs and expects either vout to replay independently. Canonical instance derivation explicitly rejects an empty creation-input set. @@ -296,11 +306,11 @@ registration rejects it. **Affected components:** -- [`maker_order/compiled.rs`](../crates/deadcat-contracts/src/maker_order/compiled.rs#L30-L50) -- [`maker_order.simf`](../crates/deadcat-contracts/simplicityhl/maker_order.simf#L67-L84) -- [`interpreter.rs::validate_atomic_claims`](../crates/deadcat-node/src/interpreter.rs#L378-L408) +- [`maker_order/compiled.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/src/maker_order/compiled.rs#L30-L50) +- [`maker_order.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/maker_order.simf#L67-L84) +- [`interpreter.rs::validate_atomic_claims`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/interpreter.rs#L378-L408) - The uniqueness claim in - [`protocol-v1.md`](protocol-v1.md#fill-layout) + [`protocol-v1.md`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/docs/protocol-v1.md#fill-layout) #### Current derivation @@ -366,8 +376,8 @@ Use one or more of: **Affected components:** -- [`deadcat-client/src/keys.rs`](../crates/deadcat-client/src/keys.rs#L109-L164) -- [`maker_order/compiled.rs`](../crates/deadcat-contracts/src/maker_order/compiled.rs#L30-L50) +- [`deadcat-client/src/keys.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/keys.rs#L109-L164) +- [`maker_order/compiled.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/src/maker_order/compiled.rs#L30-L50) Both private keys have the form: @@ -404,11 +414,11 @@ remainder_quote >= min_active_base * price ``` See -[`maker_order.simf`](../crates/deadcat-contracts/simplicityhl/maker_order.simf#L121-L152). +[`maker_order.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/maker_order.simf#L121-L152). It does not require `remainder_quote % price == 0`. The Rust economics/interpreter rejects a non-integral remainder at -[`maker_order.rs`](../crates/deadcat-contracts/src/maker_order.rs#L204-L227). +[`maker_order.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/src/maker_order.rs#L204-L227). For example: @@ -471,7 +481,7 @@ creation validation should prove that a full or partial fill exists. **Severity:** Low to medium API footgun -[`maker_order_creation_outputs`](../crates/deadcat-client/src/maker_builder.rs#L22-L59) +[`maker_order_creation_outputs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/maker_builder.rs#L22-L59) is described as constructing canonical creation outputs, but it receives no verified parent market. It cannot validate: @@ -492,12 +502,12 @@ not registerable/canonical, outputs. **Severity:** Low -[`ADR 0002`](adr/0002-v1-contract-scope.md#compatibility-policy) says recovery +[`ADR 0002`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/docs/adr/0002-v1-contract-scope.md#compatibility-policy) says recovery hints are advisory and that manual registration can track an otherwise canonical contract without one. Maker registration unconditionally requires an adjacent matching hint at -[`registration.rs`](../crates/deadcat-node/src/registration.rs#L750-L778). +[`registration.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/registration.rs#L750-L778). Independent client replay checks no hint at all. As a result, the sets of: @@ -512,7 +522,7 @@ are different in a way the documented policy does not describe. **Severity:** Low -[`build.rs`](../crates/deadcat-contracts/build.rs#L3-L21) accepts a compiler +[`build.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/build.rs#L3-L21) accepts a compiler version when: ```rust @@ -530,10 +540,10 @@ out-of-environment builds safer and more reproducible. **Affected components:** -- [`maker_order.simf`](../crates/deadcat-contracts/simplicityhl/maker_order.simf#L67-L181) -- [`interpreter.rs`](../crates/deadcat-node/src/interpreter.rs#L445-L481) +- [`maker_order.simf`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/simplicityhl/maker_order.simf#L67-L181) +- [`interpreter.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-node/src/interpreter.rs#L445-L481) - The fill-layout uniqueness discussion in - [`protocol-v1.md`](protocol-v1.md#fill-layout) + [`protocol-v1.md`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/docs/protocol-v1.md#fill-layout) Each maker execution reads its held amount from the current input but selects its maker-payment and optional continuation outputs by witness indices. If a @@ -763,7 +773,7 @@ independent live vulnerabilities. The Simplicity source itself validates payout and expiry parameters. The Rust compiler additionally validates the oracle x-only key and requires collateral, outcome-token, and RT asset IDs to be distinct at -[`binary_market/compiled.rs`](../crates/deadcat-contracts/src/binary_market/compiled.rs#L170-L195). +[`binary_market/compiled.rs`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-contracts/src/binary_market/compiled.rs#L170-L195). Bypassing that wrapper creates an unsupported foreign program. ### Maker order @@ -958,8 +968,8 @@ routing stops after observing a terminal parent, but that is policy rather than consensus protection. This is explicitly documented in -[`protocol-v1.md`](protocol-v1.md#parent-market-terminal-state) and -[`ADR 0002`](adr/0002-v1-contract-scope.md#order-responsibility). +[`protocol-v1.md`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/docs/protocol-v1.md#parent-market-terminal-state) and +[`ADR 0002`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/docs/adr/0002-v1-contract-scope.md#order-responsibility). ### Expiry opens a path; it does not force termination diff --git a/docs/storage-sync-rpc.md b/docs/storage-sync-rpc.md index a9f1ded..79e83c2 100644 --- a/docs/storage-sync-rpc.md +++ b/docs/storage-sync-rpc.md @@ -1,51 +1,14 @@ # Storage, synchronization, and RPC -## Canonical types +## Canonical records -```rust -#[repr(transparent)] -pub struct ContractId(elements::OutPoint); // canonical creation anchor - -pub struct ChainPosition { - pub block_height: u32, - pub tx_index: u32, -} - -pub struct ChainAnchor { - pub height: u32, - pub hash: BlockHash, -} -``` - -`ContractId` is not the oracle `market_id`. The former identifies an on-chain -contract instance by one exact creation-anchor output; the latter is the binary -asset-pair digest signed by the oracle. Market ContractIds anchor at the initial -dormant YES RT output; maker ContractIds anchor at the initial order output. -The ID remains stable as live outpoints change and is not itself proof that the -claimed contract is valid. - -Keys use stable, manually encoded big-endian components. Rust struct -serialization is not used directly for redb keys. Values use an explicitly -versioned encoding. A ContractId key is 36 bytes: -`txid_internal_bytes[32] || vout_be_u32[4]`. Its strict human-readable RPC form -is `{"txid":"...","vout":n}`. - -Ordinary chain and live-state references use `elements::OutPoint` directly. -Only the creation anchor receives the nominal ContractId wrapper, preventing a -current or wallet outpoint from being used accidentally as identity without -duplicating rust-elements' native Liquid transaction type or coupling storage -to a wallet library. - -### Portable registration types +`ContractId` is the exact creation-anchor outpoint. For `BinaryMarketV1`, that +anchor is the initial dormant YES reissuance-token output verified from the +complete creation transaction. ```rust pub enum ContractDescriptor { BinaryMarketV1 { params: BinaryMarketParams }, - MakerOrderV1 { - parent_market: ContractId, - side: OrderSide, - params: MakerOrderParams, - }, } pub struct ContractDeclaration { @@ -54,298 +17,121 @@ pub struct ContractDeclaration { } pub struct ContractPackage { - pub format_version: u16, // exactly 1 in v1 - pub chain: ChainIdentity, // network + genesis hash + pub format_version: u16, + pub chain: ChainIdentity, pub roots: Vec, pub declarations: Vec, } ``` -The descriptor says what to compile, the declaration claims where it was -created, and the package carries the requested roots plus any parent dependency -closure. All are untrusted input. V1 accepts 1..=16 unique declared roots and -1..=64 unique declarations. It rejects duplicate IDs/roots, self-dependencies, -undeclared roots, and declarations unreachable from a root. A referenced parent -must be declared in the package or already verified in this node; an included -maker parent must be a binary market. Package order is irrelevant to dependency -verification. A successful receipt returns roots in package root order and -per-contract results in declaration order, including idempotent results. +Packages are untrusted, chain-scoped atomic registration requests. The current +single-family protocol requires every independent declaration to be named as a +root. A shared creation transaction is fetched and stored once even when it +creates several declared markets. Caller order is preserved in receipts, but +has no authority over verification. ## Redb schema -The initial schema contains: +Schema version 1 uses fixed binary keys and versioned values. Its logical +tables are: | Table | Purpose | |---|---| -| `meta` | schema version, chain identity, immutable activation anchor, event epoch/high-watermark, sync status | -| `chain_tip` | singleton indexed canonical tip | -| `chain_checkpoints` | height to block hash and previous hash | -| `contracts` | immutable params, kind/version, creation, current state, provenance | -| `retained_contract_declarations` | explicitly registered, normalized declarations retained across rollback/rebuild | -| `outpoint_owners` | outpoint to contract and slot/role | -| `contract_outpoints` | contract and slot/role to current outpoint | -| `script_index` | script hash, contract, and role multimap | -| `asset_relations` | asset, relation kind, contract, and role multimap | -| `market_children` | market to outcome/side and child contract relationships | -| `order_book` | market, side, direction, price, FIFO position, order to capacity | -| `recovery_hints` | chain position/output index to validated envelope and parsed public fields | -| `chain_transactions` | position to block hash, txid, raw tx, and all transitions | -| `contract_history` | contract and position to transition reference | -| `backfill_progress` | catching-up contract to pinned anchor and next scan position | -| `undo_transactions` | recent pre-state and index mutations for rollback | -| `events` | durable epoch/sequence cursor to event envelope | - -Current state is materialized for read performance. `chain_transactions` and -`contract_history` are the canonical audit trail and are retained indefinitely -in v1. Undo retention is a separate two-block operational window. - -`retained_contract_declarations` is the deliberate exception to the -chain-derived tables. A declaration is written only after its package has been -fully verified, but it remains watch intent rather than chain authority. A -rollback or rebuild preserves it and canonical replay recompiles it against the -replacement branch before recreating any materialized contract state. Package -roots and boundaries are not retained: the normalized union of accepted -declarations is the dependency/watch set. - -The asset index is many-to-many. A market token can be referenced by its parent -market and by many orders, and later by pools. - -The order-book key uses exact `u32` price followed by confirmed creation -position and contract ID. Asks scan ascending and bids scan descending. FIFO is -canonical chain order, never Nostr timestamp or server arrival time. +| `meta` | schema, chain identity, activation anchor, sync status, event cursor | +| `chain_tip` / `chain_checkpoints` | indexed tip and retained block anchors | +| `chain_transactions` | shared raw transaction evidence by chain position | +| `outputs` | retained transaction outputs referenced by materialized state | +| `contracts` | verified market parameters, state, readiness, and live outputs | +| `retained_contract_declarations` | chain-independent watch intent | +| `outpoint_owners` / `contract_outpoints` | bidirectional live-output ownership | +| `script_index` | compiled script to market/slot candidates | +| `asset_relations` | market outcome and reissuance-token relationships | +| `recovery_hints` | discovered public market hints | +| `contract_history` | confirmed transition records | +| `backfill_progress` | late-registration replay progress | +| `undo_transactions` | shallow-reorg inverse data | +| `events` | durable append-only subscriber journal | + +There is intentionally no order-book, parent/child, routing, or maker-state +index. Databases from the pre-removal alpha are unsupported and must be rebuilt; +the schema number remains 1 because no maker database reached production. ## Atomic write model -The coordinator interprets one `ChainTxDelta` per confirmed transaction: +A complete block is the physical commit unit. Transactions are interpreted in +canonical block order, and all market legs affected by one transaction form one +`ChainTxDelta`. One redb write transaction applies: -```rust -pub struct ChainTxDelta { - pub position: ChainPosition, - pub block_hash: BlockHash, - pub txid: Txid, - pub raw_tx: Transaction, - pub created_contracts: Vec, - pub state_updates: Vec, -} +1. shared raw evidence; +2. every affected market's before/after state and history; +3. live-output ownership and indexes; +4. recovery hints and backfill progress; +5. block and undo records; and +6. the new indexed tip. -pub struct BlockDelta { - pub anchor: ChainAnchor, - pub prev_block_hash: BlockHash, - pub ordered_txids: Vec, - pub relevant_transactions: Vec, -} -``` +If any leg fails, none of the transaction or block becomes visible. A retry of +the same canonical block is idempotent. Events are emitted only after the +corresponding state commit and remain in a durable journal across reorgs. -If one transaction advances a market and three orders, all four legs must be in -the delta or interpretation fails. +## Synchronization -`ordered_txids` covers the complete block. `relevant_transactions` contains a -delta for every transaction that creates or touches a tracked contract; redb -does not archive unrelated Liquid transactions. +The coordinator compares the source tip with the indexed tip, locates the +common ancestor within the two-block undo window, rolls back if necessary, and +then fetches and applies complete blocks in order. Static script candidates may +be batched, but interpretation always sees the overlay produced by earlier +transactions in the same block. -The public store operation is `apply_block(BlockDelta)`. Relevant deltas are -validated and applied in `tx_index` order inside one redb write transaction. -This is stronger than the required per-chain-transaction atomicity and prevents -a crash from exposing a partially indexed block. Each chain transaction remains -a separate history and event unit. +`ContractSyncState` is per-market: -One commit changes together: +- `CatchingUp { synced_through }` while a newly registered declaration is being + replayed; +- `Ready { synced_through }` once it reaches the indexed tip. -- current states; -- live outpoints; -- script, asset, relationship, and order-book indexes; -- raw transaction and contract histories; -- undo records; -- durable events; and -- the indexed checkpoint/tip. +Global `SyncStatus` is `Starting`, `Syncing`, `Ready`, `BackendUnavailable`, or +sticky `RescanRequired`. Chain-derived reads fail closed unless both the global +and per-market anchors satisfy the requested snapshot. -Retrying the same position, block hash, and txid is a no-op only if the persisted -result matches. A different transaction at an occupied position is a fork -conflict and requires rollback. Spending a tracked contract input without a -valid corresponding transition is fail-closed. +### Discovery and registration -Before mutation, `apply_block` rejects: +With archival Elements Core, global discovery scans complete blocks after the +exclusive activation anchor for canonical market recovery outputs. A candidate +is accepted only after the node: -- a height other than the indexed tip plus one or a mismatched previous hash; -- an empty complete-block txid list, or a duplicate, non-monotonic, or - out-of-range relevant transaction index; -- a delta txid that does not match both its raw transaction and the complete - block txid at that index; -- more than one state update for the same `ContractId` in one transaction; or -- any tracked input that is not accounted for exactly once by the complete - transition batch. +1. parses the compact hint; +2. locates the defining YES and NO issuances; +3. derives token and reissuance-token IDs; +4. compiles the market; +5. proves exact side-A dormant commitments and scripts; and +6. verifies the complete creation invariant. -Redb work runs through a dedicated writer actor or blocking worker, not directly -on Tokio/Iroh request tasks. +Esplora lacks a standard global OP_RETURN-prefix query, so ordinary +Esplora-backed nodes use explicit `ContractPackage` registration. Registration +fetches canonical evidence from the node's own source, validates every market, +and inserts the entire package atomically. Late registration backfills only +transactions relevant to the market's compiled scripts or live outpoints. -## Synchronization +Normalized declarations survive destructive reset. Chain-derived contracts, +history, outputs, indexes, and undo data do not. Replay from the immutable +activation checkpoint revalidates retained declarations against the replacement +branch; absent or invalid declarations remain dormant and do not block +unrelated synchronization. -One chain-ordered coordinator replaces one follower task per contract: - -1. compare the source tip with stored checkpoints; -2. fetch complete blocks in ascending order; -3. process transactions by block index; -4. detect tracked inputs through `outpoint_owners`; -5. discover candidate creations from registered references and canonical hints; -6. interpret every affected contract against the same raw transaction; -7. commit the complete block; and -8. wake subscribers after the durable commit. - -Same-block child spends see the state produced by earlier transactions in that -block. Static market/order scripts can be batched during catch-up. A future LMSR -pool will be followed primarily through its active outpoint lineage. - -The coordinator pins a source anchor for each fetch range and verifies every -returned block's height, hash, previous hash, transaction indexes, and txids. -It restarts the range if Elements Core or Esplora changes branches during the -fetch. - -Canonical redb state contains confirmed transactions only. A future mempool -preview is explicitly noncanonical and cannot alter current state or history. - -### Global hint discovery - -Each protocol release defines a network-specific activation anchor. The anchor -is an exclusive checkpoint: valid v1 creation and scanning begin at -`anchor.height + 1`. V1 fixes these production checkpoints: - -| Network | Height | Block hash | Native policy asset | -|---|---:|---|---| -| Liquid | 3,974,391 | `705d699fe1d7f9433837f5f8fec9347c2d5f25aebec5c70ce838db50db890c35` | `6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d` | -| Liquid testnet | 2,529,866 | `78fe3d5ce6a0df49e7f41adf2e20e610f34f2813dfeaaf50be869ad0e32f510e` | `144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49` | - -Elements regtest uses its dynamically selected checkpoint, genesis by default. -It also requires its dynamic policy asset explicitly. For production networks, -the node derives the policy asset from the selected network; a matching CLI -override is accepted, but a conflict is rejected before backend access or -database creation. The node verifies the exact checkpoint height/hash against -its backend and atomically binds chain identity, activation, and the initial -tip. Store initialization revalidates policy before starting a write, and RPC -handler construction validates persisted policy before it can advertise -discovery coverage. Package registration rejects every creation at or before -the checkpoint, which makes activation-forward retained-declaration replay -complete. - -An archival Elements Core backend scans complete blocks once from immediately -after that anchor to the pinned tip. During the same ordered pass the node: - -- stores every length-valid recognized recovery-hint envelope with its chain - position and output index; -- fully reconstructs and registers canonical standalone market creations; and -- fully reconstructs canonical maker orders whose adjacent public hint resolves - to a verified parent market. - -Automatic market discovery accepts only the fixed standalone creation shape. -Composed creations use complete contract-package registration, avoiding -combinatorial scans over attacker-supplied issuance sets. Canonical maker -discovery is linear: the hint output identifies the immediately preceding order -vout, and the creation input set derives its instance ID. A same-transaction -parent uses the all-zero-txid sentinel plus its explicit vout. A standard -Esplora service has no global OP_RETURN-prefix index, so activation-to-tip -scanning requires downloading all raw blocks and may be unavailable or -operationally expensive. Nostr and manual registration remain fast-start paths. - -`GetInfo` reports discovery coverage separately from contract synchronization: +## Reorgs and rebuild -```text -mode: FullHintScan | AdvisoryOnly -from: ChainAnchor -scanned_through: ChainAnchor -target_tip: ChainAnchor -canonical_market_complete: bool -``` +The store retains undo information for the latest two blocks. A replacement +within that window restores exact prior state and then applies the new branch. +A deeper replacement atomically records `RescanRequired`; no chain-derived RPC +may present the now-untrusted branch as current. -`from` is the exclusive activation checkpoint. `scanned_through` is the -persisted indexed tip. `canonical_market_complete` is true only for -`FullHintScan` while sync status is `Ready` and the current source tip exactly -equals the indexed tip; it is derived rather than stored as a mutable flag. - -A node can be fully synchronized for every registered contract while its global -market discovery remains incomplete. - -### Package registration, late registration, and backfill - -The verifier checks the package's exact chain identity and dependency graph, -fetches confirmed creation evidence from its configured chain source, resolves -parents before children, compiles each canonical descriptor, and validates each -nominated anchor plus the full family-specific creation invariant. The market -anchor must be the exact initial dormant YES RT output; the maker anchor must be -the exact initial order output. Every declaration is verified before one redb -transaction inserts the complete package, so failure cannot leave a partial -dependency graph. An identical retry is idempotent. - -Registration initially stores a verified contract as `CatchingUp`, excluded -from active listings, order routing, and current snapshots. A backfill worker: - -1. pins the current indexed anchor; -2. scans from the verified creation position through that anchor using stored - evidence and/or the configured chain source; -3. replays all transitions, including a same-block creation and spend; and -4. calls an idempotent `apply_backfill_batch` operation. - -The backfill operation verifies that every referenced block hash is still -canonical, merges newly recognized legs into any existing -`chain_transactions` row, updates contract history/state/indexes, advances -`backfill_progress`, and appends durable backfill events in one redb write -transaction. If the global tip advances, backfill continues to the newer -anchor. The final write changes the contract to `Ready` only when its -`synced_through` anchor equals the current indexed tip. A supplied current -outpoint is merely a scan hint; lineage replay is authoritative. - -## Reorgs - -The store keeps undo batches for the latest two blocks. - -For a one- or two-block reorg, the coordinator finds the common ancestor and -rolls back orphaned blocks in reverse order. State, outpoints, indexes, history -visibility, contracts created on the orphan, and indexed tip are restored -atomically. Previously delivered event rows are not erased; a durable rollback -event records the affected contract IDs and market ancestry needed for -server-side filtering. - -Orphaned rows are removed from the position-keyed `chain_transactions` and -`contract_history` canonical tables before replacement rows reuse those -positions. The append-only event log is the durable record that the orphaned -branch was once observed; an optional raw-transaction cache may retain its -bytes, but it is never returned as canonical history. - -If no common ancestor exists in the retained window: - -1. set `SyncStatus::RescanRequired`; -2. make that status sticky and reject chain-derived reads, interpretation, - routing, and registration; -3. rotate the durable-event epoch; -4. require the local operator to stop the daemon and invoke - `deadcat-node rebuild`; -5. reverify the backend genesis and immutable activation checkpoint before any - destructive write; -6. clear chain materialization, history, index, and undo tables while retaining - normalized declarations and the durable event journal; -7. reset the tip to the activation checkpoint; and -8. replay complete blocks before returning to `Ready`. - -`RescanRequired` cannot be overwritten by a later backend outage or ordinary -status update; only the atomic reset exits it. Repeated invalidation is -idempotent and does not rotate the event epoch again. A crash before reset -leaves the invalidated database untouched. A crash after reset or any replayed -block leaves a complete persisted prefix, so `rebuild` or ordinary `run` can -resume without clearing again. - -Two-block undo data is not claimed to restore an older checkpoint. The rebuild -is explicit and observable. The node never silently wipes, guesses, or -continues from inconsistent state. +The operator rebuild command verifies network, genesis, policy asset, and +activation hash before clearing derived state. It preserves retained +declarations and the durable event journal, changes the event epoch so old +cursors fail as stale, and resumes complete-block replay. The reset operation +is retryable after interruption. ## RPC -While `RescanRequired` is active, `GetInfo`, `SubscribeEvents`, -`EstimateFeerate`, and `BroadcastSignedTransaction` remain available. Every -registration and chain-derived query, interpretation, or routing request fails -with typed `RescanRequired` before dispatch. Chain-derived reads also -revalidate the durable-event epoch before returning, so an invalidation that -commits while a request is in flight cannot release known-stale materialized -state. - -The transport-neutral request set begins with: +The versioned RPC surface is intentionally evidence-first and market-only: ```text GetInfo @@ -353,125 +139,34 @@ RegisterContractPackage GetContract ListMarkets GetMarketSnapshot -ListOrders / GetOrderBook ListRecoveryHints GetContractHistory GetTransaction InterpretTransaction LookupAsset -SubscribeEvents EstimateFeerate -SuggestRoute (advisory) BroadcastSignedTransaction +SubscribeEvents ``` -`GetInfo` returns protocol/schema versions, server identity, network/genesis, -backend kind, source and indexed tips, sync status, rollback retention, and -capabilities. - -Snapshot and list responses come from one redb read transaction and include an -exact `as_of` anchor plus durable-event high-watermark. Pagination never splits -a block's canonical ordering semantics. - -Every page cursor binds to that original anchor, event watermark, and exact -query scope. The scope records the endpoint and every result-set filter: the -market ID, side, and direction for orders, or the optional family for recovery -hints. Reusing a continuation with different filters is rejected rather than -silently skipping matches before its opaque `after_key`. Because v1 does not -retain arbitrary materialized snapshots, a subsequent page is also rejected -with `SnapshotInvalidated` if the indexed tip no longer exactly equals the -cursor anchor, including an ordinary tip advance. The client restarts from a -fresh snapshot rather than mixing versions or skipping entries. - -`ListRecoveryHints` uses this same `PageRequest` / `SnapshotCursor` contract; -its optional family filter is part of the cursor's `RecoveryHints` scope. A -contract registration or recovery-hint association advances the durable-event -watermark, so a continuation created before that write is invalidated even when -the chain tip itself has not changed. - -`InterpretTransaction` returns every recognized contract transition, not the -first match. It is a pure advisory RPC; canonical state changes only through the -coordinator. - -Evidence responses contain raw creation/transition transactions, block hash, -chain position, parameters, CMR/script data, and typed input/output roles. The -client recompiles and replays locally. Before replay, an independent chain -source must authenticate the complete consensus transaction (including all -witnesses) at the reported block position. Matching only `txid` is insufficient -because Elements transaction IDs exclude the witness data that selects and -parameterizes Deadcat covenant transitions. - -Typed errors include at least: - -```text -UnsupportedVersion -NotFound -NotSynced -RescanRequired -StaleCursor -SnapshotInvalidated -InvalidRegistration -ForkConflict -RateLimited -BackendUnavailable -InvalidTransaction -CovenantInvariantViolation -``` - -## Durable events - -```rust -pub struct EventCursor { - pub epoch: [u8; 16], - pub sequence: u64, -} - -pub struct EventEnvelope { - pub cursor: EventCursor, - pub event: Event, -} -``` - -Events are append-only within an epoch and delivered at least once. Clients -deduplicate by the full cursor. A fresh random epoch is created on database -initialization and when the node first enters `RescanRequired`; sequence starts -at zero within it. The explicit reset and replay preserve that epoch, and -repeated invalidation while already invalidated does not rotate it again. A -cursor from another epoch, or ahead of the server's high-watermark, returns -`StaleCursor`. Backup and restore epoch policy remains future operational work. +There are no quote, order-book, or routing methods. RFQ/AMM/DLOB venue +interfaces belong to the client router and separate venue processes. -```text -TransactionApplied { anchor, txid, position, transitions } -BackfillApplied { contract_id, through, transitions } -ChainRolledBack { - old_tip, - new_tip, - orphaned_positions, - affected_contract_ids, - affected_market_ids -} -ContractRegistered -SyncStatusChanged -CaughtUp { through_cursor, indexed_tip } -``` +Responses that enumerate mutable state include: -`SyncStatusChanged` passes every subscription filter so contract- and -market-scoped subscribers observe invalidation and recovery even when no -contract transition is involved. +- an exact `as_of { height, hash }` anchor; +- an event high-watermark; +- a scope-bound continuation cursor; and +- explicit readiness or stale-cursor errors. -Subscriptions accept an optional prior cursor and an actual server-side filter: +`GetContractHistory` and `GetTransaction` expose enough raw evidence for the +client to recompile and replay a market independently. `InterpretTransaction` +is advisory and never substitutes for local signing-intent validation. -```text -All -Contract(ids) -MarketTree(market_id) -``` +## Durable events -The server reads a durable high-watermark, replays matching rows through it, -emits `CaughtUp` with that cursor even when no event matched the filter, and -then follows committed wakeups. Event sequence allocation and the meta -high-watermark commit atomically with the corresponding state change. Event -rows and their sequence counter are excluded from reorg undo; rollback appends -a new event containing immutable filter metadata. Broadcast notifications are -only wakeups; redb is the replay source. This makes snapshot-to-live handoff -gapless across reconnects and process restarts within an epoch. +The event journal uses an epoch plus monotonically increasing sequence. +Subscriptions replay from an explicit cursor and then stream committed events. +A destructive rebuild starts a new epoch, making every old cursor +deterministically stale. Reorg events do not erase prior journal entries; they +describe the canonicality change so consumers can update their own views. diff --git a/fixtures/wire-v1/list-recovery-hints-request.json b/fixtures/wire-v1/list-recovery-hints-request.json index 0f125f3..f9540e0 100644 --- a/fixtures/wire-v1/list-recovery-hints-request.json +++ b/fixtures/wire-v1/list-recovery-hints-request.json @@ -1 +1 @@ -{"schema_version":1,"request_id":"8","request":{"list_recovery_hints":{"family":"maker_order_v1","page":{"cursor":null,"limit":2}}}} +{"schema_version":1,"request_id":"8","request":{"list_recovery_hints":{"family":"binary_market_v1","page":{"cursor":null,"limit":2}}}} diff --git a/fixtures/wire-v1/list-recovery-hints-response.json b/fixtures/wire-v1/list-recovery-hints-response.json index 354cb8f..0983e59 100644 --- a/fixtures/wire-v1/list-recovery-hints-response.json +++ b/fixtures/wire-v1/list-recovery-hints-response.json @@ -1 +1 @@ -{"schema_version":1,"request_id":"8","frame":{"unary":{"outcome":{"success":{"value":{"recovery_hints":{"page":{"snapshot":{"as_of":{"height":42,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"event_high_watermark":{"epoch":"0102030405060708090a0b0c0d0e0f10","sequence":"9"}},"hints":[{"location":{"position":{"block_height":42,"tx_index":3},"output_index":4},"creation_txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","family":"maker_order_v1","payload":"4000011f1e1d1c1b1a191817161514131211100f0e0d0c0b0a0908070605040302010000000009000009c40000000a50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0","associated_contract":null}],"next":{"as_of":{"height":42,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"event_high_watermark":{"epoch":"0102030405060708090a0b0c0d0e0f10","sequence":"9"},"scope":{"recovery_hints":{"family":"maker_order_v1"}},"after_key":"0000002a0000000300000004"}}}}}}}}} +{"schema_version":1,"request_id":"8","frame":{"unary":{"outcome":{"success":{"value":{"recovery_hints":{"page":{"snapshot":{"as_of":{"height":42,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"event_high_watermark":{"epoch":"0102030405060708090a0b0c0d0e0f10","sequence":"9"}},"hints":[{"location":{"position":{"block_height":42,"tx_index":3},"output_index":4},"creation_txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","family":"binary_market_v1","payload":"10020202020202020202020202020202020202020202020202020202020202020200000004d2","associated_contract":null}],"next":{"as_of":{"height":42,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"event_high_watermark":{"epoch":"0102030405060708090a0b0c0d0e0f10","sequence":"9"},"scope":{"recovery_hints":{"family":"binary_market_v1"}},"after_key":"0000002a0000000300000004"}}}}}}}}} diff --git a/fixtures/wire-v1/register-contract-package-receipt.json b/fixtures/wire-v1/register-contract-package-receipt.json index 9e07782..3361fb2 100644 --- a/fixtures/wire-v1/register-contract-package-receipt.json +++ b/fixtures/wire-v1/register-contract-package-receipt.json @@ -1 +1 @@ -{"schema_version":1,"request_id":"7","frame":{"unary":{"outcome":{"success":{"value":{"registration_accepted":{"registration":{"roots":[{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9}],"contracts":[{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},"sync_state":{"catching_up":{"synced_through":{"height":321,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}},"already_registered":false},{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2},"sync_state":{"catching_up":{"synced_through":{"height":321,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}},"already_registered":false}]}}}}}}}} +{"schema_version":1,"request_id":"7","frame":{"unary":{"outcome":{"success":{"value":{"registration_accepted":{"registration":{"roots":[{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2}],"contracts":[{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},"sync_state":{"catching_up":{"synced_through":{"height":321,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}},"already_registered":false},{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2},"sync_state":{"catching_up":{"synced_through":{"height":321,"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}},"already_registered":false}]}}}}}}}} diff --git a/fixtures/wire-v1/register-contract-package-request.json b/fixtures/wire-v1/register-contract-package-request.json index 993e6d0..a3f18d0 100644 --- a/fixtures/wire-v1/register-contract-package-request.json +++ b/fixtures/wire-v1/register-contract-package-request.json @@ -1 +1 @@ -{"schema_version":1,"request_id":"7","request":{"register_contract_package":{"package":{"format_version":1,"chain":{"network":"elements_regtest","genesis_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"roots":[{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9}],"declarations":[{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},"descriptor":{"maker_order_v1":{"parent_market":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2},"side":"yes","params":{"base_asset_id":"2222222222222222222222222222222222222222222222222222222222222222","quote_asset_id":"1111111111111111111111111111111111111111111111111111111111111111","price":2500,"min_active_base":10,"direction":"sell_quote","instance_id":"6666666666666666666666666666666666666666666666666666666666666666","maker_pubkey":"7777777777777777777777777777777777777777777777777777777777777777"}}}},{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2},"descriptor":{"binary_market_v1":{"params":{"oracle_public_key":"0202020202020202020202020202020202020202020202020202020202020202","collateral_asset_id":"1111111111111111111111111111111111111111111111111111111111111111","yes_token_asset_id":"2222222222222222222222222222222222222222222222222222222222222222","no_token_asset_id":"3333333333333333333333333333333333333333333333333333333333333333","yes_reissuance_token_id":"4444444444444444444444444444444444444444444444444444444444444444","no_reissuance_token_id":"5555555555555555555555555555555555555555555555555555555555555555","base_payout":"100000000","expiry_height":1234}}}}]},"bearer_token":null}}} +{"schema_version":1,"request_id":"7","request":{"register_contract_package":{"package":{"format_version":1,"chain":{"network":"elements_regtest","genesis_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"roots":[{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2}],"declarations":[{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":9},"descriptor":{"binary_market_v1":{"params":{"oracle_public_key":"0303030303030303030303030303030303030303030303030303030303030303","collateral_asset_id":"6666666666666666666666666666666666666666666666666666666666666666","yes_token_asset_id":"7777777777777777777777777777777777777777777777777777777777777777","no_token_asset_id":"8888888888888888888888888888888888888888888888888888888888888888","yes_reissuance_token_id":"9999999999999999999999999999999999999999999999999999999999999999","no_reissuance_token_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","base_payout":"50000000","expiry_height":2345}}}},{"contract_id":{"txid":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","vout":2},"descriptor":{"binary_market_v1":{"params":{"oracle_public_key":"0202020202020202020202020202020202020202020202020202020202020202","collateral_asset_id":"1111111111111111111111111111111111111111111111111111111111111111","yes_token_asset_id":"2222222222222222222222222222222222222222222222222222222222222222","no_token_asset_id":"3333333333333333333333333333333333333333333333333333333333333333","yes_reissuance_token_id":"4444444444444444444444444444444444444444444444444444444444444444","no_reissuance_token_id":"5555555555555555555555555555555555555555555555555555555555555555","base_payout":"100000000","expiry_height":1234}}}}]},"bearer_token":null}}} diff --git a/justfile b/justfile index ce53988..fb97634 100644 --- a/justfile +++ b/justfile @@ -32,28 +32,14 @@ regtest-market-ab: generate binary_market_ab_lifecycle_is_accepted_by_elementsd \ -- --ignored --nocapture --test-threads=1 -# Run the real maker-order lifecycle, package/backfill, restart, client replay, -# and one-/two-block branch replacement gate against isolated liquidregtest. -regtest-maker-orders: generate - cargo test --locked -p deadcat-client --test market_regtest \ - maker_order_lifecycle_is_accepted_by_elementsd \ - -- --ignored --nocapture --test-threads=1 - # Run one real transaction that advances two independent binary markets, then # prove transaction-atomic indexing, replay, reorg, and rebuild behavior without -# relying on a maker-order fixture. +# relying on a second contract family. regtest-multi-market: generate cargo test --locked -p deadcat-client --test market_regtest \ multi_market_transaction_is_accepted_and_indexed_by_elementsd \ -- --ignored --nocapture --test-threads=1 -# Run one real transaction that advances multiple covenant instances, then -# prove transaction-atomic indexing and canonical replay on liquidregtest. -regtest-multi-contract: generate - cargo test --locked -p deadcat-client --test market_regtest \ - multi_contract_transaction_is_accepted_and_indexed_by_elementsd \ - -- --ignored --nocapture --test-threads=1 - # Drive the production Elements RPC and Esplora sources against the same # liquidregtest branch, including broadcast, indexing, and a real reorg. regtest-backend-equivalence: generate @@ -70,7 +56,7 @@ regtest-process-boundary: generate -- --ignored --nocapture --test-threads=1 # Every isolated live-chain protocol gate required before CI succeeds. -regtest: regtest-market-ab regtest-maker-orders regtest-multi-market regtest-multi-contract regtest-backend-equivalence regtest-process-boundary +regtest: regtest-market-ab regtest-multi-market regtest-backend-equivalence regtest-process-boundary wasm-check: NIX_HARDENING_ENABLE=pic cargo check --locked -p deadcat-iroh --lib --target wasm32-unknown-unknown