From f987484db60df5bd13af084a02f7d00c19de9f78 Mon Sep 17 00:00:00 2001 From: Tommy Volk Date: Wed, 12 Aug 2026 12:59:10 -0500 Subject: [PATCH 1/2] feat(rfq): add inventory-aware quote engine --- Cargo.lock | 2 + README.md | 27 +- crates/deadcat-rfq-provider/Cargo.toml | 2 + crates/deadcat-rfq-provider/src/inventory.rs | 141 +- crates/deadcat-rfq-provider/src/lib.rs | 19 +- crates/deadcat-rfq-provider/src/model.rs | 48 +- crates/deadcat-rfq-provider/src/quote.rs | 2587 +++++++++++++++++ .../deadcat-rfq-provider/src/quote/tests.rs | 1651 +++++++++++ crates/deadcat-rfq-provider/src/store.rs | 1184 +++++++- .../deadcat-rfq-provider/src/store/tests.rs | 38 +- crates/deadcat-rfq-provider/src/wallet.rs | 30 +- docs/adr/0007-rfq-provider-state-machine.md | 53 +- docs/liquidity-roadmap.md | 29 +- 13 files changed, 5748 insertions(+), 63 deletions(-) create mode 100644 crates/deadcat-rfq-provider/src/quote.rs create mode 100644 crates/deadcat-rfq-provider/src/quote/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e92493f..407ad95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -973,6 +973,8 @@ dependencies = [ name = "deadcat-rfq-provider" version = "0.1.0-alpha" dependencies = [ + "deadcat-client", + "deadcat-types", "elements", "postcard", "redb", diff --git a/README.md b/README.md index bf68fdf..fa9f134 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,27 @@ transaction construction. A future AMM or decentralized limit-order book can implement the same venue boundary. [ADR 0007](docs/adr/0007-rfq-provider-state-machine.md) defines the provider's durable reservation and commit-before-sign boundary. The transport-free provider state core and backend-neutral wallet capability -boundary are implemented. A production wallet/RPC/HSM backend, pricing, -transaction validator, signer adapter, remote service, and relay remain future -work. Until the validator and signer adapter land, the safety-critical commit -and signed-result transitions are intentionally crate-internal. The RFQ -provider remains separate from `deadcat-node`; future AMM and DLOB protocols -are not implemented by this repository today. +boundary are implemented, along with configurable, inventory-aware firm-quote +construction for exact-in and exact-out trades. The quote engine applies exact +integer pricing, deterministically selects fresh available inventory, reserves +its exact outpoints, and durably replays the same symbolic transaction +contribution for an idempotent request. Its `FirmQuote` is an internal, +unauthenticated artifact, not yet a provider-signed network quote. A production +wallet/RPC/HSM backend, market-data pricing source, transaction validator, +signer adapter, authenticated remote protocol, and relay remain future work. +The eventual service must derive market assets from chain-validated canonical +parameters and add authenticated-owner rate limits plus bounded history +retention; the library's live-quote quotas only cap concurrent reservations. +Until the validator and signer adapter land, the safety-critical commit and +signed-result transitions are intentionally crate-internal. The RFQ provider +remains separate from `deadcat-node`; future AMM and DLOB protocols are not +implemented by this repository today. + +The RFQ provider database is still clean-slate preproduction state. Its schema +and private record-layout versions intentionally remain `1` while the provider +core evolves; local databases created by earlier alpha builds must be deleted +and recreated rather than migrated. This exception must end before any provider +database is treated as production data. ## Assurance diff --git a/crates/deadcat-rfq-provider/Cargo.toml b/crates/deadcat-rfq-provider/Cargo.toml index efabf1c..75ad7f8 100644 --- a/crates/deadcat-rfq-provider/Cargo.toml +++ b/crates/deadcat-rfq-provider/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true workspace = true [dependencies] +deadcat-types.workspace = true elements.workspace = true postcard.workspace = true redb.workspace = true @@ -17,4 +18,5 @@ sha2.workspace = true thiserror.workspace = true [dev-dependencies] +deadcat-client.workspace = true tempfile.workspace = true diff --git a/crates/deadcat-rfq-provider/src/inventory.rs b/crates/deadcat-rfq-provider/src/inventory.rs index a75da92..5242a75 100644 --- a/crates/deadcat-rfq-provider/src/inventory.rs +++ b/crates/deadcat-rfq-provider/src/inventory.rs @@ -6,20 +6,31 @@ //! publishes only outputs present in a recent complete wallet snapshot *and* //! durably unallocated, and it holds the snapshot lock while reserving them. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; +#[cfg(test)] +use std::collections::BTreeSet; use std::sync::{Mutex, MutexGuard}; use elements::OutPoint; +use elements::hashes::Hash as _; +use sha2::{Digest as _, Sha256}; use thiserror::Error; -use crate::model::{Clock, InventoryState, ProviderIdentity, ReservationPlan, UnixMillis}; -use crate::store::{ProviderError, ReservationBook, ReserveOutcome}; +#[cfg(test)] +use crate::model::ReservationPlan; +use crate::model::{Clock, InventoryState, ProviderIdentity, UnixMillis}; +use crate::model::{IdempotencyKey, OwnerId, QuoteRequestDigest}; +use crate::quote::{FirmQuoteDraft, FirmQuoteOutcome, QuoteEnginePolicy}; +#[cfg(test)] +use crate::store::ReserveOutcome; +use crate::store::{ProviderError, ReservationBook}; use crate::wallet::{ InventorySnapshotCommitment, InventorySource, WalletOwnedOutput, WalletScanAnchor, }; /// Conservative default upper bound for one complete wallet scan. pub const DEFAULT_MAX_INVENTORY_OUTPUTS: usize = 10_000; +const ELIGIBLE_INVENTORY_DOMAIN: &[u8] = b"deadcat/rfq/eligible-inventory/v1"; /// Quote-admission policy for wallet inventory snapshots. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -98,6 +109,8 @@ impl EligibilityToken { pub struct EligibleInventory { token: EligibilityToken, anchor: WalletScanAnchor, + allocation_revision: u64, + eligible_commitment: [u8; 32], outputs: Vec, } @@ -152,6 +165,18 @@ impl EligibleInventory { self.anchor } + /// Monotonic durable revision of inventory allocation state. + #[must_use] + pub const fn allocation_revision(&self) -> u64 { + self.allocation_revision + } + + /// Commitment to the exact eligible outpoints and wallet bindings. + #[must_use] + pub const fn eligible_commitment(&self) -> [u8; 32] { + self.eligible_commitment + } + #[must_use] pub fn outputs(&self) -> &[WalletOwnedOutput] { &self.outputs @@ -308,7 +333,8 @@ where /// /// Existing exact idempotent requests are replayed independently of the /// old discovery token; they never allocate inventory a second time. - pub fn reserve( + #[cfg(test)] + pub(crate) fn reserve( &self, eligible: &EligibleInventory, plan: &ReservationPlan, @@ -376,13 +402,100 @@ where .map_err(Into::into) } + /// Atomically reserve the exact provider inputs and persist the complete + /// firm quote selected from `eligible`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn reserve_firm_quote( + &self, + eligible: &EligibleInventory, + owner: OwnerId, + key: IdempotencyKey, + request_digest: QuoteRequestDigest, + draft: &FirmQuoteDraft, + policy: QuoteEnginePolicy, + clock: &C, + ) -> Result> { + draft.validate().map_err(|_| { + InventoryCoordinatorError::Provider(ProviderError::FirmQuoteDraftInvalid) + })?; + let state = self.lock_state()?; + // Preflight runs before pricing so failed capacity checks cannot burn + // wallet destinations. Recheck only idempotency after taking the + // snapshot lock: a concurrent identical request may have won in the + // interval, and replay must not depend on the now-stale snapshot. + if let Some(replayed) = self + .book + .replay_firm_quote(owner, key, request_digest, clock)? + { + return Ok(replayed); + } + let now = clock.now(); + let latest = self.require_fresh(&state, now)?; + if latest.token != eligible.token { + return Err(InventoryCoordinatorError::SnapshotSuperseded { + requested: eligible.token, + current: latest.token, + }); + } + let current_eligible = self.eligible_from_snapshot(latest)?; + if draft.snapshot.allocation_revision() != current_eligible.allocation_revision + || draft.snapshot.eligible_commitment() != current_eligible.eligible_commitment + { + return Err(InventoryCoordinatorError::Provider( + ProviderError::EligibleInventoryChanged, + )); + } + if draft.snapshot.anchor() != latest.anchor + || draft.snapshot.commitment() != latest.token.snapshot + { + return Err(InventoryCoordinatorError::Provider( + ProviderError::FirmQuoteSnapshotMismatch, + )); + } + for quoted_input in draft.contribution.inputs() { + let Some(output) = current_eligible + .outputs + .iter() + .find(|output| output.outpoint() == quoted_input.outpoint()) + else { + return Err(InventoryCoordinatorError::OutpointNotInEligibleView( + quoted_input.outpoint(), + )); + }; + if output.asset() != draft.selected_asset + || output.txout() != quoted_input.witness_utxo() + || output.binding() != quoted_input.inventory_binding() + { + return Err(InventoryCoordinatorError::Provider( + ProviderError::FirmQuoteInventoryMismatch(quoted_input.outpoint()), + )); + } + } + self.book + .reserve_firm_quote_from_snapshot( + owner, + key, + request_digest, + draft, + policy, + latest.token.observed_at, + self.policy.max_snapshot_age_millis, + clock, + ) + .map_err(Into::into) + } + fn eligible_from_snapshot( &self, latest: &PublishedSnapshot, ) -> Result> { - let durable = self - .book - .inventory_all()? + let outpoints = latest + .outputs + .iter() + .map(WalletOwnedOutput::outpoint) + .collect::>(); + let (durable, allocation_revision) = self.book.inventory_state_for(&outpoints)?; + let durable = durable .into_iter() .map(|view| (view.item().outpoint(), view)) .collect::>(); @@ -408,6 +521,8 @@ where Ok(EligibleInventory { token: latest.token, anchor: latest.anchor, + allocation_revision, + eligible_commitment: eligible_commitment(&outputs), outputs, }) } @@ -452,6 +567,18 @@ where } } +fn eligible_commitment(outputs: &[WalletOwnedOutput]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(ELIGIBLE_INVENTORY_DOMAIN); + hasher.update((outputs.len() as u64).to_be_bytes()); + for output in outputs { + hasher.update(output.outpoint().txid.to_byte_array()); + hasher.update(output.outpoint().vout.to_be_bytes()); + hasher.update(output.binding().to_bytes()); + } + hasher.finalize().into() +} + /// Fail-closed discovery, freshness, or durable-allocation error. #[derive(Debug, Error)] pub enum InventoryCoordinatorError diff --git a/crates/deadcat-rfq-provider/src/lib.rs b/crates/deadcat-rfq-provider/src/lib.rs index 1eb4d8e..ae1ddda 100644 --- a/crates/deadcat-rfq-provider/src/lib.rs +++ b/crates/deadcat-rfq-provider/src/lib.rs @@ -20,6 +20,7 @@ mod inventory; mod model; +mod quote; mod store; mod wallet; @@ -32,13 +33,25 @@ pub use model::{ AuditEntry, AuditEvent, Clock, FeePolicy, FeePolicyViolation, FeeSizeMetric, IdempotencyKey, InventoryBinding, InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS, MAX_SETTLEMENT_BYTES, ModelError, OwnerId, ProviderId, ProviderIdentity, QuoteCommitment, - RecoveryAction, ReleaseReason, ReservationAccess, ReservationId, ReservationPlan, + QuoteRequestDigest, RecoveryAction, ReleaseReason, ReservationAccess, ReservationId, ReservationState, ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment, SigningJob, SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator, }; +pub use quote::{ + AmountRange, AssetAmount, BinaryMarketAssets, DEFAULT_MAX_LIVE_QUOTES_PER_OWNER, + DEFAULT_MAX_QUOTE_INPUTS, DEFAULT_QUOTE_LIFETIME_MILLIS, DEFAULT_SELECTION_SEARCH_NODE_BUDGET, + FirmQuote, FirmQuoteOutcome, FirmQuoteRequest, InventorySummary, + MAX_QUOTE_RECIPIENT_SCRIPT_BYTES, MarketQuoteConfig, PairLimits, PairRule, PricingDecision, + PricingPolicy, PricingPolicyId, PricingRequest, PricingRevision, PricingSide, + QuoteAdmissionError, QuoteBlinderRole, QuoteConfigurationError, QuoteContext, + QuoteContribution, QuoteEngine, QuoteEngineError, QuoteEnginePolicy, QuoteExecution, + QuoteInputId, QuoteKind, QuoteModelError, QuoteOutputId, QuoteOutputRole, QuoteRecipient, + QuoteSnapshotEvidence, QuotedOutput, QuotedProviderInput, RationalRate, StaticPricingError, + StaticRateRule, StaticRationalPricing, +}; pub use store::{ - CommitOutcome, MAX_EXPIRATION_BATCH, ProviderError, ReservationBook, ReserveOutcome, - SCHEMA_VERSION, SignedOutcome, + CommitOutcome, MAX_EXPIRATION_BATCH, ProviderError, ReservationBook, SCHEMA_VERSION, + SignedOutcome, }; pub use wallet::{ ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshot, diff --git a/crates/deadcat-rfq-provider/src/model.rs b/crates/deadcat-rfq-provider/src/model.rs index 001b39c..11feb9a 100644 --- a/crates/deadcat-rfq-provider/src/model.rs +++ b/crates/deadcat-rfq-provider/src/model.rs @@ -49,6 +49,10 @@ fixed_id!( /// Commitment to the exact authenticated quote and leg economics. QuoteCommitment ); +fixed_id!( + /// Commitment to the normalized semantic firm-quote request. + QuoteRequestDigest +); fixed_id!( /// Domain-separated commitment to the exact durable pre-sign transcript. SigningCommitment @@ -396,9 +400,10 @@ impl TransactionFee { /// Exact inventory allocation requested by one authenticated client operation. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReservationPlan { +pub(crate) struct ReservationPlan { owner: OwnerId, idempotency_key: IdempotencyKey, + request_digest: QuoteRequestDigest, quote_commitment: QuoteCommitment, outpoints: Vec, accept_before: UnixMillis, @@ -406,10 +411,31 @@ pub struct ReservationPlan { } impl ReservationPlan { - pub fn new( + #[cfg(test)] + pub(crate) fn new( owner: OwnerId, idempotency_key: IdempotencyKey, quote_commitment: QuoteCommitment, + outpoints: Vec, + accept_before: UnixMillis, + fee_policy: FeePolicy, + ) -> Result { + Self::with_request_digest( + owner, + idempotency_key, + QuoteRequestDigest::new(quote_commitment.to_bytes()), + quote_commitment, + outpoints, + accept_before, + fee_policy, + ) + } + + pub(crate) fn with_request_digest( + owner: OwnerId, + idempotency_key: IdempotencyKey, + request_digest: QuoteRequestDigest, + quote_commitment: QuoteCommitment, mut outpoints: Vec, accept_before: UnixMillis, fee_policy: FeePolicy, @@ -433,6 +459,7 @@ impl ReservationPlan { Ok(Self { owner, idempotency_key, + request_digest, quote_commitment, outpoints, accept_before, @@ -441,32 +468,37 @@ impl ReservationPlan { } #[must_use] - pub const fn owner(&self) -> OwnerId { + pub(crate) const fn owner(&self) -> OwnerId { self.owner } #[must_use] - pub const fn idempotency_key(&self) -> IdempotencyKey { + pub(crate) const fn idempotency_key(&self) -> IdempotencyKey { self.idempotency_key } #[must_use] - pub const fn quote_commitment(&self) -> QuoteCommitment { + pub(crate) const fn request_digest(&self) -> QuoteRequestDigest { + self.request_digest + } + + #[must_use] + pub(crate) const fn quote_commitment(&self) -> QuoteCommitment { self.quote_commitment } #[must_use] - pub fn outpoints(&self) -> &[OutPoint] { + pub(crate) fn outpoints(&self) -> &[OutPoint] { &self.outpoints } #[must_use] - pub const fn accept_before(&self) -> UnixMillis { + pub(crate) const fn accept_before(&self) -> UnixMillis { self.accept_before } #[must_use] - pub const fn fee_policy(&self) -> FeePolicy { + pub(crate) const fn fee_policy(&self) -> FeePolicy { self.fee_policy } } diff --git a/crates/deadcat-rfq-provider/src/quote.rs b/crates/deadcat-rfq-provider/src/quote.rs new file mode 100644 index 0000000..c03862f --- /dev/null +++ b/crates/deadcat-rfq-provider/src/quote.rs @@ -0,0 +1,2587 @@ +//! Transport-free construction of exact, inventory-backed RFQ quotes. +//! +//! A quote describes only the provider's symbolic transaction contribution. +//! It deliberately does not contain a taker funding outpoint or a complete +//! PSET, so a client can combine the contribution with other venue legs. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; + +use deadcat_types::{ChainIdentity, ContractId}; +use elements::secp256k1_zkp::PublicKey; +use elements::{AssetId, OutPoint, Script, TxOut}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use thiserror::Error; + +use crate::inventory::{EligibleInventory, InventoryCoordinator, InventoryCoordinatorError}; +use crate::model::{ + Clock, FeePolicy, IdempotencyKey, InventoryBinding, MAX_RESERVATION_INPUTS, OwnerId, + ProviderIdentity, QuoteCommitment, QuoteRequestDigest, ReservationId, ReservationView, + UnixMillis, +}; +use crate::store::ProviderError; +use crate::wallet::{ + ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshotCommitment, + InventorySource, WalletOwnedOutput, WalletScanAnchor, +}; + +const REQUEST_DOMAIN: &[u8] = b"deadcat/rfq/firm-quote-request/v1"; +const QUOTE_DOMAIN: &[u8] = b"deadcat/rfq/firm-quote/v1"; +const RECOVERY_METADATA_DOMAIN: &[u8] = b"deadcat/rfq/recovery-metadata/v1"; +const STATIC_PRICING_DOMAIN: &[u8] = b"deadcat/rfq/static-rational-pricing/v1"; + +mod inventory_binding_serde { + use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer}; + + use crate::model::InventoryBinding; + + pub(super) fn serialize(value: &InventoryBinding, serializer: S) -> Result + where + S: Serializer, + { + value.to_bytes().serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(InventoryBinding::new(<[u8; 32]>::deserialize( + deserializer, + )?)) + } +} + +mod wallet_scan_anchor_serde { + use elements::BlockHash; + use elements::hashes::Hash as _; + use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer}; + + use crate::wallet::WalletScanAnchor; + + pub(super) fn serialize(value: &WalletScanAnchor, serializer: S) -> Result + where + S: Serializer, + { + (value.block_hash().to_byte_array(), value.block_height()).serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (block_hash, block_height) = <([u8; 32], u32)>::deserialize(deserializer)?; + Ok(WalletScanAnchor::new( + BlockHash::from_byte_array(block_hash), + block_height, + )) + } +} + +mod inventory_snapshot_commitment_serde { + use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer}; + + use crate::wallet::InventorySnapshotCommitment; + + pub(super) fn serialize( + value: &InventorySnapshotCommitment, + serializer: S, + ) -> Result + where + S: Serializer, + { + value.to_bytes().serialize(serializer) + } + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> Result + where + D: Deserializer<'de>, + { + Ok(InventorySnapshotCommitment::from_bytes( + <[u8; 32]>::deserialize(deserializer)?, + )) + } +} + +mod txout_serde { + use elements::secp256k1_zkp::{RangeProof, SurjectionProof}; + use elements::{TxOut, TxOutWitness}; + use serde::de::Error as _; + use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer}; + + #[derive(serde::Serialize, serde::Deserialize)] + struct StoredTxOut { + base: Vec, + surjection_proof: Option>, + rangeproof: Option>, + } + + pub(super) fn serialize(value: &TxOut, serializer: S) -> Result + where + S: Serializer, + { + StoredTxOut { + base: elements::encode::serialize(value), + surjection_proof: value + .witness + .surjection_proof + .as_deref() + .map(SurjectionProof::serialize), + rangeproof: value + .witness + .rangeproof + .as_deref() + .map(RangeProof::serialize), + } + .serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let stored = StoredTxOut::deserialize(deserializer)?; + let mut txout = + elements::encode::deserialize::(&stored.base).map_err(D::Error::custom)?; + txout.witness = TxOutWitness { + surjection_proof: stored + .surjection_proof + .map(|proof| SurjectionProof::from_slice(&proof).map(Box::new)) + .transpose() + .map_err(D::Error::custom)?, + rangeproof: stored + .rangeproof + .map(|proof| RangeProof::from_slice(&proof).map(Box::new)) + .transpose() + .map_err(D::Error::custom)?, + }; + Ok(txout) + } +} + +/// Initial provider-input limit, leaving room below the 64-input durable +/// safety ceiling and the client's whole-transaction resource limits. +pub const DEFAULT_MAX_QUOTE_INPUTS: usize = 8; +/// Initial duration used by tests and local configuration. Production should +/// tune this from measured preparation and signing latency. +pub const DEFAULT_QUOTE_LIFETIME_MILLIS: u64 = 30_000; +/// Initial per-owner count limit for live, uncommitted firm reservations. +pub const DEFAULT_MAX_LIVE_QUOTES_PER_OWNER: usize = 4; +/// Maximum search nodes spent looking for an exact bounded inventory subset +/// after the deterministic greedy selector cannot make policy-compliant +/// change. Hitting the budget is reported distinctly from true fragmentation. +pub const DEFAULT_SELECTION_SEARCH_NODE_BUDGET: usize = 250_000; +/// Maximum accepted taker destination script, aligned with the client +/// transaction composer's default resource limit. +pub const MAX_QUOTE_RECIPIENT_SCRIPT_BYTES: usize = 10_000; + +/// Exact chain and market context bound by a firm quote. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuoteContext { + chain: ChainIdentity, + market: ContractId, + policy_asset: AssetId, +} + +impl QuoteContext { + #[must_use] + pub const fn new(chain: ChainIdentity, market: ContractId, policy_asset: AssetId) -> Self { + Self { + chain, + market, + policy_asset, + } + } + + #[must_use] + pub const fn chain(self) -> ChainIdentity { + self.chain + } + + #[must_use] + pub const fn market(self) -> ContractId { + self.market + } + + #[must_use] + pub const fn policy_asset(self) -> AssetId { + self.policy_asset + } +} + +/// Exact amount of one Liquid asset. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetAmount { + asset: AssetId, + amount: u64, +} + +impl AssetAmount { + pub fn new(asset: AssetId, amount: u64) -> Result { + if amount == 0 { + return Err(QuoteModelError::ZeroAmount); + } + Ok(Self { asset, amount }) + } + + #[must_use] + pub const fn asset(self) -> AssetId { + self.asset + } + + #[must_use] + pub const fn amount(self) -> u64 { + self.amount + } +} + +/// Confidential destination selected by the taker or provider. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuoteRecipient { + script_pubkey: Script, + blinding_public_key: PublicKey, +} + +impl QuoteRecipient { + pub fn new( + script_pubkey: Script, + blinding_public_key: PublicKey, + ) -> Result { + if script_pubkey.is_empty() + || script_pubkey.is_provably_unspendable() + || script_pubkey.len() > MAX_QUOTE_RECIPIENT_SCRIPT_BYTES + { + return Err(QuoteModelError::InvalidRecipientScript); + } + Ok(Self { + script_pubkey, + blinding_public_key, + }) + } + + #[must_use] + pub const fn script_pubkey(&self) -> &Script { + &self.script_pubkey + } + + #[must_use] + pub const fn blinding_public_key(&self) -> PublicKey { + self.blinding_public_key + } + + fn validate(&self) -> Result<(), QuoteModelError> { + if self.script_pubkey.is_empty() + || self.script_pubkey.is_provably_unspendable() + || self.script_pubkey.len() > MAX_QUOTE_RECIPIENT_SCRIPT_BYTES + { + return Err(QuoteModelError::InvalidRecipientScript); + } + Ok(()) + } +} + +impl From<&ConfidentialDestination> for QuoteRecipient { + fn from(value: &ConfidentialDestination) -> Self { + Self { + script_pubkey: value.script_pubkey().clone(), + blinding_public_key: value.blinding_public_key(), + } + } +} + +/// Exact-side semantics and the taker's per-leg economic guard. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuoteKind { + ExactIn { + input: AssetAmount, + output_asset: AssetId, + minimum_output: u64, + }, + ExactOut { + input_asset: AssetId, + maximum_input: u64, + output: AssetAmount, + }, +} + +impl QuoteKind { + fn validate(self) -> Result { + let (input_asset, input_bound, output_asset, output_bound) = match self { + Self::ExactIn { + input, + output_asset, + minimum_output, + } => (input.asset, input.amount, output_asset, minimum_output), + Self::ExactOut { + input_asset, + maximum_input, + output, + } => (input_asset, maximum_input, output.asset, output.amount), + }; + if input_asset == output_asset { + return Err(QuoteModelError::SameAssetPair); + } + if input_bound == 0 || output_bound == 0 { + return Err(QuoteModelError::ZeroAmount); + } + Ok(self) + } + + #[must_use] + pub const fn pair(self) -> (AssetId, AssetId) { + match self { + Self::ExactIn { + input, + output_asset, + .. + } => (input.asset, output_asset), + Self::ExactOut { + input_asset, + output, + .. + } => (input_asset, output.asset), + } + } +} + +/// Validated semantic request. +/// +/// The engine does not authenticate callers. Its embedding transport must +/// authenticate the caller and derive the separately supplied [`OwnerId`] +/// from that identity; neither owner nor idempotency key is sent to a pricing +/// policy. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FirmQuoteRequest { + context: QuoteContext, + kind: QuoteKind, + recipient: QuoteRecipient, + maximum_input_asset_venue_fee: u64, +} + +impl FirmQuoteRequest { + pub fn new( + context: QuoteContext, + kind: QuoteKind, + recipient: QuoteRecipient, + maximum_input_asset_venue_fee: u64, + ) -> Result { + recipient.validate()?; + Ok(Self { + context, + kind: kind.validate()?, + recipient, + maximum_input_asset_venue_fee, + }) + } + + #[must_use] + pub const fn context(&self) -> QuoteContext { + self.context + } + + #[must_use] + pub const fn kind(&self) -> QuoteKind { + self.kind + } + + #[must_use] + pub const fn recipient(&self) -> &QuoteRecipient { + &self.recipient + } + + #[must_use] + pub const fn maximum_input_asset_venue_fee(&self) -> u64 { + self.maximum_input_asset_venue_fee + } + + fn validate(&self) -> Result<(), QuoteAdmissionError> { + self.kind.validate().map_err(QuoteAdmissionError::from)?; + self.recipient.validate().map_err(QuoteAdmissionError::from) + } +} + +/// Assets belonging to one binary market. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BinaryMarketAssets { + collateral: AssetId, + yes: AssetId, + no: AssetId, +} + +impl BinaryMarketAssets { + pub fn new( + collateral: AssetId, + yes: AssetId, + no: AssetId, + ) -> Result { + if collateral == yes || collateral == no || yes == no { + return Err(QuoteConfigurationError::MarketAssetsNotDistinct); + } + Ok(Self { + collateral, + yes, + no, + }) + } + + #[must_use] + pub const fn collateral(self) -> AssetId { + self.collateral + } + + #[must_use] + pub const fn yes(self) -> AssetId { + self.yes + } + + #[must_use] + pub const fn no(self) -> AssetId { + self.no + } + + fn contains(self, asset: AssetId) -> bool { + asset == self.collateral || asset == self.yes || asset == self.no + } + + fn is_launch_pair(self, input: AssetId, output: AssetId) -> bool { + self.contains(input) + && self.contains(output) + && input != output + && (input == self.collateral || output == self.collateral) + } +} + +/// Inclusive nonzero amount range in atomic asset units. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AmountRange { + minimum: u64, + maximum: u64, +} + +impl AmountRange { + pub fn new(minimum: u64, maximum: u64) -> Result { + if minimum == 0 || minimum > maximum { + return Err(QuoteConfigurationError::InvalidAmountRange { minimum, maximum }); + } + Ok(Self { minimum, maximum }) + } + + #[must_use] + pub const fn minimum(self) -> u64 { + self.minimum + } + + #[must_use] + pub const fn maximum(self) -> u64 { + self.maximum + } + + fn contains(self, amount: u64) -> bool { + (self.minimum..=self.maximum).contains(&amount) + } +} + +/// Resource and fill limits for one directed pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairLimits { + input: AmountRange, + output: AmountRange, + maximum_provider_inputs: usize, + minimum_positive_change: u64, + selection_search_node_budget: usize, +} + +impl PairLimits { + pub fn new( + input: AmountRange, + output: AmountRange, + maximum_provider_inputs: usize, + minimum_positive_change: u64, + ) -> Result { + if maximum_provider_inputs == 0 || maximum_provider_inputs > MAX_RESERVATION_INPUTS { + return Err(QuoteConfigurationError::InvalidProviderInputLimit { + actual: maximum_provider_inputs, + maximum: MAX_RESERVATION_INPUTS, + }); + } + Ok(Self { + input, + output, + maximum_provider_inputs, + minimum_positive_change, + selection_search_node_budget: DEFAULT_SELECTION_SEARCH_NODE_BUDGET, + }) + } + + #[must_use] + pub fn launch_default(input: AmountRange, output: AmountRange) -> Self { + Self { + input, + output, + maximum_provider_inputs: DEFAULT_MAX_QUOTE_INPUTS, + minimum_positive_change: 0, + selection_search_node_budget: DEFAULT_SELECTION_SEARCH_NODE_BUDGET, + } + } + + #[must_use] + pub const fn input(self) -> AmountRange { + self.input + } + + #[must_use] + pub const fn output(self) -> AmountRange { + self.output + } + + #[must_use] + pub const fn maximum_provider_inputs(self) -> usize { + self.maximum_provider_inputs + } + + #[must_use] + pub const fn minimum_positive_change(self) -> u64 { + self.minimum_positive_change + } + + /// Bound the exact-subset fallback used when greedy selection would create + /// dust. This is primarily useful for deterministic tests and deployments + /// with unusually fragmented wallets. + pub fn with_selection_search_node_budget( + mut self, + selection_search_node_budget: usize, + ) -> Result { + if selection_search_node_budget == 0 { + return Err(QuoteConfigurationError::ZeroSelectionSearchNodeBudget); + } + self.selection_search_node_budget = selection_search_node_budget; + Ok(self) + } + + #[must_use] + pub const fn selection_search_node_budget(self) -> usize { + self.selection_search_node_budget + } +} + +/// One independently configured quote direction. Reverse rates and limits are +/// never inferred. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairRule { + input_asset: AssetId, + output_asset: AssetId, + limits: PairLimits, +} + +impl PairRule { + #[must_use] + pub const fn new(input_asset: AssetId, output_asset: AssetId, limits: PairLimits) -> Self { + Self { + input_asset, + output_asset, + limits, + } + } + + #[must_use] + pub const fn input_asset(self) -> AssetId { + self.input_asset + } + + #[must_use] + pub const fn output_asset(self) -> AssetId { + self.output_asset + } + + #[must_use] + pub const fn limits(self) -> PairLimits { + self.limits + } +} + +/// Configured binary market and its enabled launch directions. +/// +/// This type checks internal consistency only. The embedding service must +/// construct it from independently chain-validated canonical market +/// parameters; a [`ContractId`] alone does not authenticate these asset IDs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MarketQuoteConfig { + context: QuoteContext, + assets: BinaryMarketAssets, + pairs: Vec, +} + +impl MarketQuoteConfig { + pub fn new( + context: QuoteContext, + assets: BinaryMarketAssets, + mut pairs: Vec, + ) -> Result { + let anchor = context.market.creation_anchor(); + if anchor.is_null() || anchor.vout & 0xc000_0000 != 0 { + return Err(QuoteConfigurationError::InvalidMarketId(context.market)); + } + if pairs.is_empty() { + return Err(QuoteConfigurationError::NoEnabledPairs); + } + pairs.sort_by_key(|rule| { + ( + rule.input_asset.into_inner().to_byte_array(), + rule.output_asset.into_inner().to_byte_array(), + ) + }); + let mut seen = BTreeSet::new(); + for rule in &pairs { + if !assets.is_launch_pair(rule.input_asset, rule.output_asset) { + return Err(QuoteConfigurationError::UnsupportedPair { + input: rule.input_asset, + output: rule.output_asset, + }); + } + if !seen.insert(( + rule.input_asset.into_inner().to_byte_array(), + rule.output_asset.into_inner().to_byte_array(), + )) { + return Err(QuoteConfigurationError::DuplicatePair { + input: rule.input_asset, + output: rule.output_asset, + }); + } + } + Ok(Self { + context, + assets, + pairs, + }) + } + + #[must_use] + pub const fn context(&self) -> QuoteContext { + self.context + } + + #[must_use] + pub const fn assets(&self) -> BinaryMarketAssets { + self.assets + } + + #[must_use] + pub fn pairs(&self) -> &[PairRule] { + &self.pairs + } +} + +#[derive(Clone, Debug)] +struct PairCatalog { + markets: Vec, +} + +impl PairCatalog { + fn new( + identity: ProviderIdentity, + mut markets: Vec, + ) -> Result { + if markets.is_empty() { + return Err(QuoteConfigurationError::NoMarkets); + } + markets.sort_by_key(|market| market.context.market); + let mut previous = None; + for market in &markets { + if market.context.chain.genesis_hash != identity.genesis_hash() { + return Err(QuoteConfigurationError::WrongGenesis); + } + if market.context.policy_asset != identity.policy_asset() { + return Err(QuoteConfigurationError::WrongPolicyAsset); + } + if previous == Some(market.context.market) { + return Err(QuoteConfigurationError::DuplicateMarket( + market.context.market, + )); + } + previous = Some(market.context.market); + } + Ok(Self { markets }) + } + + fn resolve( + &self, + context: QuoteContext, + input: AssetId, + output: AssetId, + ) -> Result<(&MarketQuoteConfig, PairRule), QuoteAdmissionError> { + let market = self + .markets + .iter() + .find(|market| market.context == context) + .ok_or(QuoteAdmissionError::MarketNotConfigured)?; + let pair = market + .pairs + .iter() + .copied() + .find(|rule| rule.input_asset == input && rule.output_asset == output) + .ok_or(QuoteAdmissionError::PairNotConfigured)?; + Ok((market, pair)) + } +} + +/// Reduced inventory information exposed to pricing. It contains no +/// outpoints, denominations, openings, wallet locators, keys, or recipient. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InventorySummary { + balances: Vec, +} + +impl InventorySummary { + #[must_use] + pub fn balances(&self) -> &[AssetAmount] { + &self.balances + } + + #[must_use] + pub fn amount(&self, asset: AssetId) -> u64 { + self.balances + .iter() + .find(|balance| balance.asset == asset) + .map_or(0, |balance| balance.amount) + } +} + +/// Normalized positive rational, interpreted as output units per input unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RationalRate { + numerator: u64, + denominator: u64, +} + +impl RationalRate { + pub fn new(numerator: u64, denominator: u64) -> Result { + if numerator == 0 || denominator == 0 { + return Err(QuoteModelError::ZeroRate); + } + let divisor = gcd(numerator, denominator); + Ok(Self { + numerator: numerator / divisor, + denominator: denominator / divisor, + }) + } + + #[must_use] + pub const fn numerator(self) -> u64 { + self.numerator + } + + #[must_use] + pub const fn denominator(self) -> u64 { + self.denominator + } +} + +const fn gcd(mut left: u64, mut right: u64) -> u64 { + while right != 0 { + let remainder = left % right; + left = right; + right = remainder; + } + left +} + +/// Stable identity of a pricing configuration or implementation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PricingPolicyId([u8; 32]); + +impl PricingPolicyId { + #[must_use] + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +/// Monotonic operator-selected revision of a pricing policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PricingRevision(u64); + +impl PricingRevision { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Exact-side amount visible to the price policy, without the user's guard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PricingSide { + ExactIn { gross_input: AssetAmount }, + ExactOut { output: AssetAmount }, +} + +/// Redacted pricing request. +#[derive(Clone, Copy, Debug)] +pub struct PricingRequest<'a> { + context: QuoteContext, + input_asset: AssetId, + output_asset: AssetId, + side: PricingSide, + inventory: &'a InventorySummary, +} + +impl PricingRequest<'_> { + #[must_use] + pub const fn context(&self) -> QuoteContext { + self.context + } + + #[must_use] + pub const fn input_asset(&self) -> AssetId { + self.input_asset + } + + #[must_use] + pub const fn output_asset(&self) -> AssetId { + self.output_asset + } + + #[must_use] + pub const fn side(&self) -> PricingSide { + self.side + } + + #[must_use] + pub const fn inventory(&self) -> &InventorySummary { + self.inventory + } +} + +/// Pricing result. The fee is denominated in the trade input asset and is +/// included in the taker's gross debit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PricingDecision { + rate: RationalRate, + input_asset_venue_fee: u64, + policy_id: PricingPolicyId, + revision: PricingRevision, +} + +impl PricingDecision { + #[must_use] + pub const fn new( + rate: RationalRate, + input_asset_venue_fee: u64, + policy_id: PricingPolicyId, + revision: PricingRevision, + ) -> Self { + Self { + rate, + input_asset_venue_fee, + policy_id, + revision, + } + } + + #[must_use] + pub const fn rate(self) -> RationalRate { + self.rate + } + + #[must_use] + pub const fn input_asset_venue_fee(self) -> u64 { + self.input_asset_venue_fee + } + + #[must_use] + pub const fn policy_id(self) -> PricingPolicyId { + self.policy_id + } + + #[must_use] + pub const fn revision(self) -> PricingRevision { + self.revision + } + + fn validate(self) -> Result<(), QuoteAdmissionError> { + if self.rate.numerator == 0 || self.rate.denominator == 0 { + return Err(QuoteAdmissionError::InvalidPricingDecision); + } + if gcd(self.rate.numerator, self.rate.denominator) != 1 { + return Err(QuoteAdmissionError::InvalidPricingDecision); + } + Ok(()) + } +} + +/// Injected pricing strategy. The engine, not the strategy, applies and checks +/// exact integer rounding. +pub trait PricingPolicy { + type Error: Error + Send + Sync + 'static; + + fn price(&self, request: PricingRequest<'_>) -> Result; +} + +/// One independently configured static directed rate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StaticRateRule { + market: ContractId, + input_asset: AssetId, + output_asset: AssetId, + rate: RationalRate, +} + +impl StaticRateRule { + #[must_use] + pub const fn new( + market: ContractId, + input_asset: AssetId, + output_asset: AssetId, + rate: RationalRate, + ) -> Self { + Self { + market, + input_asset, + output_asset, + rate, + } + } +} + +/// Simple spread-only pricing suitable for configuration and deterministic +/// tests. Every enabled direction must have its own explicit rate. +#[derive(Clone, Debug)] +pub struct StaticRationalPricing { + rules: Vec, + policy_id: PricingPolicyId, + revision: PricingRevision, +} + +impl StaticRationalPricing { + pub fn new( + mut rules: Vec, + revision: PricingRevision, + ) -> Result { + if rules.is_empty() { + return Err(StaticPricingError::NoRates); + } + rules.sort_by_key(static_rate_key); + if let Some(duplicate) = rules + .windows(2) + .find(|pair| static_rate_key(&pair[0]) == static_rate_key(&pair[1])) + .map(|pair| pair[0]) + { + return Err(StaticPricingError::DuplicateRate { + market: duplicate.market, + input: duplicate.input_asset, + output: duplicate.output_asset, + }); + } + let transcript = rules + .iter() + .map(|rule| StoredStaticRateV1 { + market: rule.market.creation_anchor(), + input_asset: rule.input_asset, + output_asset: rule.output_asset, + numerator: rule.rate.numerator, + denominator: rule.rate.denominator, + }) + .collect::>(); + let policy_id = PricingPolicyId(domain_digest( + STATIC_PRICING_DOMAIN, + &StoredStaticPricingV1 { + revision: revision.value(), + rules: &transcript, + }, + )?); + Ok(Self { + rules, + policy_id, + revision, + }) + } + + #[must_use] + pub const fn policy_id(&self) -> PricingPolicyId { + self.policy_id + } +} + +impl PricingPolicy for StaticRationalPricing { + type Error = StaticPricingError; + + fn price(&self, request: PricingRequest<'_>) -> Result { + let rule = self + .rules + .iter() + .find(|rule| { + rule.market == request.context.market + && rule.input_asset == request.input_asset + && rule.output_asset == request.output_asset + }) + .ok_or(StaticPricingError::RateNotConfigured)?; + Ok(PricingDecision::new( + rule.rate, + 0, + self.policy_id, + self.revision, + )) + } +} + +fn static_rate_key(rule: &StaticRateRule) -> ([u8; 36], [u8; 32], [u8; 32]) { + ( + rule.market.to_fixed_key(), + rule.input_asset.into_inner().to_byte_array(), + rule.output_asset.into_inner().to_byte_array(), + ) +} + +/// Exact gross taker debit and net taker receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuoteExecution { + input: AssetAmount, + output: AssetAmount, + input_asset_venue_fee: u64, +} + +impl QuoteExecution { + #[must_use] + pub const fn input(self) -> AssetAmount { + self.input + } + + #[must_use] + pub const fn output(self) -> AssetAmount { + self.output + } + + #[must_use] + pub const fn input_asset_venue_fee(self) -> u64 { + self.input_asset_venue_fee + } +} + +/// Quote-local symbolic input identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct QuoteInputId(u16); + +impl QuoteInputId { + #[must_use] + pub const fn new(value: u16) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u16 { + self.0 + } +} + +/// Quote-local symbolic output identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct QuoteOutputId(u16); + +impl QuoteOutputId { + #[must_use] + pub const fn new(value: u16) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u16 { + self.0 + } +} + +/// Input responsible for blinding an exact quoted output. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuoteBlinderRole { + /// Resolved client-side to whichever taker input funds the route. + TakerPaymentInput, + /// One provider input local to this quote contribution. + ProviderInput(QuoteInputId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum QuoteOutputRole { + ProviderPayment, + TakerReceive, + ProviderChange, +} + +/// Full public provider prevout required by client transaction composition. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuotedProviderInput { + id: QuoteInputId, + outpoint: OutPoint, + #[serde(with = "txout_serde")] + witness_utxo: TxOut, + #[serde(with = "inventory_binding_serde")] + inventory_binding: InventoryBinding, +} + +impl QuotedProviderInput { + #[must_use] + pub const fn id(&self) -> QuoteInputId { + self.id + } + + #[must_use] + pub const fn outpoint(&self) -> OutPoint { + self.outpoint + } + + #[must_use] + pub const fn witness_utxo(&self) -> &TxOut { + &self.witness_utxo + } + + #[must_use] + pub const fn inventory_binding(&self) -> InventoryBinding { + self.inventory_binding + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuotedOutput { + id: QuoteOutputId, + role: QuoteOutputRole, + asset: AssetId, + amount: u64, + destination: QuoteRecipient, + blinder: QuoteBlinderRole, +} + +impl QuotedOutput { + #[must_use] + pub const fn id(&self) -> QuoteOutputId { + self.id + } + + #[must_use] + pub const fn role(&self) -> QuoteOutputRole { + self.role + } + + #[must_use] + pub const fn asset(&self) -> AssetId { + self.asset + } + + #[must_use] + pub const fn amount(&self) -> u64 { + self.amount + } + + #[must_use] + pub const fn destination(&self) -> &QuoteRecipient { + &self.destination + } + + #[must_use] + pub const fn blinder(&self) -> QuoteBlinderRole { + self.blinder + } +} + +/// Provider-owned symbolic fragment. V1 inputs use final sequence and the +/// contribution makes no transaction locktime claim. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuoteContribution { + inputs: Vec, + outputs: Vec, +} + +impl QuoteContribution { + #[must_use] + pub fn inputs(&self) -> &[QuotedProviderInput] { + &self.inputs + } + + #[must_use] + pub fn outputs(&self) -> &[QuotedOutput] { + &self.outputs + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuoteSnapshotEvidence { + #[serde(with = "wallet_scan_anchor_serde")] + anchor: WalletScanAnchor, + #[serde(with = "inventory_snapshot_commitment_serde")] + commitment: InventorySnapshotCommitment, + allocation_revision: u64, + eligible_commitment: [u8; 32], +} + +impl QuoteSnapshotEvidence { + #[must_use] + pub const fn anchor(self) -> WalletScanAnchor { + self.anchor + } + + #[must_use] + pub const fn commitment(self) -> InventorySnapshotCommitment { + self.commitment + } + + /// Durable allocation revision used for the final compare-and-swap. + #[must_use] + pub const fn allocation_revision(self) -> u64 { + self.allocation_revision + } + + /// Commitment to the exact inventory set presented to pricing. + #[must_use] + pub const fn eligible_commitment(self) -> [u8; 32] { + self.eligible_commitment + } +} + +/// Exact non-secret quote artifact durably replayed for one semantic request. +/// +/// This internal artifact is not a wire message, provider signature, or +/// attestation. A remote RFQ protocol must authenticate its caller-provided +/// owner and authenticate whatever response envelope carries this value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FirmQuote { + reservation_id: ReservationId, + provider: ProviderIdentity, + request: FirmQuoteRequest, + execution: QuoteExecution, + pricing: PricingDecision, + snapshot: QuoteSnapshotEvidence, + contribution: QuoteContribution, + created_at: UnixMillis, + accept_before: UnixMillis, + fee_policy: FeePolicy, + recovery_metadata_commitment: [u8; 32], + commitment: QuoteCommitment, +} + +impl FirmQuote { + #[must_use] + pub const fn reservation_id(&self) -> ReservationId { + self.reservation_id + } + + #[must_use] + pub const fn provider(&self) -> ProviderIdentity { + self.provider + } + + #[must_use] + pub const fn request(&self) -> &FirmQuoteRequest { + &self.request + } + + #[must_use] + pub const fn execution(&self) -> QuoteExecution { + self.execution + } + + #[must_use] + pub const fn pricing(&self) -> PricingDecision { + self.pricing + } + + #[must_use] + pub const fn snapshot(&self) -> QuoteSnapshotEvidence { + self.snapshot + } + + #[must_use] + pub const fn contribution(&self) -> &QuoteContribution { + &self.contribution + } + + #[must_use] + pub const fn created_at(&self) -> UnixMillis { + self.created_at + } + + #[must_use] + pub const fn accept_before(&self) -> UnixMillis { + self.accept_before + } + + #[must_use] + pub const fn fee_policy(&self) -> FeePolicy { + self.fee_policy + } + + /// Opaque binding to provider-only receive/change recovery metadata. + /// + /// The preimage contains wallet locators and is deliberately never exposed + /// by a firm quote. This commitment only detects accidental durable-state + /// disagreement; it is not provider authentication. + #[must_use] + pub const fn recovery_metadata_commitment(&self) -> [u8; 32] { + self.recovery_metadata_commitment + } + + #[must_use] + pub const fn commitment(&self) -> QuoteCommitment { + self.commitment + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FirmQuoteOutcome { + quote: FirmQuote, + reservation: ReservationView, + created: bool, +} + +impl FirmQuoteOutcome { + #[must_use] + pub const fn quote(&self) -> &FirmQuote { + &self.quote + } + + #[must_use] + pub const fn reservation(&self) -> &ReservationView { + &self.reservation + } + + /// Whether this outcome created the reservation in this call. + /// + /// A `false` outcome is exact durable replay and may be terminal. Callers + /// must inspect [`ReservationView::state`] before treating the quote as + /// currently acceptable; replay never resurrects an expired, cancelled, + /// committed, or signed reservation. + #[must_use] + pub const fn created(&self) -> bool { + self.created + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DestinationRecovery { + pub(crate) internal_key: [u8; 32], + pub(crate) wallet_locator: [u8; 32], +} + +impl From<&ConfidentialDestination> for DestinationRecovery { + fn from(value: &ConfidentialDestination) -> Self { + Self { + internal_key: value.internal_key().serialize(), + wallet_locator: value.wallet_locator().to_bytes(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct FirmQuoteDraft { + pub(crate) request: FirmQuoteRequest, + pub(crate) execution: QuoteExecution, + pub(crate) pricing: PricingDecision, + pub(crate) snapshot: QuoteSnapshotEvidence, + pub(crate) contribution: QuoteContribution, + pub(crate) provider_receive_recovery: DestinationRecovery, + pub(crate) provider_change_recovery: Option, + pub(crate) selected_asset: AssetId, + pub(crate) selected_amount: u64, +} + +impl FirmQuoteDraft { + pub(crate) fn selected_outpoints(&self) -> Vec { + self.contribution + .inputs + .iter() + .map(|input| input.outpoint) + .collect() + } + + pub(crate) fn validate(&self) -> Result<(), QuoteAdmissionError> { + self.request.validate()?; + self.pricing.validate()?; + if self.selected_amount == 0 || self.selected_asset != self.execution.output.asset { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + let change = self + .selected_amount + .checked_sub(self.execution.output.amount) + .ok_or(QuoteAdmissionError::InvalidDerivedQuote)?; + if self.contribution.inputs.is_empty() + || self.contribution.inputs.len() > MAX_RESERVATION_INPUTS + { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + for (index, input) in self.contribution.inputs.iter().enumerate() { + let id = + u16::try_from(index + 1).map_err(|_| QuoteAdmissionError::InvalidDerivedQuote)?; + if input.id.value() != id { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + } + let expected_output_count = if change == 0 { 2 } else { 3 }; + if self.contribution.outputs.len() != expected_output_count { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + let outputs = &self.contribution.outputs; + let provider_blinder = QuoteBlinderRole::ProviderInput(self.contribution.inputs[0].id); + if outputs[0].id != QuoteOutputId(1) + || outputs[0].role != QuoteOutputRole::ProviderPayment + || outputs[0].asset != self.execution.input.asset + || outputs[0].amount != self.execution.input.amount + || outputs[0].blinder != QuoteBlinderRole::TakerPaymentInput + || outputs[1].id != QuoteOutputId(2) + || outputs[1].role != QuoteOutputRole::TakerReceive + || outputs[1].asset != self.execution.output.asset + || outputs[1].amount != self.execution.output.amount + || outputs[1].destination != self.request.recipient + || outputs[1].blinder != provider_blinder + { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + if change != 0 + && (outputs[2].id != QuoteOutputId(3) + || outputs[2].role != QuoteOutputRole::ProviderChange + || outputs[2].asset != self.selected_asset + || outputs[2].amount != change + || outputs[2].blinder != provider_blinder) + { + return Err(QuoteAdmissionError::InvalidDerivedQuote); + } + Ok(()) + } +} + +/// Service-level firm-quote policy. Per-asset amount caps and reserve floors +/// are deployment-specific and can be added without changing quote semantics. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QuoteEnginePolicy { + quote_lifetime_millis: u64, + maximum_live_quotes_per_owner: usize, + maximum_live_quotes_global: usize, + fee_policy: FeePolicy, +} + +impl QuoteEnginePolicy { + pub fn new( + quote_lifetime_millis: u64, + maximum_live_quotes_per_owner: usize, + maximum_live_quotes_global: usize, + fee_policy: FeePolicy, + ) -> Result { + if quote_lifetime_millis == 0 { + return Err(QuoteConfigurationError::ZeroQuoteLifetime); + } + if maximum_live_quotes_per_owner == 0 || maximum_live_quotes_global == 0 { + return Err(QuoteConfigurationError::ZeroLiveQuoteLimit); + } + if maximum_live_quotes_per_owner > maximum_live_quotes_global { + return Err(QuoteConfigurationError::OwnerLimitExceedsGlobal); + } + Ok(Self { + quote_lifetime_millis, + maximum_live_quotes_per_owner, + maximum_live_quotes_global, + fee_policy, + }) + } + + #[must_use] + pub fn launch_default(fee_policy: FeePolicy) -> Self { + Self { + quote_lifetime_millis: DEFAULT_QUOTE_LIFETIME_MILLIS, + maximum_live_quotes_per_owner: DEFAULT_MAX_LIVE_QUOTES_PER_OWNER, + maximum_live_quotes_global: 1_024, + fee_policy, + } + } + + #[must_use] + pub const fn quote_lifetime_millis(self) -> u64 { + self.quote_lifetime_millis + } + + #[must_use] + pub const fn maximum_live_quotes_per_owner(self) -> usize { + self.maximum_live_quotes_per_owner + } + + #[must_use] + pub const fn maximum_live_quotes_global(self) -> usize { + self.maximum_live_quotes_global + } + + #[must_use] + pub const fn fee_policy(self) -> FeePolicy { + self.fee_policy + } +} + +/// Exact quote construction over fresh wallet inventory. +/// +/// The embedding service must supply chain-validated market configuration, +/// authenticate `owner` at its transport boundary, enforce request-rate and +/// durable-retention policy, and restart the whole quote operation after +/// [`ProviderError::EligibleInventoryChanged`]. Retrying only the final +/// reservation with a stale draft is invalid, and destinations already issued +/// during a lost race must remain permanently burned. +pub struct QuoteEngine { + inventory: InventoryCoordinator, + destinations: D, + pricing: P, + catalog: PairCatalog, + policy: QuoteEnginePolicy, +} + +impl QuoteEngine +where + S: InventorySource, + D: DestinationSource, + P: PricingPolicy, +{ + pub fn new( + inventory: InventoryCoordinator, + destinations: D, + pricing: P, + markets: Vec, + policy: QuoteEnginePolicy, + ) -> Result { + let identity = inventory.identity(); + if policy.fee_policy.policy_asset() != identity.policy_asset() { + return Err(QuoteConfigurationError::WrongPolicyAsset); + } + Ok(Self { + catalog: PairCatalog::new(identity, markets)?, + inventory, + destinations, + pricing, + policy, + }) + } + + #[must_use] + pub const fn inventory(&self) -> &InventoryCoordinator { + &self.inventory + } + + /// Issue or exactly replay one inventory-backed firm quote. + #[allow(clippy::type_complexity)] + pub fn firm_quote( + &self, + owner: OwnerId, + key: IdempotencyKey, + request: FirmQuoteRequest, + clock: &C, + ) -> Result> { + request.validate().map_err(QuoteEngineError::Admission)?; + let request_digest = quote_request_digest(self.inventory.identity(), owner, key, &request) + .map_err(QuoteEngineError::Provider)?; + if let Some(replayed) = self + .inventory + .reservation_book() + .preflight_firm_quote(owner, key, request_digest, self.policy, clock) + .map_err(QuoteEngineError::Provider)? + { + return Ok(replayed); + } + + // The bounded preflight sweep keeps each write predictable, but there + // may be more than one batch of expired allocations. Re-evaluate the + // current asset view after each committed batch before pricing or + // destination generation; otherwise an expired output beyond the first + // batch could look unavailable and cause a false insufficient-inventory + // rejection. This loop is intentionally outside any one redb write. + loop { + let expired = self + .inventory + .reservation_book() + .expire_due(clock, crate::store::MAX_EXPIRATION_BATCH) + .map_err(QuoteEngineError::Provider)?; + if expired.len() < crate::store::MAX_EXPIRATION_BATCH { + break; + } + } + + let (input_asset, output_asset) = request.kind.pair(); + let (market, pair) = self + .catalog + .resolve(request.context, input_asset, output_asset) + .map_err(QuoteEngineError::Admission)?; + let eligible = self + .inventory + .eligible(clock) + .map_err(QuoteEngineError::Inventory)?; + let summary = + inventory_summary(&eligible, market.assets).map_err(QuoteEngineError::Admission)?; + let side = match request.kind { + QuoteKind::ExactIn { input, .. } => PricingSide::ExactIn { gross_input: input }, + QuoteKind::ExactOut { output, .. } => PricingSide::ExactOut { output }, + }; + let pricing = self + .pricing + .price(PricingRequest { + context: request.context, + input_asset, + output_asset, + side, + inventory: &summary, + }) + .map_err(QuoteEngineError::Pricing)?; + pricing.validate().map_err(QuoteEngineError::Admission)?; + let execution = calculate_execution(&request, pricing, pair.limits) + .map_err(QuoteEngineError::Admission)?; + let selected = select_inventory( + &eligible, + output_asset, + execution.output.amount, + pair.limits, + ) + .map_err(QuoteEngineError::Admission)?; + let selected_amount = selected + .iter() + .try_fold(0_u64, |total, output| total.checked_add(output.amount())); + let selected_amount = selected_amount.ok_or(QuoteEngineError::Admission( + QuoteAdmissionError::AmountOverflow, + ))?; + let change = selected_amount.checked_sub(execution.output.amount).ok_or( + QuoteEngineError::Admission(QuoteAdmissionError::AmountOverflow), + )?; + let provider_receive = self + .destinations + .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .map_err(QuoteEngineError::Destination)?; + let provider_change = if change == 0 { + None + } else { + Some( + self.destinations + .fresh_confidential_destination(DestinationPurpose::SettlementChange) + .map_err(QuoteEngineError::Destination)?, + ) + }; + if provider_change.as_ref().is_some_and(|destination| { + destination.script_pubkey() == provider_receive.script_pubkey() + || destination.blinding_public_key() == provider_receive.blinding_public_key() + || destination.wallet_locator() == provider_receive.wallet_locator() + }) { + return Err(QuoteEngineError::Admission( + QuoteAdmissionError::ReusedProviderDestination, + )); + } + let contribution = quote_contribution( + &selected, + execution, + request.recipient.clone(), + &provider_receive, + provider_change.as_ref(), + change, + ) + .map_err(QuoteEngineError::Admission)?; + let draft = FirmQuoteDraft { + request, + execution, + pricing, + snapshot: QuoteSnapshotEvidence { + anchor: eligible.anchor(), + commitment: eligible.token().snapshot(), + allocation_revision: eligible.allocation_revision(), + eligible_commitment: eligible.eligible_commitment(), + }, + contribution, + provider_receive_recovery: DestinationRecovery::from(&provider_receive), + provider_change_recovery: provider_change.as_ref().map(DestinationRecovery::from), + selected_asset: output_asset, + selected_amount, + }; + self.inventory + .reserve_firm_quote( + &eligible, + owner, + key, + request_digest, + &draft, + self.policy, + clock, + ) + .map_err(QuoteEngineError::Inventory) + } +} + +fn inventory_summary( + eligible: &EligibleInventory, + assets: BinaryMarketAssets, +) -> Result { + let mut totals = BTreeMap::<[u8; 32], (AssetId, u64)>::new(); + for output in eligible.outputs() { + if !assets.contains(output.asset()) { + continue; + } + let key = output.asset().into_inner().to_byte_array(); + let entry = totals.entry(key).or_insert((output.asset(), 0)); + entry.1 = entry + .1 + .checked_add(output.amount()) + .ok_or(QuoteAdmissionError::AmountOverflow)?; + } + Ok(InventorySummary { + balances: totals + .into_values() + .map(|(asset, amount)| AssetAmount { asset, amount }) + .collect(), + }) +} + +fn calculate_execution( + request: &FirmQuoteRequest, + pricing: PricingDecision, + limits: PairLimits, +) -> Result { + request.validate()?; + pricing.validate()?; + let fee = pricing.input_asset_venue_fee; + if fee > request.maximum_input_asset_venue_fee { + return Err(QuoteAdmissionError::VenueFeeLimitExceeded); + } + let (input, output) = match request.kind { + QuoteKind::ExactIn { + input, + output_asset, + minimum_output, + } => { + let priced_input = input + .amount + .checked_sub(fee) + .filter(|amount| *amount != 0) + .ok_or(QuoteAdmissionError::FeeConsumesInput)?; + let output_amount = multiply_divide_floor( + priced_input, + pricing.rate.numerator, + pricing.rate.denominator, + )?; + if output_amount < minimum_output { + return Err(QuoteAdmissionError::MinimumOutputNotMet); + } + (input, AssetAmount::new(output_asset, output_amount)?) + } + QuoteKind::ExactOut { + input_asset, + maximum_input, + output, + } => { + let priced_input = multiply_divide_ceil( + output.amount, + pricing.rate.denominator, + pricing.rate.numerator, + )?; + let gross_input = priced_input + .checked_add(fee) + .ok_or(QuoteAdmissionError::AmountOverflow)?; + if gross_input > maximum_input { + return Err(QuoteAdmissionError::MaximumInputExceeded); + } + (AssetAmount::new(input_asset, gross_input)?, output) + } + }; + if !limits.input.contains(input.amount) || !limits.output.contains(output.amount) { + return Err(QuoteAdmissionError::FillOutsideConfiguredRange); + } + Ok(QuoteExecution { + input, + output, + input_asset_venue_fee: fee, + }) +} + +fn multiply_divide_floor( + value: u64, + multiplier: u64, + divisor: u64, +) -> Result { + let quotient = u128::from(value) * u128::from(multiplier) / u128::from(divisor); + let quotient = u64::try_from(quotient).map_err(|_| QuoteAdmissionError::AmountOverflow)?; + if quotient == 0 { + return Err(QuoteAdmissionError::RoundedAmountIsZero); + } + Ok(quotient) +} + +fn multiply_divide_ceil( + value: u64, + multiplier: u64, + divisor: u64, +) -> Result { + let product = u128::from(value) * u128::from(multiplier); + let divisor = u128::from(divisor); + let quotient = product / divisor + u128::from(!product.is_multiple_of(divisor)); + let quotient = u64::try_from(quotient).map_err(|_| QuoteAdmissionError::AmountOverflow)?; + if quotient == 0 { + return Err(QuoteAdmissionError::RoundedAmountIsZero); + } + Ok(quotient) +} + +fn select_inventory( + eligible: &EligibleInventory, + asset: AssetId, + required: u64, + limits: PairLimits, +) -> Result, QuoteAdmissionError> { + let mut candidates = eligible + .outputs() + .iter() + .filter(|output| output.asset() == asset) + .collect::>(); + candidates.sort_by_key(|output| output.outpoint()); + + if let Some(exact) = candidates + .iter() + .copied() + .find(|output| output.amount() == required) + { + return Ok(vec![exact]); + } + if let Some(singleton) = candidates + .iter() + .copied() + .filter(|output| valid_selected_total(output.amount(), required, limits)) + .min_by_key(|output| (output.amount(), output.outpoint())) + { + return Ok(vec![singleton]); + } + + let total = candidates + .iter() + .try_fold(0_u64, |sum, output| sum.checked_add(output.amount())); + let total = total.ok_or(QuoteAdmissionError::AmountOverflow)?; + if total < required { + return Err(QuoteAdmissionError::InsufficientInventory); + } + + candidates.sort_by_key(|output| (std::cmp::Reverse(output.amount()), output.outpoint())); + let mut selected = Vec::new(); + let mut selected_total = 0_u64; + for output in &candidates { + if selected.len() == limits.maximum_provider_inputs { + break; + } + selected_total = selected_total + .checked_add(output.amount()) + .ok_or(QuoteAdmissionError::AmountOverflow)?; + selected.push(*output); + if valid_selected_total(selected_total, required, limits) { + selected.sort_by_key(|output| output.outpoint()); + return Ok(selected); + } + } + + let maximum_selected_total = selected_total; + if maximum_selected_total < required { + return Err(QuoteAdmissionError::InventoryTooFragmented); + } + + // If the largest admissible set falls into the forbidden positive-change + // band, a smaller exact subset can still be valid. Search only for that + // exact target: no other subset can make enough positive change when the + // maximum sum cannot. The hard node budget prevents adversarially + // fragmented inventory from turning quote admission into unbounded work. + let exact_candidates = candidates + .into_iter() + .filter(|output| output.amount() <= required) + .collect::>(); + let mut search = ExactSubsetSearch { + candidates: &exact_candidates, + required, + maximum_inputs: limits.maximum_provider_inputs, + remaining_nodes: limits.selection_search_node_budget, + exhausted_budget: false, + }; + if let Some(mut exact) = search.find()? { + exact.sort_by_key(|output| output.outpoint()); + return Ok(exact); + } + Err(QuoteAdmissionError::InventoryTooFragmented) +} + +struct ExactSubsetSearch<'search, 'inventory> { + candidates: &'search [&'inventory WalletOwnedOutput], + required: u64, + maximum_inputs: usize, + remaining_nodes: usize, + exhausted_budget: bool, +} + +impl<'inventory> ExactSubsetSearch<'_, 'inventory> { + fn find(&mut self) -> Result>, QuoteAdmissionError> { + for cardinality in 2..=self.maximum_inputs.min(self.candidates.len()) { + let mut selected = Vec::with_capacity(cardinality); + if self.visit(0, cardinality, 0, &mut selected) { + return Ok(Some(selected)); + } + if self.exhausted_budget { + return Err(QuoteAdmissionError::SelectionSearchBudgetExceeded); + } + } + Ok(None) + } + + fn visit( + &mut self, + start: usize, + slots: usize, + sum: u64, + selected: &mut Vec<&'inventory WalletOwnedOutput>, + ) -> bool { + if self.remaining_nodes == 0 { + self.exhausted_budget = true; + return false; + } + self.remaining_nodes -= 1; + if slots == 0 { + return sum == self.required; + } + if self.candidates.len().saturating_sub(start) < slots || sum >= self.required { + return false; + } + + let need = self.required - sum; + let maximum = self.candidates[start..] + .iter() + .take(slots) + .fold(0_u128, |total, output| total + u128::from(output.amount())); + let minimum = self.candidates[self.candidates.len() - slots..] + .iter() + .fold(0_u128, |total, output| total + u128::from(output.amount())); + if u128::from(need) > maximum || u128::from(need) < minimum { + return false; + } + + let last_start = self.candidates.len() - slots; + let mut index = start; + let mut previous_amount = None; + while index <= last_start { + let output = self.candidates[index]; + if previous_amount == Some(output.amount()) { + index += 1; + continue; + } + previous_amount = Some(output.amount()); + let Some(next_sum) = sum.checked_add(output.amount()) else { + index += 1; + continue; + }; + if next_sum <= self.required { + selected.push(output); + if self.visit(index + 1, slots - 1, next_sum, selected) { + return true; + } + selected.pop(); + if self.exhausted_budget { + return false; + } + } + index += 1; + } + false + } +} + +fn valid_selected_total(total: u64, required: u64, limits: PairLimits) -> bool { + total == required + || total + .checked_sub(required) + .is_some_and(|change| change >= limits.minimum_positive_change) +} + +fn quote_contribution( + selected: &[&WalletOwnedOutput], + execution: QuoteExecution, + recipient: QuoteRecipient, + provider_receive: &ConfidentialDestination, + provider_change: Option<&ConfidentialDestination>, + change: u64, +) -> Result { + let inputs = selected + .iter() + .enumerate() + .map(|(index, output)| { + let id = u16::try_from(index + 1) + .map(QuoteInputId) + .map_err(|_| QuoteAdmissionError::TooManyProviderInputs)?; + Ok(QuotedProviderInput { + id, + outpoint: output.outpoint(), + witness_utxo: output.txout().clone(), + inventory_binding: output.binding(), + }) + }) + .collect::, QuoteAdmissionError>>()?; + let provider_blinder = inputs + .first() + .map(|input| QuoteBlinderRole::ProviderInput(input.id)) + .ok_or(QuoteAdmissionError::InsufficientInventory)?; + let mut outputs = vec![ + QuotedOutput { + id: QuoteOutputId(1), + role: QuoteOutputRole::ProviderPayment, + asset: execution.input.asset, + amount: execution.input.amount, + destination: QuoteRecipient::from(provider_receive), + blinder: QuoteBlinderRole::TakerPaymentInput, + }, + QuotedOutput { + id: QuoteOutputId(2), + role: QuoteOutputRole::TakerReceive, + asset: execution.output.asset, + amount: execution.output.amount, + destination: recipient, + blinder: provider_blinder, + }, + ]; + if change != 0 { + let destination = provider_change.ok_or(QuoteAdmissionError::MissingChangeDestination)?; + outputs.push(QuotedOutput { + id: QuoteOutputId(3), + role: QuoteOutputRole::ProviderChange, + asset: execution.output.asset, + amount: change, + destination: QuoteRecipient::from(destination), + blinder: provider_blinder, + }); + } + Ok(QuoteContribution { inputs, outputs }) +} + +pub(crate) fn quote_request_digest( + provider: ProviderIdentity, + owner: OwnerId, + key: IdempotencyKey, + request: &FirmQuoteRequest, +) -> Result { + Ok(QuoteRequestDigest::new(domain_digest( + REQUEST_DOMAIN, + &StoredQuoteRequestV1::from_domain(provider, owner, key, request), + )?)) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn finalize_quote( + provider: ProviderIdentity, + owner: OwnerId, + key: IdempotencyKey, + request_digest: QuoteRequestDigest, + reservation_id: ReservationId, + draft: &FirmQuoteDraft, + created_at: UnixMillis, + accept_before: UnixMillis, + fee_policy: FeePolicy, +) -> Result { + let recovery_metadata_commitment = recovery_metadata_commitment( + provider, + reservation_id, + draft.provider_receive_recovery, + draft + .provider_change_recovery + .map(|recovery| recovery.internal_key), + draft + .provider_change_recovery + .map(|recovery| recovery.wallet_locator), + )?; + let mut quote = FirmQuote { + reservation_id, + provider, + request: draft.request.clone(), + execution: draft.execution, + pricing: draft.pricing, + snapshot: draft.snapshot, + contribution: draft.contribution.clone(), + created_at, + accept_before, + fee_policy, + recovery_metadata_commitment, + commitment: QuoteCommitment::new([0; 32]), + }; + let transcript = StoredQuoteTranscriptV1::from_domain(owner, key, request_digest, "e); + quote.commitment = QuoteCommitment::new(domain_digest(QUOTE_DOMAIN, &transcript)?); + Ok(quote) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn quote_from_stored_parts( + reservation_id: ReservationId, + provider: ProviderIdentity, + request: FirmQuoteRequest, + execution: QuoteExecution, + pricing: PricingDecision, + snapshot: QuoteSnapshotEvidence, + contribution: QuoteContribution, + created_at: UnixMillis, + accept_before: UnixMillis, + fee_policy: FeePolicy, + recovery_metadata_commitment: [u8; 32], + commitment: QuoteCommitment, +) -> FirmQuote { + FirmQuote { + reservation_id, + provider, + request, + execution, + pricing, + snapshot, + contribution, + created_at, + accept_before, + fee_policy, + recovery_metadata_commitment, + commitment, + } +} + +pub(crate) fn recovery_metadata_commitment( + provider: ProviderIdentity, + reservation_id: ReservationId, + provider_receive: DestinationRecovery, + provider_change_internal_key: Option<[u8; 32]>, + provider_change_wallet_locator: Option<[u8; 32]>, +) -> Result<[u8; 32], ProviderError> { + domain_digest( + RECOVERY_METADATA_DOMAIN, + &StoredRecoveryMetadataV1 { + provider: provider.provider().to_bytes(), + genesis_hash: provider.genesis_hash(), + policy_asset: provider.policy_asset(), + reservation_id: reservation_id.to_bytes(), + provider_receive_internal_key: provider_receive.internal_key, + provider_receive_wallet_locator: provider_receive.wallet_locator, + provider_change_internal_key, + provider_change_wallet_locator, + }, + ) +} + +pub(crate) fn quote_outcome( + quote: FirmQuote, + reservation: ReservationView, + created: bool, +) -> FirmQuoteOutcome { + FirmQuoteOutcome { + quote, + reservation, + created, + } +} + +pub(crate) fn recompute_quote_commitment( + owner: OwnerId, + key: IdempotencyKey, + request_digest: QuoteRequestDigest, + quote: &FirmQuote, +) -> Result { + Ok(QuoteCommitment::new(domain_digest( + QUOTE_DOMAIN, + &StoredQuoteTranscriptV1::from_domain(owner, key, request_digest, quote), + )?)) +} + +fn domain_digest(domain: &[u8], value: &T) -> Result<[u8; 32], ProviderError> { + let encoded = postcard::to_allocvec(value)?; + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update((encoded.len() as u64).to_be_bytes()); + hasher.update(encoded); + Ok(hasher.finalize().into()) +} + +#[derive(Serialize)] +struct StoredQuoteRequestV1<'a> { + provider: [u8; 32], + genesis_hash: elements::BlockHash, + provider_policy_asset: AssetId, + owner: [u8; 32], + idempotency_key: [u8; 32], + network: deadcat_types::LiquidNetwork, + market: OutPoint, + policy_asset: AssetId, + kind: StoredQuoteKindV1, + recipient_script: &'a Script, + recipient_blinding_key: Vec, + maximum_input_asset_venue_fee: u64, +} + +impl<'a> StoredQuoteRequestV1<'a> { + fn from_domain( + provider: ProviderIdentity, + owner: OwnerId, + key: IdempotencyKey, + request: &'a FirmQuoteRequest, + ) -> Self { + Self { + provider: provider.provider().to_bytes(), + genesis_hash: provider.genesis_hash(), + provider_policy_asset: provider.policy_asset(), + owner: owner.to_bytes(), + idempotency_key: key.to_bytes(), + network: request.context.chain.network, + market: request.context.market.creation_anchor(), + policy_asset: request.context.policy_asset, + kind: request.kind.into(), + recipient_script: &request.recipient.script_pubkey, + recipient_blinding_key: request.recipient.blinding_public_key.serialize().to_vec(), + maximum_input_asset_venue_fee: request.maximum_input_asset_venue_fee, + } + } +} + +#[derive(Clone, Copy, Serialize)] +enum StoredQuoteKindV1 { + ExactIn { + input_asset: AssetId, + input_amount: u64, + output_asset: AssetId, + minimum_output: u64, + }, + ExactOut { + input_asset: AssetId, + maximum_input: u64, + output_asset: AssetId, + output_amount: u64, + }, +} + +impl From for StoredQuoteKindV1 { + fn from(value: QuoteKind) -> Self { + match value { + QuoteKind::ExactIn { + input, + output_asset, + minimum_output, + } => Self::ExactIn { + input_asset: input.asset, + input_amount: input.amount, + output_asset, + minimum_output, + }, + QuoteKind::ExactOut { + input_asset, + maximum_input, + output, + } => Self::ExactOut { + input_asset, + maximum_input, + output_asset: output.asset, + output_amount: output.amount, + }, + } + } +} + +#[derive(Serialize)] +struct StoredStaticRateV1 { + market: OutPoint, + input_asset: AssetId, + output_asset: AssetId, + numerator: u64, + denominator: u64, +} + +#[derive(Serialize)] +struct StoredStaticPricingV1<'a> { + revision: u64, + rules: &'a [StoredStaticRateV1], +} + +#[derive(Serialize)] +struct StoredQuoteTranscriptV1<'a> { + request_digest: [u8; 32], + owner: [u8; 32], + idempotency_key: [u8; 32], + reservation_id: [u8; 32], + provider: [u8; 32], + genesis_hash: elements::BlockHash, + provider_policy_asset: AssetId, + request: StoredQuoteRequestV1<'a>, + execution_input_asset: AssetId, + execution_input_amount: u64, + execution_output_asset: AssetId, + execution_output_amount: u64, + input_asset_venue_fee: u64, + rate_numerator: u64, + rate_denominator: u64, + pricing_policy_id: [u8; 32], + pricing_revision: u64, + snapshot_hash: elements::BlockHash, + snapshot_height: u32, + snapshot_commitment: [u8; 32], + allocation_revision: u64, + eligible_commitment: [u8; 32], + inputs: Vec>, + outputs: Vec>, + created_at: u64, + accept_before: u64, + fee_policy_asset: AssetId, + minimum_sats_per_kvb: u64, + minimum_absolute_fee: u64, + maximum_transaction_weight: u64, + fee_size_metric: u8, + recovery_metadata_commitment: [u8; 32], +} + +impl<'a> StoredQuoteTranscriptV1<'a> { + fn from_domain( + owner: OwnerId, + key: IdempotencyKey, + request_digest: QuoteRequestDigest, + quote: &'a FirmQuote, + ) -> Self { + Self { + request_digest: request_digest.to_bytes(), + owner: owner.to_bytes(), + idempotency_key: key.to_bytes(), + reservation_id: quote.reservation_id.to_bytes(), + provider: quote.provider.provider().to_bytes(), + genesis_hash: quote.provider.genesis_hash(), + provider_policy_asset: quote.provider.policy_asset(), + request: StoredQuoteRequestV1::from_domain(quote.provider, owner, key, "e.request), + execution_input_asset: quote.execution.input.asset, + execution_input_amount: quote.execution.input.amount, + execution_output_asset: quote.execution.output.asset, + execution_output_amount: quote.execution.output.amount, + input_asset_venue_fee: quote.execution.input_asset_venue_fee, + rate_numerator: quote.pricing.rate.numerator, + rate_denominator: quote.pricing.rate.denominator, + pricing_policy_id: quote.pricing.policy_id.to_bytes(), + pricing_revision: quote.pricing.revision.value(), + snapshot_hash: quote.snapshot.anchor.block_hash(), + snapshot_height: quote.snapshot.anchor.block_height(), + snapshot_commitment: quote.snapshot.commitment.to_bytes(), + allocation_revision: quote.snapshot.allocation_revision, + eligible_commitment: quote.snapshot.eligible_commitment, + inputs: quote + .contribution + .inputs + .iter() + .map(StoredQuotedInputV1::from) + .collect(), + outputs: quote + .contribution + .outputs + .iter() + .map(StoredQuotedOutputV1::from) + .collect(), + created_at: quote.created_at.value(), + accept_before: quote.accept_before.value(), + fee_policy_asset: quote.fee_policy.policy_asset(), + minimum_sats_per_kvb: quote.fee_policy.minimum_sats_per_kvb(), + minimum_absolute_fee: quote.fee_policy.minimum_absolute_fee(), + maximum_transaction_weight: quote.fee_policy.maximum_transaction_weight(), + fee_size_metric: match quote.fee_policy.size_metric() { + crate::model::FeeSizeMetric::RegularVbytes => 0, + crate::model::FeeSizeMetric::DiscountVbytes => 1, + }, + recovery_metadata_commitment: quote.recovery_metadata_commitment, + } + } +} + +#[derive(Serialize)] +struct StoredRecoveryMetadataV1 { + provider: [u8; 32], + genesis_hash: elements::BlockHash, + policy_asset: AssetId, + reservation_id: [u8; 32], + provider_receive_internal_key: [u8; 32], + provider_receive_wallet_locator: [u8; 32], + provider_change_internal_key: Option<[u8; 32]>, + provider_change_wallet_locator: Option<[u8; 32]>, +} + +#[derive(Serialize)] +struct StoredQuotedInputV1<'a> { + id: u16, + outpoint: OutPoint, + witness_utxo: &'a TxOut, + inventory_binding: [u8; 32], +} + +impl<'a> From<&'a QuotedProviderInput> for StoredQuotedInputV1<'a> { + fn from(value: &'a QuotedProviderInput) -> Self { + Self { + id: value.id.value(), + outpoint: value.outpoint, + witness_utxo: &value.witness_utxo, + inventory_binding: value.inventory_binding.to_bytes(), + } + } +} + +#[derive(Serialize)] +struct StoredQuotedOutputV1<'a> { + id: u16, + role: u8, + asset: AssetId, + amount: u64, + script_pubkey: &'a Script, + blinding_public_key: Vec, + blinder_kind: u8, + blinder_input: u16, +} + +impl<'a> From<&'a QuotedOutput> for StoredQuotedOutputV1<'a> { + fn from(value: &'a QuotedOutput) -> Self { + let role = match value.role { + QuoteOutputRole::ProviderPayment => 0, + QuoteOutputRole::TakerReceive => 1, + QuoteOutputRole::ProviderChange => 2, + }; + let (blinder_kind, blinder_input) = match value.blinder { + QuoteBlinderRole::TakerPaymentInput => (0, 0), + QuoteBlinderRole::ProviderInput(id) => (1, id.value()), + }; + Self { + id: value.id.value(), + role, + asset: value.asset, + amount: value.amount, + script_pubkey: &value.destination.script_pubkey, + blinding_public_key: value.destination.blinding_public_key.serialize().to_vec(), + blinder_kind, + blinder_input, + } + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum QuoteModelError { + #[error("quote amounts must be nonzero")] + ZeroAmount, + #[error("input and output assets must differ")] + SameAssetPair, + #[error("quote recipient script must be spendable and nonempty")] + InvalidRecipientScript, + #[error("rational pricing numerator and denominator must be nonzero")] + ZeroRate, +} + +impl From for QuoteAdmissionError { + fn from(value: QuoteModelError) -> Self { + match value { + QuoteModelError::ZeroAmount => Self::RoundedAmountIsZero, + QuoteModelError::SameAssetPair + | QuoteModelError::InvalidRecipientScript + | QuoteModelError::ZeroRate => Self::InvalidDerivedQuote, + } + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum QuoteConfigurationError { + #[error("binary-market collateral, YES, and NO assets must be distinct")] + MarketAssetsNotDistinct, + #[error("market ID is not a valid contract anchor: {0}")] + InvalidMarketId(ContractId), + #[error("a market must enable at least one quote pair")] + NoEnabledPairs, + #[error("quote engine must configure at least one market")] + NoMarkets, + #[error("invalid amount range {minimum}..={maximum}")] + InvalidAmountRange { minimum: u64, maximum: u64 }, + #[error("provider-input limit {actual} is outside 1..={maximum}")] + InvalidProviderInputLimit { actual: usize, maximum: usize }, + #[error("unsupported launch pair {input} -> {output}")] + UnsupportedPair { input: AssetId, output: AssetId }, + #[error("duplicate configured pair {input} -> {output}")] + DuplicatePair { input: AssetId, output: AssetId }, + #[error("duplicate configured market {0}")] + DuplicateMarket(ContractId), + #[error("configured market uses the wrong Liquid genesis hash")] + WrongGenesis, + #[error("configured policy or fee asset disagrees with provider identity")] + WrongPolicyAsset, + #[error("quote lifetime must be nonzero")] + ZeroQuoteLifetime, + #[error("live quote limits must be nonzero")] + ZeroLiveQuoteLimit, + #[error("inventory subset-search node budget must be nonzero")] + ZeroSelectionSearchNodeBudget, + #[error("per-owner live quote limit exceeds the global limit")] + OwnerLimitExceedsGlobal, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum QuoteAdmissionError { + #[error("market context is not configured")] + MarketNotConfigured, + #[error("directed asset pair is not configured")] + PairNotConfigured, + #[error("calculated fill is outside configured pair limits")] + FillOutsideConfiguredRange, + #[error("firm quote exceeds the taker's venue-fee bound")] + VenueFeeLimitExceeded, + #[error("pricing policy returned an invalid or non-normalized decision")] + InvalidPricingDecision, + #[error("the input-asset fee consumes the entire exact input")] + FeeConsumesInput, + #[error("calculated output is below the taker's minimum")] + MinimumOutputNotMet, + #[error("calculated input exceeds the taker's maximum")] + MaximumInputExceeded, + #[error("quote amount arithmetic overflowed")] + AmountOverflow, + #[error("exact pricing rounded a nonzero amount to zero")] + RoundedAmountIsZero, + #[error("provider has insufficient eligible inventory")] + InsufficientInventory, + #[error("eligible inventory is too fragmented for the configured input cap")] + InventoryTooFragmented, + #[error("inventory subset search reached its configured work budget")] + SelectionSearchBudgetExceeded, + #[error("quote contains too many provider inputs")] + TooManyProviderInputs, + #[error("positive change has no provider destination")] + MissingChangeDestination, + #[error("wallet reused one provider destination within a quote")] + ReusedProviderDestination, + #[error("derived quote is internally invalid")] + InvalidDerivedQuote, +} + +#[derive(Debug, Error)] +pub enum StaticPricingError { + #[error("static pricing must configure at least one directed rate")] + NoRates, + #[error("duplicate static rate for market {market}, {input} -> {output}")] + DuplicateRate { + market: ContractId, + input: AssetId, + output: AssetId, + }, + #[error("static rate is not configured")] + RateNotConfigured, + #[error("failed to commit static pricing configuration: {0}")] + Commitment(#[from] ProviderError), +} + +#[derive(Debug, Error)] +pub enum QuoteEngineError +where + SourceError: Error + Send + Sync + 'static, + DestinationError: Error + Send + Sync + 'static, + PricingError: Error + Send + Sync + 'static, +{ + #[error("provider state rejected the quote: {0}")] + Provider(#[source] ProviderError), + #[error("inventory admission failed: {0}")] + Inventory(#[source] InventoryCoordinatorError), + #[error("quote admission failed: {0}")] + Admission(#[source] QuoteAdmissionError), + #[error("provider destination generation failed: {0}")] + Destination(#[source] DestinationError), + #[error("pricing policy failed: {0}")] + Pricing(#[source] PricingError), +} + +#[cfg(test)] +mod tests; diff --git a/crates/deadcat-rfq-provider/src/quote/tests.rs b/crates/deadcat-rfq-provider/src/quote/tests.rs new file mode 100644 index 0000000..361cf8b --- /dev/null +++ b/crates/deadcat-rfq-provider/src/quote/tests.rs @@ -0,0 +1,1651 @@ +use std::collections::{BTreeMap, VecDeque}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use deadcat_client::composition::{ + BlinderRef, InputId, InputSequence, InputSpec, LockTimeConstraint, OutputId, OutputSpec, + TransactionContribution, +}; +use deadcat_client::venue::{ + AssetAmount as ClientAssetAmount, ConfidentialRecipient as ClientRecipient, + ExactExecution as ClientExactExecution, ExecutionError as ClientExecutionError, + ExecutionRequest as ClientExecutionRequest, LegId, LegPreparationRequest, ProposedLeg, + VenueContext, +}; +use deadcat_types::{ChainIdentity, ContractId, LiquidNetwork}; +use elements::bitcoin::PublicKey as BitcoinPublicKey; +use elements::confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}; +use elements::hashes::Hash as _; +use elements::secp256k1_zkp::rand::thread_rng; +use elements::secp256k1_zkp::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use elements::{AssetId, BlockHash, OutPoint, Script, TxOut, TxOutSecrets, TxOutWitness, Txid}; +use tempfile::TempDir; +use thiserror::Error; + +use super::*; +use crate::inventory::{InventoryCoordinator, InventoryFreshnessPolicy}; +use crate::model::{ + FeePolicy, FeeSizeMetric, IdempotencyKey, OwnerId, ProviderId, ProviderIdentity, ReleaseReason, + ReservationPlan, ReservationState, WalletKeyLocator, +}; +use crate::store::{MAX_EXPIRATION_BATCH, ProviderError, ReservationBook}; +use crate::wallet::{ + ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshot, + InventorySource, WalletOwnedOutput, WalletScanAnchor, +}; + +const COLLATERAL_MARKER: u8 = 1; +const YES_MARKER: u8 = 2; +const NO_MARKER: u8 = 3; + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +enum FixtureError { + #[error("fixture source exhausted")] + Exhausted, +} + +#[derive(Clone)] +struct WalletFixture { + internal_key: XOnlyPublicKey, + blinding_public_key: PublicKey, + script_pubkey: Script, +} + +impl WalletFixture { + fn new(spend_marker: u8, blind_marker: u8) -> Self { + let secp = Secp256k1::new(); + let spend_secret = SecretKey::from_slice(&[spend_marker; 32]).expect("spend key"); + let spend_keypair = Keypair::from_secret_key(&secp, &spend_secret); + let (internal_key, _) = spend_keypair.x_only_public_key(); + let blinding_secret = SecretKey::from_slice(&[blind_marker; 32]).expect("blinding key"); + let blinding_public_key = PublicKey::from_secret_key(&secp, &blinding_secret); + let script_pubkey = Script::new_v1_p2tr(&secp, internal_key, None); + Self { + internal_key, + blinding_public_key, + script_pubkey, + } + } + + fn owned_output(&self, marker: u8, asset: AssetId, amount: u64) -> WalletOwnedOutput { + let explicit = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: self.script_pubkey.clone(), + witness: TxOutWitness::default(), + }; + let (txout, asset_bf, value_bf, _) = explicit + .to_non_last_confidential( + &mut thread_rng(), + &Secp256k1::new(), + self.blinding_public_key, + &[TxOutSecrets::new( + asset, + AssetBlindingFactor::zero(), + amount, + ValueBlindingFactor::zero(), + )], + ) + .expect("confidential output"); + WalletOwnedOutput::new( + outpoint(marker), + txout, + TxOutSecrets::new(asset, asset_bf, amount, value_bf), + self.internal_key, + WalletKeyLocator::new([marker; 32]).expect("wallet locator"), + ) + .expect("wallet-owned output") + } + + fn indexed_owned_outputs( + &self, + namespace: u8, + count: usize, + asset: AssetId, + amount: u64, + ) -> Vec { + let explicit = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: self.script_pubkey.clone(), + witness: TxOutWitness::default(), + }; + let (txout, asset_bf, value_bf, _) = explicit + .to_non_last_confidential( + &mut thread_rng(), + &Secp256k1::new(), + self.blinding_public_key, + &[TxOutSecrets::new( + asset, + AssetBlindingFactor::zero(), + amount, + ValueBlindingFactor::zero(), + )], + ) + .expect("confidential output template"); + (0..count) + .map(|index| { + let index = u32::try_from(index).expect("fixture index"); + WalletOwnedOutput::new( + indexed_outpoint(namespace, index), + txout.clone(), + TxOutSecrets::new(asset, asset_bf, amount, value_bf), + self.internal_key, + WalletKeyLocator::new(indexed_bytes(namespace, index)) + .expect("indexed wallet locator"), + ) + .expect("indexed wallet-owned output") + }) + .collect() + } +} + +struct MockSource { + snapshots: Mutex>, +} + +impl MockSource { + fn new(snapshots: impl IntoIterator) -> Self { + Self { + snapshots: Mutex::new(snapshots.into_iter().collect()), + } + } +} + +impl InventorySource for MockSource { + type Error = FixtureError; + + fn inventory_snapshot(&self) -> Result { + self.snapshots + .lock() + .expect("snapshot fixture lock") + .pop_front() + .ok_or(FixtureError::Exhausted) + } +} + +#[derive(Clone)] +struct MockDestinations { + state: Arc>>, + calls: Arc, +} + +impl MockDestinations { + fn new(destinations: impl IntoIterator) -> Self { + Self { + state: Arc::new(Mutex::new(destinations.into_iter().collect())), + calls: Arc::new(AtomicUsize::new(0)), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl DestinationSource for MockDestinations { + type Error = FixtureError; + + fn fresh_confidential_destination( + &self, + _purpose: DestinationPurpose, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.state + .lock() + .expect("destination fixture lock") + .pop_front() + .ok_or(FixtureError::Exhausted) + } +} + +#[derive(Clone)] +struct CountingPricing { + inner: StaticRationalPricing, + calls: Arc, + input_asset_venue_fee: u64, +} + +impl CountingPricing { + fn new(inner: StaticRationalPricing, calls: Arc) -> Self { + Self { + inner, + calls, + input_asset_venue_fee: 0, + } + } + + fn with_input_asset_venue_fee(mut self, input_asset_venue_fee: u64) -> Self { + self.input_asset_venue_fee = input_asset_venue_fee; + self + } +} + +impl PricingPolicy for CountingPricing { + type Error = StaticPricingError; + + fn price(&self, request: PricingRequest<'_>) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let decision = self.inner.price(request)?; + Ok(PricingDecision::new( + decision.rate(), + self.input_asset_venue_fee, + decision.policy_id(), + decision.revision(), + )) + } +} + +fn asset(marker: u8) -> AssetId { + AssetId::from_byte_array([marker; 32]) +} + +fn outpoint(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), u32::from(marker)) +} + +fn indexed_bytes(namespace: u8, index: u32) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + bytes[0] = namespace; + bytes[28..].copy_from_slice( + &index + .checked_add(1) + .expect("fixture index overflow") + .to_be_bytes(), + ); + bytes +} + +fn indexed_outpoint(namespace: u8, index: u32) -> OutPoint { + OutPoint::new( + Txid::from_byte_array(indexed_bytes(namespace, index)), + index, + ) +} + +fn identity(marker: u8) -> ProviderIdentity { + ProviderIdentity::new( + ProviderId::new([marker; 32]), + BlockHash::from_byte_array([marker.wrapping_add(1); 32]), + asset(COLLATERAL_MARKER), + ) +} + +fn quote_context(identity: ProviderIdentity) -> QuoteContext { + QuoteContext::new( + ChainIdentity { + network: LiquidNetwork::ElementsRegtest, + genesis_hash: identity.genesis_hash(), + }, + ContractId::new(outpoint(90)), + identity.policy_asset(), + ) +} + +fn snapshot( + identity: ProviderIdentity, + marker: u8, + outputs: Vec, +) -> InventorySnapshot { + InventorySnapshot::new( + identity, + WalletScanAnchor::new(BlockHash::from_byte_array([marker; 32]), u32::from(marker)), + outputs, + ) + .expect("inventory snapshot") +} + +fn destination(spend_marker: u8, blind_marker: u8, locator_marker: u8) -> ConfidentialDestination { + let secp = Secp256k1::new(); + let spend_secret = SecretKey::from_slice(&[spend_marker; 32]).expect("spend key"); + let spend_keypair = Keypair::from_secret_key(&secp, &spend_secret); + let (internal_key, _) = spend_keypair.x_only_public_key(); + let blinding_secret = SecretKey::from_slice(&[blind_marker; 32]).expect("blinding key"); + let blinding_public_key = PublicKey::from_secret_key(&secp, &blinding_secret); + ConfidentialDestination::new( + Script::new_v1_p2tr(&secp, internal_key, None), + blinding_public_key, + internal_key, + WalletKeyLocator::new([locator_marker; 32]).expect("destination locator"), + ) + .expect("confidential destination") +} + +fn indexed_secret(namespace: u8, index: u32) -> SecretKey { + let mut bytes = [0_u8; 32]; + bytes[27] = namespace; + bytes[28..].copy_from_slice( + &index + .checked_add(1) + .expect("secret index overflow") + .to_be_bytes(), + ); + SecretKey::from_slice(&bytes).expect("indexed secret key") +} + +fn indexed_destination(index: u32) -> ConfidentialDestination { + let secp = Secp256k1::new(); + let spend_keypair = Keypair::from_secret_key(&secp, &indexed_secret(1, index)); + let (internal_key, _) = spend_keypair.x_only_public_key(); + let blinding_public_key = PublicKey::from_secret_key(&secp, &indexed_secret(2, index)); + ConfidentialDestination::new( + Script::new_v1_p2tr(&secp, internal_key, None), + blinding_public_key, + internal_key, + WalletKeyLocator::new(indexed_bytes(3, index)).expect("indexed destination locator"), + ) + .expect("indexed confidential destination") +} + +fn recipient(marker: u8) -> QuoteRecipient { + let destination = destination(marker, marker.wrapping_add(1), marker.wrapping_add(2)); + QuoteRecipient::new( + destination.script_pubkey().clone(), + destination.blinding_public_key(), + ) + .expect("quote recipient") +} + +fn fee_policy(identity: ProviderIdentity) -> FeePolicy { + FeePolicy::new( + identity.policy_asset(), + 2_000, + 50, + 100_000, + FeeSizeMetric::DiscountVbytes, + ) + .expect("fee policy") +} + +fn broad_limits(maximum_provider_inputs: usize) -> PairLimits { + PairLimits::new( + AmountRange::new(1, u64::MAX).expect("input range"), + AmountRange::new(1, u64::MAX).expect("output range"), + maximum_provider_inputs, + 0, + ) + .expect("pair limits") +} + +fn market_config(identity: ProviderIdentity, limits: PairLimits) -> MarketQuoteConfig { + directed_market_config( + identity, + asset(COLLATERAL_MARKER), + asset(YES_MARKER), + limits, + ) +} + +fn directed_market_config( + identity: ProviderIdentity, + input_asset: AssetId, + output_asset: AssetId, + limits: PairLimits, +) -> MarketQuoteConfig { + let context = quote_context(identity); + MarketQuoteConfig::new( + context, + BinaryMarketAssets::new( + asset(COLLATERAL_MARKER), + asset(YES_MARKER), + asset(NO_MARKER), + ) + .expect("market assets"), + vec![PairRule::new(input_asset, output_asset, limits)], + ) + .expect("market config") +} + +fn static_pricing(identity: ProviderIdentity) -> StaticRationalPricing { + directed_static_pricing( + identity, + asset(COLLATERAL_MARKER), + asset(YES_MARKER), + RationalRate::new(1, 1).expect("rate"), + ) +} + +fn directed_static_pricing( + identity: ProviderIdentity, + input_asset: AssetId, + output_asset: AssetId, + rate: RationalRate, +) -> StaticRationalPricing { + let context = quote_context(identity); + StaticRationalPricing::new( + vec![StaticRateRule::new( + context.market(), + input_asset, + output_asset, + rate, + )], + PricingRevision::new(1), + ) + .expect("static pricing") +} + +fn open_engine( + directory: &TempDir, + identity: ProviderIdentity, + source: MockSource, + destinations: MockDestinations, + pricing: CountingPricing, +) -> QuoteEngine { + open_engine_with_policy( + directory, + identity, + source, + destinations, + pricing, + 100, + QuoteEnginePolicy::new(1_000, 2, 8, fee_policy(identity)).expect("engine policy"), + ) +} + +fn open_engine_with_market( + directory: &TempDir, + identity: ProviderIdentity, + source: MockSource, + destinations: MockDestinations, + pricing: CountingPricing, + market: MarketQuoteConfig, +) -> QuoteEngine { + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = InventoryCoordinator::new( + book, + source, + InventoryFreshnessPolicy::new(10_000, 100).expect("freshness policy"), + ); + QuoteEngine::new( + coordinator, + destinations, + pricing, + vec![market], + QuoteEnginePolicy::new(1_000, 2, 8, fee_policy(identity)).expect("engine policy"), + ) + .expect("quote engine") +} + +#[allow(clippy::too_many_arguments)] +fn open_engine_with_policy( + directory: &TempDir, + identity: ProviderIdentity, + source: MockSource, + destinations: MockDestinations, + pricing: CountingPricing, + maximum_inventory_outputs: usize, + policy: QuoteEnginePolicy, +) -> QuoteEngine { + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = InventoryCoordinator::new( + book, + source, + InventoryFreshnessPolicy::new(10_000, maximum_inventory_outputs).expect("freshness policy"), + ); + QuoteEngine::new( + coordinator, + destinations, + pricing, + vec![market_config(identity, broad_limits(8))], + policy, + ) + .expect("quote engine") +} + +fn exact_in_request(identity: ProviderIdentity, recipient: QuoteRecipient) -> FirmQuoteRequest { + FirmQuoteRequest::new( + quote_context(identity), + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 50).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 50, + }, + recipient, + 0, + ) + .expect("firm quote request") +} + +fn price_decision(rate: RationalRate, fee: u64) -> PricingDecision { + PricingDecision::new( + rate, + fee, + PricingPolicyId::new([42; 32]), + PricingRevision::new(7), + ) +} + +#[test] +fn persisted_quote_contribution_round_trips_through_the_store_codec() { + let recipient = recipient(19); + let encoded = postcard::to_allocvec(&recipient).expect("encode quote recipient"); + let decoded: QuoteRecipient = postcard::from_bytes(&encoded).expect("decode quote recipient"); + assert_eq!(decoded, recipient); + + let wallet = WalletFixture::new(17, 18); + let owned = wallet.owned_output(19, asset(YES_MARKER), 65); + let quoted_input = QuotedProviderInput { + id: QuoteInputId::new(1), + outpoint: owned.outpoint(), + witness_utxo: owned.txout().clone(), + inventory_binding: owned.binding(), + }; + let encoded = postcard::to_allocvec("ed_input).expect("encode quoted input"); + let decoded: QuotedProviderInput = postcard::from_bytes(&encoded).expect("decode quoted input"); + assert_eq!(decoded, quoted_input); + let quoted_output = QuotedOutput { + id: QuoteOutputId::new(1), + role: QuoteOutputRole::ProviderPayment, + asset: asset(COLLATERAL_MARKER), + amount: 50, + destination: recipient, + blinder: QuoteBlinderRole::TakerPaymentInput, + }; + let encoded = postcard::to_allocvec("ed_output).expect("encode quoted output"); + let decoded: QuotedOutput = postcard::from_bytes(&encoded).expect("decode quoted output"); + assert_eq!(decoded, quoted_output); + let contribution = QuoteContribution { + inputs: vec![quoted_input], + outputs: vec![quoted_output], + }; + let encoded = postcard::to_allocvec(&contribution).expect("encode quote contribution"); + let decoded: QuoteContribution = + postcard::from_bytes(&encoded).expect("decode quote contribution"); + assert_eq!(decoded, contribution); +} + +#[test] +fn pricing_uses_checked_floor_and_ceiling_with_fee_in_gross_input() { + let identity = identity(10); + let context = quote_context(identity); + let rate = RationalRate::new(6, 9).expect("normalized rate"); + assert_eq!((rate.numerator(), rate.denominator()), (2, 3)); + let limits = broad_limits(8); + + let exact_in = FirmQuoteRequest::new( + context, + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 10).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 6, + }, + recipient(20), + 1, + ) + .expect("exact-in request"); + let exact_in_execution = calculate_execution(&exact_in, price_decision(rate, 1), limits) + .expect("exact-in execution"); + assert_eq!(exact_in_execution.input().amount(), 10); + assert_eq!(exact_in_execution.output().amount(), 6); + assert_eq!(exact_in_execution.input_asset_venue_fee(), 1); + + let exact_out = FirmQuoteRequest::new( + context, + QuoteKind::ExactOut { + input_asset: asset(COLLATERAL_MARKER), + maximum_input: 12, + output: AssetAmount::new(asset(YES_MARKER), 7).expect("output"), + }, + recipient(21), + 1, + ) + .expect("exact-out request"); + let exact_out_execution = calculate_execution(&exact_out, price_decision(rate, 1), limits) + .expect("exact-out execution"); + assert_eq!(exact_out_execution.input().amount(), 12); + assert_eq!(exact_out_execution.output().amount(), 7); + assert_eq!(exact_out_execution.input_asset_venue_fee(), 1); +} + +#[test] +fn pricing_fails_closed_on_zero_rounding_overflow_and_user_guards() { + let identity = identity(11); + let context = quote_context(identity); + let limits = broad_limits(8); + let rounded_zero = FirmQuoteRequest::new( + context, + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 1).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 1, + }, + recipient(22), + 0, + ) + .expect("rounded-zero request"); + assert_eq!( + calculate_execution( + &rounded_zero, + price_decision(RationalRate::new(1, 2).expect("rate"), 0), + limits, + ), + Err(QuoteAdmissionError::RoundedAmountIsZero) + ); + + let overflow = FirmQuoteRequest::new( + context, + QuoteKind::ExactOut { + input_asset: asset(COLLATERAL_MARKER), + maximum_input: u64::MAX, + output: AssetAmount::new(asset(YES_MARKER), u64::MAX).expect("output"), + }, + recipient(23), + 0, + ) + .expect("overflow request"); + assert_eq!( + calculate_execution( + &overflow, + price_decision(RationalRate::new(1, u64::MAX).expect("rate"), 0), + limits, + ), + Err(QuoteAdmissionError::AmountOverflow) + ); + + let fee_bound = FirmQuoteRequest::new( + context, + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 10).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 1, + }, + recipient(24), + 1, + ) + .expect("fee-bound request"); + assert_eq!( + calculate_execution( + &fee_bound, + price_decision(RationalRate::new(1, 1).expect("rate"), 2), + limits, + ), + Err(QuoteAdmissionError::VenueFeeLimitExceeded) + ); +} + +#[test] +fn firm_quote_request_revalidates_an_invalid_recipient() { + let identity = identity(17); + let invalid_recipient = QuoteRecipient { + script_pubkey: Script::new(), + blinding_public_key: recipient(18).blinding_public_key(), + }; + + assert_eq!( + FirmQuoteRequest::new( + quote_context(identity), + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 50).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 50, + }, + invalid_recipient, + 0, + ), + Err(QuoteModelError::InvalidRecipientScript) + ); +} + +#[test] +fn provider_destination_reuse_includes_the_opaque_wallet_locator() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(20); + let wallet = WalletFixture::new(114, 115); + let inventory = wallet.owned_output(116, asset(YES_MARKER), 65); + let receive = destination(117, 118, 119); + let secp = Secp256k1::new(); + let change_spend = SecretKey::from_slice(&[120; 32]).expect("change spend key"); + let change_keypair = Keypair::from_secret_key(&secp, &change_spend); + let (change_internal_key, _) = change_keypair.x_only_public_key(); + let change_blind = SecretKey::from_slice(&[121; 32]).expect("change blinding key"); + let change = ConfidentialDestination::new( + Script::new_v1_p2tr(&secp, change_internal_key, None), + PublicKey::from_secret_key(&secp, &change_blind), + change_internal_key, + receive.wallet_locator(), + ) + .expect("change destination"); + let engine = open_engine( + &directory, + identity, + MockSource::new([snapshot(identity, 122, vec![inventory])]), + MockDestinations::new([receive, change]), + CountingPricing::new(static_pricing(identity), Arc::new(AtomicUsize::new(0))), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + + assert!(matches!( + engine.firm_quote( + OwnerId::new([123; 32]), + IdempotencyKey::new([124; 32]), + exact_in_request(identity, recipient(125)), + &UnixMillis::new(101), + ), + Err(QuoteEngineError::Admission( + QuoteAdmissionError::ReusedProviderDestination + )) + )); +} + +#[test] +fn firm_quote_ignores_superseded_historical_inventory_rows() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(21); + let wallet = WalletFixture::new(126, 127); + let superseded = wallet.owned_output(128, asset(YES_MARKER), 50); + let current = wallet.owned_output(129, asset(YES_MARKER), 65); + let engine = open_engine( + &directory, + identity, + MockSource::new([ + snapshot(identity, 130, vec![superseded.clone(), current.clone()]), + snapshot(identity, 131, vec![current.clone()]), + ]), + MockDestinations::new([destination(132, 133, 134), destination(135, 136, 137)]), + CountingPricing::new(static_pricing(identity), Arc::new(AtomicUsize::new(0))), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("initial inventory refresh"); + engine + .inventory() + .refresh(&UnixMillis::new(101)) + .expect("replacement inventory refresh"); + engine + .inventory() + .reservation_book() + .poison_inventory_record_for_test(superseded.outpoint()) + .expect("poison superseded durable row"); + + let outcome = engine + .firm_quote( + OwnerId::new([138; 32]), + IdempotencyKey::new([139; 32]), + exact_in_request(identity, recipient(140)), + &UnixMillis::new(102), + ) + .expect("quote from current bounded snapshot"); + assert!(outcome.created()); + assert_eq!( + outcome.quote().contribution().inputs()[0].outpoint(), + current.outpoint() + ); +} + +#[test] +fn selection_prefers_exact_then_smallest_singleton_then_bounded_largest_first() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(12); + let wallet = WalletFixture::new(30, 31); + let yes = asset(YES_MARKER); + let outputs = vec![ + wallet.owned_output(34, yes, 15), + wallet.owned_output(31, yes, 4), + wallet.owned_output(35, asset(NO_MARKER), 100), + wallet.owned_output(33, yes, 10), + wallet.owned_output(32, yes, 8), + ]; + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = InventoryCoordinator::new( + book, + MockSource::new([snapshot(identity, 40, outputs)]), + InventoryFreshnessPolicy::new(1_000, 10).expect("freshness policy"), + ); + let eligible = coordinator + .refresh(&UnixMillis::new(100)) + .expect("eligible inventory"); + let limits = broad_limits(2); + + let exact = select_inventory(&eligible, yes, 10, limits).expect("exact selection"); + assert_eq!( + exact + .iter() + .map(|output| output.outpoint()) + .collect::>(), + vec![outpoint(33)] + ); + + let singleton = select_inventory(&eligible, yes, 9, limits).expect("singleton selection"); + assert_eq!( + singleton + .iter() + .map(|output| output.outpoint()) + .collect::>(), + vec![outpoint(33)] + ); + + let accumulated = select_inventory(&eligible, yes, 17, limits).expect("accumulated selection"); + assert_eq!( + accumulated + .iter() + .map(|output| output.outpoint()) + .collect::>(), + vec![outpoint(33), outpoint(34)] + ); + + assert_eq!( + select_inventory(&eligible, yes, 26, limits), + Err(QuoteAdmissionError::InventoryTooFragmented) + ); +} + +#[test] +fn selection_finds_a_valid_bounded_subset_when_largest_first_has_dust_change() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(15); + let wallet = WalletFixture::new(91, 92); + let yes = asset(YES_MARKER); + let eight = wallet.owned_output(93, yes, 8); + let seven = wallet.owned_output(94, yes, 7); + let six = wallet.owned_output(95, yes, 6); + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = InventoryCoordinator::new( + book, + MockSource::new([snapshot( + identity, + 96, + vec![eight, seven.clone(), six.clone()], + )]), + InventoryFreshnessPolicy::new(1_000, 3).expect("freshness policy"), + ); + let eligible = coordinator + .refresh(&UnixMillis::new(100)) + .expect("eligible inventory"); + let limits = PairLimits::new( + AmountRange::new(1, u64::MAX).expect("input range"), + AmountRange::new(1, u64::MAX).expect("output range"), + 2, + 3, + ) + .expect("pair limits"); + + let selected = select_inventory(&eligible, yes, 13, limits) + .expect("the exact 7 + 6 subset is a valid two-input selection"); + assert_eq!( + selected + .iter() + .map(|output| output.outpoint()) + .collect::>(), + vec![seven.outpoint(), six.outpoint()] + ); +} + +#[test] +fn firm_quote_is_exactly_replayed_before_inventory_pricing_or_destination_work() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(13); + let context = quote_context(identity); + let wallet = WalletFixture::new(40, 41); + let inventory = wallet.owned_output(42, asset(YES_MARKER), 65); + let source = MockSource::new([snapshot(identity, 50, vec![inventory.clone()])]); + let destinations = MockDestinations::new([destination(51, 52, 53), destination(54, 55, 56)]); + let destination_probe = destinations.clone(); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let pricing = CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)); + let engine = open_engine(&directory, identity, source, destinations.clone(), pricing); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let request = exact_in_request(identity, recipient(60)); + let owner = OwnerId::new([61; 32]); + let key = IdempotencyKey::new([62; 32]); + + let created = engine + .firm_quote(owner, key, request.clone(), &UnixMillis::new(101)) + .expect("firm quote"); + assert!(created.created()); + assert_eq!(created.quote().request().context(), context); + assert_eq!(created.quote().execution().input().amount(), 50); + assert_eq!(created.quote().execution().output().amount(), 50); + assert_eq!(created.quote().accept_before(), UnixMillis::new(1_101)); + assert_eq!( + created.quote().commitment(), + created.reservation().quote_commitment() + ); + assert_eq!(created.reservation().outpoints(), &[inventory.outpoint()]); + let outputs = created.quote().contribution().outputs(); + assert_eq!(outputs.len(), 3); + assert_eq!(outputs[0].role(), QuoteOutputRole::ProviderPayment); + assert_eq!(outputs[0].amount(), 50); + assert_eq!(outputs[0].blinder(), QuoteBlinderRole::TakerPaymentInput); + assert_eq!(outputs[1].role(), QuoteOutputRole::TakerReceive); + assert_eq!(outputs[1].amount(), 50); + assert_eq!( + outputs[1].blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + assert_eq!(outputs[2].role(), QuoteOutputRole::ProviderChange); + assert_eq!(outputs[2].amount(), 15); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 2); + + let replay = engine + .firm_quote(owner, key, request.clone(), &UnixMillis::new(102)) + .expect("in-process replay"); + assert!(!replay.created()); + assert_eq!(replay.quote(), created.quote()); + assert_eq!(replay.reservation(), created.reservation()); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 2); + + let changed = FirmQuoteRequest::new( + context, + QuoteKind::ExactIn { + input: AssetAmount::new(asset(COLLATERAL_MARKER), 50).expect("input"), + output_asset: asset(YES_MARKER), + minimum_output: 49, + }, + request.recipient().clone(), + 0, + ) + .expect("changed request"); + assert!(matches!( + engine.firm_quote(owner, key, changed, &UnixMillis::new(103)), + Err(QuoteEngineError::Provider( + ProviderError::IdempotencyConflict { .. } + )) + )); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 2); + + let expected_quote = created.quote().clone(); + let expected_reservation = created.reservation().clone(); + drop(engine); + + let reopened_pricing = + CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)); + let reopened = open_engine( + &directory, + identity, + MockSource::new([]), + destinations, + reopened_pricing, + ); + let replayed_after_restart = reopened + .firm_quote(owner, key, request, &UnixMillis::new(104)) + .expect("restart replay without inventory refresh"); + assert!(!replayed_after_restart.created()); + assert_eq!(replayed_after_restart.quote(), &expected_quote); + assert_eq!(replayed_after_restart.reservation(), &expected_reservation); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 2); +} + +#[test] +fn exact_out_quote_includes_input_asset_fee_and_omits_zero_change_output() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(31); + let wallet = WalletFixture::new(131, 132); + let inventory = wallet.owned_output(133, asset(YES_MARKER), 7); + let provider_receive = destination(134, 135, 136); + let destinations = MockDestinations::new([provider_receive.clone()]); + let destination_probe = destinations.clone(); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let rate = RationalRate::new(2, 3).expect("rate"); + let pricing = CountingPricing::new( + directed_static_pricing(identity, asset(COLLATERAL_MARKER), asset(YES_MARKER), rate), + Arc::clone(&pricing_calls), + ) + .with_input_asset_venue_fee(1); + let engine = open_engine_with_market( + &directory, + identity, + MockSource::new([snapshot(identity, 137, vec![inventory.clone()])]), + destinations, + pricing, + directed_market_config( + identity, + asset(COLLATERAL_MARKER), + asset(YES_MARKER), + broad_limits(8), + ), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let taker_recipient = recipient(138); + let request = FirmQuoteRequest::new( + quote_context(identity), + QuoteKind::ExactOut { + input_asset: asset(COLLATERAL_MARKER), + maximum_input: 12, + output: AssetAmount::new(asset(YES_MARKER), 7).expect("output"), + }, + taker_recipient.clone(), + 1, + ) + .expect("exact-out request"); + + let outcome = engine + .firm_quote( + OwnerId::new([139; 32]), + IdempotencyKey::new([140; 32]), + request, + &UnixMillis::new(101), + ) + .expect("firm quote"); + let quote = outcome.quote(); + assert_eq!(quote.pricing().rate(), rate); + assert_eq!(quote.execution().input().asset(), asset(COLLATERAL_MARKER)); + assert_eq!(quote.execution().input().amount(), 12); + assert_eq!(quote.execution().output().asset(), asset(YES_MARKER)); + assert_eq!(quote.execution().output().amount(), 7); + assert_eq!(quote.execution().input_asset_venue_fee(), 1); + + let contribution = quote.contribution(); + assert_eq!(contribution.inputs().len(), 1); + assert_eq!(contribution.inputs()[0].id(), QuoteInputId::new(1)); + assert_eq!(contribution.inputs()[0].outpoint(), inventory.outpoint()); + assert_eq!(contribution.outputs().len(), 2); + let payment = &contribution.outputs()[0]; + assert_eq!(payment.role(), QuoteOutputRole::ProviderPayment); + assert_eq!(payment.asset(), asset(COLLATERAL_MARKER)); + assert_eq!(payment.amount(), 12); + assert_eq!( + payment.destination(), + &QuoteRecipient::from(&provider_receive) + ); + assert_eq!(payment.blinder(), QuoteBlinderRole::TakerPaymentInput); + let receive = &contribution.outputs()[1]; + assert_eq!(receive.role(), QuoteOutputRole::TakerReceive); + assert_eq!(receive.asset(), asset(YES_MARKER)); + assert_eq!(receive.amount(), 7); + assert_eq!(receive.destination(), &taker_recipient); + assert_eq!( + receive.blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + assert_eq!(outcome.reservation().outpoints(), &[inventory.outpoint()]); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 1); +} + +#[test] +fn reverse_no_to_collateral_quote_preserves_direction_and_change_economics() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(32); + let wallet = WalletFixture::new(141, 142); + let inventory = wallet.owned_output(143, asset(COLLATERAL_MARKER), 20); + let provider_receive = destination(144, 145, 146); + let provider_change = destination(147, 148, 149); + let destinations = MockDestinations::new([provider_receive.clone(), provider_change.clone()]); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let rate = RationalRate::new(3, 2).expect("rate"); + let pricing = CountingPricing::new( + directed_static_pricing(identity, asset(NO_MARKER), asset(COLLATERAL_MARKER), rate), + Arc::clone(&pricing_calls), + ); + let engine = open_engine_with_market( + &directory, + identity, + MockSource::new([snapshot(identity, 150, vec![inventory.clone()])]), + destinations, + pricing, + directed_market_config( + identity, + asset(NO_MARKER), + asset(COLLATERAL_MARKER), + broad_limits(8), + ), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let taker_recipient = recipient(151); + let request = FirmQuoteRequest::new( + quote_context(identity), + QuoteKind::ExactIn { + input: AssetAmount::new(asset(NO_MARKER), 10).expect("input"), + output_asset: asset(COLLATERAL_MARKER), + minimum_output: 15, + }, + taker_recipient.clone(), + 0, + ) + .expect("reverse exact-in request"); + + let outcome = engine + .firm_quote( + OwnerId::new([152; 32]), + IdempotencyKey::new([153; 32]), + request, + &UnixMillis::new(101), + ) + .expect("reverse firm quote"); + let quote = outcome.quote(); + assert_eq!(quote.pricing().rate(), rate); + assert_eq!(quote.execution().input().asset(), asset(NO_MARKER)); + assert_eq!(quote.execution().input().amount(), 10); + assert_eq!(quote.execution().output().asset(), asset(COLLATERAL_MARKER)); + assert_eq!(quote.execution().output().amount(), 15); + assert_eq!(quote.execution().input_asset_venue_fee(), 0); + + let contribution = quote.contribution(); + assert_eq!(contribution.inputs().len(), 1); + assert_eq!(contribution.inputs()[0].outpoint(), inventory.outpoint()); + assert_eq!(contribution.outputs().len(), 3); + let payment = &contribution.outputs()[0]; + assert_eq!(payment.role(), QuoteOutputRole::ProviderPayment); + assert_eq!(payment.asset(), asset(NO_MARKER)); + assert_eq!(payment.amount(), 10); + assert_eq!( + payment.destination(), + &QuoteRecipient::from(&provider_receive) + ); + assert_eq!(payment.blinder(), QuoteBlinderRole::TakerPaymentInput); + let receive = &contribution.outputs()[1]; + assert_eq!(receive.role(), QuoteOutputRole::TakerReceive); + assert_eq!(receive.asset(), asset(COLLATERAL_MARKER)); + assert_eq!(receive.amount(), 15); + assert_eq!(receive.destination(), &taker_recipient); + assert_eq!( + receive.blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + let change = &contribution.outputs()[2]; + assert_eq!(change.role(), QuoteOutputRole::ProviderChange); + assert_eq!(change.asset(), asset(COLLATERAL_MARKER)); + assert_eq!(change.amount(), 5); + assert_eq!( + change.destination(), + &QuoteRecipient::from(&provider_change) + ); + assert_eq!( + change.blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn multi_input_quote_uses_stable_ids_and_first_provider_input_for_output_blinding() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(33); + let wallet = WalletFixture::new(154, 155); + let first = wallet.owned_output(156, asset(YES_MARKER), 30); + let second = wallet.owned_output(157, asset(YES_MARKER), 25); + let destinations = + MockDestinations::new([destination(158, 159, 160), destination(161, 162, 163)]); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let engine = open_engine( + &directory, + identity, + MockSource::new([snapshot(identity, 164, vec![second.clone(), first.clone()])]), + destinations, + CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + + let outcome = engine + .firm_quote( + OwnerId::new([165; 32]), + IdempotencyKey::new([166; 32]), + exact_in_request(identity, recipient(167)), + &UnixMillis::new(101), + ) + .expect("multi-input firm quote"); + let contribution = outcome.quote().contribution(); + assert_eq!(contribution.inputs().len(), 2); + assert_eq!(contribution.inputs()[0].id(), QuoteInputId::new(1)); + assert_eq!(contribution.inputs()[0].outpoint(), first.outpoint()); + assert_eq!(contribution.inputs()[1].id(), QuoteInputId::new(2)); + assert_eq!(contribution.inputs()[1].outpoint(), second.outpoint()); + assert_eq!( + outcome.reservation().outpoints(), + &[first.outpoint(), second.outpoint()] + ); + + let outputs = contribution.outputs(); + assert_eq!(outputs.len(), 3); + assert_eq!(outputs[0].role(), QuoteOutputRole::ProviderPayment); + assert_eq!(outputs[0].asset(), asset(COLLATERAL_MARKER)); + assert_eq!(outputs[0].amount(), 50); + assert_eq!(outputs[0].blinder(), QuoteBlinderRole::TakerPaymentInput); + assert_eq!(outputs[1].role(), QuoteOutputRole::TakerReceive); + assert_eq!(outputs[1].asset(), asset(YES_MARKER)); + assert_eq!(outputs[1].amount(), 50); + assert_eq!( + outputs[1].blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + assert_eq!(outputs[2].role(), QuoteOutputRole::ProviderChange); + assert_eq!(outputs[2].asset(), asset(YES_MARKER)); + assert_eq!(outputs[2].amount(), 5); + assert_eq!( + outputs[2].blinder(), + QuoteBlinderRole::ProviderInput(QuoteInputId::new(1)) + ); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn expired_firm_quote_replay_is_exact_and_remains_terminal() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(18); + let wallet = WalletFixture::new(85, 86); + let inventory = wallet.owned_output(87, asset(YES_MARKER), 50); + let destinations = MockDestinations::new([destination(88, 89, 90)]); + let destination_probe = destinations.clone(); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let engine = open_engine_with_policy( + &directory, + identity, + MockSource::new([snapshot(identity, 91, vec![inventory])]), + destinations.clone(), + CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)), + 10, + QuoteEnginePolicy::new(1, 2, 8, fee_policy(identity)).expect("engine policy"), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let owner = OwnerId::new([92; 32]); + let key = IdempotencyKey::new([93; 32]); + let request = exact_in_request(identity, recipient(94)); + + let created = engine + .firm_quote(owner, key, request.clone(), &UnixMillis::new(100)) + .expect("firm quote"); + assert!(created.created()); + assert_eq!(created.quote().accept_before(), UnixMillis::new(101)); + + let expired = engine + .firm_quote(owner, key, request.clone(), &UnixMillis::new(101)) + .expect("expired idempotent replay"); + assert!(!expired.created()); + assert_eq!(expired.quote(), created.quote()); + assert_eq!( + expired.reservation().state(), + ReservationState::Released { + reason: ReleaseReason::Expired, + at: UnixMillis::new(101), + } + ); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 1); + + let expected = expired; + drop(engine); + let reopened = open_engine_with_policy( + &directory, + identity, + MockSource::new([]), + destinations, + CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)), + 10, + QuoteEnginePolicy::new(1, 2, 8, fee_policy(identity)).expect("engine policy"), + ); + let replayed_after_restart = reopened + .firm_quote(owner, key, request, &UnixMillis::new(102)) + .expect("terminal replay after restart"); + assert_eq!(replayed_after_restart, expected); + assert_eq!(pricing_calls.load(Ordering::SeqCst), 1); + assert_eq!(destination_probe.calls(), 1); +} + +#[test] +fn authoritative_firm_quote_reserve_rejects_a_stale_allocation_revision() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(19); + let wallet = WalletFixture::new(101, 102); + let outputs = vec![ + wallet.owned_output(103, asset(YES_MARKER), 50), + wallet.owned_output(104, asset(YES_MARKER), 50), + ]; + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = InventoryCoordinator::new( + book, + MockSource::new([snapshot(identity, 105, outputs)]), + InventoryFreshnessPolicy::new(1_000, 10).expect("freshness policy"), + ); + let eligible = coordinator + .refresh(&UnixMillis::new(100)) + .expect("eligible inventory"); + let selected = eligible.outputs().first().expect("selected output"); + let disjoint = eligible.outputs().get(1).expect("disjoint output"); + let request = exact_in_request(identity, recipient(106)); + let pricing = price_decision(RationalRate::new(1, 1).expect("rate"), 0); + let execution = + calculate_execution(&request, pricing, broad_limits(8)).expect("quote execution"); + let provider_receive = destination(107, 108, 109); + let contribution = quote_contribution( + &[selected], + execution, + request.recipient().clone(), + &provider_receive, + None, + 0, + ) + .expect("quote contribution"); + let draft = FirmQuoteDraft { + request: request.clone(), + execution, + pricing, + snapshot: QuoteSnapshotEvidence { + anchor: eligible.anchor(), + commitment: eligible.token().snapshot(), + allocation_revision: eligible.allocation_revision(), + eligible_commitment: eligible.eligible_commitment(), + }, + contribution, + provider_receive_recovery: DestinationRecovery::from(&provider_receive), + provider_change_recovery: None, + selected_asset: asset(YES_MARKER), + selected_amount: 50, + }; + let owner = OwnerId::new([110; 32]); + let key = IdempotencyKey::new([111; 32]); + let request_digest = + quote_request_digest(identity, owner, key, &request).expect("semantic request digest"); + + let disjoint_plan = ReservationPlan::new( + OwnerId::new([112; 32]), + IdempotencyKey::new([113; 32]), + QuoteCommitment::new([114; 32]), + vec![disjoint.outpoint()], + UnixMillis::new(1_000), + fee_policy(identity), + ) + .expect("disjoint reservation plan"); + coordinator + .reserve(&eligible, &disjoint_plan, &UnixMillis::new(101)) + .expect("disjoint allocation"); + + assert!(matches!( + coordinator.reserve_firm_quote( + &eligible, + owner, + key, + request_digest, + &draft, + QuoteEnginePolicy::new(1_000, 2, 8, fee_policy(identity)).expect("engine policy"), + &UnixMillis::new(102), + ), + Err(crate::inventory::InventoryCoordinatorError::Provider( + ProviderError::EligibleInventoryChanged + )) + )); + + let current = coordinator + .eligible(&UnixMillis::new(102)) + .expect("current eligible inventory"); + assert!( + current + .outputs() + .iter() + .any(|output| output.outpoint() == selected.outpoint()) + ); + assert!( + current + .outputs() + .iter() + .all(|output| output.outpoint() != disjoint.outpoint()) + ); +} + +#[test] +fn quote_admission_reclaims_more_than_one_expiration_batch_without_a_worker() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(16); + let expired_count = MAX_EXPIRATION_BATCH + .checked_add(1) + .expect("expiration fixture size"); + let total_outputs = expired_count + .checked_add(1) + .expect("inventory fixture size"); + let wallet = WalletFixture::new(97, 98); + // Reuse one valid confidential proof body across distinct outpoints. The + // wallet boundary authenticates each outpoint independently, while this + // keeps a >256-reservation regression test reasonably fast. + let outputs = wallet.indexed_owned_outputs(99, total_outputs, asset(YES_MARKER), 50); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let initial_destinations = MockDestinations::new( + (0..expired_count) + .map(|index| indexed_destination(u32::try_from(index).expect("destination index"))), + ); + let initial_engine = open_engine_with_policy( + &directory, + identity, + MockSource::new([snapshot(identity, 100, outputs.clone())]), + initial_destinations, + CountingPricing::new(static_pricing(identity), Arc::clone(&pricing_calls)), + total_outputs, + QuoteEnginePolicy::new(1, 1, expired_count, fee_policy(identity)) + .expect("initial engine policy"), + ); + initial_engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("initial inventory refresh"); + let request = exact_in_request(identity, recipient(101)); + for index in 0..expired_count { + let index = u32::try_from(index).expect("quote index"); + initial_engine + .firm_quote( + OwnerId::new(indexed_bytes(10, index)), + IdempotencyKey::new(indexed_bytes(11, index)), + request.clone(), + &UnixMillis::new(100), + ) + .expect("initial live quote"); + } + assert_eq!(pricing_calls.load(Ordering::SeqCst), expired_count); + drop(initial_engine); + + // Simulate a valid restart-time policy reduction. Every old quote is now + // expired, so none of their quota entries should count against a fresh + // authenticated owner even though there are more than one sweep batch. + let reopened = open_engine_with_policy( + &directory, + identity, + MockSource::new([snapshot(identity, 102, outputs)]), + MockDestinations::new([indexed_destination( + u32::try_from(expired_count).expect("fresh destination index"), + )]), + CountingPricing::new(static_pricing(identity), pricing_calls), + total_outputs, + QuoteEnginePolicy::new(1_000, 1, 1, fee_policy(identity)).expect("reopened engine policy"), + ); + reopened + .inventory() + .refresh(&UnixMillis::new(101)) + .expect("reopened inventory refresh"); + let fresh_index = u32::try_from(total_outputs).expect("fresh quote index"); + let admitted = reopened + .firm_quote( + OwnerId::new(indexed_bytes(12, fresh_index)), + IdempotencyKey::new(indexed_bytes(13, fresh_index)), + request, + &UnixMillis::new(102), + ) + .expect("expired quota entries must not require an external worker"); + assert!(admitted.created()); +} + +fn map_to_client_proposal(request: &LegPreparationRequest, quote: &FirmQuote) -> ProposedLeg { + let inputs = quote + .contribution() + .inputs() + .iter() + .map(|input| { + InputSpec::new( + InputId::new(u64::from(input.id().value())), + input.outpoint(), + input.witness_utxo().clone(), + InputSequence::Final, + ) + }) + .collect::>(); + let outputs = quote + .contribution() + .outputs() + .iter() + .map(|output| { + let blinder = match output.blinder() { + QuoteBlinderRole::TakerPaymentInput => { + BlinderRef::External(request.payer_blinder()) + } + QuoteBlinderRole::ProviderInput(id) => { + BlinderRef::Local(InputId::new(u64::from(id.value()))) + } + }; + OutputSpec::confidential( + OutputId::new(u64::from(output.id().value())), + output.asset(), + output.amount(), + output.destination().script_pubkey().clone(), + BitcoinPublicKey::new(output.destination().blinding_public_key()), + blinder, + ) + }) + .collect::>(); + let execution = quote.execution(); + let mut fees = BTreeMap::new(); + if execution.input_asset_venue_fee() != 0 { + fees.insert(execution.input().asset(), execution.input_asset_venue_fee()); + } + let payment = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::ProviderPayment) + .expect("payment output"); + let receive = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::TakerReceive) + .expect("receive output"); + ProposedLeg::new( + ClientExactExecution::new( + ClientAssetAmount::new(execution.input().asset(), execution.input().amount()) + .expect("client input"), + ClientAssetAmount::new(execution.output().asset(), execution.output().amount()) + .expect("client output"), + ) + .expect("client execution"), + fees, + TransactionContribution::new(inputs, outputs, LockTimeConstraint::Unconstrained), + OutputId::new(u64::from(payment.id().value())), + OutputId::new(u64::from(receive.id().value())), + ) + .expect("client proposal") +} + +#[test] +fn symbolic_firm_quote_maps_to_a_client_authorized_leg_without_provider_coupling() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(14); + let wallet = WalletFixture::new(70, 71); + let source = MockSource::new([snapshot( + identity, + 72, + vec![wallet.owned_output(73, asset(YES_MARKER), 65)], + )]); + let destinations = MockDestinations::new([destination(74, 75, 76), destination(77, 78, 79)]); + let pricing_calls = Arc::new(AtomicUsize::new(0)); + let engine = open_engine( + &directory, + identity, + source, + destinations, + CountingPricing::new(static_pricing(identity), pricing_calls), + ); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let request = exact_in_request(identity, recipient(80)); + let outcome = engine + .firm_quote( + OwnerId::new([81; 32]), + IdempotencyKey::new([82; 32]), + request.clone(), + &UnixMillis::new(101), + ) + .expect("firm quote"); + let quote = outcome.quote(); + let client_recipient = ClientRecipient::new( + request.recipient().script_pubkey().clone(), + BitcoinPublicKey::new(request.recipient().blinding_public_key()), + ) + .expect("client recipient"); + let context = quote_context(identity); + let client_request = ClientExecutionRequest::exact_in( + VenueContext { + chain: context.chain(), + market: context.market(), + policy_asset: context.policy_asset(), + }, + ClientAssetAmount::new(asset(COLLATERAL_MARKER), 50).expect("client input"), + asset(YES_MARKER), + 50, + client_recipient, + BTreeMap::new(), + 1_000, + ) + .expect("client execution request"); + let payer_blinder = outpoint(83); + let leg_request = client_request + .exact_in_leg(LegId::new(1), 50, payer_blinder) + .expect("leg request"); + let proposal = map_to_client_proposal(&leg_request, quote); + let prepared = leg_request + .authorize(proposal) + .expect("client-authorized firm quote"); + assert_eq!(prepared.execution().input().amount(), 50); + assert_eq!(prepared.execution().output().amount(), 50); + assert!(prepared.venue_fees().is_empty()); + let payment = prepared + .contribution() + .outputs() + .iter() + .find(|output| output.id() == prepared.payment_output()) + .expect("prepared payment"); + assert_eq!(payment.blinder(), Some(BlinderRef::External(payer_blinder))); + + let wrong_recipient = ClientRecipient::new( + recipient(84).script_pubkey().clone(), + BitcoinPublicKey::new(recipient(84).blinding_public_key()), + ) + .expect("wrong client recipient"); + let mismatched_request = ClientExecutionRequest::exact_in( + VenueContext { + chain: context.chain(), + market: context.market(), + policy_asset: context.policy_asset(), + }, + ClientAssetAmount::new(asset(COLLATERAL_MARKER), 50).expect("client input"), + asset(YES_MARKER), + 50, + wrong_recipient, + BTreeMap::new(), + 1_000, + ) + .expect("mismatched execution request") + .exact_in_leg(LegId::new(2), 50, payer_blinder) + .expect("mismatched leg request"); + let mismatched_proposal = map_to_client_proposal(&mismatched_request, quote); + assert_eq!( + mismatched_request.authorize(mismatched_proposal), + Err(ClientExecutionError::RecipientMismatch) + ); +} diff --git a/crates/deadcat-rfq-provider/src/store.rs b/crates/deadcat-rfq-provider/src/store.rs index e083074..e22b75a 100644 --- a/crates/deadcat-rfq-provider/src/store.rs +++ b/crates/deadcat-rfq-provider/src/store.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, MutexGuard}; use elements::hashes::Hash as _; -use elements::secp256k1_zkp::XOnlyPublicKey; +use elements::secp256k1_zkp::{Secp256k1, XOnlyPublicKey}; use elements::{AssetId, BlockHash, OutPoint}; use redb::{ Database, Durability, ReadableDatabase as _, ReadableTable as _, TableDefinition, @@ -23,6 +23,13 @@ use crate::model::{ ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment, SigningJob, SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator, }; +use crate::quote::{ + FirmQuote, FirmQuoteDraft, FirmQuoteOutcome, FirmQuoteRequest, PricingDecision, + QuoteContribution, QuoteEnginePolicy, QuoteExecution, QuoteOutputRole, QuoteSnapshotEvidence, + QuotedProviderInput, finalize_quote, quote_from_stored_parts, quote_outcome, + recompute_quote_commitment, recovery_metadata_commitment, +}; +use crate::wallet::recompute_inventory_binding; pub const SCHEMA_VERSION: u32 = 1; /// Maximum number of unrelated expirations one explicit sweep may mutate in a @@ -36,12 +43,15 @@ const ALLOCATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("allocat const RESERVATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("reservations"); const REQUEST_KEYS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("request_keys"); const EXPIRATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("expirations"); +const LIVE_QUOTES_BY_OWNER: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("live_quotes_by_owner"); const AUDIT: TableDefinition = TableDefinition::new("audit"); const SCHEMA_VERSION_KEY: &str = "schema_version"; const PROVIDER_IDENTITY_KEY: &str = "provider_identity"; const LAST_OBSERVED_TIME_KEY: &str = "last_observed_unix_millis"; const AUDIT_SEQUENCE_KEY: &str = "audit_sequence"; +const ALLOCATION_REVISION_KEY: &str = "allocation_revision"; const RESERVATION_ID_DOMAIN: &[u8] = b"deadcat/rfq/reservation-id/v1"; const REQUEST_DOMAIN: &[u8] = b"deadcat/rfq/reservation-request/v1"; @@ -232,23 +242,299 @@ impl ReservationBook { Ok(Some(InventoryView::new(item.to_domain()?, state))) } - pub(crate) fn inventory_all(&self) -> Result, ProviderError> { + /// Read only the requested durable inventory records and the allocation + /// CAS token from one database snapshot. + /// + /// Durable inventory history is append-only, while a wallet snapshot is + /// explicitly bounded. Point-reading the current snapshot prevents quote + /// admission cost from growing with the provider's lifetime output history. + pub(crate) fn inventory_state_for( + &self, + outpoints: &[OutPoint], + ) -> Result<(Vec, u64), ProviderError> { self.ensure_healthy()?; + if outpoints.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(ProviderError::CorruptState( + "inventory-state lookup outpoints are not strictly sorted".to_owned(), + )); + } let read = self.database.begin_read()?; let inventory = read.open_table(INVENTORY)?; let allocations = read.open_table(ALLOCATIONS)?; - let mut result = Vec::new(); - for entry in inventory.iter()? { - let (key, item) = entry?; + let mut result = Vec::with_capacity(outpoints.len()); + for outpoint in outpoints { + let key = outpoint_key(*outpoint); + let item = inventory.get(key.as_slice())?.ok_or_else(|| { + ProviderError::CorruptState(format!( + "published wallet output {outpoint:?} has no durable inventory record" + )) + })?; let item: StoredInventoryItem = decode_record(item.value())?; + let domain = item.to_domain()?; + if domain.outpoint() != *outpoint { + return Err(ProviderError::CorruptState(format!( + "inventory key does not match requested outpoint {outpoint:?}" + ))); + } let state = allocations - .get(key.value())? + .get(key.as_slice())? .map(|allocation| decode_record::(allocation.value())) .transpose()? .map_or(InventoryState::Available, StoredAllocation::to_view); - result.push(InventoryView::new(item.to_domain()?, state)); + result.push(InventoryView::new(domain, state)); } - Ok(result) + let meta = read.open_table(META)?; + let revision = meta + .get(ALLOCATION_REVISION_KEY)? + .ok_or(ProviderError::MissingMetadata(ALLOCATION_REVISION_KEY))?; + let revision = + decode_u64(revision.value()).map_err(|()| ProviderError::CorruptAllocationRevision)?; + Ok((result, revision)) + } + + /// Replay an exact request or preflight capacity before any pricing, + /// inventory selection, or destination generation occurs. + /// + /// The bounded expiry cleanup is committed even when the caller is still + /// over quota. That makes a backlog monotonically drain under ordinary + /// quote traffic instead of rolling cleanup back with an admission error. + pub(crate) fn preflight_firm_quote( + &self, + owner: OwnerId, + key: IdempotencyKey, + request_digest: crate::model::QuoteRequestDigest, + policy: QuoteEnginePolicy, + clock: &C, + ) -> Result, ProviderError> { + let (_operation_guard, write, now) = self.begin_timed_write(clock)?; + if let Some(binding) = read_request_binding(&write, owner, key)? { + if binding.semantic_request_digest != request_digest.to_bytes() { + return Err(ProviderError::IdempotencyConflict { owner, key }); + } + let record = replay_binding_in_write(&write, binding, now)?; + let outcome = record.to_firm_quote_outcome(self.identity, false)?; + self.commit_write(write)?; + return Ok(Some(outcome)); + } + + expire_due_in_write(&write, now, MAX_EXPIRATION_BATCH)?; + match enforce_live_quote_limits(&write, owner, policy, now) { + Ok(()) => { + self.commit_write(write)?; + Ok(None) + } + Err( + error @ (ProviderError::OwnerLiveQuoteLimit { .. } + | ProviderError::GlobalLiveQuoteLimit { .. }), + ) => { + self.commit_write(write)?; + Err(error) + } + Err(error) => Err(error), + } + } + + /// Replay a request that may have won after quote preflight but before the + /// caller acquired the inventory-snapshot lock. + pub(crate) fn replay_firm_quote( + &self, + owner: OwnerId, + key: IdempotencyKey, + request_digest: crate::model::QuoteRequestDigest, + clock: &C, + ) -> Result, ProviderError> { + let (_operation_guard, write, now) = self.begin_timed_write(clock)?; + let Some(binding) = read_request_binding(&write, owner, key)? else { + self.commit_write(write)?; + return Ok(None); + }; + if binding.semantic_request_digest != request_digest.to_bytes() { + return Err(ProviderError::IdempotencyConflict { owner, key }); + } + let record = replay_binding_in_write(&write, binding, now)?; + let outcome = record.to_firm_quote_outcome(self.identity, false)?; + self.commit_write(write)?; + Ok(Some(outcome)) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn reserve_firm_quote_from_snapshot( + &self, + owner: OwnerId, + key: IdempotencyKey, + semantic_request_digest: crate::model::QuoteRequestDigest, + draft: &FirmQuoteDraft, + policy: QuoteEnginePolicy, + snapshot_observed_at: UnixMillis, + maximum_snapshot_age_millis: u64, + clock: &C, + ) -> Result { + if policy.fee_policy().policy_asset() != self.identity.policy_asset() { + return Err(ProviderError::WrongPolicyAsset { + expected: self.identity.policy_asset(), + actual: policy.fee_policy().policy_asset(), + }); + } + let reservation_id = derive_reservation_id(owner, key); + let (_operation_guard, write, now) = self.begin_timed_write(clock)?; + if let Some(binding) = read_request_binding(&write, owner, key)? { + if binding.semantic_request_digest != semantic_request_digest.to_bytes() { + return Err(ProviderError::IdempotencyConflict { owner, key }); + } + let record = replay_binding_in_write(&write, binding, now)?; + let outcome = record.to_firm_quote_outcome(self.identity, false)?; + self.commit_write(write)?; + return Ok(outcome); + } + if now < snapshot_observed_at { + self.commit_write(write)?; + return Err(ProviderError::InventorySnapshotObservedInFuture { + observed_at: snapshot_observed_at, + now, + }); + } + if now.value() - snapshot_observed_at.value() >= maximum_snapshot_age_millis { + self.commit_write(write)?; + return Err(ProviderError::InventorySnapshotStale { + observed_at: snapshot_observed_at, + now, + maximum_age_millis: maximum_snapshot_age_millis, + }); + } + let current_allocation_revision = allocation_revision(&write)?; + if current_allocation_revision != draft.snapshot.allocation_revision() { + return Err(ProviderError::EligibleInventoryChanged); + } + let accept_before = UnixMillis::new( + now.value() + .checked_add(policy.quote_lifetime_millis()) + .ok_or(ProviderError::QuoteDeadlineOverflow)?, + ); + let derived_request_digest = + crate::quote::quote_request_digest(self.identity, owner, key, &draft.request)?; + if derived_request_digest != semantic_request_digest { + return Err(ProviderError::FirmQuoteRequestDigestMismatch); + } + let outpoints = draft.selected_outpoints(); + if read_reservation_from_write(&write, reservation_id)?.is_some() { + return Err(ProviderError::ReservationIdCollision(reservation_id)); + } + for outpoint in &outpoints { + let key = outpoint_key(*outpoint); + let item = read_record_from_write::(&write, INVENTORY, &key)? + .ok_or(ProviderError::UnknownInventory(*outpoint))?; + let quoted_input = draft + .contribution + .inputs() + .iter() + .find(|input| input.outpoint() == *outpoint) + .ok_or(ProviderError::FirmQuoteInventoryMismatch(*outpoint))?; + if item.asset != draft.selected_asset + || item.binding != quoted_input.inventory_binding().to_bytes() + { + return Err(ProviderError::FirmQuoteInventoryMismatch(*outpoint)); + } + if let Some(allocation) = + read_record_from_write::(&write, ALLOCATIONS, &key)? + { + return Err(ProviderError::OutpointUnavailable { + outpoint: *outpoint, + state: allocation.to_view(), + }); + } + } + if let Err(error) = enforce_live_quote_limits(&write, owner, policy, now) { + // Preflight is advisory: another request may consume the final + // slot before this authoritative allocation. Preserve bounded + // expiry progress even when that race loses at the quota gate. + if matches!( + error, + ProviderError::OwnerLiveQuoteLimit { .. } + | ProviderError::GlobalLiveQuoteLimit { .. } + ) { + self.commit_write(write)?; + } + return Err(error); + } + let quote = finalize_quote( + self.identity, + owner, + key, + semantic_request_digest, + reservation_id, + draft, + now, + accept_before, + policy.fee_policy(), + )?; + let plan = ReservationPlan::with_request_digest( + owner, + key, + semantic_request_digest, + quote.commitment(), + outpoints, + accept_before, + policy.fee_policy(), + ) + .map_err(|error| { + ProviderError::CorruptState(format!( + "firm quote produced an invalid reservation plan: {error}" + )) + })?; + let request_digest = request_digest(self.identity, &plan)?; + let record = StoredReservation { + id: reservation_id.to_bytes(), + owner: owner.to_bytes(), + idempotency_key: key.to_bytes(), + semantic_request_digest: semantic_request_digest.to_bytes(), + request_digest, + quote_commitment: quote.commitment().to_bytes(), + quote: Some(StoredFirmQuote::from_domain("e, draft)), + outpoints: plan.outpoints().to_vec(), + created_at: now.value(), + accept_before: accept_before.value(), + fee_policy: StoredFeePolicy::from(policy.fee_policy()), + state: StoredReservationState::Reserved, + }; + // Validate the exact durable representation before exposing a quote. + // Replay and startup perform the same check, but doing it before the + // first commit prevents an internal construction regression from + // returning a quote that would make the database unreopenable. + record.validate()?; + let persisted_quote = record + .quote + .as_ref() + .ok_or(ProviderError::FirmQuoteDraftInvalid)?; + let validated_quote = persisted_quote.to_domain(self.identity, &record)?; + let mut validated_selected_amount = 0_u64; + for quoted_input in validated_quote.contribution().inputs() { + let item = read_record_from_write::( + &write, + INVENTORY, + &outpoint_key(quoted_input.outpoint()), + )? + .ok_or(ProviderError::UnknownInventory(quoted_input.outpoint()))?; + let domain_item = item.to_domain()?; + if item.asset != persisted_quote.selected_asset + || item.binding != quoted_input.inventory_binding().to_bytes() + || recompute_inventory_binding(domain_item, quoted_input.witness_utxo()) + != quoted_input.inventory_binding() + { + return Err(ProviderError::FirmQuoteInventoryMismatch( + quoted_input.outpoint(), + )); + } + validated_selected_amount = validated_selected_amount + .checked_add(item.amount) + .ok_or(ProviderError::FirmQuoteDraftInvalid)?; + } + if validated_selected_amount != persisted_quote.selected_amount { + return Err(ProviderError::FirmQuoteDraftInvalid); + } + persist_new_reservation(&write, &record)?; + let reservation = record.to_view()?; + self.commit_write(write)?; + Ok(quote_outcome(quote, reservation, true)) } /// Whether this exact authenticated request already has a durable binding. @@ -257,6 +543,7 @@ impl ReservationBook { /// retries even when the discovery snapshot used by the original request /// has since been superseded. A positive result must still be passed to /// [`Self::reserve`] so deadline expiry and state replay happen atomically. + #[cfg(test)] pub(crate) fn has_matching_request( &self, plan: &ReservationPlan, @@ -268,7 +555,6 @@ impl ReservationBook { actual: plan.fee_policy().policy_asset(), }); } - let expected_digest = request_digest(self.identity, plan)?; let read = self.database.begin_read()?; let request_keys = read.open_table(REQUEST_KEYS)?; let key = request_key(plan.owner(), plan.idempotency_key()); @@ -276,6 +562,7 @@ impl ReservationBook { return Ok(false); }; let binding: StoredRequestBinding = decode_record(binding.value())?; + let expected_digest = request_digest(self.identity, plan)?; if binding.request_digest != expected_digest { return Err(ProviderError::IdempotencyConflict { owner: plan.owner(), @@ -305,6 +592,7 @@ impl ReservationBook { /// /// The clock is sampled after acquiring redb's serial writer, so a request /// queued behind another writer cannot commit using a stale pre-lock time. + #[cfg(test)] pub(crate) fn reserve( &self, plan: &ReservationPlan, @@ -316,6 +604,7 @@ impl ReservationBook { /// Reserve from one wallet snapshot, rechecking its exclusive freshness /// deadline using the same post-writer-lock observation as the quote /// deadline and durable allocation. + #[cfg(test)] pub(crate) fn reserve_from_snapshot( &self, plan: &ReservationPlan, @@ -330,6 +619,7 @@ impl ReservationBook { ) } + #[cfg(test)] fn reserve_inner( &self, plan: &ReservationPlan, @@ -411,8 +701,10 @@ impl ReservationBook { id: reservation_id.to_bytes(), owner: plan.owner().to_bytes(), idempotency_key: plan.idempotency_key().to_bytes(), + semantic_request_digest: plan.request_digest().to_bytes(), request_digest, quote_commitment: plan.quote_commitment().to_bytes(), + quote: None, outpoints: plan.outpoints().to_vec(), created_at: now.value(), accept_before: plan.accept_before().value(), @@ -424,6 +716,7 @@ impl ReservationBook { mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_RECORD)?; let binding = StoredRequestBinding { reservation_id: reservation_id.to_bytes(), + semantic_request_digest: plan.request_digest().to_bytes(), request_digest, }; write_record( @@ -446,6 +739,7 @@ impl ReservationBook { #[cfg(test)] mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_ALLOCATION)?; } + advance_allocation_revision(&write)?; let expiration_key = expiration_key(plan.accept_before(), reservation_id); let empty: &[u8] = &[]; write @@ -684,6 +978,7 @@ impl ReservationBook { #[cfg(test)] mutation_failpoints::hit(mutation_failpoints::COMMIT_AFTER_ALLOCATION)?; } + advance_allocation_revision(&write)?; let expiration_key = expiration_key(UnixMillis::new(record.accept_before), record.id()); let removed_expiration = { let mut expirations = write.open_table(EXPIRATIONS)?; @@ -694,6 +989,7 @@ impl ReservationBook { "reserved reservation has no expiration index entry".to_owned(), )); } + remove_live_quote_index(&write, &record)?; #[cfg(test)] mutation_failpoints::hit(mutation_failpoints::COMMIT_AFTER_EXPIRATION)?; record.state = StoredReservationState::Committed { @@ -884,6 +1180,9 @@ impl ReservationBook { if meta.get(AUDIT_SEQUENCE_KEY)?.is_none() { return Err(ProviderError::MissingMetadata(AUDIT_SEQUENCE_KEY)); } + if meta.get(ALLOCATION_REVISION_KEY)?.is_none() { + return Err(ProviderError::MissingMetadata(ALLOCATION_REVISION_KEY)); + } } None => { if provider_tables_are_nonempty(&write)? { @@ -896,6 +1195,7 @@ impl ReservationBook { let identity = encode_record(&StoredProviderIdentity::from(self.identity))?; meta.insert(PROVIDER_IDENTITY_KEY, identity.as_slice())?; meta.insert(AUDIT_SEQUENCE_KEY, 0_u64.to_be_bytes().as_slice())?; + meta.insert(ALLOCATION_REVISION_KEY, 0_u64.to_be_bytes().as_slice())?; } } validate_store_integrity(&write, self.identity)?; @@ -956,22 +1256,42 @@ impl ReservationBook { } Ok(()) } + + #[cfg(test)] + pub(crate) fn poison_inventory_record_for_test( + &self, + outpoint: OutPoint, + ) -> Result<(), ProviderError> { + let _operation_guard = self + .operation_lock + .lock() + .map_err(|_| ProviderError::OperationLockPoisoned)?; + let write = self.begin_immediate_write()?; + { + let mut inventory = write.open_table(INVENTORY)?; + let key = outpoint_key(outpoint); + inventory.insert(key.as_slice(), &[0xff_u8][..])?; + } + self.commit_write(write) + } } +#[cfg(test)] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReserveOutcome { +pub(crate) struct ReserveOutcome { reservation: ReservationView, created: bool, } +#[cfg(test)] impl ReserveOutcome { #[must_use] - pub const fn reservation(&self) -> &ReservationView { + pub(crate) const fn reservation(&self) -> &ReservationView { &self.reservation } #[must_use] - pub const fn created(&self) -> bool { + pub(crate) const fn created(&self) -> bool { self.created } } @@ -1105,6 +1425,7 @@ fn expire_due_in_write( /// keeps the hot path bounded by the request and reservation input limits; /// the service is responsible for draining unrelated expirations through /// [`ReservationBook::expire_due`] with an explicit batch size. +#[cfg(test)] fn expire_requested_in_write( write: &WriteTransaction, now: UnixMillis, @@ -1182,6 +1503,7 @@ fn release_reserved( #[cfg(test)] mutation_failpoints::hit(mutation_failpoints::RELEASE_AFTER_ALLOCATION)?; } + advance_allocation_revision(write)?; let expiration_key = expiration_key(UnixMillis::new(record.accept_before), record.id()); let removed_expiration = { let mut expirations = write.open_table(EXPIRATIONS)?; @@ -1192,6 +1514,7 @@ fn release_reserved( "reserved reservation has no expiration index entry".to_owned(), )); } + remove_live_quote_index(write, record)?; #[cfg(test)] mutation_failpoints::hit(mutation_failpoints::RELEASE_AFTER_EXPIRATION)?; record.state = StoredReservationState::Released { @@ -1214,6 +1537,34 @@ fn release_reserved( Ok(()) } +fn remove_live_quote_index( + write: &WriteTransaction, + record: &StoredReservation, +) -> Result<(), ProviderError> { + let removed = write + .open_table(LIVE_QUOTES_BY_OWNER)? + .remove( + live_quote_key( + UnixMillis::new(record.accept_before), + OwnerId::new(record.owner), + record.id(), + ) + .as_slice(), + )? + .is_some(); + if record.quote.is_some() && !removed { + return Err(ProviderError::CorruptState( + "reserved firm quote has no owner live-quote index entry".to_owned(), + )); + } + if record.quote.is_none() && removed { + return Err(ProviderError::CorruptState( + "legacy reservation unexpectedly owns a firm-quote live index entry".to_owned(), + )); + } + Ok(()) +} + fn require_authorized_reservation( write: &WriteTransaction, access: ReservationAccess, @@ -1258,6 +1609,146 @@ fn read_request_binding( read_record_from_write(write, REQUEST_KEYS, &request_key(owner, key)) } +fn replay_binding_in_write( + write: &WriteTransaction, + binding: StoredRequestBinding, + now: UnixMillis, +) -> Result { + let mut record = + read_reservation_from_write(write, ReservationId::new(binding.reservation_id))? + .ok_or_else(|| { + ProviderError::CorruptState( + "idempotency binding references a missing reservation".to_owned(), + ) + })?; + if record.id != binding.reservation_id + || record.semantic_request_digest != binding.semantic_request_digest + || record.request_digest != binding.request_digest + { + return Err(ProviderError::CorruptState( + "idempotency binding disagrees with its reservation".to_owned(), + )); + } + if matches!(record.state, StoredReservationState::Reserved) + && now >= UnixMillis::new(record.accept_before) + { + release_reserved(write, &mut record, ReleaseReason::Expired, now)?; + } + Ok(record) +} + +fn enforce_live_quote_limits( + write: &WriteTransaction, + owner: OwnerId, + policy: QuoteEnginePolicy, + now: UnixMillis, +) -> Result<(), ProviderError> { + let live = write.open_table(LIVE_QUOTES_BY_OWNER)?; + let Some(first_deadline) = now.value().checked_add(1) else { + return Ok(()); + }; + let first = live_quote_key( + UnixMillis::new(first_deadline), + OwnerId::new([0; 32]), + ReservationId::new([0; 32]), + ); + let mut global_live = 0_usize; + let mut owner_live = 0_usize; + for entry in live.range(first.as_slice()..)? { + let (key, value) = entry?; + let (deadline, indexed_owner, _) = decode_live_quote_key(key.value())?; + if deadline <= now || !value.value().is_empty() { + return Err(ProviderError::CorruptState( + "owner live-quote index contains an invalid entry".to_owned(), + )); + } + global_live = global_live + .checked_add(1) + .ok_or(ProviderError::LiveQuoteCountOverflow)?; + if indexed_owner == owner { + owner_live = owner_live + .checked_add(1) + .ok_or(ProviderError::LiveQuoteCountOverflow)?; + if owner_live >= policy.maximum_live_quotes_per_owner() { + return Err(ProviderError::OwnerLiveQuoteLimit { + owner, + maximum: policy.maximum_live_quotes_per_owner(), + }); + } + } + if global_live >= policy.maximum_live_quotes_global() { + return Err(ProviderError::GlobalLiveQuoteLimit { + maximum: policy.maximum_live_quotes_global(), + }); + } + } + Ok(()) +} + +fn persist_new_reservation( + write: &WriteTransaction, + record: &StoredReservation, +) -> Result<(), ProviderError> { + write_record(write, RESERVATIONS, &record.id, record)?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_RECORD)?; + write_record( + write, + REQUEST_KEYS, + &request_key( + OwnerId::new(record.owner), + IdempotencyKey::new(record.idempotency_key), + ), + &StoredRequestBinding { + reservation_id: record.id, + semantic_request_digest: record.semantic_request_digest, + request_digest: record.request_digest, + }, + )?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_REQUEST_KEY)?; + for outpoint in &record.outpoints { + write_record( + write, + ALLOCATIONS, + &outpoint_key(*outpoint), + &StoredAllocation::Reserved { + reservation_id: record.id, + }, + )?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_ALLOCATION)?; + } + advance_allocation_revision(write)?; + let empty: &[u8] = &[]; + write.open_table(EXPIRATIONS)?.insert( + expiration_key(UnixMillis::new(record.accept_before), record.id()).as_slice(), + empty, + )?; + write.open_table(LIVE_QUOTES_BY_OWNER)?.insert( + live_quote_key( + UnixMillis::new(record.accept_before), + OwnerId::new(record.owner), + record.id(), + ) + .as_slice(), + empty, + )?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_EXPIRATION)?; + append_audit( + write, + UnixMillis::new(record.created_at), + StoredAuditEvent::ReservationCreated { + reservation_id: record.id, + outpoints: record.outpoints.clone(), + }, + )?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::RESERVE_AFTER_AUDIT)?; + Ok(()) +} + fn signing_targets_for_reservation( write: &WriteTransaction, reservation: &StoredReservation, @@ -1318,6 +1809,7 @@ fn request_digest( identity: StoredProviderIdentity::from(identity), owner: plan.owner().to_bytes(), idempotency_key: plan.idempotency_key().to_bytes(), + semantic_request_digest: plan.request_digest().to_bytes(), quote_commitment: plan.quote_commitment().to_bytes(), outpoints: plan.outpoints(), accept_before: plan.accept_before().value(), @@ -1334,6 +1826,7 @@ fn stored_request_digest( identity: StoredProviderIdentity::from(identity), owner: reservation.owner, idempotency_key: reservation.idempotency_key, + semantic_request_digest: reservation.semantic_request_digest, quote_commitment: reservation.quote_commitment, outpoints: &reservation.outpoints, accept_before: reservation.accept_before, @@ -1404,6 +1897,25 @@ fn append_audit( Ok(()) } +fn allocation_revision(write: &WriteTransaction) -> Result { + let meta = write.open_table(META)?; + let revision = meta + .get(ALLOCATION_REVISION_KEY)? + .ok_or(ProviderError::MissingMetadata(ALLOCATION_REVISION_KEY))?; + decode_u64(revision.value()).map_err(|()| ProviderError::CorruptAllocationRevision) +} + +fn advance_allocation_revision(write: &WriteTransaction) -> Result { + let current = allocation_revision(write)?; + let next = current + .checked_add(1) + .ok_or(ProviderError::AllocationRevisionOverflow)?; + write + .open_table(META)? + .insert(ALLOCATION_REVISION_KEY, next.to_be_bytes().as_slice())?; + Ok(next) +} + fn provider_tables_are_nonempty(write: &WriteTransaction) -> Result { { let table = write.open_table(META)?; @@ -1417,6 +1929,7 @@ fn provider_tables_are_nonempty(write: &WriteTransaction) -> Result Result<(), ProviderError> { - let (audit_sequence, last_observed_time) = { + let (audit_sequence, last_observed_time, _allocation_revision) = { let meta = write.open_table(META)?; let audit_sequence = meta .get(AUDIT_SEQUENCE_KEY)? @@ -1448,7 +1961,12 @@ fn validate_store_integrity( decode_u64(value.value()).map_err(|()| ProviderError::CorruptTimeHighWatermark) }) .transpose()?; - (audit_sequence, last_observed_time) + let allocation_revision = meta + .get(ALLOCATION_REVISION_KEY)? + .ok_or(ProviderError::MissingMetadata(ALLOCATION_REVISION_KEY))?; + let allocation_revision = decode_u64(allocation_revision.value()) + .map_err(|()| ProviderError::CorruptAllocationRevision)?; + (audit_sequence, last_observed_time, allocation_revision) }; let inventory = { @@ -1497,6 +2015,46 @@ fn validate_store_integrity( ))); } validate_reservation_times(&record, last_observed_time)?; + if let Some(quote) = &record.quote { + let domain = quote.to_domain(identity, &record)?; + let mut selected_amount = 0_u64; + for quoted_input in domain.contribution().inputs() { + let item = inventory + .get(&outpoint_key(quoted_input.outpoint())) + .ok_or_else(|| { + ProviderError::CorruptState( + "firm quote references missing durable inventory".to_owned(), + ) + })?; + if item.asset != quote.selected_asset + || item.binding != quoted_input.inventory_binding().to_bytes() + { + return Err(ProviderError::CorruptState( + "firm quote input disagrees with durable inventory".to_owned(), + )); + } + let domain_item = item.to_domain()?; + if recompute_inventory_binding(domain_item, quoted_input.witness_utxo()) + != quoted_input.inventory_binding() + { + return Err(ProviderError::CorruptState( + "firm quote input binding does not match durable recovery metadata" + .to_owned(), + )); + } + selected_amount = + selected_amount.checked_add(item.amount).ok_or_else(|| { + ProviderError::CorruptState( + "firm quote selected amount overflowed".to_owned(), + ) + })?; + } + if selected_amount != quote.selected_amount { + return Err(ProviderError::CorruptState( + "firm quote selected amount disagrees with durable inventory".to_owned(), + )); + } + } records.insert(key, record); } records @@ -1542,6 +2100,22 @@ fn validate_store_integrity( keys }; + let live_quotes = { + let table = write.open_table(LIVE_QUOTES_BY_OWNER)?; + let mut keys = BTreeSet::new(); + for entry in table.iter()? { + let (key, value) = entry?; + let key = decode_table_key::<72>("owner live quote", key.value())?; + if !value.value().is_empty() { + return Err(ProviderError::CorruptState( + "owner live-quote index value is not empty".to_owned(), + )); + } + keys.insert(key); + } + keys + }; + for record in reservations.values() { let expected_request_key = request_key( OwnerId::new(record.owner), @@ -1553,7 +2127,10 @@ fn validate_store_integrity( record.id() )) })?; - if binding.reservation_id != record.id || binding.request_digest != record.request_digest { + if binding.reservation_id != record.id + || binding.semantic_request_digest != record.semantic_request_digest + || binding.request_digest != record.request_digest + { return Err(ProviderError::CorruptState(format!( "reservation {:?} request-key binding disagrees with its record", record.id() @@ -1563,6 +2140,12 @@ fn validate_store_integrity( let expected_expiration = expiration_key(UnixMillis::new(record.accept_before), record.id()); let has_expiration = expirations.contains(&expected_expiration); + let expected_live_quote = live_quote_key( + UnixMillis::new(record.accept_before), + OwnerId::new(record.owner), + record.id(), + ); + let has_live_quote = live_quotes.contains(&expected_live_quote); match &record.state { StoredReservationState::Reserved => { if !has_expiration { @@ -1571,6 +2154,12 @@ fn validate_store_integrity( record.id() ))); } + if has_live_quote != record.quote.is_some() { + return Err(ProviderError::CorruptState(format!( + "reserved reservation {:?} has inconsistent live-quote indexing", + record.id() + ))); + } } StoredReservationState::Released { .. } | StoredReservationState::Committed { .. } @@ -1581,6 +2170,12 @@ fn validate_store_integrity( record.id() ))); } + if has_live_quote { + return Err(ProviderError::CorruptState(format!( + "terminal reservation {:?} still has a live-quote index entry", + record.id() + ))); + } } } @@ -1655,7 +2250,10 @@ fn validate_store_integrity( OwnerId::new(record.owner), IdempotencyKey::new(record.idempotency_key), ); - if *key != expected_key || binding.request_digest != record.request_digest { + if *key != expected_key + || binding.semantic_request_digest != record.semantic_request_digest + || binding.request_digest != record.request_digest + { return Err(ProviderError::CorruptState(format!( "request-key binding for reservation {:?} has inconsistent key or digest", record.id() @@ -1718,6 +2316,31 @@ fn validate_store_integrity( } } + for key in &live_quotes { + let deadline = UnixMillis::new(u64::from_be_bytes( + key[..8].try_into().expect("fixed slice"), + )); + let owner = OwnerId::new(key[8..40].try_into().expect("fixed slice")); + let reservation_id = ReservationId::new(key[40..].try_into().expect("fixed slice")); + let record = reservations + .get(&reservation_id.to_bytes()) + .ok_or_else(|| { + ProviderError::CorruptState( + "owner live-quote index references a missing reservation".to_owned(), + ) + })?; + if deadline != UnixMillis::new(record.accept_before) + || owner != OwnerId::new(record.owner) + || record.quote.is_none() + || !matches!(record.state, StoredReservationState::Reserved) + { + return Err(ProviderError::CorruptState(format!( + "owner live-quote index disagrees with reservation {:?}", + record.id() + ))); + } + } + validate_audit_integrity( write, audit_sequence, @@ -2019,6 +2642,7 @@ fn create_tables(write: &WriteTransaction) -> Result<(), ProviderError> { write.open_table(RESERVATIONS)?; write.open_table(REQUEST_KEYS)?; write.open_table(EXPIRATIONS)?; + write.open_table(LIVE_QUOTES_BY_OWNER)?; write.open_table(AUDIT)?; Ok(()) } @@ -2084,6 +2708,31 @@ fn request_key(owner: OwnerId, key: IdempotencyKey) -> [u8; 64] { encoded } +fn live_quote_key(deadline: UnixMillis, owner: OwnerId, reservation_id: ReservationId) -> [u8; 72] { + let mut encoded = [0_u8; 72]; + encoded[..8].copy_from_slice(&deadline.value().to_be_bytes()); + encoded[8..40].copy_from_slice(&owner.to_bytes()); + encoded[40..].copy_from_slice(&reservation_id.to_bytes()); + encoded +} + +fn decode_live_quote_key( + bytes: &[u8], +) -> Result<(UnixMillis, OwnerId, ReservationId), ProviderError> { + let encoded = decode_table_key::<72>("owner live quote", bytes)?; + let mut deadline = [0_u8; 8]; + deadline.copy_from_slice(&encoded[..8]); + let mut owner = [0_u8; 32]; + owner.copy_from_slice(&encoded[8..40]); + let mut reservation_id = [0_u8; 32]; + reservation_id.copy_from_slice(&encoded[40..]); + Ok(( + UnixMillis::new(u64::from_be_bytes(deadline)), + OwnerId::new(owner), + ReservationId::new(reservation_id), + )) +} + fn expiration_key(deadline: UnixMillis, reservation_id: ReservationId) -> [u8; 40] { let mut key = [0_u8; 40]; key[..8].copy_from_slice(&deadline.value().to_be_bytes()); @@ -2138,7 +2787,7 @@ impl StoredProviderIdentity { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] struct StoredInventoryItem { outpoint: OutPoint, asset: AssetId, @@ -2148,6 +2797,20 @@ struct StoredInventoryItem { binding: [u8; 32], } +impl std::fmt::Debug for StoredInventoryItem { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredInventoryItem") + .field("outpoint", &self.outpoint) + .field("asset", &self.asset) + .field("amount", &self.amount) + .field("wallet_locator", &"[opaque]") + .field("internal_key", &self.internal_key) + .field("binding", &self.binding) + .finish() + } +} + impl From for StoredInventoryItem { fn from(value: InventoryItem) -> Self { Self { @@ -2336,7 +2999,7 @@ impl StoredReleaseReason { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] struct StoredSigningTarget { outpoint: OutPoint, wallet_locator: [u8; 32], @@ -2344,6 +3007,18 @@ struct StoredSigningTarget { inventory_binding: [u8; 32], } +impl std::fmt::Debug for StoredSigningTarget { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredSigningTarget") + .field("outpoint", &self.outpoint) + .field("wallet_locator", &"[opaque]") + .field("internal_key", &self.internal_key) + .field("inventory_binding", &self.inventory_binding) + .finish() + } +} + impl StoredSigningTarget { fn from_inventory(item: StoredInventoryItem) -> Self { Self { @@ -2446,13 +3121,443 @@ enum StoredReservationState { }, } +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StoredFirmQuote { + request: FirmQuoteRequest, + execution: QuoteExecution, + pricing: PricingDecision, + snapshot: QuoteSnapshotEvidence, + contribution: QuoteContribution, + provider_receive_internal_key: [u8; 32], + provider_receive_wallet_locator: [u8; 32], + provider_change_internal_key: Option<[u8; 32]>, + provider_change_wallet_locator: Option<[u8; 32]>, + selected_asset: AssetId, + selected_amount: u64, +} + +impl std::fmt::Debug for StoredFirmQuote { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Wallet locators are opaque recovery capabilities. Keep them—and the + // internal recovery keys that accompany them—out of diagnostics. + formatter + .debug_struct("StoredFirmQuote") + .field("request", &self.request) + .field("execution", &self.execution) + .field("pricing", &self.pricing) + .field("snapshot", &self.snapshot) + .field("contribution", &self.contribution) + .field("selected_asset", &self.selected_asset) + .field("selected_amount", &self.selected_amount) + .finish_non_exhaustive() + } +} + +impl StoredFirmQuote { + fn from_domain(quote: &FirmQuote, draft: &FirmQuoteDraft) -> Self { + Self { + request: quote.request().clone(), + execution: quote.execution(), + pricing: quote.pricing(), + snapshot: quote.snapshot(), + contribution: quote.contribution().clone(), + provider_receive_internal_key: draft.provider_receive_recovery.internal_key, + provider_receive_wallet_locator: draft.provider_receive_recovery.wallet_locator, + provider_change_internal_key: draft + .provider_change_recovery + .map(|recovery| recovery.internal_key), + provider_change_wallet_locator: draft + .provider_change_recovery + .map(|recovery| recovery.wallet_locator), + selected_asset: draft.selected_asset, + selected_amount: draft.selected_amount, + } + } + + fn to_domain( + &self, + provider: ProviderIdentity, + reservation: &StoredReservation, + ) -> Result { + let quote = quote_from_stored_parts( + reservation.id(), + provider, + self.request.clone(), + self.execution, + self.pricing, + self.snapshot, + self.contribution.clone(), + UnixMillis::new(reservation.created_at), + UnixMillis::new(reservation.accept_before), + reservation.fee_policy.to_domain()?, + recovery_metadata_commitment( + provider, + reservation.id(), + crate::quote::DestinationRecovery { + internal_key: self.provider_receive_internal_key, + wallet_locator: self.provider_receive_wallet_locator, + }, + self.provider_change_internal_key, + self.provider_change_wallet_locator, + )?, + QuoteCommitment::new(reservation.quote_commitment), + ); + let expected = recompute_quote_commitment( + OwnerId::new(reservation.owner), + IdempotencyKey::new(reservation.idempotency_key), + crate::model::QuoteRequestDigest::new(reservation.semantic_request_digest), + "e, + )?; + if expected != quote.commitment() { + return Err(ProviderError::CorruptState( + "persisted firm quote commitment does not match its transcript".to_owned(), + )); + } + self.validate(provider, reservation, "e)?; + Ok(quote) + } + + fn validate( + &self, + provider: ProviderIdentity, + reservation: &StoredReservation, + quote: &FirmQuote, + ) -> Result<(), ProviderError> { + if self.request.context().chain().genesis_hash != provider.genesis_hash() + || self.request.context().policy_asset() != provider.policy_asset() + || self.request.context().market().creation_anchor().is_null() + { + return Err(ProviderError::CorruptState( + "persisted firm quote context disagrees with provider identity".to_owned(), + )); + } + let request_digest = crate::quote::quote_request_digest( + provider, + OwnerId::new(reservation.owner), + IdempotencyKey::new(reservation.idempotency_key), + &self.request, + )?; + if request_digest.to_bytes() != reservation.semantic_request_digest { + return Err(ProviderError::CorruptState( + "persisted firm quote request digest does not match its semantics".to_owned(), + )); + } + let outpoints = quote + .contribution() + .inputs() + .iter() + .map(QuotedProviderInput::outpoint) + .collect::>(); + if outpoints != reservation.outpoints { + return Err(ProviderError::CorruptState( + "persisted firm quote inputs do not match reservation outpoints".to_owned(), + )); + } + validate_firm_quote_shape(quote)?; + let normalized_rate = crate::quote::RationalRate::new( + self.pricing.rate().numerator(), + self.pricing.rate().denominator(), + ) + .map_err(|error| { + ProviderError::CorruptState(format!( + "persisted firm quote has an invalid rate: {error}" + )) + })?; + if normalized_rate != self.pricing.rate() { + return Err(ProviderError::CorruptState( + "persisted firm quote rate is not normalized".to_owned(), + )); + } + let (request_input_asset, request_output_asset) = self.request.kind().pair(); + if request_input_asset == request_output_asset + || self.execution.input().asset() != request_input_asset + || self.execution.output().asset() != request_output_asset + { + return Err(ProviderError::CorruptState( + "persisted firm quote pair is inconsistent".to_owned(), + )); + } + let recipient_script = self.request.recipient().script_pubkey(); + if recipient_script.is_empty() + || recipient_script.is_provably_unspendable() + || recipient_script.len() > crate::quote::MAX_QUOTE_RECIPIENT_SCRIPT_BYTES + { + return Err(ProviderError::CorruptState( + "persisted firm quote recipient is invalid".to_owned(), + )); + } + if self.selected_amount == 0 || self.selected_asset != quote.execution().output().asset() { + return Err(ProviderError::CorruptState( + "persisted firm quote selected inventory is invalid".to_owned(), + )); + } + let expected_execution = match self.request.kind() { + crate::quote::QuoteKind::ExactIn { + input, + output_asset, + minimum_output, + } => { + if input.amount() == 0 || minimum_output == 0 { + return Err(ProviderError::CorruptState( + "persisted exact-input quote contains a zero amount".to_owned(), + )); + } + let priced_input = input + .amount() + .checked_sub(self.pricing.input_asset_venue_fee()) + .filter(|amount| *amount != 0) + .ok_or_else(|| { + ProviderError::CorruptState( + "persisted firm quote fee consumes exact input".to_owned(), + ) + })?; + let output = u128::from(priced_input) + .checked_mul(u128::from(self.pricing.rate().numerator())) + .ok_or_else(|| { + ProviderError::CorruptState( + "persisted firm quote pricing overflowed".to_owned(), + ) + })? + / u128::from(self.pricing.rate().denominator()); + let output = u64::try_from(output).map_err(|_| { + ProviderError::CorruptState("persisted firm quote output overflowed".to_owned()) + })?; + if output == 0 + || output < minimum_output + || self.execution.input() != input + || self.execution.output().asset() != output_asset + || self.execution.output().amount() != output + { + return Err(ProviderError::CorruptState( + "persisted exact-input quote has inconsistent pricing".to_owned(), + )); + } + self.execution + } + crate::quote::QuoteKind::ExactOut { + input_asset, + maximum_input, + output, + } => { + if maximum_input == 0 || output.amount() == 0 { + return Err(ProviderError::CorruptState( + "persisted exact-output quote contains a zero amount".to_owned(), + )); + } + let product = + u128::from(output.amount()) * u128::from(self.pricing.rate().denominator()); + let divisor = u128::from(self.pricing.rate().numerator()); + if divisor == 0 { + return Err(ProviderError::CorruptState( + "persisted firm quote has a zero rate".to_owned(), + )); + } + let priced_input = product / divisor + u128::from(!product.is_multiple_of(divisor)); + let gross_input = u64::try_from(priced_input) + .ok() + .and_then(|amount| amount.checked_add(self.pricing.input_asset_venue_fee())) + .ok_or_else(|| { + ProviderError::CorruptState( + "persisted exact-output quote input overflowed".to_owned(), + ) + })?; + if gross_input == 0 + || gross_input > maximum_input + || self.execution.input().asset() != input_asset + || self.execution.input().amount() != gross_input + || self.execution.output() != output + { + return Err(ProviderError::CorruptState( + "persisted exact-output quote has inconsistent pricing".to_owned(), + )); + } + self.execution + } + }; + if expected_execution.input_asset_venue_fee() > self.request.maximum_input_asset_venue_fee() + { + return Err(ProviderError::CorruptState( + "persisted firm quote exceeds its venue-fee bound".to_owned(), + )); + } + let change = self + .selected_amount + .checked_sub(quote.execution().output().amount()) + .ok_or_else(|| { + ProviderError::CorruptState( + "persisted firm quote inventory does not cover output".to_owned(), + ) + })?; + let change_outputs = quote + .contribution() + .outputs() + .iter() + .filter(|output| output.role() == QuoteOutputRole::ProviderChange) + .collect::>(); + if (change == 0 && !change_outputs.is_empty()) + || (change != 0 + && !matches!(change_outputs.as_slice(), [output] + if output.asset() == self.selected_asset && output.amount() == change)) + { + return Err(ProviderError::CorruptState( + "persisted firm quote change is inconsistent".to_owned(), + )); + } + let has_change_recovery = self.provider_change_internal_key.is_some() + && self.provider_change_wallet_locator.is_some(); + if has_change_recovery != (change != 0) + || self.provider_change_internal_key.is_some() + != self.provider_change_wallet_locator.is_some() + { + return Err(ProviderError::CorruptState( + "persisted firm quote change recovery is inconsistent".to_owned(), + )); + } + WalletKeyLocator::new(self.provider_receive_wallet_locator).map_err(|error| { + ProviderError::CorruptState(format!( + "invalid persisted provider receive recovery: {error}" + )) + })?; + let receive_key = + XOnlyPublicKey::from_slice(&self.provider_receive_internal_key).map_err(|error| { + ProviderError::CorruptState(format!( + "invalid persisted provider receive recovery: {error}" + )) + })?; + let provider_payment = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::ProviderPayment) + .ok_or_else(|| { + ProviderError::CorruptState( + "persisted firm quote has no provider payment".to_owned(), + ) + })?; + if provider_payment.destination().script_pubkey() + != &elements::Script::new_v1_p2tr(&Secp256k1::new(), receive_key, None) + { + return Err(ProviderError::CorruptState( + "provider receive recovery key does not match the quoted script".to_owned(), + )); + } + if let (Some(locator), Some(key)) = ( + self.provider_change_wallet_locator, + self.provider_change_internal_key, + ) { + WalletKeyLocator::new(locator).map_err(|error| { + ProviderError::CorruptState(format!( + "invalid persisted provider change recovery: {error}" + )) + })?; + let change_key = XOnlyPublicKey::from_slice(&key).map_err(|error| { + ProviderError::CorruptState(format!( + "invalid persisted provider change recovery: {error}" + )) + })?; + let provider_change = change_outputs.first().ok_or_else(|| { + ProviderError::CorruptState( + "provider change recovery has no quoted change output".to_owned(), + ) + })?; + if provider_change.destination().script_pubkey() + != &elements::Script::new_v1_p2tr(&Secp256k1::new(), change_key, None) + { + return Err(ProviderError::CorruptState( + "provider change recovery key does not match the quoted script".to_owned(), + )); + } + } + Ok(()) + } +} + +fn validate_firm_quote_shape(quote: &FirmQuote) -> Result<(), ProviderError> { + let inputs = quote.contribution().inputs(); + if inputs.is_empty() || inputs.len() > MAX_RESERVATION_INPUTS { + return Err(ProviderError::CorruptState( + "persisted firm quote has an invalid input count".to_owned(), + )); + } + for (index, input) in inputs.iter().enumerate() { + let expected_id = u16::try_from(index + 1).map_err(|_| { + ProviderError::CorruptState("persisted firm quote has too many inputs".to_owned()) + })?; + if input.id().value() != expected_id + || !input.witness_utxo().asset.is_confidential() + || !input.witness_utxo().value.is_confidential() + || !input.witness_utxo().nonce.is_confidential() + || input.witness_utxo().witness.surjection_proof.is_none() + || input.witness_utxo().witness.rangeproof.is_none() + { + return Err(ProviderError::CorruptState( + "persisted firm quote has an invalid provider input".to_owned(), + )); + } + } + + let outputs = quote.contribution().outputs(); + let expected_roles = if outputs.len() == 2 { + &[ + QuoteOutputRole::ProviderPayment, + QuoteOutputRole::TakerReceive, + ][..] + } else if outputs.len() == 3 { + &[ + QuoteOutputRole::ProviderPayment, + QuoteOutputRole::TakerReceive, + QuoteOutputRole::ProviderChange, + ][..] + } else { + return Err(ProviderError::CorruptState( + "persisted firm quote has an invalid output count".to_owned(), + )); + }; + let provider_blinder = crate::quote::QuoteBlinderRole::ProviderInput(inputs[0].id()); + for (index, (output, expected_role)) in outputs.iter().zip(expected_roles).enumerate() { + let expected_id = u16::try_from(index + 1).expect("firm quote has at most three outputs"); + let expected_blinder = if *expected_role == QuoteOutputRole::ProviderPayment { + crate::quote::QuoteBlinderRole::TakerPaymentInput + } else { + provider_blinder + }; + if output.id().value() != expected_id + || output.role() != *expected_role + || output.amount() == 0 + || output.blinder() != expected_blinder + { + return Err(ProviderError::CorruptState( + "persisted firm quote has an invalid output shape".to_owned(), + )); + } + } + + let execution = quote.execution(); + let payment = &outputs[0]; + let receive = &outputs[1]; + if payment.asset() != execution.input().asset() + || payment.amount() != execution.input().amount() + || receive.asset() != execution.output().asset() + || receive.amount() != execution.output().amount() + || receive.destination() != quote.request().recipient() + || execution.input_asset_venue_fee() != quote.pricing().input_asset_venue_fee() + { + return Err(ProviderError::CorruptState( + "persisted firm quote contribution disagrees with its economics".to_owned(), + )); + } + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct StoredReservation { id: [u8; 32], owner: [u8; 32], idempotency_key: [u8; 32], + semantic_request_digest: [u8; 32], request_digest: [u8; 32], quote_commitment: [u8; 32], + quote: Option, outpoints: Vec, created_at: u64, accept_before: u64, @@ -2499,6 +3604,11 @@ impl StoredReservation { )); } let policy = self.fee_policy.to_domain()?; + if self.quote.is_none() && self.semantic_request_digest != self.quote_commitment { + return Err(ProviderError::CorruptState( + "legacy reservation semantic request digest is inconsistent".to_owned(), + )); + } match &self.state { StoredReservationState::Committed { intent } | StoredReservationState::Signed { intent, .. } => { @@ -2543,6 +3653,20 @@ impl StoredReservation { Ok(()) } + fn to_firm_quote_outcome( + &self, + provider: ProviderIdentity, + created: bool, + ) -> Result { + let stored = self.quote.as_ref().ok_or_else(|| { + ProviderError::CorruptState( + "reservation was not created by the firm quote engine".to_owned(), + ) + })?; + let quote = stored.to_domain(provider, self)?; + Ok(quote_outcome(quote, self.to_view()?, created)) + } + fn to_view(&self) -> Result { self.validate()?; let fee_policy = self.fee_policy.to_domain()?; @@ -2579,6 +3703,7 @@ impl StoredReservation { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] struct StoredRequestBinding { reservation_id: [u8; 32], + semantic_request_digest: [u8; 32], request_digest: [u8; 32], } @@ -2587,6 +3712,7 @@ struct StoredRequestFingerprint<'a> { identity: StoredProviderIdentity, owner: [u8; 32], idempotency_key: [u8; 32], + semantic_request_digest: [u8; 32], quote_commitment: [u8; 32], outpoints: &'a [OutPoint], accept_before: u64, @@ -2747,6 +3873,10 @@ pub enum ProviderError { CorruptAuditSequence, #[error("audit sequence overflowed")] AuditSequenceOverflow, + #[error("persisted allocation revision is corrupt")] + CorruptAllocationRevision, + #[error("allocation revision overflowed")] + AllocationRevisionOverflow, #[error("expiration index key has length {0}, expected 40")] CorruptExpirationKey(usize), #[error("provider state is internally inconsistent: {0}")] @@ -2766,6 +3896,16 @@ pub enum ProviderError { }, #[error("idempotency key {key:?} for owner {owner:?} was reused with different terms")] IdempotencyConflict { owner: OwnerId, key: IdempotencyKey }, + #[error("firm quote request digest disagrees with its semantic request")] + FirmQuoteRequestDigestMismatch, + #[error("firm quote snapshot evidence disagrees with the current eligible inventory")] + FirmQuoteSnapshotMismatch, + #[error("eligible inventory changed while the firm quote was being constructed; retry")] + EligibleInventoryChanged, + #[error("firm quote input disagrees with fresh wallet inventory at {0:?}")] + FirmQuoteInventoryMismatch(OutPoint), + #[error("firm quote draft is internally inconsistent")] + FirmQuoteDraftInvalid, #[error("derived reservation ID collided: {0:?}")] ReservationIdCollision(ReservationId), #[error("reservation deadline {accept_before:?} elapsed at {now:?}")] @@ -2773,6 +3913,14 @@ pub enum ProviderError { accept_before: UnixMillis, now: UnixMillis, }, + #[error("firm quote deadline calculation overflowed")] + QuoteDeadlineOverflow, + #[error("live quote count overflowed")] + LiveQuoteCountOverflow, + #[error("owner {owner:?} reached the live quote limit of {maximum}")] + OwnerLiveQuoteLimit { owner: OwnerId, maximum: usize }, + #[error("provider reached the global live quote limit of {maximum}")] + GlobalLiveQuoteLimit { maximum: usize }, #[error("reservation not found: {0:?}")] ReservationNotFound(ReservationId), #[error("reservation owner authentication failed: {0:?}")] diff --git a/crates/deadcat-rfq-provider/src/store/tests.rs b/crates/deadcat-rfq-provider/src/store/tests.rs index f360031..8ba0a9e 100644 --- a/crates/deadcat-rfq-provider/src/store/tests.rs +++ b/crates/deadcat-rfq-provider/src/store/tests.rs @@ -189,6 +189,39 @@ fn wallet_inventory_batch_is_atomic_and_exact_rediscovery_is_idempotent() { ); } +#[test] +fn inventory_state_lookup_does_not_decode_unrelated_history() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(82); + let book = open_book(&directory, identity); + let requested = inventory(122); + let unrelated = inventory(123); + let now = UnixMillis::new(100); + book.import_inventory_batch(&[requested, unrelated], &now) + .expect("inventory import"); + + // Poison an unrelated historical row after startup. A bounded snapshot + // lookup must point-read only the requested outpoints; decoding the entire + // append-only inventory table would encounter this row and fail. + let write = book.database.begin_write().expect("raw write"); + { + let mut inventory = write.open_table(INVENTORY).expect("inventory"); + let key = outpoint_key(unrelated.outpoint()); + inventory + .insert(key.as_slice(), &[0xff_u8][..]) + .expect("poison unrelated inventory row"); + } + write.commit().expect("commit fixture"); + + let (views, allocation_revision) = book + .inventory_state_for(&[requested.outpoint()]) + .expect("bounded inventory lookup"); + assert_eq!(allocation_revision, 0); + assert_eq!(views.len(), 1); + assert_eq!(views[0].item(), requested); + assert_eq!(views[0].state(), InventoryState::Available); +} + #[test] fn reservation_is_atomic_idempotent_and_owner_authenticated() { let directory = TempDir::new().expect("tempdir"); @@ -636,8 +669,10 @@ fn signing_commitment_covers_every_durable_wallet_target_field() { id: derive_reservation_id(request.owner(), request.idempotency_key()).to_bytes(), owner: request.owner().to_bytes(), idempotency_key: request.idempotency_key().to_bytes(), + semantic_request_digest: request.request_digest().to_bytes(), request_digest: request_digest(identity, &request).expect("request digest"), quote_commitment: request.quote_commitment().to_bytes(), + quote: None, outpoints: request.outpoints().to_vec(), created_at: 100, accept_before: request.accept_before().value(), @@ -1419,7 +1454,8 @@ fn missing_schema_metadata_cannot_reinitialize_a_nonempty_database() { fn strict_record_codec_rejects_wrong_versions_and_trailing_bytes() { let encoded = encode_record(&StoredRequestBinding { reservation_id: [1; 32], - request_digest: [2; 32], + semantic_request_digest: [2; 32], + request_digest: [3; 32], }) .expect("encode"); let mut wrong_version = encoded.clone(); diff --git a/crates/deadcat-rfq-provider/src/wallet.rs b/crates/deadcat-rfq-provider/src/wallet.rs index bba05a1..2a63b20 100644 --- a/crates/deadcat-rfq-provider/src/wallet.rs +++ b/crates/deadcat-rfq-provider/src/wallet.rs @@ -283,6 +283,11 @@ impl WalletScanAnchor { pub struct InventorySnapshotCommitment([u8; 32]); impl InventorySnapshotCommitment { + #[must_use] + pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + #[must_use] pub const fn to_bytes(self) -> [u8; 32] { self.0 @@ -432,7 +437,7 @@ impl fmt::Debug for ConfidentialDestination { .field("script_pubkey", &self.script_pubkey) .field("blinding_public_key", &self.blinding_public_key) .field("internal_key", &self.internal_key) - .field("wallet_locator", &self.wallet_locator) + .field("wallet_locator", &"[opaque]") .finish() } } @@ -441,10 +446,14 @@ impl fmt::Debug for ConfidentialDestination { pub trait DestinationSource { type Error: Error + Send + Sync + 'static; - /// Return a destination never previously issued for this purpose. + /// Return a globally fresh destination whose recovery metadata remains + /// usable after the caller durably persists it and the process restarts. /// - /// Non-reuse is a required backend guarantee; this interface cannot infer - /// wallet derivation history and therefore cannot enforce it itself. + /// The destination must never have been returned for either purpose. A + /// caller may permanently burn an issued destination when a concurrent + /// idempotent request wins or a database mutation rolls back; the backend + /// must never recycle it. Global non-reuse and durable recoverability are + /// backend guarantees that this interface cannot infer or enforce. fn fresh_confidential_destination( &self, purpose: DestinationPurpose, @@ -624,6 +633,19 @@ fn output_binding( InventoryBinding::new(hasher.finalize().into()) } +/// Recompute a durable inventory binding when the full public prevout is +/// available (for example inside a persisted firm quote). +pub(crate) fn recompute_inventory_binding(item: InventoryItem, txout: &TxOut) -> InventoryBinding { + output_binding( + item.outpoint(), + txout, + item.asset(), + item.amount(), + item.internal_key(), + item.wallet_locator(), + ) +} + fn snapshot_commitment( identity: ProviderIdentity, anchor: WalletScanAnchor, diff --git a/docs/adr/0007-rfq-provider-state-machine.md b/docs/adr/0007-rfq-provider-state-machine.md index e4f3c43..9ee5fdc 100644 --- a/docs/adr/0007-rfq-provider-state-machine.md +++ b/docs/adr/0007-rfq-provider-state-machine.md @@ -35,6 +35,14 @@ The initial provider core is transport-free. Its persistence types are private versioned records, not wire DTOs. It does not extend the node RPC, reuse the `deadcat/1` ALPN, or make a network compatibility promise. +This remains clean-slate preproduction storage. The provider schema version and +private record-layout version intentionally remain `1` while firm-quote records +and indexes are added. A local provider database created by an earlier alpha +build must be deleted and recreated; there is no migration or compatibility +decoder. Keeping version `1` is acceptable only because no provider database +has reached testnet, mainnet, or production, and a compatibility policy must be +chosen before that changes. + ### Monotonic inventory states Each provider outpoint has one authoritative allocation: @@ -239,17 +247,22 @@ validator/signer-adapter layer. replay. - Immediate provider relay and optional provider-funded CPFP reduce the time committed inventory remains unavailable; cooperative RBF is deferred. -- The persistence core stores no private keys and implements no pricing, +- The persistence core stores no private keys. The transport-free quote engine + owns exact arithmetic, inventory selection, and an injected pricing-policy + boundary, with a static rational policy supplied for configuration and + deterministic tests. It implements no production market-data source, transaction validation, signing, networking, relay, mempool, or reorg policy. Backend-neutral discovery and signer capabilities surround it, but a concrete wallet/RPC/HSM backend remains a separate security principal. - Multiple interactive RFQ signers remain deferred. Future AMM and DLOB legs may coexist because a reservation covers only the provider's exact leg and inputs, not the entire route. -- Reservation requests lazily expire only reservations blocking their requested - outpoints. A service worker drains unrelated expirations through explicitly - bounded batches (capped by the state core), so an accumulated expiry backlog - cannot make one request's write transaction unbounded. +- Firm-quote admission drains all due reservations through explicitly bounded + batches (capped by the state core) before selecting inventory. The lower-level + reservation primitive used by state-core tests still reclaims only expirations + blocking its requested outpoints, and an explicit sweep remains available to + service maintenance. No single write transaction grows with an accumulated + expiry backlog. ## Implementation and follow-up @@ -273,11 +286,31 @@ and metadata-conflict coverage. Destination non-reuse and authoritative chain/mempool freshness are explicit backend obligations; the types cannot prove them. The crate deliberately supplies no concrete wallet backend. +Its quote layer now provides configured collateral-to-outcome and +outcome-to-collateral directions, exact-in and exact-out arithmetic with +direction-appropriate rounding and taker bounds, an injected pricing-policy +interface, deterministic bounded inventory selection, confidential provider +receive and change destinations, a symbolic contribution compatible with the +client's venue model, live-quote admission limits, and durable exact replay +across restart. Quote construction and reservation are one fail-closed path +over a fresh snapshot. The resulting `FirmQuote` is deliberately an internal, +unauthenticated artifact: it is neither a signed provider attestation nor a +wire response, and clients must not treat it as either until the dedicated +authenticated RFQ protocol lands. + +Market quote configuration is likewise not chain evidence. The service must +derive each configured contract ID and collateral/YES/NO asset tuple from an +independently validated canonical market view, rather than trusting operator or +remote asset labels. The remote service must also authenticate the owner, +rate-limit quote churn, and choose a bounded durable-retention/compaction policy +before exposing this engine publicly. Live-reservation quotas bound concurrent +inventory pressure; they do not by themselves bound terminal quote history. + The remaining provider milestones are: -1. add configurable inventory-aware quote construction; -2. validate a concrete final Liquid PSET and derive its exact fee metrics; -3. define a dedicated RFQ protocol, identity, and ALPN; -4. persist relay and chain-reconciliation observations without ever reopening +1. validate a concrete final Liquid PSET and derive its exact fee metrics; +2. define a dedicated authenticated RFQ protocol, signed quote envelope, + identity, and ALPN; +3. persist relay and chain-reconciliation observations without ever reopening a committed outpoint; and -5. pass process-kill, signer ambiguity, mempool, confirmation, and reorg gates. +4. pass process-kill, signer ambiguity, mempool, confirmation, and reorg gates. diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index a0ffeb8..cb808c2 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -419,8 +419,11 @@ The provisional ordinary-output API uses the narrower name `PreparedLeg`: its economics and output claims are authorized, but venue-specific completion and the final signer checks still have to succeed before it is executable on chain. -For an RFQ leg, preparation reserves exact provider inventory and returns a -signed short-lived commitment. +For an RFQ leg, preparation reserves exact provider inventory. The eventual +remote protocol returns a signed short-lived commitment. The current +transport-free provider engine instead returns an internal, unauthenticated +`FirmQuote`; it proves construction and durable replay semantics but is not a +network quote or provider attestation. For a DLOB or AMM leg, preparation refreshes and pins exact public state. It does not reserve that state against another valid transaction. @@ -594,6 +597,14 @@ interface proposed here: explicit `SIGHASH_ALL` targets. Destination non-reuse and authoritative scan freshness are backend obligations. It intentionally does not choose a production wallet, RPC, descriptor, or HSM backend. +- Its inventory-aware quote engine now supports configured + collateral-to-YES/NO and YES/NO-to-collateral directions, exact-in and + exact-out integer arithmetic, injected pricing policy, deterministic bounded + inventory selection, confidential provider receive/change outputs, live + quote admission limits, and exact idempotent replay across restart. It emits + a symbolic provider contribution and is covered by a conformance test against + the client venue authorization model. The returned `FirmQuote` remains an + internal unauthenticated artifact until the signed remote protocol exists. - The provisional client-local [venue model](../crates/deadcat-client/src/venue.rs) and [transaction composer](../crates/deadcat-client/src/composition.rs) separate aggregate user intent from exact per-leg allocation, bind an @@ -626,10 +637,16 @@ interface proposed here: Phase 1 has extracted and tested the smallest generic plan/composer seam from these patterns without making the router depend on maker-specific types, and -has added the provider's durable state plus wallet-capability boundary. The API -remains provisional until configurable quote construction, concrete final-PSET -validation, real remote RFQ evidence, and a production wallet/signer backend -exercise it. +has added the provider's durable state, wallet-capability boundary, and +transport-free inventory-aware quote construction. The API remains provisional +until concrete final-PSET validation, authenticated signed remote RFQ evidence, +and a production wallet/signer backend exercise it. + +The provider database remains disposable preproduction state during this +work. Its schema and private record-layout versions intentionally stay at `1`; +local provider databases created by earlier alpha builds must be deleted and +recreated, not migrated. A real compatibility and migration policy is required +before any provider database is deployed or treated as production data. ### Symbolic transaction contributions From 42942d54dc2f272a219dd04f7cfacb59cea06f2d Mon Sep 17 00:00:00 2001 From: Tommy Volk Date: Thu, 13 Aug 2026 10:00:35 -0500 Subject: [PATCH 2/2] feat(rfq): validate final settlement PSETs --- README.md | 26 +- crates/deadcat-rfq-provider/src/lib.rs | 23 +- crates/deadcat-rfq-provider/src/store.rs | 192 +- .../src/store/settlement.rs | 1185 +++++++++++ .../src/store/settlement/tests.rs | 1798 +++++++++++++++++ crates/deadcat-rfq-provider/src/wallet.rs | 44 +- docs/adr/0007-rfq-provider-state-machine.md | 44 +- docs/liquidity-roadmap.md | 17 +- 8 files changed, 3281 insertions(+), 48 deletions(-) create mode 100644 crates/deadcat-rfq-provider/src/store/settlement.rs create mode 100644 crates/deadcat-rfq-provider/src/store/settlement/tests.rs diff --git a/README.md b/README.md index fa9f134..b8f85e4 100644 --- a/README.md +++ b/README.md @@ -37,17 +37,27 @@ boundary are implemented, along with configurable, inventory-aware firm-quote construction for exact-in and exact-out trades. The quote engine applies exact integer pricing, deterministically selects fresh available inventory, reserves its exact outpoints, and durably replays the same symbolic transaction -contribution for an idempotent request. Its `FirmQuote` is an internal, -unauthenticated artifact, not yet a provider-signed network quote. A production -wallet/RPC/HSM backend, market-data pricing source, transaction validator, -signer adapter, authenticated remote protocol, and relay remain future work. +contribution for an idempotent request. The provider now also validates a +concrete final Liquid PSET before the irreversible signing transition: it binds +the persisted RFQ leg inside a venue-neutral transaction, checks authoritative +unspent prevouts, finalized taker P2TR `SIGHASH_ALL` signatures, confidential +disclosures/proofs/balance and provider output recovery, and derives fee and +weight facts with the missing provider witnesses projected. Its `FirmQuote` is +still an internal, unauthenticated artifact, not yet a provider-signed network +quote. A production wallet/RPC/HSM backend, market-data pricing source, signer +adapter, authenticated remote protocol, and relay remain future work. +This initial validator accepts ordinary finalized tree-less P2TR +`SIGHASH_ALL` inputs outside the current RFQ leg; Simplicity covenant inputs +and a second interactive RFQ signer need a later authenticated venue/script +verification seam. The eventual service must derive market assets from chain-validated canonical parameters and add authenticated-owner rate limits plus bounded history retention; the library's live-quote quotas only cap concurrent reservations. -Until the validator and signer adapter land, the safety-critical commit and -signed-result transitions are intentionally crate-internal. The RFQ provider -remains separate from `deadcat-node`; future AMM and DLOB protocols are not -implemented by this repository today. +The safety-critical commit is reachable only by consuming the validator's +opaque one-shot intent; the signed-result transition remains crate-internal +until the signer adapter lands. The RFQ provider remains separate from +`deadcat-node`; future AMM and DLOB protocols are not implemented by this +repository today. The RFQ provider database is still clean-slate preproduction state. Its schema and private record-layout versions intentionally remain `1` while the provider diff --git a/crates/deadcat-rfq-provider/src/lib.rs b/crates/deadcat-rfq-provider/src/lib.rs index ae1ddda..2229a6b 100644 --- a/crates/deadcat-rfq-provider/src/lib.rs +++ b/crates/deadcat-rfq-provider/src/lib.rs @@ -13,10 +13,13 @@ //! //! Wallet discovery admits only confidential tree-less P2TR outputs and quote //! eligibility is the intersection of a fresh complete scan with durable -//! unallocated state. Concrete wallet/RPC/HSM implementations remain outside -//! this crate. The commit and signed-result transitions also remain private -//! until the concrete transaction validator and signer adapter can be their -//! only producers. +//! unallocated state. The final-PSET validator is the sole production path to +//! the commit transition: it rechecks the durable quote, authoritative +//! prevouts, complete taker signatures, confidential proofs and openings, and +//! exact fee/weight facts before it emits a one-shot signing capability. +//! Concrete wallet/RPC/HSM implementations remain outside this crate. The +//! signed-result transition remains private until a concrete signer adapter +//! can be its only producer. mod inventory; mod model; @@ -50,12 +53,16 @@ pub use quote::{ StaticRateRule, StaticRationalPricing, }; pub use store::{ - CommitOutcome, MAX_EXPIRATION_BATCH, ProviderError, ReservationBook, SCHEMA_VERSION, - SignedOutcome, + AuthoritativePrevout, CommitOutcome, DEFAULT_MAX_SETTLEMENT_INPUTS, + DEFAULT_MAX_SETTLEMENT_OUTPUTS, MAX_EXPIRATION_BATCH, ProviderError, + ProviderSettlementValidator, ReservationBook, SCHEMA_VERSION, SettlementChainSource, + SettlementInputPlacement, SettlementLayout, SettlementLayoutError, SettlementLimitsError, + SettlementOutputPlacement, SettlementValidationError, SettlementValidationLimits, + SignedOutcome, ValidatedSigningIntent, }; pub use wallet::{ ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshot, InventorySnapshotCommitment, InventorySource, P2TR_SIGHASH_ALL_SCRIPT_WITNESS_BYTES, - P2TR_SIGHASH_ALL_SIGNATURE_BYTES, ProviderInputSignature, ProviderSigner, SigningResponse, - WalletBoundaryError, WalletOwnedOutput, WalletScanAnchor, + P2TR_SIGHASH_ALL_SIGNATURE_BYTES, ProviderInputSignature, ProviderOutputRecovery, + ProviderSigner, SigningResponse, WalletBoundaryError, WalletOwnedOutput, WalletScanAnchor, }; diff --git a/crates/deadcat-rfq-provider/src/store.rs b/crates/deadcat-rfq-provider/src/store.rs index e22b75a..a3e84bb 100644 --- a/crates/deadcat-rfq-provider/src/store.rs +++ b/crates/deadcat-rfq-provider/src/store.rs @@ -24,13 +24,22 @@ use crate::model::{ SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator, }; use crate::quote::{ - FirmQuote, FirmQuoteDraft, FirmQuoteOutcome, FirmQuoteRequest, PricingDecision, - QuoteContribution, QuoteEnginePolicy, QuoteExecution, QuoteOutputRole, QuoteSnapshotEvidence, - QuotedProviderInput, finalize_quote, quote_from_stored_parts, quote_outcome, - recompute_quote_commitment, recovery_metadata_commitment, + DestinationRecovery, FirmQuote, FirmQuoteDraft, FirmQuoteOutcome, FirmQuoteRequest, + PricingDecision, QuoteContribution, QuoteEnginePolicy, QuoteExecution, QuoteOutputRole, + QuoteSnapshotEvidence, QuotedProviderInput, finalize_quote, quote_from_stored_parts, + quote_outcome, recompute_quote_commitment, recovery_metadata_commitment, }; use crate::wallet::recompute_inventory_binding; +mod settlement; + +pub use settlement::{ + AuthoritativePrevout, DEFAULT_MAX_SETTLEMENT_INPUTS, DEFAULT_MAX_SETTLEMENT_OUTPUTS, + ProviderSettlementValidator, SettlementChainSource, SettlementInputPlacement, SettlementLayout, + SettlementLayoutError, SettlementLimitsError, SettlementOutputPlacement, + SettlementValidationError, SettlementValidationLimits, ValidatedSigningIntent, +}; + pub const SCHEMA_VERSION: u32 = 1; /// Maximum number of unrelated expirations one explicit sweep may mutate in a /// single immediate-durability transaction. @@ -788,6 +797,102 @@ impl ReservationBook { .transpose() } + /// Load the authenticated, durable inputs needed to validate a final + /// settlement. The returned quote and recovery metadata are reconstructed + /// from the reservation record rather than accepted from the submitter. + /// + /// Committed and signed records include their exact durable signing job so + /// the settlement layer can recognize retries without consulting live + /// wallet or chain state. Released reservations are terminal and therefore + /// do not produce a validation context. + pub(super) fn settlement_context( + &self, + access: ReservationAccess, + ) -> Result { + self.ensure_healthy()?; + let read = self.database.begin_read()?; + let reservations = read.open_table(RESERVATIONS)?; + let record = reservations + .get(access.reservation_id().to_bytes().as_slice())? + .map(|value| decode_record::(value.value())) + .transpose()? + .ok_or(ProviderError::ReservationNotFound(access.reservation_id()))?; + if record.id() != access.reservation_id() { + return Err(ProviderError::CorruptState( + "reservation key and record ID disagree".to_owned(), + )); + } + record.validate()?; + if record.owner != access.owner().to_bytes() { + return Err(ProviderError::ReservationOwnerMismatch( + access.reservation_id(), + )); + } + let stored_quote = record.quote.as_ref().ok_or_else(|| { + ProviderError::CorruptState( + "settlement validation requires a firm-quote reservation".to_owned(), + ) + })?; + let quote = stored_quote.to_domain(self.identity, &record)?; + let provider_receive_recovery = DestinationRecovery { + internal_key: stored_quote.provider_receive_internal_key, + wallet_locator: stored_quote.provider_receive_wallet_locator, + }; + let provider_change_recovery = match ( + stored_quote.provider_change_internal_key, + stored_quote.provider_change_wallet_locator, + ) { + (Some(internal_key), Some(wallet_locator)) => Some(DestinationRecovery { + internal_key, + wallet_locator, + }), + (None, None) => None, + (Some(_), None) | (None, Some(_)) => { + return Err(ProviderError::CorruptState( + "persisted provider change recovery is incomplete".to_owned(), + )); + } + }; + let inventory = read.open_table(INVENTORY)?; + let mut provider_targets = Vec::with_capacity(record.outpoints.len()); + for outpoint in &record.outpoints { + let stored = inventory + .get(outpoint_key(*outpoint).as_slice())? + .map(|value| decode_record::(value.value())) + .transpose()? + .ok_or_else(|| { + ProviderError::CorruptState(format!( + "reserved outpoint {outpoint:?} has no inventory metadata" + )) + })?; + stored.to_domain()?; + provider_targets.push(StoredSigningTarget::from_inventory(stored).to_domain()?); + } + let state = match &record.state { + StoredReservationState::Reserved => SettlementContextState::Reserved, + StoredReservationState::Released { .. } => { + return Err(ProviderError::ReservationAlreadyReleased(record.id())); + } + StoredReservationState::Committed { intent } => { + SettlementContextState::Committed(intent.to_job(record.id())?) + } + StoredReservationState::Signed { intent, artifact } => { + let job = intent.to_job(record.id())?; + artifact.to_domain(record.id(), job.commitment())?; + SettlementContextState::Signed(job) + } + }; + Ok(SettlementContext { + provider: self.identity, + access, + quote, + provider_receive_recovery, + provider_change_recovery, + provider_targets, + state, + }) + } + /// Cancel a reservation only while it remains before the signing point of /// no return. Cancellation at or after the deadline is recorded as expiry. pub fn cancel( @@ -874,16 +979,40 @@ impl ReservationBook { /// before any wallet or HSM signer is invoked. /// /// `pre_sign_payload` must be the complete immutable provider signing - /// transcript produced by the later settlement validator, including the - /// finalized transaction body, proofs, authoritative prevouts, existing - /// user witnesses, approved sighash profile, and quote economics. - // The concrete validator added in the next provider layer will be this - // method's only production caller. Keeping the transition crate-private - // prevents detached fee assertions from crossing the trust boundary. - #[allow(dead_code)] - pub(crate) fn commit_before_sign( + /// transcript produced by the settlement validator, including the + /// finalized transaction body, proofs, authoritatively checked PSET + /// prevouts, existing user witnesses, approved sighash profile, and quote + /// economics. + // The concrete validator is this method's only production caller. Keeping + // the transition private prevents detached fee assertions from crossing + // the trust boundary. + fn commit_validated_before_sign( &self, + expected_provider: ProviderIdentity, access: ReservationAccess, + expected_quote_commitment: QuoteCommitment, + pre_sign_payload: Vec, + fee: TransactionFee, + clock: &C, + ) -> Result { + if self.identity != expected_provider { + return Err(ProviderError::ValidatedIntentBindingMismatch( + access.reservation_id(), + )); + } + self.commit_before_sign_inner( + access, + Some(expected_quote_commitment), + pre_sign_payload, + fee, + clock, + ) + } + + fn commit_before_sign_inner( + &self, + access: ReservationAccess, + expected_quote_commitment: Option, pre_sign_payload: Vec, fee: TransactionFee, clock: &C, @@ -891,6 +1020,11 @@ impl ReservationBook { validate_settlement_bytes(&pre_sign_payload)?; let (_operation_guard, write, now) = self.begin_timed_write(clock)?; let mut record = require_authorized_reservation(&write, access)?; + if expected_quote_commitment.is_some_and(|expected| { + record.quote.is_none() || record.quote_commitment != expected.to_bytes() + }) { + return Err(ProviderError::ValidatedIntentBindingMismatch(record.id())); + } match &record.state { StoredReservationState::Committed { intent } => { @@ -1013,6 +1147,19 @@ impl ReservationBook { Ok(CommitOutcome::NewlyCommitted(job)) } + /// Legacy state-machine test seam. Production code can cross this boundary + /// only through [`ValidatedSigningIntent`](settlement::ValidatedSigningIntent). + #[cfg(test)] + pub(crate) fn commit_before_sign( + &self, + access: ReservationAccess, + pre_sign_payload: Vec, + fee: TransactionFee, + clock: &C, + ) -> Result { + self.commit_before_sign_inner(access, None, pre_sign_payload, fee, clock) + } + /// Persist exact signed bytes before they can be returned or relayed. // The signer adapter added in the next provider layer will verify and // canonicalize its result before invoking this crate-private transition. @@ -1276,6 +1423,25 @@ impl ReservationBook { } } +/// Authenticated durable state from which the settlement validator derives +/// one opaque signing intent. +pub(super) struct SettlementContext { + pub(super) provider: ProviderIdentity, + pub(super) access: ReservationAccess, + pub(super) quote: FirmQuote, + pub(super) provider_receive_recovery: DestinationRecovery, + pub(super) provider_change_recovery: Option, + pub(super) provider_targets: Vec, + pub(super) state: SettlementContextState, +} + +/// Durable replay state paired with a settlement context. +pub(super) enum SettlementContextState { + Reserved, + Committed(SigningJob), + Signed(SigningJob), +} + #[cfg(test)] #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ReserveOutcome { @@ -3929,6 +4095,8 @@ pub enum ProviderError { ReservationAlreadyReleased(ReservationId), #[error("reservation crossed the irreversible signing point: {0:?}")] PointOfNoReturn(ReservationId), + #[error("validated settlement does not match the provider or firm quote for reservation {0:?}")] + ValidatedIntentBindingMismatch(ReservationId), #[error("reservation is already committed to a different signing intent: {0:?}")] DifferentSigningIntent(ReservationId), #[error("settlement payload must not be empty")] diff --git a/crates/deadcat-rfq-provider/src/store/settlement.rs b/crates/deadcat-rfq-provider/src/store/settlement.rs new file mode 100644 index 0000000..841f865 --- /dev/null +++ b/crates/deadcat-rfq-provider/src/store/settlement.rs @@ -0,0 +1,1185 @@ +//! Provider-side authorization of a complete Liquid settlement. +//! +//! This is the last fallible validation boundary before provider inventory is +//! durably committed and a wallet or HSM may sign it. It deliberately derives +//! its authority from the persisted firm quote, authoritative unspent +//! prevouts, and wallet-owned output recovery—not from client manifests. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; + +use elements::bitcoin::PublicKey as BitcoinPublicKey; +use elements::encode::{deserialize, serialize}; +use elements::hashes::Hash as _; +use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTransaction}; +use elements::schnorr::TapTweak as _; +use elements::secp256k1_zkp::{Message, Secp256k1}; +use elements::sighash::{Prevouts, SighashCache}; +use elements::{ + AssetId, BlindAssetProofs as _, BlindValueProofs as _, BlockHash, LockTime, OutPoint, + SchnorrSig, SchnorrSighashType, Sequence, Transaction, TxOut, +}; +use thiserror::Error; + +use super::{ + CommitOutcome, ProviderError, ReservationBook, SettlementContext, SettlementContextState, +}; +use crate::model::{ + Clock, MAX_SETTLEMENT_BYTES, ProviderIdentity, QuoteCommitment, ReservationAccess, + ReservationId, TransactionFee, WalletKeyLocator, +}; +use crate::quote::{ + DestinationRecovery, FirmQuote, QuoteBlinderRole, QuoteInputId, QuoteOutputId, QuoteOutputRole, + QuotedOutput, +}; +use crate::wallet::{P2TR_SIGHASH_ALL_SIGNATURE_BYTES, ProviderOutputRecovery}; + +/// Default whole-transaction input bound, aligned with the client composer. +pub const DEFAULT_MAX_SETTLEMENT_INPUTS: usize = 32; +/// Default whole-transaction output bound, aligned with the client composer. +pub const DEFAULT_MAX_SETTLEMENT_OUTPUTS: usize = 32; + +/// One chain-authoritative output that is unspent in the source's coherent +/// snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthoritativePrevout { + outpoint: OutPoint, + txout: TxOut, +} + +impl AuthoritativePrevout { + #[must_use] + pub const fn new(outpoint: OutPoint, txout: TxOut) -> Self { + Self { outpoint, txout } + } + + #[must_use] + pub const fn outpoint(&self) -> OutPoint { + self.outpoint + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + &self.txout + } +} + +/// Trusted chain/mempool view used immediately before commitment. +/// +/// Implementations must return one entry per requested outpoint, in request +/// order, including each complete consensus [`TxOut`] and its rangeproof +/// witness, and must fail if any output is missing or spent. The adapter should +/// minimize skew across the batch; a later outspend can still race this read +/// and make the transaction unrelayable, but cannot authorize a different +/// provider spend. +pub trait SettlementChainSource { + type Error: Error + Send + Sync + 'static; + + /// Genesis hash of the Liquid chain backing this source. + fn genesis_hash(&self) -> BlockHash; + + fn unspent_prevouts( + &self, + outpoints: &[OutPoint], + ) -> Result, Self::Error>; +} + +/// Global placement of one quote-local provider input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SettlementInputPlacement { + quote_input: QuoteInputId, + transaction_index: usize, +} + +impl SettlementInputPlacement { + #[must_use] + pub const fn new(quote_input: QuoteInputId, transaction_index: usize) -> Self { + Self { + quote_input, + transaction_index, + } + } + + #[must_use] + pub const fn quote_input(self) -> QuoteInputId { + self.quote_input + } + + #[must_use] + pub const fn transaction_index(self) -> usize { + self.transaction_index + } +} + +/// Global placement of one quote-local output. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SettlementOutputPlacement { + quote_output: QuoteOutputId, + transaction_index: usize, +} + +impl SettlementOutputPlacement { + #[must_use] + pub const fn new(quote_output: QuoteOutputId, transaction_index: usize) -> Self { + Self { + quote_output, + transaction_index, + } + } + + #[must_use] + pub const fn quote_output(self) -> QuoteOutputId { + self.quote_output + } + + #[must_use] + pub const fn transaction_index(self) -> usize { + self.transaction_index + } +} + +/// Injective quote-local to whole-transaction placement supplied with a final +/// PSET. +/// +/// The mapping is only a locator. Every referenced object is rechecked against +/// the durable quote, and no two quote roles may alias one transaction object. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SettlementLayout { + taker_payment_input: usize, + provider_inputs: Vec, + quote_outputs: Vec, +} + +impl SettlementLayout { + pub fn new( + taker_payment_input: usize, + mut provider_inputs: Vec, + mut quote_outputs: Vec, + ) -> Result { + if provider_inputs.is_empty() { + return Err(SettlementLayoutError::NoProviderInputs); + } + if quote_outputs.is_empty() { + return Err(SettlementLayoutError::NoQuoteOutputs); + } + provider_inputs.sort_by_key(|placement| placement.quote_input); + quote_outputs.sort_by_key(|placement| placement.quote_output); + if let Some(duplicate) = provider_inputs + .windows(2) + .find(|pair| pair[0].quote_input == pair[1].quote_input) + { + return Err(SettlementLayoutError::DuplicateQuoteInput( + duplicate[0].quote_input, + )); + } + if let Some(duplicate) = quote_outputs + .windows(2) + .find(|pair| pair[0].quote_output == pair[1].quote_output) + { + return Err(SettlementLayoutError::DuplicateQuoteOutput( + duplicate[0].quote_output, + )); + } + let mut input_indexes = BTreeSet::new(); + input_indexes.insert(taker_payment_input); + for placement in &provider_inputs { + if !input_indexes.insert(placement.transaction_index) { + return Err(SettlementLayoutError::AliasedInput( + placement.transaction_index, + )); + } + } + let mut output_indexes = BTreeSet::new(); + for placement in "e_outputs { + if !output_indexes.insert(placement.transaction_index) { + return Err(SettlementLayoutError::AliasedOutput( + placement.transaction_index, + )); + } + } + Ok(Self { + taker_payment_input, + provider_inputs, + quote_outputs, + }) + } + + #[must_use] + pub const fn taker_payment_input(&self) -> usize { + self.taker_payment_input + } + + #[must_use] + pub fn provider_inputs(&self) -> &[SettlementInputPlacement] { + &self.provider_inputs + } + + #[must_use] + pub fn quote_outputs(&self) -> &[SettlementOutputPlacement] { + &self.quote_outputs + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum SettlementLayoutError { + #[error("settlement layout has no provider inputs")] + NoProviderInputs, + #[error("settlement layout has no quote outputs")] + NoQuoteOutputs, + #[error("settlement layout repeats quote input {0:?}")] + DuplicateQuoteInput(QuoteInputId), + #[error("settlement layout repeats quote output {0:?}")] + DuplicateQuoteOutput(QuoteOutputId), + #[error("settlement layout aliases transaction input {0}")] + AliasedInput(usize), + #[error("settlement layout aliases transaction output {0}")] + AliasedOutput(usize), +} + +/// Bounded resource profile enforced before proof or signature verification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SettlementValidationLimits { + maximum_inputs: usize, + maximum_outputs: usize, +} + +impl SettlementValidationLimits { + pub fn new( + maximum_inputs: usize, + maximum_outputs: usize, + ) -> Result { + if maximum_inputs == 0 { + return Err(SettlementLimitsError::ZeroMaximumInputs); + } + if maximum_outputs == 0 { + return Err(SettlementLimitsError::ZeroMaximumOutputs); + } + Ok(Self { + maximum_inputs, + maximum_outputs, + }) + } + + #[must_use] + pub const fn maximum_inputs(self) -> usize { + self.maximum_inputs + } + + #[must_use] + pub const fn maximum_outputs(self) -> usize { + self.maximum_outputs + } +} + +impl Default for SettlementValidationLimits { + fn default() -> Self { + Self { + maximum_inputs: DEFAULT_MAX_SETTLEMENT_INPUTS, + maximum_outputs: DEFAULT_MAX_SETTLEMENT_OUTPUTS, + } + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum SettlementLimitsError { + #[error("maximum settlement input count must be nonzero")] + ZeroMaximumInputs, + #[error("maximum settlement output count must be nonzero")] + ZeroMaximumOutputs, +} + +/// Non-forgeable authorization to cross the durable provider signing point. +/// +/// This type is intentionally not cloneable or serializable. Dropping it has +/// no effect; consuming it atomically rechecks the quote binding, deadline, +/// fee policy, and durable allocations before returning a signing job. +pub struct ValidatedSigningIntent { + provider: ProviderIdentity, + access: ReservationAccess, + quote_commitment: QuoteCommitment, + canonical_pset: Vec, + fee: TransactionFee, +} + +impl ValidatedSigningIntent { + #[must_use] + pub const fn reservation_id(&self) -> ReservationId { + self.access.reservation_id() + } + + #[must_use] + pub fn canonical_pset(&self) -> &[u8] { + &self.canonical_pset + } + + #[must_use] + pub const fn fee(&self) -> TransactionFee { + self.fee + } + + pub fn commit( + self, + book: &ReservationBook, + clock: &C, + ) -> Result { + book.commit_validated_before_sign( + self.provider, + self.access, + self.quote_commitment, + self.canonical_pset, + self.fee, + clock, + ) + } +} + +impl fmt::Debug for ValidatedSigningIntent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ValidatedSigningIntent") + .field("provider", &self.provider) + .field("reservation_id", &self.access.reservation_id()) + .field("quote_commitment", &self.quote_commitment) + .field("canonical_pset_bytes", &self.canonical_pset.len()) + .field("fee", &self.fee) + .finish() + } +} + +/// Complete provider-side final-PSET validator for the initial RFQ profile. +/// +/// Every non-provider input must already be a finalized tree-less P2TR +/// key-path `SIGHASH_ALL` spend. This permits ordinary taker funding and +/// already-signed wallet contributions, but intentionally does not yet execute +/// Simplicity covenant witnesses or coordinate a second interactive RFQ +/// signer. Supporting those venues requires an authenticated script/venue +/// verifier rather than treating an arbitrary nonempty witness as valid. +pub struct ProviderSettlementValidator<'a, C, R> { + book: &'a ReservationBook, + chain: &'a C, + output_recovery: &'a R, + limits: SettlementValidationLimits, +} + +impl<'a, C, R> ProviderSettlementValidator<'a, C, R> +where + C: SettlementChainSource, + R: ProviderOutputRecovery, +{ + #[must_use] + pub fn new(book: &'a ReservationBook, chain: &'a C, output_recovery: &'a R) -> Self { + Self { + book, + chain, + output_recovery, + limits: SettlementValidationLimits::default(), + } + } + + #[must_use] + pub const fn with_limits(mut self, limits: SettlementValidationLimits) -> Self { + self.limits = limits; + self + } + + /// Validate and canonicalize one complete taker-signed PSET. + /// + /// Exact retries of a committed or signed payload are recognized from + /// durable state before live chain/wallet calls, because those inputs may + /// already have been spent by the exact settlement. Replay compares only + /// the canonical payload; the supplied layout is not consulted because it + /// cannot authorize a new signing intent after commitment. + pub fn validate( + &self, + access: ReservationAccess, + layout: &SettlementLayout, + submitted_pset: &[u8], + ) -> Result { + let canonical_pset = canonical_pset(submitted_pset)?; + let context = self.book.settlement_context(access)?; + match &context.state { + SettlementContextState::Committed(job) | SettlementContextState::Signed(job) => { + if job.pre_sign_payload() != canonical_pset { + return Err( + ProviderError::DifferentSigningIntent(access.reservation_id()).into(), + ); + } + return Ok(ValidatedSigningIntent { + provider: context.provider, + access, + quote_commitment: context.quote.commitment(), + canonical_pset, + fee: job.fee(), + }); + } + SettlementContextState::Reserved => {} + } + let pset = deserialize::(&canonical_pset) + .map_err(|error| SettlementValidationError::InvalidPset(error.to_string()))?; + self.validate_reserved(&context, layout, canonical_pset, &pset) + } + + fn validate_reserved( + &self, + context: &SettlementContext, + layout: &SettlementLayout, + canonical_pset: Vec, + pset: &PartiallySignedTransaction, + ) -> Result { + validate_global(pset, self.limits)?; + validate_layout(&context.quote, layout, pset)?; + let chain_genesis = self.chain.genesis_hash(); + if chain_genesis != context.provider.genesis_hash() { + return Err(SettlementValidationError::WrongChain { + expected: context.provider.genesis_hash(), + actual: chain_genesis, + }); + } + + let outpoints = pset.inputs().iter().map(input_outpoint).collect::>(); + let authoritative = self + .chain + .unspent_prevouts(&outpoints) + .map_err(|error| SettlementValidationError::ChainSource(Box::new(error)))?; + if authoritative.len() != outpoints.len() { + return Err(SettlementValidationError::AuthoritativePrevoutCount { + expected: outpoints.len(), + actual: authoritative.len(), + }); + } + for (index, (expected, actual)) in outpoints.iter().zip(&authoritative).enumerate() { + if actual.outpoint() != *expected { + return Err(SettlementValidationError::AuthoritativePrevoutMismatch( + index, + )); + } + } + let prevouts = authoritative + .iter() + .map(|prevout| prevout.txout.clone()) + .collect::>(); + let transaction = pset + .extract_tx() + .map_err(|error| SettlementValidationError::InvalidPset(error.to_string()))?; + + let provider_by_id = layout + .provider_inputs + .iter() + .map(|placement| (placement.quote_input, placement.transaction_index)) + .collect::>(); + let provider_indexes = provider_by_id.values().copied().collect::>(); + let targets_by_outpoint = context + .provider_targets + .iter() + .map(|target| (target.outpoint(), *target)) + .collect::>(); + + for (index, input) in pset.inputs().iter().enumerate() { + validate_common_input(input, &authoritative[index], index)?; + if provider_indexes.contains(&index) { + let quoted = context + .quote + .contribution() + .inputs() + .iter() + .find(|quoted| provider_by_id.get("ed.id()) == Some(&index)) + .ok_or(SettlementValidationError::InvalidProviderInput { + index, + reason: "layout does not resolve to a quoted input", + })?; + let target = targets_by_outpoint.get("ed.outpoint()).ok_or( + SettlementValidationError::InvalidProviderInput { + index, + reason: "durable signing target is missing", + }, + )?; + if quoted.inventory_binding() != target.inventory_binding() { + return Err(SettlementValidationError::InvalidProviderInput { + index, + reason: "quoted inventory binding disagrees with durable signing target", + }); + } + validate_provider_input( + input, + &authoritative[index], + quoted.witness_utxo(), + *target, + index, + )?; + } else { + validate_taker_input( + input, + &transaction, + &prevouts, + context.provider.genesis_hash(), + index, + )?; + } + } + + let fee_amount = + validate_outputs(pset, &transaction, context, layout, self.output_recovery)?; + transaction + .verify_tx_amt_proofs(&Secp256k1::new(), &prevouts) + .map_err(|error| SettlementValidationError::ConfidentialProofs(error.to_string()))?; + + let mut projected = transaction; + for index in provider_indexes { + projected.input[index].witness.script_witness = + vec![vec![0_u8; P2TR_SIGHASH_ALL_SIGNATURE_BYTES]]; + } + let fee = TransactionFee::new( + context.provider.policy_asset(), + fee_amount, + u64::try_from(projected.weight()) + .map_err(|_| SettlementValidationError::TransactionSizeOverflow)?, + u64::try_from(projected.vsize()) + .map_err(|_| SettlementValidationError::TransactionSizeOverflow)?, + u64::try_from(projected.discount_vsize()) + .map_err(|_| SettlementValidationError::TransactionSizeOverflow)?, + ) + .map_err(|error| SettlementValidationError::InvalidFeeFacts(error.to_string()))?; + context.quote.fee_policy().validate(fee)?; + + Ok(ValidatedSigningIntent { + provider: context.provider, + access: context.access, + quote_commitment: context.quote.commitment(), + canonical_pset, + fee, + }) + } +} + +fn canonical_pset(bytes: &[u8]) -> Result, SettlementValidationError> { + if bytes.is_empty() { + return Err(SettlementValidationError::EmptyPayload); + } + if bytes.len() > MAX_SETTLEMENT_BYTES { + return Err(SettlementValidationError::PayloadTooLarge { + maximum: MAX_SETTLEMENT_BYTES, + actual: bytes.len(), + }); + } + let pset = deserialize::(bytes) + .map_err(|error| SettlementValidationError::InvalidPset(error.to_string()))?; + pset.sanity_check() + .map_err(|error| SettlementValidationError::InvalidPset(error.to_string()))?; + let canonical = serialize(&pset); + if canonical != bytes { + return Err(SettlementValidationError::NonCanonicalPset); + } + Ok(canonical) +} + +fn validate_global( + pset: &PartiallySignedTransaction, + limits: SettlementValidationLimits, +) -> Result<(), SettlementValidationError> { + if pset.inputs().is_empty() || pset.inputs().len() > limits.maximum_inputs { + return Err(SettlementValidationError::InputCount { + maximum: limits.maximum_inputs, + actual: pset.inputs().len(), + }); + } + if pset.outputs().is_empty() || pset.outputs().len() > limits.maximum_outputs { + return Err(SettlementValidationError::OutputCount { + maximum: limits.maximum_outputs, + actual: pset.outputs().len(), + }); + } + if pset.global.version != 2 || pset.global.tx_data.version != 2 { + return Err(SettlementValidationError::InvalidGlobal( + "PSET and transaction versions must both be 2", + )); + } + if !pset.global.xpub.is_empty() + || !pset.global.scalars.is_empty() + || !pset.global.proprietary.is_empty() + || !pset.global.unknown.is_empty() + { + return Err(SettlementValidationError::InvalidGlobal( + "unexpected global wallet, blinding, proprietary, or unknown metadata", + )); + } + if pset.global.tx_data.tx_modifiable.unwrap_or(0) != 0 + || pset.global.elements_tx_modifiable_flag.unwrap_or(0) != 0 + { + return Err(SettlementValidationError::InvalidGlobal( + "transaction remains modifiable", + )); + } + if pset + .global + .tx_data + .fallback_locktime + .is_some_and(|locktime| locktime != LockTime::ZERO) + { + return Err(SettlementValidationError::InvalidGlobal( + "nonzero locktime is unsupported", + )); + } + let mut outpoints = BTreeSet::new(); + for (index, input) in pset.inputs().iter().enumerate() { + let outpoint = input_outpoint(input); + if outpoint.is_null() || input.previous_output_index & 0xc000_0000 != 0 { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "null, issuance, or pegin outpoint", + }); + } + if !outpoints.insert(outpoint) { + return Err(SettlementValidationError::DuplicateInput(outpoint)); + } + } + Ok(()) +} + +fn validate_layout( + quote: &FirmQuote, + layout: &SettlementLayout, + pset: &PartiallySignedTransaction, +) -> Result<(), SettlementValidationError> { + if layout.taker_payment_input >= pset.inputs().len() { + return Err(SettlementValidationError::LayoutIndexOutOfRange); + } + let expected_inputs = quote + .contribution() + .inputs() + .iter() + .map(|input| input.id()) + .collect::>(); + let actual_inputs = layout + .provider_inputs + .iter() + .map(|placement| placement.quote_input) + .collect::>(); + if expected_inputs != actual_inputs + || layout + .provider_inputs + .iter() + .any(|placement| placement.transaction_index >= pset.inputs().len()) + { + return Err(SettlementValidationError::LayoutInputMismatch); + } + let expected_outputs = quote + .contribution() + .outputs() + .iter() + .map(|output| output.id()) + .collect::>(); + let actual_outputs = layout + .quote_outputs + .iter() + .map(|placement| placement.quote_output) + .collect::>(); + if expected_outputs != actual_outputs + || layout + .quote_outputs + .iter() + .any(|placement| placement.transaction_index >= pset.outputs().len()) + { + return Err(SettlementValidationError::LayoutOutputMismatch); + } + for placement in &layout.provider_inputs { + let quoted = quote + .contribution() + .inputs() + .iter() + .find(|input| input.id() == placement.quote_input) + .ok_or(SettlementValidationError::LayoutInputMismatch)?; + if input_outpoint(&pset.inputs()[placement.transaction_index]) != quoted.outpoint() { + return Err(SettlementValidationError::LayoutInputMismatch); + } + } + Ok(()) +} + +fn validate_common_input( + input: &PsetInput, + authoritative: &AuthoritativePrevout, + index: usize, +) -> Result<(), SettlementValidationError> { + let Some(witness_utxo) = input.witness_utxo.as_ref() else { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "missing witness UTXO", + }); + }; + if !same_prevout_body(witness_utxo, authoritative.txout()) + || input.in_utxo_rangeproof != authoritative.txout().witness.rangeproof + { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "PSET witness UTXO disagrees with authoritative prevout", + }); + } + if input.non_witness_utxo.is_some() + || !input.partial_sigs.is_empty() + || !input.bip32_derivation.is_empty() + || !input.ripemd160_preimages.is_empty() + || !input.sha256_preimages.is_empty() + || !input.hash160_preimages.is_empty() + || !input.hash256_preimages.is_empty() + || input.redeem_script.is_some() + || input.witness_script.is_some() + || input.final_script_sig.is_some() + || !input.tap_script_sigs.is_empty() + || !input.tap_scripts.is_empty() + || !input.tap_key_origins.is_empty() + || input.tap_merkle_root.is_some() + || input.amount.is_some() + || input.blind_value_proof.is_some() + || input.asset.is_some() + || input.blind_asset_proof.is_some() + || !input.proprietary.is_empty() + || !input.unknown.is_empty() + { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "unsupported signing or wallet metadata", + }); + } + if input + .sequence + .is_some_and(|sequence| sequence != Sequence::MAX) + || input.required_time_locktime.is_some() + || input.required_height_locktime.is_some() + { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "non-final sequence or input locktime requirement", + }); + } + if has_issuance_or_pegin_metadata(input) { + return Err(SettlementValidationError::InvalidInput { + index, + reason: "issuance or pegin metadata is unsupported", + }); + } + Ok(()) +} + +fn validate_provider_input( + input: &PsetInput, + authoritative: &AuthoritativePrevout, + quoted_prevout: &TxOut, + target: crate::model::SigningTarget, + index: usize, +) -> Result<(), SettlementValidationError> { + if authoritative.txout() != quoted_prevout || authoritative.outpoint() != target.outpoint() { + return Err(SettlementValidationError::InvalidProviderInput { + index, + reason: "authoritative prevout disagrees with durable quote", + }); + } + if authoritative.txout().script_pubkey + != elements::Script::new_v1_p2tr(&Secp256k1::new(), target.internal_key(), None) + || input.tap_internal_key != Some(target.internal_key()) + || input.sighash_type != Some(SchnorrSighashType::All.into()) + || input.tap_key_sig.is_some() + || input.final_script_witness.is_some() + { + return Err(SettlementValidationError::InvalidProviderInput { + index, + reason: "provider input is not unsigned tree-less P2TR SIGHASH_ALL", + }); + } + Ok(()) +} + +fn validate_taker_input( + input: &PsetInput, + transaction: &Transaction, + prevouts: &[TxOut], + genesis_hash: elements::BlockHash, + index: usize, +) -> Result<(), SettlementValidationError> { + let prevout = &prevouts[index]; + let internal_key = + input + .tap_internal_key + .ok_or(SettlementValidationError::InvalidTakerInput { + index, + reason: "missing Taproot internal key", + })?; + if prevout.script_pubkey != elements::Script::new_v1_p2tr(&Secp256k1::new(), internal_key, None) + || input.sighash_type != Some(SchnorrSighashType::All.into()) + { + return Err(SettlementValidationError::InvalidTakerInput { + index, + reason: "input is not tree-less P2TR with explicit SIGHASH_ALL", + }); + } + let signature = input + .tap_key_sig + .ok_or(SettlementValidationError::InvalidTakerInput { + index, + reason: "missing finalized Taproot signature", + })?; + if signature.hash_ty != SchnorrSighashType::All + || input.final_script_witness.as_ref() != Some(&vec![signature.to_vec()]) + { + return Err(SettlementValidationError::InvalidTakerInput { + index, + reason: "final witness is not the exact explicit-ALL key-path signature", + }); + } + verify_taproot_signature( + transaction, + prevouts, + index, + signature, + internal_key, + genesis_hash, + ) +} + +fn verify_taproot_signature( + transaction: &Transaction, + prevouts: &[TxOut], + index: usize, + signature: SchnorrSig, + internal_key: elements::secp256k1_zkp::XOnlyPublicKey, + genesis_hash: elements::BlockHash, +) -> Result<(), SettlementValidationError> { + let sighash = SighashCache::new(transaction) + .taproot_key_spend_signature_hash( + index, + &Prevouts::All(prevouts), + SchnorrSighashType::All, + genesis_hash, + ) + .map_err(|error| SettlementValidationError::InvalidSignature { + index, + detail: error.to_string(), + })?; + let message = Message::from_digest(sighash.to_byte_array()); + let (output_key, _) = internal_key.tap_tweak(&Secp256k1::new(), None); + Secp256k1::new() + .verify_schnorr(&signature.sig, &message, output_key.as_inner()) + .map_err(|error| SettlementValidationError::InvalidSignature { + index, + detail: error.to_string(), + }) +} + +fn validate_outputs( + pset: &PartiallySignedTransaction, + transaction: &Transaction, + context: &SettlementContext, + layout: &SettlementLayout, + output_recovery: &R, +) -> Result { + let mut fee = None; + for (index, output) in pset.outputs().iter().enumerate() { + if output.script_pubkey.is_empty() { + if fee.is_some() { + return Err(SettlementValidationError::InvalidFeeOutput( + "multiple fee outputs", + )); + } + fee = Some(validate_fee_output( + output, + context.provider.policy_asset(), + )?); + } else { + validate_confidential_output(output, index, pset.inputs().len())?; + } + } + let fee = fee.ok_or(SettlementValidationError::InvalidFeeOutput( + "missing fee output", + ))?; + + for placement in &layout.quote_outputs { + let quoted = context + .quote + .contribution() + .outputs() + .iter() + .find(|output| output.id() == placement.quote_output) + .ok_or(SettlementValidationError::LayoutOutputMismatch)?; + let output = &pset.outputs()[placement.transaction_index]; + validate_quoted_output(output, quoted, layout, placement.transaction_index)?; + match quoted.role() { + QuoteOutputRole::ProviderPayment => validate_provider_recovery( + output_recovery, + context.provider_receive_recovery, + &transaction.output[placement.transaction_index], + quoted, + )?, + QuoteOutputRole::ProviderChange => { + let recovery = context.provider_change_recovery.ok_or( + SettlementValidationError::InvalidQuotedOutput { + index: placement.transaction_index, + reason: "provider change recovery metadata is missing", + }, + )?; + validate_provider_recovery( + output_recovery, + recovery, + &transaction.output[placement.transaction_index], + quoted, + )?; + } + QuoteOutputRole::TakerReceive => {} + } + } + Ok(fee) +} + +fn validate_fee_output( + output: &PsetOutput, + policy_asset: AssetId, +) -> Result { + let amount = output + .amount + .ok_or(SettlementValidationError::InvalidFeeOutput( + "fee amount is missing", + ))?; + if amount == 0 + || output.asset != Some(policy_asset) + || output.asset_comm.is_some() + || output.amount_comm.is_some() + || output.blinding_key.is_some() + || output.ecdh_pubkey.is_some() + || output.blinder_index.is_some() + || output.value_rangeproof.is_some() + || output.asset_surjection_proof.is_some() + || output.blind_value_proof.is_some() + || output.blind_asset_proof.is_some() + || has_output_wallet_metadata(output) + { + return Err(SettlementValidationError::InvalidFeeOutput( + "fee output is not one exact explicit policy-asset fee", + )); + } + Ok(amount) +} + +fn validate_confidential_output( + output: &PsetOutput, + index: usize, + input_count: usize, +) -> Result<(), SettlementValidationError> { + if output.script_pubkey.is_provably_unspendable() + || output.blinding_key.is_none() + || output.ecdh_pubkey.is_none() + || output.asset.is_none() + || output.amount.is_none() + || output.asset_comm.is_none() + || output.amount_comm.is_none() + || output.value_rangeproof.is_none() + || output.asset_surjection_proof.is_none() + || output.blind_asset_proof.is_none() + || output.blind_value_proof.is_none() + || output + .blinder_index + .is_none_or(|blinder| usize::try_from(blinder).map_or(true, |i| i >= input_count)) + || has_output_wallet_metadata(output) + { + return Err(SettlementValidationError::InvalidOutput { + index, + reason: "ordinary output is not a fully disclosed confidential output", + }); + } + let asset = output.asset.expect("presence checked"); + let amount = output.amount.expect("presence checked"); + let asset_commitment = output.asset_comm.expect("presence checked"); + let value_commitment = output.amount_comm.expect("presence checked"); + if !output + .blind_asset_proof + .as_deref() + .expect("presence checked") + .blind_asset_proof_verify(&Secp256k1::new(), asset, asset_commitment) + || !output + .blind_value_proof + .as_deref() + .expect("presence checked") + .blind_value_proof_verify( + &Secp256k1::new(), + amount, + asset_commitment, + value_commitment, + ) + { + return Err(SettlementValidationError::InvalidOutput { + index, + reason: "disclosed asset or amount does not match its commitment", + }); + } + Ok(()) +} + +fn validate_quoted_output( + output: &PsetOutput, + quoted: &QuotedOutput, + layout: &SettlementLayout, + index: usize, +) -> Result<(), SettlementValidationError> { + let expected_blinder = match quoted.blinder() { + QuoteBlinderRole::TakerPaymentInput => layout.taker_payment_input, + QuoteBlinderRole::ProviderInput(id) => layout + .provider_inputs + .iter() + .find(|placement| placement.quote_input == id) + .map(|placement| placement.transaction_index) + .ok_or(SettlementValidationError::LayoutInputMismatch)?, + }; + let expected_blinder = u32::try_from(expected_blinder) + .map_err(|_| SettlementValidationError::LayoutIndexOutOfRange)?; + if output.script_pubkey != *quoted.destination().script_pubkey() + || output.blinding_key + != Some(BitcoinPublicKey::new( + quoted.destination().blinding_public_key(), + )) + || output.asset != Some(quoted.asset()) + || output.amount != Some(quoted.amount()) + || output.blinder_index != Some(expected_blinder) + { + return Err(SettlementValidationError::InvalidQuotedOutput { + index, + reason: "output disagrees with durable quote economics, destination, or blinder role", + }); + } + Ok(()) +} + +fn validate_provider_recovery( + output_recovery: &R, + recovery: DestinationRecovery, + txout: &TxOut, + quoted: &QuotedOutput, +) -> Result<(), SettlementValidationError> { + let locator = WalletKeyLocator::new(recovery.wallet_locator) + .map_err(|error| SettlementValidationError::InvalidRecoveryMetadata(error.to_string()))?; + output_recovery + .validate_confidential_output( + locator, + elements::secp256k1_zkp::XOnlyPublicKey::from_slice(&recovery.internal_key).map_err( + |error| SettlementValidationError::InvalidRecoveryMetadata(error.to_string()), + )?, + txout, + quoted.asset(), + quoted.amount(), + ) + .map_err(|error| SettlementValidationError::OutputRecovery { + role: quoted.role(), + source: Box::new(error), + }) +} + +fn has_output_wallet_metadata(output: &PsetOutput) -> bool { + output.redeem_script.is_some() + || output.witness_script.is_some() + || !output.bip32_derivation.is_empty() + || output.tap_internal_key.is_some() + || output.tap_tree.is_some() + || !output.tap_key_origins.is_empty() + || !output.proprietary.is_empty() + || !output.unknown.is_empty() +} + +fn has_issuance_or_pegin_metadata(input: &PsetInput) -> bool { + input.issuance_value_amount.is_some() + || input.issuance_value_comm.is_some() + || input.issuance_inflation_keys.is_some() + || input.issuance_inflation_keys_comm.is_some() + || input.issuance_value_rangeproof.is_some() + || input.issuance_keys_rangeproof.is_some() + || input.issuance_blinding_nonce.is_some() + || input.issuance_asset_entropy.is_some() + || input.in_issuance_blind_value_proof.is_some() + || input.in_issuance_blind_inflation_keys_proof.is_some() + || input.blinded_issuance.is_some() + || input.pegin_tx.is_some() + || input.pegin_txout_proof.is_some() + || input.pegin_genesis_hash.is_some() + || input.pegin_claim_script.is_some() + || input.pegin_value.is_some() + || input.pegin_witness.is_some() +} + +fn input_outpoint(input: &PsetInput) -> OutPoint { + OutPoint::new(input.previous_txid, input.previous_output_index) +} + +fn same_prevout_body(actual: &TxOut, expected: &TxOut) -> bool { + actual.asset == expected.asset + && actual.value == expected.value + && actual.nonce == expected.nonce + && actual.script_pubkey == expected.script_pubkey +} + +#[derive(Debug, Error)] +pub enum SettlementValidationError { + #[error("settlement payload must not be empty")] + EmptyPayload, + #[error("settlement payload has {actual} bytes; maximum is {maximum}")] + PayloadTooLarge { maximum: usize, actual: usize }, + #[error("invalid PSET: {0}")] + InvalidPset(String), + #[error("PSET is not in the canonical encoding committed by the provider")] + NonCanonicalPset, + #[error("provider state rejected settlement validation: {0}")] + Provider(#[from] ProviderError), + #[error("settlement has {actual} inputs; accepted range is 1..={maximum}")] + InputCount { maximum: usize, actual: usize }, + #[error("settlement has {actual} outputs; accepted range is 1..={maximum}")] + OutputCount { maximum: usize, actual: usize }, + #[error("invalid global settlement policy: {0}")] + InvalidGlobal(&'static str), + #[error("duplicate settlement input {0:?}")] + DuplicateInput(OutPoint), + #[error("settlement layout contains an out-of-range index")] + LayoutIndexOutOfRange, + #[error("settlement provider-input layout does not match the durable quote")] + LayoutInputMismatch, + #[error("settlement output layout does not match the durable quote")] + LayoutOutputMismatch, + #[error("authoritative chain source failed: {0}")] + ChainSource(#[source] Box), + #[error("chain source returned {actual} prevouts; expected {expected}")] + AuthoritativePrevoutCount { expected: usize, actual: usize }, + #[error("chain source returned the wrong outpoint at input {0}")] + AuthoritativePrevoutMismatch(usize), + #[error("chain source is on {actual}, expected provider chain {expected}")] + WrongChain { + expected: BlockHash, + actual: BlockHash, + }, + #[error("invalid input {index}: {reason}")] + InvalidInput { index: usize, reason: &'static str }, + #[error("invalid provider input {index}: {reason}")] + InvalidProviderInput { index: usize, reason: &'static str }, + #[error("invalid taker input {index}: {reason}")] + InvalidTakerInput { index: usize, reason: &'static str }, + #[error("invalid signature at input {index}: {detail}")] + InvalidSignature { index: usize, detail: String }, + #[error("invalid output {index}: {reason}")] + InvalidOutput { index: usize, reason: &'static str }, + #[error("invalid quoted output {index}: {reason}")] + InvalidQuotedOutput { index: usize, reason: &'static str }, + #[error("invalid fee output: {0}")] + InvalidFeeOutput(&'static str), + #[error("provider cannot recover {role:?} output: {source}")] + OutputRecovery { + role: QuoteOutputRole, + #[source] + source: Box, + }, + #[error("invalid provider output recovery metadata: {0}")] + InvalidRecoveryMetadata(String), + #[error("confidential transaction proof or balance verification failed: {0}")] + ConfidentialProofs(String), + #[error("transaction size does not fit the provider fee model")] + TransactionSizeOverflow, + #[error("invalid derived fee facts: {0}")] + InvalidFeeFacts(String), + #[error("fee policy rejected the final transaction: {0}")] + FeePolicy(#[from] crate::model::FeePolicyViolation), +} + +#[cfg(test)] +mod tests; diff --git a/crates/deadcat-rfq-provider/src/store/settlement/tests.rs b/crates/deadcat-rfq-provider/src/store/settlement/tests.rs new file mode 100644 index 0000000..8a193fb --- /dev/null +++ b/crates/deadcat-rfq-provider/src/store/settlement/tests.rs @@ -0,0 +1,1798 @@ +//! Reusable confidential-settlement fixtures for provider-side validation. +//! +//! This module intentionally builds quotes through the real quote engine and +//! transactions through the real client composition seam. Individual validator +//! tests should start from [`SettlementFixture::new`] and mutate either the +//! submitted PSET, its symbolic-to-physical layout, or the authoritative chain +//! view. Keeping those three inputs separate makes it difficult for a negative +//! test to accidentally update both the untrusted claim and its authority. + +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::sync::Mutex; + +use deadcat_client::composition::{ + BlinderRef, CompositionLimits, InputId, InputSequence, InputSpec, LockTimeConstraint, + NetworkFee, OutputId, OutputSpec, TransactionContribution, UnblindedStructureManifest, +}; +use deadcat_client::venue::{ + AssetAmount as ClientAssetAmount, ConfidentialRecipient as ClientRecipient, + ExactExecution as ClientExactExecution, ExecutionRequest as ClientExecutionRequest, LegId, + LegPreparationRequest, ProposedLeg, RouteAuthorization, VenueContext, +}; +use deadcat_types::{ChainIdentity, ContractId, LiquidNetwork}; +use elements::bitcoin::PublicKey as BitcoinPublicKey; +use elements::confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}; +use elements::encode::{deserialize, serialize}; +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, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use elements::sighash::{Prevouts, SighashCache}; +use elements::{ + AssetId, BlindAssetProofs as _, BlindValueProofs as _, BlockHash, LockTime, OutPoint, + SchnorrSig, SchnorrSighashType, Script, Sequence, Transaction, TxOut, TxOutSecrets, + TxOutWitness, Txid, +}; +use tempfile::TempDir; +use thiserror::Error; + +use crate::inventory::{InventoryCoordinator, InventoryFreshnessPolicy}; +use crate::model::{ + FeePolicy, FeeSizeMetric, IdempotencyKey, InventoryState, OwnerId, ProviderId, + ProviderIdentity, ReleaseReason, ReservationAccess, ReservationState, ReservationView, + UnixMillis, WalletKeyLocator, +}; +use crate::quote::{ + AmountRange, AssetAmount, BinaryMarketAssets, FirmQuote, FirmQuoteRequest, MarketQuoteConfig, + PairLimits, PairRule, PricingRevision, QuoteBlinderRole, QuoteContext, QuoteEngine, + QuoteEnginePolicy, QuoteInputId, QuoteKind, QuoteOutputId, QuoteOutputRole, QuoteRecipient, + RationalRate, StaticRateRule, StaticRationalPricing, +}; +use crate::store::ReservationBook; +use crate::wallet::{ + ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshot, + InventorySource, ProviderOutputRecovery, WalletOwnedOutput, WalletScanAnchor, +}; + +use super::{ + AuthoritativePrevout, CommitOutcome, ProviderSettlementValidator, SettlementChainSource, + SettlementInputPlacement, SettlementLayout, SettlementLayoutError, SettlementOutputPlacement, + SettlementValidationError, +}; + +pub(super) const QUOTE_TIME: UnixMillis = UnixMillis::new(101); +pub(super) const VALIDATION_TIME: UnixMillis = UnixMillis::new(102); +pub(super) const NETWORK_FEE: u64 = 1_000; +pub(super) const TAKER_FEE_INPUT_VALUE: u64 = 5_000; +pub(super) const TAKER_PAYMENT_INPUT_VALUE: u64 = 100; +pub(super) const QUOTED_PAYMENT_VALUE: u64 = 50; +pub(super) const QUOTED_RECEIVE_VALUE: u64 = 50; +pub(super) const PROVIDER_INVENTORY_VALUE: u64 = 65; + +const POLICY_MARKER: u8 = 1; +const YES_MARKER: u8 = 2; +const NO_MARKER: u8 = 3; +const WALLET_FEE_INPUT_ID: InputId = InputId::new(1); +const WALLET_PAYMENT_INPUT_ID: InputId = InputId::new(2); +const WALLET_FEE_CHANGE_ID: OutputId = OutputId::new(1); +const WALLET_PAYMENT_CHANGE_ID: OutputId = OutputId::new(2); + +type FixtureQuoteEngine = + QuoteEngine; + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub(super) enum FixtureBackendError { + #[error("fixture backend exhausted")] + Exhausted, + #[error("fixture wallet does not recognize the destination locator")] + UnknownDestination, + #[error("fixture wallet cannot recover the confidential output")] + OutputRecovery, + #[error("fixture chain view is missing an unspent prevout")] + MissingOrSpentPrevout, +} + +#[derive(Clone)] +pub(super) struct FixtureWallet { + keypair: Keypair, + internal_key: XOnlyPublicKey, + blinding_secret: SecretKey, + blinding_public_key: PublicKey, + script_pubkey: Script, +} + +impl FixtureWallet { + pub(super) fn deterministic(spend_marker: u8, blind_marker: u8) -> Self { + let secp = Secp256k1::new(); + let spend_secret = SecretKey::from_slice(&[spend_marker; 32]).expect("spend key"); + let keypair = Keypair::from_secret_key(&secp, &spend_secret); + let (internal_key, _) = keypair.x_only_public_key(); + let blinding_secret = SecretKey::from_slice(&[blind_marker; 32]).expect("blinding key"); + let blinding_public_key = PublicKey::from_secret_key(&secp, &blinding_secret); + let script_pubkey = Script::new_v1_p2tr(&secp, internal_key, None); + Self { + keypair, + internal_key, + blinding_secret, + blinding_public_key, + script_pubkey, + } + } + + pub(super) fn recipient(&self) -> QuoteRecipient { + QuoteRecipient::new(self.script_pubkey.clone(), self.blinding_public_key) + .expect("fixture recipient") + } + + pub(super) fn confidential_output_spec( + &self, + id: OutputId, + asset: AssetId, + amount: u64, + blinder: BlinderRef, + ) -> OutputSpec { + OutputSpec::confidential( + id, + asset, + amount, + self.script_pubkey.clone(), + BitcoinPublicKey::new(self.blinding_public_key), + blinder, + ) + } + + pub(super) fn owned_utxo(&self, marker: u8, asset: AssetId, amount: u64) -> FixtureUtxo { + let explicit = TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey: self.script_pubkey.clone(), + witness: TxOutWitness::default(), + }; + let (txout, asset_bf, value_bf, _) = explicit + .to_non_last_confidential( + &mut thread_rng(), + &Secp256k1::new(), + self.blinding_public_key, + &[explicit_secrets(asset, amount)], + ) + .expect("synthetic confidential UTXO"); + FixtureUtxo { + outpoint: outpoint(marker), + txout, + secrets: TxOutSecrets::new(asset, asset_bf, amount, value_bf), + } + } + + pub(super) fn configure_input(&self, input: &mut PsetInput) { + input.sighash_type = Some(SchnorrSighashType::All.into()); + input.tap_internal_key = Some(self.internal_key); + } + + pub(super) fn sign_input( + &self, + pset: &mut PartiallySignedTransaction, + input_index: usize, + genesis_hash: BlockHash, + ) -> SchnorrSig { + let digest = pset_sighash(pset, input_index, genesis_hash); + let message = Message::from_digest(digest); + let tweaked = self.keypair.tap_tweak(&Secp256k1::new(), None); + let signature = SchnorrSig { + sig: Secp256k1::new().sign_schnorr(&message, &tweaked.to_inner()), + hash_ty: SchnorrSighashType::All, + }; + let input = &mut pset.inputs_mut()[input_index]; + input.tap_key_sig = Some(signature); + input.final_script_witness = Some(vec![signature.to_vec()]); + signature + } + + pub(super) fn unblind(&self, output: &TxOut) -> TxOutSecrets { + output + .unblind(&Secp256k1::new(), self.blinding_secret) + .expect("fixture wallet can unblind output") + } +} + +#[derive(Clone)] +pub(super) struct FixtureDestination { + pub(super) destination: ConfidentialDestination, + blinding_secret: SecretKey, +} + +impl FixtureDestination { + pub(super) fn deterministic(spend_marker: u8, blind_marker: u8, locator_marker: u8) -> Self { + let secp = Secp256k1::new(); + let spend_secret = SecretKey::from_slice(&[spend_marker; 32]).expect("spend key"); + let spend_keypair = Keypair::from_secret_key(&secp, &spend_secret); + let (internal_key, _) = spend_keypair.x_only_public_key(); + let blinding_secret = SecretKey::from_slice(&[blind_marker; 32]).expect("blinding key"); + let blinding_public_key = PublicKey::from_secret_key(&secp, &blinding_secret); + let destination = ConfidentialDestination::new( + Script::new_v1_p2tr(&secp, internal_key, None), + blinding_public_key, + internal_key, + WalletKeyLocator::new([locator_marker; 32]).expect("wallet locator"), + ) + .expect("fixture destination"); + Self { + destination, + blinding_secret, + } + } + + pub(super) fn unblind(&self, output: &TxOut) -> TxOutSecrets { + output + .unblind(&Secp256k1::new(), self.blinding_secret) + .expect("fixture destination can unblind output") + } +} + +pub(super) struct FixtureOutputRecovery { + wallet_keys: BTreeMap<[u8; 32], (XOnlyPublicKey, SecretKey)>, +} + +impl FixtureOutputRecovery { + fn new(destinations: &[&FixtureDestination]) -> Self { + let wallet_keys = destinations + .iter() + .map(|destination| { + ( + destination.destination.wallet_locator().to_bytes(), + ( + destination.destination.internal_key(), + destination.blinding_secret, + ), + ) + }) + .collect(); + Self { wallet_keys } + } +} + +impl ProviderOutputRecovery for FixtureOutputRecovery { + type Error = FixtureBackendError; + + fn validate_confidential_output( + &self, + wallet_locator: WalletKeyLocator, + expected_internal_key: XOnlyPublicKey, + txout: &TxOut, + expected_asset: AssetId, + expected_amount: u64, + ) -> Result<(), Self::Error> { + let (wallet_internal_key, secret) = self + .wallet_keys + .get(&wallet_locator.to_bytes()) + .ok_or(FixtureBackendError::UnknownDestination)?; + if *wallet_internal_key != expected_internal_key + || txout.script_pubkey + != Script::new_v1_p2tr(&Secp256k1::new(), expected_internal_key, None) + { + return Err(FixtureBackendError::OutputRecovery); + } + let opening = txout + .unblind(&Secp256k1::new(), *secret) + .map_err(|_| FixtureBackendError::OutputRecovery)?; + if opening.asset != expected_asset || opening.value != expected_amount { + return Err(FixtureBackendError::OutputRecovery); + } + Ok(()) + } +} + +#[derive(Clone)] +pub(super) struct FixtureUtxo { + pub(super) outpoint: OutPoint, + pub(super) txout: TxOut, + pub(super) secrets: TxOutSecrets, +} + +impl FixtureUtxo { + fn as_wallet_owned(&self, wallet: &FixtureWallet, locator_marker: u8) -> WalletOwnedOutput { + WalletOwnedOutput::new( + self.outpoint, + self.txout.clone(), + self.secrets, + wallet.internal_key, + WalletKeyLocator::new([locator_marker; 32]).expect("inventory locator"), + ) + .expect("wallet-owned inventory") + } +} + +pub(super) struct FixtureInventorySource { + snapshots: Mutex>, +} + +impl FixtureInventorySource { + fn new(snapshot: InventorySnapshot) -> Self { + Self { + snapshots: Mutex::new(VecDeque::from([snapshot])), + } + } +} + +impl InventorySource for FixtureInventorySource { + type Error = FixtureBackendError; + + fn inventory_snapshot(&self) -> Result { + self.snapshots + .lock() + .expect("inventory fixture lock") + .pop_front() + .ok_or(FixtureBackendError::Exhausted) + } +} + +pub(super) struct FixtureDestinationSource { + destinations: Mutex>, +} + +impl FixtureDestinationSource { + fn new(destinations: impl IntoIterator) -> Self { + Self { + destinations: Mutex::new(destinations.into_iter().collect()), + } + } +} + +impl DestinationSource for FixtureDestinationSource { + type Error = FixtureBackendError; + + fn fresh_confidential_destination( + &self, + _purpose: DestinationPurpose, + ) -> Result { + self.destinations + .lock() + .expect("destination fixture lock") + .pop_front() + .ok_or(FixtureBackendError::Exhausted) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FixtureLayout { + pub(super) provider_inputs: BTreeMap, + pub(super) quote_outputs: BTreeMap, + pub(super) taker_fee_input: usize, + pub(super) taker_payment_input: usize, + pub(super) taker_fee_change: usize, + pub(super) taker_payment_change: usize, + pub(super) fee_output: usize, +} + +impl FixtureLayout { + pub(super) fn provider_input(&self, id: QuoteInputId) -> usize { + self.provider_inputs[&id] + } + + pub(super) fn quote_output(&self, id: QuoteOutputId) -> usize { + self.quote_outputs[&id] + } + + pub(super) fn output_for_role(&self, quote: &FirmQuote, role: QuoteOutputRole) -> usize { + let id = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == role) + .expect("quoted output role") + .id(); + self.quote_output(id) + } + + pub(super) fn settlement_layout(&self) -> Result { + SettlementLayout::new( + self.taker_payment_input, + self.provider_inputs + .iter() + .map(|("e_input, &transaction_index)| { + SettlementInputPlacement::new(quote_input, transaction_index) + }) + .collect(), + self.quote_outputs + .iter() + .map(|("e_output, &transaction_index)| { + SettlementOutputPlacement::new(quote_output, transaction_index) + }) + .collect(), + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FixtureChainEntry { + pub(super) txout: TxOut, + pub(super) unspent: bool, +} + +/// Test representation of a coherent authoritative prevout lookup. +/// +/// The production settlement module is expected to define its own trait and can +/// implement it for this type inside `cfg(test)` once that trait's exact method +/// names are fixed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FixtureChainView { + pub(super) genesis_hash: BlockHash, + pub(super) entries: BTreeMap, +} + +impl FixtureChainView { + pub(super) fn entry(&self, outpoint: OutPoint) -> Option<&FixtureChainEntry> { + self.entries.get(&outpoint) + } + + pub(super) fn ordered_prevouts(&self, pset: &PartiallySignedTransaction) -> Option> { + pset.inputs() + .iter() + .map(|input| { + self.entries + .get(&input_outpoint(input)) + .filter(|entry| entry.unspent) + .map(|entry| entry.txout.clone()) + }) + .collect() + } +} + +impl SettlementChainSource for FixtureChainView { + type Error = FixtureBackendError; + + fn genesis_hash(&self) -> BlockHash { + self.genesis_hash + } + + fn unspent_prevouts( + &self, + outpoints: &[OutPoint], + ) -> Result, Self::Error> { + outpoints + .iter() + .map(|outpoint| { + let entry = self + .entries + .get(outpoint) + .filter(|entry| entry.unspent) + .ok_or(FixtureBackendError::MissingOrSpentPrevout)?; + Ok(AuthoritativePrevout::new(*outpoint, entry.txout.clone())) + }) + .collect() + } +} + +#[derive(Clone)] +pub(super) struct FixtureSubmission { + pub(super) pset: PartiallySignedTransaction, + pub(super) layout: FixtureLayout, + pub(super) chain: FixtureChainView, +} + +impl FixtureSubmission { + pub(super) fn canonical_pset_bytes(&self) -> Vec { + serialize(&self.pset) + } + + pub(super) fn transaction(&self) -> Transaction { + self.pset.extract_tx().expect("fixture transaction") + } +} + +pub(super) struct SettlementFixture { + _directory: TempDir, + pub(super) engine: FixtureQuoteEngine, + pub(super) identity: ProviderIdentity, + pub(super) owner: OwnerId, + pub(super) access: ReservationAccess, + pub(super) quote: FirmQuote, + pub(super) reservation: ReservationView, + pub(super) manifest: UnblindedStructureManifest, + pub(super) route_authorization: RouteAuthorization, + pub(super) baseline: FixtureSubmission, + pub(super) provider_inventory_wallet: FixtureWallet, + pub(super) provider_receive: FixtureDestination, + pub(super) provider_change: FixtureDestination, + pub(super) output_recovery: FixtureOutputRecovery, + pub(super) taker_wallet: FixtureWallet, + pub(super) fee_input: FixtureUtxo, + pub(super) payment_input: FixtureUtxo, + pub(super) inventory_input: FixtureUtxo, +} + +impl SettlementFixture { + pub(super) fn new() -> Self { + Self::with_fee_policy(1, 1_000_000) + } + + fn with_fee_policy(minimum_absolute_fee: u64, maximum_transaction_weight: u64) -> Self { + let directory = TempDir::new().expect("fixture directory"); + let identity = identity(20); + let owner = OwnerId::new([21; 32]); + let taker_wallet = FixtureWallet::deterministic(31, 32); + let provider_inventory_wallet = FixtureWallet::deterministic(41, 42); + let provider_receive = FixtureDestination::deterministic(51, 52, 53); + let provider_change = FixtureDestination::deterministic(54, 55, 56); + let output_recovery = FixtureOutputRecovery::new(&[&provider_receive, &provider_change]); + + let fee_input = taker_wallet.owned_utxo(61, identity.policy_asset(), TAKER_FEE_INPUT_VALUE); + let payment_input = + taker_wallet.owned_utxo(62, identity.policy_asset(), TAKER_PAYMENT_INPUT_VALUE); + let inventory_input = + provider_inventory_wallet.owned_utxo(63, asset(YES_MARKER), PROVIDER_INVENTORY_VALUE); + let wallet_owned_inventory = + inventory_input.as_wallet_owned(&provider_inventory_wallet, 64); + let snapshot = InventorySnapshot::new( + identity, + WalletScanAnchor::new(BlockHash::from_byte_array([65; 32]), 65), + vec![wallet_owned_inventory], + ) + .expect("inventory snapshot"); + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let inventory = InventoryCoordinator::new( + book, + FixtureInventorySource::new(snapshot), + InventoryFreshnessPolicy::new(10_000, 32).expect("inventory policy"), + ); + let destinations = FixtureDestinationSource::new([ + provider_receive.destination.clone(), + provider_change.destination.clone(), + ]); + let context = quote_context(identity); + let pricing = StaticRationalPricing::new( + vec![StaticRateRule::new( + context.market(), + identity.policy_asset(), + asset(YES_MARKER), + RationalRate::new(1, 1).expect("rate"), + )], + PricingRevision::new(1), + ) + .expect("pricing"); + let limits = PairLimits::new( + AmountRange::new(1, 10_000).expect("input range"), + AmountRange::new(1, 10_000).expect("output range"), + 8, + 0, + ) + .expect("pair limits"); + let market = MarketQuoteConfig::new( + context, + BinaryMarketAssets::new(identity.policy_asset(), asset(YES_MARKER), asset(NO_MARKER)) + .expect("market assets"), + vec![PairRule::new( + identity.policy_asset(), + asset(YES_MARKER), + limits, + )], + ) + .expect("market configuration"); + let engine = QuoteEngine::new( + inventory, + destinations, + pricing, + vec![market], + QuoteEnginePolicy::new( + 30_000, + 4, + 32, + fee_policy(identity, minimum_absolute_fee, maximum_transaction_weight), + ) + .expect("quote policy"), + ) + .expect("quote engine"); + engine + .inventory() + .refresh(&UnixMillis::new(100)) + .expect("inventory refresh"); + let request = FirmQuoteRequest::new( + context, + QuoteKind::ExactIn { + input: AssetAmount::new(identity.policy_asset(), QUOTED_PAYMENT_VALUE) + .expect("quote input"), + output_asset: asset(YES_MARKER), + minimum_output: QUOTED_RECEIVE_VALUE, + }, + taker_wallet.recipient(), + 0, + ) + .expect("quote request"); + let outcome = engine + .firm_quote( + owner, + IdempotencyKey::new([22; 32]), + request.clone(), + "E_TIME, + ) + .expect("firm quote"); + let quote = outcome.quote().clone(); + let reservation = outcome.reservation().clone(); + let access = ReservationAccess::new(reservation.id(), owner); + + let client_recipient = ClientRecipient::new( + request.recipient().script_pubkey().clone(), + BitcoinPublicKey::new(request.recipient().blinding_public_key()), + ) + .expect("client recipient"); + let client_request = ClientExecutionRequest::exact_in( + VenueContext { + chain: context.chain(), + market: context.market(), + policy_asset: identity.policy_asset(), + }, + ClientAssetAmount::new(identity.policy_asset(), QUOTED_PAYMENT_VALUE) + .expect("client input"), + asset(YES_MARKER), + QUOTED_RECEIVE_VALUE, + client_recipient, + BTreeMap::new(), + NETWORK_FEE, + ) + .expect("client request"); + let leg_request = client_request + .exact_in_leg(LegId::new(1), QUOTED_PAYMENT_VALUE, payment_input.outpoint) + .expect("leg allocation"); + let proposal = client_proposal(&leg_request, "e); + let leg = leg_request + .authorize(proposal) + .expect("client-authorized quote"); + let route = client_request + .validate_route( + vec![leg], + NetworkFee::new(identity.policy_asset(), NETWORK_FEE).expect("network fee"), + ) + .expect("validated route"); + let wallet_contribution = TransactionContribution::new( + vec![ + InputSpec::new( + WALLET_FEE_INPUT_ID, + fee_input.outpoint, + fee_input.txout.clone(), + InputSequence::Final, + ), + InputSpec::new( + WALLET_PAYMENT_INPUT_ID, + payment_input.outpoint, + payment_input.txout.clone(), + InputSequence::Final, + ), + ], + vec![ + taker_wallet.confidential_output_spec( + WALLET_FEE_CHANGE_ID, + identity.policy_asset(), + TAKER_FEE_INPUT_VALUE - NETWORK_FEE, + BlinderRef::Local(WALLET_FEE_INPUT_ID), + ), + taker_wallet.confidential_output_spec( + WALLET_PAYMENT_CHANGE_ID, + identity.policy_asset(), + TAKER_PAYMENT_INPUT_VALUE - QUOTED_PAYMENT_VALUE, + BlinderRef::Local(WALLET_PAYMENT_INPUT_ID), + ), + ], + LockTimeConstraint::Unconstrained, + ); + let composed_route = route + .compose(CompositionLimits::default(), wallet_contribution) + .expect("route composition"); + let wallet_handle = composed_route.layout().wallet(); + let venue_handle = composed_route + .layout() + .leg(LegId::new(1)) + .expect("venue handle"); + let (composed, route_authorization) = composed_route.into_parts(); + let layout = FixtureLayout { + provider_inputs: quote + .contribution() + .inputs() + .iter() + .map(|input| { + let index = composed + .layout() + .input_index(venue_handle, InputId::new(u64::from(input.id().value()))) + .expect("provider input placement"); + (input.id(), index) + }) + .collect(), + quote_outputs: quote + .contribution() + .outputs() + .iter() + .map(|output| { + let index = composed + .layout() + .output_index(venue_handle, OutputId::new(u64::from(output.id().value()))) + .expect("quote output placement"); + (output.id(), index) + }) + .collect(), + taker_fee_input: composed + .layout() + .input_index(wallet_handle, WALLET_FEE_INPUT_ID) + .expect("fee input placement"), + taker_payment_input: composed + .layout() + .input_index(wallet_handle, WALLET_PAYMENT_INPUT_ID) + .expect("payment input placement"), + taker_fee_change: composed + .layout() + .output_index(wallet_handle, WALLET_FEE_CHANGE_ID) + .expect("fee change placement"), + taker_payment_change: composed + .layout() + .output_index(wallet_handle, WALLET_PAYMENT_CHANGE_ID) + .expect("payment change placement"), + fee_output: composed.layout().fee_output_index(), + }; + let (mut pset, _, manifest) = composed.into_parts(); + taker_wallet.configure_input(&mut pset.inputs_mut()[layout.taker_fee_input]); + taker_wallet.configure_input(&mut pset.inputs_mut()[layout.taker_payment_input]); + for input in quote.contribution().inputs() { + let index = layout.provider_input(input.id()); + provider_inventory_wallet.configure_input(&mut pset.inputs_mut()[index]); + } + manifest + .validate(&pset) + .expect("configured PSET preserves manifest"); + + let mut provider_secrets = HashMap::new(); + provider_secrets.insert( + layout.provider_input(quote.contribution().inputs()[0].id()), + inventory_input.secrets, + ); + pset.blind_non_last(&mut thread_rng(), &Secp256k1::new(), &provider_secrets) + .expect("provider non-last blinding"); + pset = deserialize(&serialize(&pset)).expect("provider PSET handoff"); + let mut taker_secrets = HashMap::new(); + taker_secrets.insert(layout.taker_fee_input, fee_input.secrets); + taker_secrets.insert(layout.taker_payment_input, payment_input.secrets); + pset.blind_last(&mut thread_rng(), &Secp256k1::new(), &taker_secrets) + .expect("taker final blinding"); + taker_wallet.sign_input(&mut pset, layout.taker_fee_input, identity.genesis_hash()); + taker_wallet.sign_input( + &mut pset, + layout.taker_payment_input, + identity.genesis_hash(), + ); + pset = deserialize(&serialize(&pset)).expect("taker-signed PSET handoff"); + + // Build authority from the wallet-discovered outputs, not from the + // submitter-controlled PSET. In particular, these copies retain the + // original input witnesses that PSET serializes into separate fields. + let entries = [&fee_input, &payment_input, &inventory_input] + .into_iter() + .map(|input| { + ( + input.outpoint, + FixtureChainEntry { + txout: input.txout.clone(), + unspent: true, + }, + ) + }) + .collect(); + let baseline = FixtureSubmission { + pset, + layout, + chain: FixtureChainView { + genesis_hash: identity.genesis_hash(), + entries, + }, + }; + let fixture = Self { + _directory: directory, + engine, + identity, + owner, + access, + quote, + reservation, + manifest, + route_authorization, + baseline, + provider_inventory_wallet, + provider_receive, + provider_change, + output_recovery, + taker_wallet, + fee_input, + payment_input, + inventory_input, + }; + fixture.assert_baseline(); + fixture + } + + pub(super) fn book(&self) -> &ReservationBook { + self.engine.inventory().reservation_book() + } + + pub(super) fn submission(&self) -> FixtureSubmission { + self.baseline.clone() + } + + pub(super) fn mutated(&self, mutation: FixtureMutation) -> FixtureSubmission { + let mut submission = self.submission(); + mutation.apply(&mut submission, self); + if mutation.resign_taker_after_mutation() { + self.resign_taker_inputs(&mut submission.pset, self.identity.genesis_hash()); + } + submission + } + + fn resign_taker_inputs(&self, pset: &mut PartiallySignedTransaction, genesis_hash: BlockHash) { + for index in [ + self.baseline.layout.taker_fee_input, + self.baseline.layout.taker_payment_input, + ] { + let input = &mut pset.inputs_mut()[index]; + input.tap_key_sig = None; + input.final_script_witness = None; + } + self.taker_wallet + .sign_input(pset, self.baseline.layout.taker_fee_input, genesis_hash); + self.taker_wallet + .sign_input(pset, self.baseline.layout.taker_payment_input, genesis_hash); + } + + fn assert_baseline(&self) { + self.manifest + .validate(&self.baseline.pset) + .expect("signed baseline preserves manifest"); + let prevouts = self + .baseline + .chain + .ordered_prevouts(&self.baseline.pset) + .expect("authoritative baseline prevouts"); + let transaction = self.baseline.transaction(); + transaction + .verify_tx_amt_proofs(&Secp256k1::new(), &prevouts) + .expect("baseline confidential proofs and balance"); + + for output in self.baseline.pset.outputs() { + if output.blinding_key.is_some() { + assert_output_disclosure(output); + } + } + let provider_payment = self + .baseline + .layout + .output_for_role(&self.quote, QuoteOutputRole::ProviderPayment); + let provider_change = self + .baseline + .layout + .output_for_role(&self.quote, QuoteOutputRole::ProviderChange); + let taker_receive = self + .baseline + .layout + .output_for_role(&self.quote, QuoteOutputRole::TakerReceive); + let payment_opening = self + .provider_receive + .unblind(&transaction.output[provider_payment]); + assert_eq!(payment_opening.asset, self.identity.policy_asset()); + assert_eq!(payment_opening.value, QUOTED_PAYMENT_VALUE); + let change_opening = self + .provider_change + .unblind(&transaction.output[provider_change]); + assert_eq!(change_opening.asset, asset(YES_MARKER)); + assert_eq!( + change_opening.value, + PROVIDER_INVENTORY_VALUE - QUOTED_RECEIVE_VALUE + ); + let taker_opening = self + .taker_wallet + .unblind(&transaction.output[taker_receive]); + assert_eq!(taker_opening.asset, asset(YES_MARKER)); + assert_eq!(taker_opening.value, QUOTED_RECEIVE_VALUE); + assert_eq!( + transaction.fee_in(self.identity.policy_asset()), + NETWORK_FEE + ); + } +} + +/// Independent one-field mutations used by fail-closed validator tests. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FixtureMutation { + WrongPsetVersion, + WrongTransactionVersion, + TransactionModifiable, + NonzeroLocktime, + ProviderInputMappingOutOfRange, + AliasProviderInputMapping, + AliasProviderPaymentAndReceive, + RemoveProviderInput, + DuplicateProviderOutpoint, + WrongProviderWitnessUtxo, + UnexpectedInputDisclosure, + NonFinalProviderSequence, + ProviderAlreadySigned, + MissingTakerWitness, + WrongTakerSighash, + IssuanceMetadata, + PeginMetadata, + RemoveQuotedOutput, + SwapProviderPaymentAndReceive, + WrongProviderPaymentScript, + WrongProviderPaymentDisclosure, + MissingBlindValueProof, + MissingRangeproof, + MissingSurjectionProof, + MissingProviderNonce, + WrongProviderNonce, + WrongFeeAmount, + WrongFeeAsset, + ExtraFeeOutput, + MissingAuthoritativePrevout, + SpentProviderPrevout, + WrongAuthoritativePrevout, + WrongGenesis, +} + +impl FixtureMutation { + fn resign_taker_after_mutation(self) -> bool { + matches!( + self, + Self::WrongTransactionVersion + | Self::NonzeroLocktime + | Self::DuplicateProviderOutpoint + | Self::WrongProviderWitnessUtxo + | Self::NonFinalProviderSequence + | Self::RemoveQuotedOutput + | Self::SwapProviderPaymentAndReceive + | Self::WrongProviderPaymentScript + | Self::MissingRangeproof + | Self::MissingSurjectionProof + | Self::MissingProviderNonce + | Self::WrongProviderNonce + | Self::WrongFeeAmount + | Self::WrongFeeAsset + | Self::ExtraFeeOutput + ) + } + + fn apply(self, submission: &mut FixtureSubmission, fixture: &SettlementFixture) { + let first_provider_id = fixture.quote.contribution().inputs()[0].id(); + let provider_input = submission.layout.provider_input(first_provider_id); + let provider_payment = submission + .layout + .output_for_role(&fixture.quote, QuoteOutputRole::ProviderPayment); + let taker_receive = submission + .layout + .output_for_role(&fixture.quote, QuoteOutputRole::TakerReceive); + let provider_change = submission + .layout + .output_for_role(&fixture.quote, QuoteOutputRole::ProviderChange); + match self { + Self::WrongPsetVersion => submission.pset.global.version = 0, + Self::WrongTransactionVersion => submission.pset.global.tx_data.version = 3, + Self::TransactionModifiable => submission.pset.global.tx_data.tx_modifiable = Some(1), + Self::NonzeroLocktime => { + submission.pset.global.tx_data.fallback_locktime = + Some(LockTime::from_consensus(1)); + } + Self::ProviderInputMappingOutOfRange => { + submission + .layout + .provider_inputs + .insert(first_provider_id, submission.pset.inputs().len()); + } + Self::AliasProviderInputMapping => { + submission + .layout + .provider_inputs + .insert(first_provider_id, submission.layout.taker_payment_input); + } + Self::AliasProviderPaymentAndReceive => { + let receive_id = fixture + .quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::TakerReceive) + .expect("receive output") + .id(); + submission + .layout + .quote_outputs + .insert(receive_id, provider_payment); + } + Self::RemoveProviderInput => { + submission.pset.remove_input(provider_input); + } + Self::DuplicateProviderOutpoint => { + let duplicate = input_outpoint( + &submission.pset.inputs()[submission.layout.taker_payment_input], + ); + let input = &mut submission.pset.inputs_mut()[provider_input]; + input.previous_txid = duplicate.txid; + input.previous_output_index = duplicate.vout; + } + Self::WrongProviderWitnessUtxo => { + let wrong_prevout = submission.pset.inputs()[submission.layout.taker_payment_input] + .witness_utxo + .clone() + .expect("payment prevout"); + submission.pset.inputs_mut()[provider_input].witness_utxo = Some(wrong_prevout); + } + Self::UnexpectedInputDisclosure => { + submission.pset.inputs_mut()[provider_input].amount = + Some(PROVIDER_INVENTORY_VALUE); + } + Self::NonFinalProviderSequence => { + submission.pset.inputs_mut()[provider_input].sequence = Some(Sequence::ZERO); + } + Self::ProviderAlreadySigned => { + fixture.provider_inventory_wallet.sign_input( + &mut submission.pset, + provider_input, + fixture.identity.genesis_hash(), + ); + } + Self::MissingTakerWitness => { + let input = + &mut submission.pset.inputs_mut()[submission.layout.taker_payment_input]; + input.tap_key_sig = None; + input.final_script_witness = None; + } + Self::WrongTakerSighash => { + let input = + &mut submission.pset.inputs_mut()[submission.layout.taker_payment_input]; + let mut signature = input.tap_key_sig.expect("taker signature"); + signature.hash_ty = SchnorrSighashType::Single; + input.tap_key_sig = Some(signature); + input.final_script_witness = Some(vec![signature.to_vec()]); + } + Self::IssuanceMetadata => { + submission.pset.inputs_mut()[provider_input].issuance_value_amount = Some(1); + } + Self::PeginMetadata => { + submission.pset.inputs_mut()[provider_input].pegin_value = Some(1); + } + Self::RemoveQuotedOutput => { + submission.pset.remove_output(provider_payment); + } + Self::SwapProviderPaymentAndReceive => { + let payment = submission.pset.outputs()[provider_payment].clone(); + let receive = submission.pset.outputs()[taker_receive].clone(); + submission.pset.outputs_mut()[provider_payment] = receive; + submission.pset.outputs_mut()[taker_receive] = payment; + } + Self::WrongProviderPaymentScript => { + submission.pset.outputs_mut()[provider_payment].script_pubkey = Script::new(); + } + Self::WrongProviderPaymentDisclosure => { + submission.pset.outputs_mut()[provider_payment].amount = + Some(QUOTED_PAYMENT_VALUE + 1); + } + Self::MissingBlindValueProof => { + submission.pset.outputs_mut()[provider_payment].blind_value_proof = None; + } + Self::MissingRangeproof => { + submission.pset.outputs_mut()[taker_receive].value_rangeproof = None; + } + Self::MissingSurjectionProof => { + submission.pset.outputs_mut()[taker_receive].asset_surjection_proof = None; + } + Self::MissingProviderNonce => { + submission.pset.outputs_mut()[provider_change].ecdh_pubkey = None; + } + Self::WrongProviderNonce => { + let wrong_nonce = submission.pset.outputs()[taker_receive] + .ecdh_pubkey + .expect("taker receive nonce"); + submission.pset.outputs_mut()[provider_change].ecdh_pubkey = Some(wrong_nonce); + } + Self::WrongFeeAmount => { + submission.pset.outputs_mut()[submission.layout.fee_output].amount = + Some(NETWORK_FEE - 1); + } + Self::WrongFeeAsset => { + submission.pset.outputs_mut()[submission.layout.fee_output].asset = + Some(asset(YES_MARKER)); + } + Self::ExtraFeeOutput => { + submission + .pset + .add_output(PsetOutput::from_txout(TxOut::new_fee( + 1, + fixture.identity.policy_asset(), + ))) + } + Self::MissingAuthoritativePrevout => { + let outpoint = input_outpoint(&submission.pset.inputs()[provider_input]); + submission.chain.entries.remove(&outpoint); + } + Self::SpentProviderPrevout => { + let outpoint = input_outpoint(&submission.pset.inputs()[provider_input]); + submission + .chain + .entries + .get_mut(&outpoint) + .expect("provider chain entry") + .unspent = false; + } + Self::WrongAuthoritativePrevout => { + let outpoint = input_outpoint(&submission.pset.inputs()[provider_input]); + submission + .chain + .entries + .get_mut(&outpoint) + .expect("provider chain entry") + .txout + .script_pubkey = Script::from(vec![0x51]); + } + Self::WrongGenesis => { + let wrong_genesis = BlockHash::from_byte_array([99; 32]); + fixture.resign_taker_inputs(&mut submission.pset, wrong_genesis); + submission.chain.genesis_hash = wrong_genesis; + } + } + } +} + +fn client_proposal(request: &LegPreparationRequest, quote: &FirmQuote) -> ProposedLeg { + let inputs = quote + .contribution() + .inputs() + .iter() + .map(|input| { + InputSpec::new( + InputId::new(u64::from(input.id().value())), + input.outpoint(), + input.witness_utxo().clone(), + InputSequence::Final, + ) + }) + .collect(); + let outputs = quote + .contribution() + .outputs() + .iter() + .map(|output| { + let blinder = match output.blinder() { + QuoteBlinderRole::TakerPaymentInput => { + BlinderRef::External(request.payer_blinder()) + } + QuoteBlinderRole::ProviderInput(id) => { + BlinderRef::Local(InputId::new(u64::from(id.value()))) + } + }; + OutputSpec::confidential( + OutputId::new(u64::from(output.id().value())), + output.asset(), + output.amount(), + output.destination().script_pubkey().clone(), + BitcoinPublicKey::new(output.destination().blinding_public_key()), + blinder, + ) + }) + .collect(); + let execution = quote.execution(); + let mut fees = BTreeMap::new(); + if execution.input_asset_venue_fee() != 0 { + fees.insert(execution.input().asset(), execution.input_asset_venue_fee()); + } + let payment = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::ProviderPayment) + .expect("payment output"); + let receive = quote + .contribution() + .outputs() + .iter() + .find(|output| output.role() == QuoteOutputRole::TakerReceive) + .expect("receive output"); + ProposedLeg::new( + ClientExactExecution::new( + ClientAssetAmount::new(execution.input().asset(), execution.input().amount()) + .expect("client input"), + ClientAssetAmount::new(execution.output().asset(), execution.output().amount()) + .expect("client output"), + ) + .expect("client execution"), + fees, + TransactionContribution::new(inputs, outputs, LockTimeConstraint::Unconstrained), + OutputId::new(u64::from(payment.id().value())), + OutputId::new(u64::from(receive.id().value())), + ) + .expect("client proposal") +} + +fn fee_policy( + identity: ProviderIdentity, + minimum_absolute_fee: u64, + maximum_transaction_weight: u64, +) -> FeePolicy { + FeePolicy::new( + identity.policy_asset(), + 1, + minimum_absolute_fee, + maximum_transaction_weight, + FeeSizeMetric::DiscountVbytes, + ) + .expect("fee policy") +} + +fn identity(marker: u8) -> ProviderIdentity { + ProviderIdentity::new( + ProviderId::new([marker; 32]), + BlockHash::from_byte_array([marker.wrapping_add(1); 32]), + asset(POLICY_MARKER), + ) +} + +fn quote_context(identity: ProviderIdentity) -> QuoteContext { + QuoteContext::new( + ChainIdentity { + network: LiquidNetwork::ElementsRegtest, + genesis_hash: identity.genesis_hash(), + }, + ContractId::new(outpoint(90)), + identity.policy_asset(), + ) +} + +fn asset(marker: u8) -> AssetId { + AssetId::from_byte_array([marker; 32]) +} + +fn outpoint(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), u32::from(marker)) +} + +fn explicit_secrets(asset: AssetId, value: u64) -> TxOutSecrets { + TxOutSecrets::new( + asset, + AssetBlindingFactor::zero(), + value, + ValueBlindingFactor::zero(), + ) +} + +fn input_outpoint(input: &PsetInput) -> OutPoint { + OutPoint::new(input.previous_txid, input.previous_output_index) +} + +fn pset_sighash( + pset: &PartiallySignedTransaction, + input_index: usize, + genesis_hash: BlockHash, +) -> [u8; 32] { + let transaction = pset.extract_tx().expect("transaction for sighash"); + let prevouts = pset + .inputs() + .iter() + .map(|input| input.witness_utxo.clone().expect("PSET prevout")) + .collect::>(); + SighashCache::new(&transaction) + .taproot_key_spend_signature_hash( + input_index, + &Prevouts::All(&prevouts), + SchnorrSighashType::All, + genesis_hash, + ) + .expect("Taproot sighash") + .to_byte_array() +} + +fn assert_output_disclosure(output: &PsetOutput) { + let secp = Secp256k1::new(); + let asset = output.asset.expect("disclosed asset"); + let amount = output.amount.expect("disclosed amount"); + let asset_commitment = output.asset_comm.expect("asset commitment"); + let value_commitment = output.amount_comm.expect("value commitment"); + assert!( + output + .blind_asset_proof + .as_deref() + .expect("asset disclosure proof") + .blind_asset_proof_verify(&secp, asset, asset_commitment) + ); + assert!( + output + .blind_value_proof + .as_deref() + .expect("value disclosure proof") + .blind_value_proof_verify(&secp, amount, asset_commitment, value_commitment,) + ); +} + +#[test] +fn baseline_final_pset_validates_and_commits_the_exact_canonical_payload() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + assert_eq!(fixture.reservation.owner(), fixture.owner); + assert_eq!(fixture.route_authorization.legs().len(), 1); + for utxo in [ + &fixture.fee_input, + &fixture.payment_input, + &fixture.inventory_input, + ] { + let authority = submission + .chain + .entry(utxo.outpoint) + .expect("fixture authority contains every composed input"); + assert!(authority.unspent); + assert_eq!(authority.txout, utxo.txout); + } + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let canonical = submission.canonical_pset_bytes(); + let validator = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ); + let intent = validator + .validate(fixture.access, &layout, &canonical) + .expect("valid final PSET"); + assert_eq!(intent.reservation_id(), fixture.reservation.id()); + assert_eq!(intent.canonical_pset(), canonical); + assert_eq!(intent.fee().policy_asset(), fixture.identity.policy_asset()); + assert_eq!(intent.fee().amount(), NETWORK_FEE); + assert!(intent.fee().weight() > 0); + assert!(intent.fee().regular_vsize() >= intent.fee().discount_vsize()); + let mut projected = submission.transaction(); + for index in submission.layout.provider_inputs.values().copied() { + projected.input[index].witness.script_witness = vec![vec![0_u8; 65]]; + } + assert_eq!( + intent.fee().weight(), + u64::try_from(projected.weight()).expect("fixture weight") + ); + assert_eq!( + intent.fee().regular_vsize(), + u64::try_from(projected.vsize()).expect("fixture vsize") + ); + assert_eq!( + intent.fee().discount_vsize(), + u64::try_from(projected.discount_vsize()).expect("fixture discount vsize") + ); + + let committed = intent + .commit(fixture.book(), &VALIDATION_TIME) + .expect("durable signing commit"); + let CommitOutcome::NewlyCommitted(job) = committed else { + panic!("first commit must create a durable signing job"); + }; + assert_eq!(job.pre_sign_payload(), canonical); + assert_eq!(job.fee().amount(), NETWORK_FEE); + assert_eq!( + job.targets().len(), + fixture.quote.contribution().inputs().len() + ); +} + +#[test] +fn cancellation_after_validation_wins_before_the_point_of_no_return() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let intent = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ) + .validate(fixture.access, &layout, &submission.canonical_pset_bytes()) + .expect("valid final PSET"); + assert!( + fixture + .book() + .cancel(fixture.access, &VALIDATION_TIME) + .expect("uncommitted reservation remains cancellable") + ); + assert!(matches!( + intent.commit(fixture.book(), &UnixMillis::new(103)), + Err(crate::store::ProviderError::ReservationAlreadyReleased(_)) + )); + assert!(matches!( + fixture + .book() + .reservation(fixture.reservation.id()) + .expect("reservation lookup") + .expect("reservation") + .state(), + ReservationState::Released { + reason: ReleaseReason::ClientCancelled, + .. + } + )); + assert!(matches!( + fixture + .book() + .inventory(fixture.inventory_input.outpoint) + .expect("inventory lookup") + .expect("inventory") + .state(), + InventoryState::Available + )); +} + +#[test] +fn validated_intent_is_bound_to_its_provider_book() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let intent = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ) + .validate(fixture.access, &layout, &submission.canonical_pset_bytes()) + .expect("valid final PSET"); + let other_directory = TempDir::new().expect("other provider directory"); + let other_book = + ReservationBook::open(other_directory.path().join("provider.redb"), identity(70)) + .expect("other provider book"); + + assert!(matches!( + intent.commit(&other_book, &VALIDATION_TIME), + Err(crate::store::ProviderError::ValidatedIntentBindingMismatch( + _ + )) + )); + assert!(matches!( + fixture + .book() + .inventory(fixture.inventory_input.outpoint) + .expect("inventory lookup") + .expect("inventory") + .state(), + InventoryState::Reserved { .. } + )); +} + +#[test] +fn durable_commit_rechecks_the_exclusive_quote_deadline() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let intent = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ) + .validate(fixture.access, &layout, &submission.canonical_pset_bytes()) + .expect("valid before deadline"); + + let error = intent + .commit(fixture.book(), &fixture.reservation.accept_before()) + .expect_err("commit at the exclusive deadline must expire"); + assert!(matches!( + error, + crate::store::ProviderError::ReservationDeadlineElapsed { .. } + )); + assert!(matches!( + fixture + .book() + .inventory(fixture.inventory_input.outpoint) + .expect("inventory lookup") + .expect("inventory") + .state(), + InventoryState::Available + )); +} + +#[test] +fn malformed_unbounded_and_unauthorized_submissions_fail_before_commitment() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let validator = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ); + assert!(matches!( + validator.validate(fixture.access, &layout, &[]), + Err(SettlementValidationError::EmptyPayload) + )); + assert!(matches!( + validator.validate(fixture.access, &layout, &[0_u8]), + Err(SettlementValidationError::InvalidPset(_)) + )); + assert!(matches!( + validator.validate( + fixture.access, + &layout, + &vec![0_u8; crate::model::MAX_SETTLEMENT_BYTES + 1], + ), + Err(SettlementValidationError::PayloadTooLarge { .. }) + )); + let wrong_owner = ReservationAccess::new(fixture.reservation.id(), OwnerId::new([0x99; 32])); + assert!(matches!( + validator.validate(wrong_owner, &layout, &submission.canonical_pset_bytes(),), + Err(SettlementValidationError::Provider( + crate::store::ProviderError::ReservationOwnerMismatch(_) + )) + )); + assert!(matches!( + fixture + .book() + .inventory(fixture.inventory_input.outpoint) + .expect("inventory lookup") + .expect("inventory") + .state(), + InventoryState::Reserved { .. } + )); +} + +#[test] +fn validator_enforces_fee_policy_on_an_otherwise_balanced_transaction() { + let underfee = SettlementFixture::with_fee_policy(NETWORK_FEE + 1, 1_000_000); + let submission = underfee.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let error = ProviderSettlementValidator::new( + underfee.book(), + &submission.chain, + &underfee.output_recovery, + ) + .validate(underfee.access, &layout, &submission.canonical_pset_bytes()) + .expect_err("absolute fee floor must be enforced"); + assert!(matches!( + error, + SettlementValidationError::FeePolicy( + crate::model::FeePolicyViolation::FeeBelowMinimum { + required, + actual: NETWORK_FEE, + } + ) if required == NETWORK_FEE + 1 + )); + + let overweight = SettlementFixture::with_fee_policy(1, 1); + let submission = overweight.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let error = ProviderSettlementValidator::new( + overweight.book(), + &submission.chain, + &overweight.output_recovery, + ) + .validate( + overweight.access, + &layout, + &submission.canonical_pset_bytes(), + ) + .expect_err("weight ceiling must be enforced"); + assert!(matches!( + error, + SettlementValidationError::FeePolicy( + crate::model::FeePolicyViolation::TransactionOverweight { + maximum: 1, + actual, + } + ) if actual > 1 + )); +} + +#[test] +fn provider_output_recovery_must_resolve_the_durable_spend_key() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let mut wrong_recovery = + FixtureOutputRecovery::new(&[&fixture.provider_receive, &fixture.provider_change]); + let receive_locator = fixture + .provider_receive + .destination + .wallet_locator() + .to_bytes(); + wrong_recovery + .wallet_keys + .get_mut(&receive_locator) + .expect("provider receive recovery") + .0 = fixture.provider_change.destination.internal_key(); + + assert!(matches!( + ProviderSettlementValidator::new(fixture.book(), &submission.chain, &wrong_recovery) + .validate(fixture.access, &layout, &submission.canonical_pset_bytes(),), + Err(SettlementValidationError::OutputRecovery { + role: QuoteOutputRole::ProviderPayment, + .. + }) + )); +} + +#[test] +fn exact_committed_retry_uses_durable_state_before_live_chain_revalidation() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let canonical = submission.canonical_pset_bytes(); + let validator = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ); + validator + .validate(fixture.access, &layout, &canonical) + .expect("initial validation") + .commit(fixture.book(), &VALIDATION_TIME) + .expect("initial commit"); + + let spent = fixture.mutated(FixtureMutation::SpentProviderPrevout); + let retry_validator = + ProviderSettlementValidator::new(fixture.book(), &spent.chain, &fixture.output_recovery); + let retry = retry_validator + .validate(fixture.access, &layout, &canonical) + .expect("exact committed replay"); + let committed = retry + .commit(fixture.book(), &UnixMillis::new(103)) + .expect("idempotent commit replay"); + assert!(matches!(committed, CommitOutcome::AlreadyCommitted(_))); +} + +#[test] +fn exact_signed_retry_uses_durable_state_before_live_chain_revalidation() { + let fixture = SettlementFixture::new(); + let submission = fixture.submission(); + let layout = submission + .layout + .settlement_layout() + .expect("settlement layout"); + let canonical = submission.canonical_pset_bytes(); + let committed = ProviderSettlementValidator::new( + fixture.book(), + &submission.chain, + &fixture.output_recovery, + ) + .validate(fixture.access, &layout, &canonical) + .expect("initial validation") + .commit(fixture.book(), &VALIDATION_TIME) + .expect("initial commit"); + let job = committed.signing_job().expect("committed signing job"); + let signed_bytes = vec![0x51_u8, 0x21, 0x02]; + fixture + .book() + .record_signed( + fixture.reservation.id(), + job.commitment(), + signed_bytes.clone(), + &UnixMillis::new(103), + ) + .expect("persist signed artifact"); + + let spent = fixture.mutated(FixtureMutation::SpentProviderPrevout); + let replay = + ProviderSettlementValidator::new(fixture.book(), &spent.chain, &fixture.output_recovery) + .validate(fixture.access, &layout, &canonical) + .expect("exact signed replay") + .commit(fixture.book(), &UnixMillis::new(104)) + .expect("signed replay commit"); + let CommitOutcome::AlreadySigned(artifact) = replay else { + panic!("signed replay must return the durable artifact"); + }; + assert_eq!(artifact.bytes(), signed_bytes); +} + +#[test] +fn a_different_payload_is_rejected_after_the_point_of_no_return() { + let fixture = SettlementFixture::new(); + let baseline = fixture.submission(); + let layout = baseline + .layout + .settlement_layout() + .expect("settlement layout"); + let validator = + ProviderSettlementValidator::new(fixture.book(), &baseline.chain, &fixture.output_recovery); + validator + .validate(fixture.access, &layout, &baseline.canonical_pset_bytes()) + .expect("initial validation") + .commit(fixture.book(), &VALIDATION_TIME) + .expect("initial commit"); + + let different = fixture.mutated(FixtureMutation::WrongFeeAmount); + let retry_validator = ProviderSettlementValidator::new( + fixture.book(), + &different.chain, + &fixture.output_recovery, + ); + assert!( + retry_validator + .validate(fixture.access, &layout, &different.canonical_pset_bytes(),) + .is_err() + ); +} + +#[test] +fn settlement_layout_rejects_input_and_output_aliasing_before_validation() { + let fixture = SettlementFixture::new(); + let aliased_input = fixture.mutated(FixtureMutation::AliasProviderInputMapping); + assert!(matches!( + aliased_input.layout.settlement_layout(), + Err(SettlementLayoutError::AliasedInput(_)) + )); + let aliased_output = fixture.mutated(FixtureMutation::AliasProviderPaymentAndReceive); + assert!(matches!( + aliased_output.layout.settlement_layout(), + Err(SettlementLayoutError::AliasedOutput(_)) + )); +} + +#[test] +fn submitted_pset_and_authority_mutations_fail_closed() { + let fixture = SettlementFixture::new(); + let mutations = [ + FixtureMutation::WrongPsetVersion, + FixtureMutation::WrongTransactionVersion, + FixtureMutation::TransactionModifiable, + FixtureMutation::NonzeroLocktime, + FixtureMutation::ProviderInputMappingOutOfRange, + FixtureMutation::RemoveProviderInput, + FixtureMutation::DuplicateProviderOutpoint, + FixtureMutation::WrongProviderWitnessUtxo, + FixtureMutation::UnexpectedInputDisclosure, + FixtureMutation::NonFinalProviderSequence, + FixtureMutation::ProviderAlreadySigned, + FixtureMutation::MissingTakerWitness, + FixtureMutation::WrongTakerSighash, + FixtureMutation::IssuanceMetadata, + FixtureMutation::PeginMetadata, + FixtureMutation::RemoveQuotedOutput, + FixtureMutation::SwapProviderPaymentAndReceive, + FixtureMutation::WrongProviderPaymentScript, + FixtureMutation::WrongProviderPaymentDisclosure, + FixtureMutation::MissingBlindValueProof, + FixtureMutation::MissingRangeproof, + FixtureMutation::MissingSurjectionProof, + FixtureMutation::MissingProviderNonce, + FixtureMutation::WrongProviderNonce, + FixtureMutation::WrongFeeAmount, + FixtureMutation::WrongFeeAsset, + FixtureMutation::ExtraFeeOutput, + FixtureMutation::MissingAuthoritativePrevout, + FixtureMutation::SpentProviderPrevout, + FixtureMutation::WrongAuthoritativePrevout, + FixtureMutation::WrongGenesis, + ]; + + for mutation in mutations { + let submitted = fixture.mutated(mutation); + let layout = submitted + .layout + .settlement_layout() + .expect("non-alias mutation keeps a constructible layout"); + let validator = ProviderSettlementValidator::new( + fixture.book(), + &submitted.chain, + &fixture.output_recovery, + ); + let result = validator.validate(fixture.access, &layout, &submitted.canonical_pset_bytes()); + assert!( + result.is_err(), + "mutation unexpectedly validated: {mutation:?}" + ); + } +} diff --git a/crates/deadcat-rfq-provider/src/wallet.rs b/crates/deadcat-rfq-provider/src/wallet.rs index 2a63b20..7953aa2 100644 --- a/crates/deadcat-rfq-provider/src/wallet.rs +++ b/crates/deadcat-rfq-provider/src/wallet.rs @@ -19,7 +19,9 @@ use elements::confidential::{Asset, Value}; use elements::encode::serialize; use elements::hashes::Hash as _; use elements::secp256k1_zkp::{PublicKey, Secp256k1, XOnlyPublicKey}; -use elements::{BlockHash, OutPoint, SchnorrSig, SchnorrSighashType, Script, TxOut, TxOutSecrets}; +use elements::{ + AssetId, BlockHash, OutPoint, SchnorrSig, SchnorrSighashType, Script, TxOut, TxOutSecrets, +}; use sha2::{Digest as _, Sha256}; use thiserror::Error; @@ -460,6 +462,41 @@ pub trait DestinationSource { ) -> Result; } +/// Trusted provider-wallet capability for validating settlement outputs. +/// +/// Output recovery belongs behind the wallet boundary because it requires the +/// destination's confidential blinding secret. Public proof verification is +/// not enough: an implementation must resolve the durable +/// [`WalletKeyLocator`], derive the ECDH nonce encoded by the output's +/// confidential nonce, and rewind the rangeproof. The blinding key, ECDH +/// shared secret, asset blinding factor, and value blinding factor must remain +/// internal to the wallet implementation. +pub trait ProviderOutputRecovery { + type Error: Error + Send + Sync + 'static; + + /// Validate that `wallet_locator` resolves to `expected_internal_key`, + /// that this tree-less key controls `txout`, and that the output is + /// recoverable and opens to exactly `expected_asset` and + /// `expected_amount`. + /// + /// The implementation must require confidential asset, value, and nonce + /// commitments and use the wallet's own durable locator state; PSET + /// blinding-key metadata is not evidence of ownership or recoverability. + /// It must return an error if the locator does not recover the expected + /// spend key, the script is not its tree-less P2TR output, ECDH nonce + /// derivation or rangeproof rewind fails, or the recovered asset or amount + /// differs. Success exposes no [`TxOutSecrets`] or other secret material to + /// the caller. + fn validate_confidential_output( + &self, + wallet_locator: WalletKeyLocator, + expected_internal_key: XOnlyPublicKey, + txout: &TxOut, + expected_asset: AssetId, + expected_amount: u64, + ) -> Result<(), Self::Error>; +} + /// One explicit-`SIGHASH_ALL` P2TR key-path signature for a provider input. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProviderInputSignature { @@ -516,8 +553,9 @@ pub struct SigningResponse { impl SigningResponse { /// Bind signatures to the exact ordered target list of `job`. /// - /// Cryptographic signature verification belongs to the concrete PSET - /// validator because it requires the final transaction and every prevout. + /// Cryptographic signature verification and insertion belong to the + /// concrete signer/finalizer adapter because they require the exact + /// committed transaction and every authoritative prevout. pub fn new( job: &SigningJob, signatures: Vec, diff --git a/docs/adr/0007-rfq-provider-state-machine.md b/docs/adr/0007-rfq-provider-state-machine.md index 9ee5fdc..9e15d9c 100644 --- a/docs/adr/0007-rfq-provider-state-machine.md +++ b/docs/adr/0007-rfq-provider-state-machine.md @@ -149,11 +149,11 @@ overpayment while the provider rejects transactions likely to strand shared inventory. CPFP is a provider-operated recovery mechanism, not a substitute for initial fee admission and not a cost silently imposed on later traders. -The durable state layer models and persists those validator-derived facts, but -deliberately does not expose its commit or signed-artifact recording transitions -as externally callable service APIs. They remain crate-internal until the -concrete PSET validator and signer adapter can construct their inputs; detached -caller assertions are not an admissible production trust boundary. +The durable state layer models and persists those validator-derived facts. Its +commit transition is reachable only by consuming the final-PSET validator's +opaque one-shot intent; its signed-artifact recording transition remains +crate-internal until the signer adapter can construct and verify that input. +Detached caller assertions are not an admissible production trust boundary. ### Wallet capability and quote-eligibility boundary @@ -234,14 +234,23 @@ require a new authenticated wallet scan to recover the opening in memory. The signer interface accepts only an unforgeable durable signing job. It cannot be asked through this boundary to sign detached caller bytes or a caller-selected sighash policy, and it returns exactly one ordered explicit -`SIGHASH_ALL` signature per durable provider target. Cryptographic signature -verification and insertion into the exact PSET remain duties of the next -validator/signer-adapter layer. +`SIGHASH_ALL` signature per durable provider target. Cryptographic provider- +signature verification and insertion into the exact committed PSET remain +duties of the signer/finalizer adapter. ## Consequences - The provider may strand inventory after an ambiguous signing failure, but it cannot silently double-allocate that inventory. +- A hostile taker may conflict-spend one of its own inputs after the provider's + authoritative unspent check. The resulting settlement cannot confirm, but + the already-committed provider outpoints still do not return to `Available`: + there is no atomic bridge between a chain read and the redb commitment, and + treating a transient or mistaken conflict as proof that reuse is safe would + reintroduce double-signing risk. The remote service must reduce this + availability exposure with authenticated-owner abuse controls, short quote + windows, bounded outstanding inventory per owner, immediate signing/relay, + and operational inventory fragmentation. - A client timeout after submitting its signature means status unknown, not automatic cancellation. The later protocol must expose idempotent status and replay. @@ -251,7 +260,8 @@ validator/signer-adapter layer. owns exact arithmetic, inventory selection, and an injected pricing-policy boundary, with a static rational policy supplied for configuration and deterministic tests. It implements no production market-data source, - transaction validation, signing, networking, relay, mempool, or reorg policy. + signing, networking, relay, mempool, or reorg policy beyond the implemented + pre-sign final-PSET validation boundary. Backend-neutral discovery and signer capabilities surround it, but a concrete wallet/RPC/HSM backend remains a separate security principal. - Multiple interactive RFQ signers remain deferred. Future AMM and DLOB legs @@ -272,9 +282,9 @@ provider/chain database binding, durable inventory import, atomic multi-input reservation, owner-scoped idempotency, bounded expiry and cancellation, fee-policy evaluation over future validator-derived facts, commit-before-sign recovery state, signed-response persistence state, clock rollback protection, -startup integrity validation, and an audit log. The safety-critical commit and -signed-artifact transitions remain crate-internal until their validator and -signer producers land. +startup integrity validation, and an audit log. The safety-critical commit is +now gated by the validator's opaque intent; signed-artifact recording remains +crate-internal until its signer/finalizer producer lands. Its wallet layer now provides validated confidential tree-less P2TR discovery, complete chain-anchored snapshots, atomic batch import followed by a @@ -306,9 +316,17 @@ rate-limit quote churn, and choose a bounded durable-retention/compaction policy before exposing this engine publicly. Live-reservation quotas bound concurrent inventory pressure; they do not by themselves bound terminal quote history. +The initial final-PSET profile treats every non-provider input as an already +finalized tree-less P2TR key-path `SIGHASH_ALL` spend. It therefore supports +ordinary wallet contributions around one interactive RFQ provider, but not yet +Simplicity covenant inputs or a second interactive provider. Those require an +authenticated venue/script verification seam; merely accepting an arbitrary +nonempty witness would not be participant authorization. + The remaining provider milestones are: -1. validate a concrete final Liquid PSET and derive its exact fee metrics; +1. connect the implemented final-PSET validator to a concrete wallet/signer + adapter and production chain backend; 2. define a dedicated authenticated RFQ protocol, signed quote envelope, identity, and ALPN; 3. persist relay and chain-reconciliation observations without ever reopening diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index cb808c2..ef1314f 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -637,10 +637,19 @@ interface proposed here: Phase 1 has extracted and tested the smallest generic plan/composer seam from these patterns without making the router depend on maker-specific types, and -has added the provider's durable state, wallet-capability boundary, and -transport-free inventory-aware quote construction. The API remains provisional -until concrete final-PSET validation, authenticated signed remote RFQ evidence, -and a production wallet/signer backend exercise it. +has added the provider's durable state, wallet-capability boundary, +transport-free inventory-aware quote construction, and concrete final-PSET +validation. The validator binds the RFQ contribution inside a venue-neutral +transaction, checks authoritative prevouts, taker-first P2TR signatures, +confidential proofs/balance and provider output recovery, derives exact fee +metrics with projected provider witnesses, and exposes only an opaque one-shot +capability for the durable commit. The API remains provisional until +authenticated signed remote RFQ evidence and a production wallet/signer/chain +backend exercise it end to end. +The initial profile verifies every non-provider input as a finalized tree-less +P2TR key-path `SIGHASH_ALL` spend. Simplicity covenant inputs and more than one +interactive RFQ signer remain later router/venue-verification work; they are +not accepted merely because they carry a witness. The provider database remains disposable preproduction state during this work. Its schema and private record-layout versions intentionally stay at `1`;