diff --git a/README.md b/README.md index 216ceaa..bf68fdf 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ prediction-market protocol on Liquid. It owns the canonical SimplicityHL contract, interprets confirmed chain state, indexes that state in redb, and serves independently verifiable evidence over Iroh. -The node is deliberately not a wallet or trading venue. Keys, wallet discovery, -PSET construction, confidential-transaction blinding, intent validation, venue -selection, and signing stay on the client. +The node is deliberately not a wallet or trading venue. End-user keys, wallet +discovery, PSET construction, intent validation, venue selection, and signing +stay on the client. The separate RFQ-provider library defines interfaces for +provider-owned inventory, confidential blinding, and signing, but no provider +wallet backend or key material runs in `deadcat-node`. ## Current scope @@ -30,12 +32,13 @@ service, with a client-side router responsible for quote validation and transaction construction. A future AMM or decentralized limit-order book can implement the same venue boundary. [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 is implemented, while its wallet, -pricing, transaction validator, signer adapter, remote service, and relay -layers 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. +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. ## Assurance diff --git a/crates/deadcat-rfq-provider/src/inventory.rs b/crates/deadcat-rfq-provider/src/inventory.rs new file mode 100644 index 0000000..a75da92 --- /dev/null +++ b/crates/deadcat-rfq-provider/src/inventory.rs @@ -0,0 +1,504 @@ +//! Fresh wallet discovery intersected with durable inventory allocation. +//! +//! The redb state machine intentionally knows only whether an outpoint is +//! allocated. In particular, its `Available` state does not mean that a wallet +//! still sees an unspent output. This coordinator is the quote-facing gate: it +//! 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::sync::{Mutex, MutexGuard}; + +use elements::OutPoint; +use thiserror::Error; + +use crate::model::{Clock, InventoryState, ProviderIdentity, ReservationPlan, UnixMillis}; +use crate::store::{ProviderError, ReservationBook, ReserveOutcome}; +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; + +/// Quote-admission policy for wallet inventory snapshots. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InventoryFreshnessPolicy { + max_snapshot_age_millis: u64, + max_inventory_outputs: usize, +} + +impl InventoryFreshnessPolicy { + pub fn new( + max_snapshot_age_millis: u64, + max_inventory_outputs: usize, + ) -> Result { + if max_snapshot_age_millis == 0 { + return Err(InventoryPolicyError::ZeroMaximumSnapshotAge); + } + if max_inventory_outputs == 0 { + return Err(InventoryPolicyError::ZeroMaximumInventoryOutputs); + } + Ok(Self { + max_snapshot_age_millis, + max_inventory_outputs, + }) + } + + #[must_use] + pub const fn max_snapshot_age_millis(self) -> u64 { + self.max_snapshot_age_millis + } + + #[must_use] + pub const fn max_inventory_outputs(self) -> usize { + self.max_inventory_outputs + } +} + +/// Invalid snapshot-admission configuration. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum InventoryPolicyError { + #[error("maximum wallet-snapshot age must be nonzero")] + ZeroMaximumSnapshotAge, + #[error("maximum wallet-snapshot output count must be nonzero")] + ZeroMaximumInventoryOutputs, +} + +/// In-process proof that an eligible view came from the latest published scan. +/// +/// Tokens are deliberately neither serialized nor persisted. A process restart +/// must complete a new authoritative wallet scan before it can quote inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EligibilityToken { + generation: u64, + snapshot: InventorySnapshotCommitment, + observed_at: UnixMillis, +} + +impl EligibilityToken { + #[must_use] + pub const fn generation(self) -> u64 { + self.generation + } + + #[must_use] + pub const fn snapshot(self) -> InventorySnapshotCommitment { + self.snapshot + } + + #[must_use] + pub const fn observed_at(self) -> UnixMillis { + self.observed_at + } +} + +/// The only inventory view suitable for quote construction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EligibleInventory { + token: EligibilityToken, + anchor: WalletScanAnchor, + outputs: Vec, +} + +/// Fresh complete wallet inventory, independent of durable allocation state. +/// +/// This view is for transaction construction and final validation after an +/// output has been reserved and therefore disappeared from +/// [`EligibleInventory`]. It carries the same authenticated snapshot token and +/// retains each output's ephemeral confidential opening. It must never be used +/// by itself to decide quoteability. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CurrentInventory { + token: EligibilityToken, + anchor: WalletScanAnchor, + outputs: Vec, +} + +impl CurrentInventory { + #[must_use] + pub const fn token(&self) -> EligibilityToken { + self.token + } + + #[must_use] + pub const fn anchor(&self) -> WalletScanAnchor { + self.anchor + } + + #[must_use] + pub fn outputs(&self) -> &[WalletOwnedOutput] { + &self.outputs + } + + /// Look up one wallet-authenticated output in this exact snapshot. + #[must_use] + pub fn output(&self, outpoint: OutPoint) -> Option<&WalletOwnedOutput> { + self.outputs + .binary_search_by_key(&outpoint, WalletOwnedOutput::outpoint) + .ok() + .map(|index| &self.outputs[index]) + } +} + +impl EligibleInventory { + #[must_use] + pub const fn token(&self) -> EligibilityToken { + self.token + } + + #[must_use] + pub const fn anchor(&self) -> WalletScanAnchor { + self.anchor + } + + #[must_use] + pub fn outputs(&self) -> &[WalletOwnedOutput] { + &self.outputs + } +} + +struct PublishedSnapshot { + token: EligibilityToken, + anchor: WalletScanAnchor, + outputs: Vec, +} + +#[derive(Default)] +struct CoordinatorState { + generation: u64, + latest: Option, +} + +/// Owns the single quote-facing path from wallet discovery to reservation. +/// +/// Discovery calls are serialized with eligibility reads and reservation. +/// Consequently an older or missing snapshot cannot race a newer scan and +/// allocate an outpoint after that newer scan removed it. Chain state can of +/// course change immediately after any scan; the final transaction validator +/// must recheck authoritative prevouts before the point of no return. +pub struct InventoryCoordinator { + book: ReservationBook, + source: S, + policy: InventoryFreshnessPolicy, + state: Mutex, +} + +impl InventoryCoordinator +where + S: InventorySource, +{ + #[must_use] + pub fn new(book: ReservationBook, source: S, policy: InventoryFreshnessPolicy) -> Self { + Self { + book, + source, + policy, + state: Mutex::new(CoordinatorState::default()), + } + } + + #[must_use] + pub const fn identity(&self) -> ProviderIdentity { + self.book.identity() + } + + /// Durable state access for cancellation, expiry, status, audit, and + /// recovery. Inventory import and reservation themselves remain private to + /// this coordinator so callers cannot bypass freshness. + #[must_use] + pub const fn reservation_book(&self) -> &ReservationBook { + &self.book + } + + /// Run and atomically publish one complete authoritative wallet scan. + /// + /// All discovered metadata is checked against redb in one transaction. + /// A source error may retain the bounded previous snapshot, but once the + /// source returns a newer complete view, any identity, policy, import, or + /// reconciliation failure invalidates the previous positive cache. No + /// rejected result can leave an older view quoteable. + pub fn refresh( + &self, + clock: &C, + ) -> Result> { + let mut state = self.lock_state()?; + let snapshot = self + .source + .inventory_snapshot() + .map_err(InventoryCoordinatorError::Source)?; + // A complete newer source result supersedes the previous observation + // even when later validation rejects it. Retaining the old positive + // cache after a contradiction could quote an output the authoritative + // source has just reported with different or unsafe metadata. + state.latest = None; + if snapshot.identity() != self.book.identity() { + return Err(InventoryCoordinatorError::IdentityMismatch { + expected: Box::new(self.book.identity()), + actual: Box::new(snapshot.identity()), + }); + } + if snapshot.outputs().len() > self.policy.max_inventory_outputs { + return Err(InventoryCoordinatorError::SnapshotTooLarge { + maximum: self.policy.max_inventory_outputs, + actual: snapshot.outputs().len(), + }); + } + let generation = state + .generation + .checked_add(1) + .ok_or(InventoryCoordinatorError::GenerationOverflow)?; + let now = clock.now(); + let inventory = snapshot + .outputs() + .iter() + .map(WalletOwnedOutput::inventory_item) + .collect::>(); + self.book.import_inventory_batch(&inventory, &now)?; + + let token = EligibilityToken { + generation, + snapshot: snapshot.commitment(), + observed_at: now, + }; + let published = PublishedSnapshot { + token, + anchor: snapshot.anchor(), + outputs: snapshot.outputs().to_vec(), + }; + // Finish the durable intersection before publishing the positive + // in-memory observation. A read/integrity failure after import leaves + // durable history intact but cannot make a partially successful scan + // current. + let eligible = self.eligible_from_snapshot(&published)?; + state.generation = generation; + state.latest = Some(published); + Ok(eligible) + } + + /// Re-evaluate durable availability against the latest in-memory scan. + /// A reopened process has no latest scan and therefore no quoteable output. + pub fn eligible( + &self, + clock: &C, + ) -> Result> { + let state = self.lock_state()?; + let latest = self.require_fresh(&state, clock.now())?; + self.eligible_from_snapshot(latest) + } + + /// Return every output in the latest fresh authenticated wallet snapshot, + /// including outputs currently reserved or committed in durable state. + /// Quote construction must use [`Self::eligible`] instead. + pub fn current( + &self, + clock: &C, + ) -> Result> { + let state = self.lock_state()?; + let latest = self.require_fresh(&state, clock.now())?; + Ok(CurrentInventory { + token: latest.token, + anchor: latest.anchor, + outputs: latest.outputs.clone(), + }) + } + + /// Reserve a plan selected from `eligible`, rechecking freshness and exact + /// membership while preventing a concurrent refresh from replacing it. + /// + /// Existing exact idempotent requests are replayed independently of the + /// old discovery token; they never allocate inventory a second time. + pub fn reserve( + &self, + eligible: &EligibleInventory, + plan: &ReservationPlan, + clock: &C, + ) -> Result> { + if self.book.has_matching_request(plan)? { + return self + .book + .reserve(plan, clock) + .map_err(InventoryCoordinatorError::Provider); + } + + let state = self.lock_state()?; + // Close the race between the first read-only retry check and acquiring + // the scan lock. Every production reservation uses this coordinator. + if self.book.has_matching_request(plan)? { + return self + .book + .reserve(plan, clock) + .map_err(InventoryCoordinatorError::Provider); + } + 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_outpoints = latest + .outputs + .iter() + .map(WalletOwnedOutput::outpoint) + .collect::>(); + if let Some(outpoint) = plan + .outpoints() + .iter() + .find(|outpoint| !current_outpoints.contains(outpoint)) + { + return Err(InventoryCoordinatorError::OutpointNotInFreshSnapshot( + *outpoint, + )); + } + let eligible_outpoints = eligible + .outputs + .iter() + .map(WalletOwnedOutput::outpoint) + .collect::>(); + if let Some(outpoint) = plan + .outpoints() + .iter() + .find(|outpoint| !eligible_outpoints.contains(outpoint)) + { + return Err(InventoryCoordinatorError::OutpointNotInEligibleView( + *outpoint, + )); + } + self.book + .reserve_from_snapshot( + plan, + 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()? + .into_iter() + .map(|view| (view.item().outpoint(), view)) + .collect::>(); + let mut outputs = Vec::new(); + for output in &latest.outputs { + let view = durable.get(&output.outpoint()).ok_or_else(|| { + ProviderError::CorruptState(format!( + "published wallet output {:?} has no durable inventory record", + output.outpoint() + )) + })?; + if view.item() != output.inventory_item() { + return Err(ProviderError::CorruptState(format!( + "published wallet output {:?} disagrees with durable metadata", + output.outpoint() + )) + .into()); + } + if view.state() == InventoryState::Available { + outputs.push(output.clone()); + } + } + Ok(EligibleInventory { + token: latest.token, + anchor: latest.anchor, + outputs, + }) + } + + fn require_fresh<'a>( + &self, + state: &'a CoordinatorState, + now: UnixMillis, + ) -> Result<&'a PublishedSnapshot, InventoryCoordinatorError> { + if let Some(previous) = self.book.last_observed_time()? + && now < previous + { + return Err(ProviderError::ClockRegression { previous, now }.into()); + } + let latest = state + .latest + .as_ref() + .ok_or(InventoryCoordinatorError::NoPublishedSnapshot)?; + if now < latest.token.observed_at { + return Err(InventoryCoordinatorError::SnapshotObservedInFuture { + observed_at: latest.token.observed_at, + now, + }); + } + let age = now.value() - latest.token.observed_at.value(); + if age >= self.policy.max_snapshot_age_millis { + return Err(InventoryCoordinatorError::SnapshotStale { + observed_at: latest.token.observed_at, + now, + maximum_age_millis: self.policy.max_snapshot_age_millis, + }); + } + Ok(latest) + } + + fn lock_state( + &self, + ) -> Result, InventoryCoordinatorError> { + self.state + .lock() + .map_err(|_| InventoryCoordinatorError::CoordinatorLockPoisoned) + } +} + +/// Fail-closed discovery, freshness, or durable-allocation error. +#[derive(Debug, Error)] +pub enum InventoryCoordinatorError +where + SourceError: std::error::Error + Send + Sync + 'static, +{ + #[error("wallet inventory discovery failed: {0}")] + Source(#[source] SourceError), + #[error(transparent)] + Provider(#[from] ProviderError), + #[error("wallet inventory coordinator lock is poisoned")] + CoordinatorLockPoisoned, + #[error("wallet snapshot identity mismatch: expected {expected:?}, got {actual:?}")] + IdentityMismatch { + expected: Box, + actual: Box, + }, + #[error("wallet snapshot has {actual} outputs; maximum is {maximum}")] + SnapshotTooLarge { maximum: usize, actual: usize }, + #[error("wallet snapshot generation counter overflowed")] + GenerationOverflow, + #[error("no wallet snapshot has been published in this process")] + NoPublishedSnapshot, + #[error("wallet snapshot observed at {observed_at:?} is in the future at {now:?}")] + SnapshotObservedInFuture { + observed_at: UnixMillis, + now: UnixMillis, + }, + #[error( + "wallet snapshot observed at {observed_at:?} is stale at {now:?}; maximum age is {maximum_age_millis} ms" + )] + SnapshotStale { + observed_at: UnixMillis, + now: UnixMillis, + maximum_age_millis: u64, + }, + #[error("wallet snapshot token was superseded: requested {requested:?}, current {current:?}")] + SnapshotSuperseded { + requested: EligibilityToken, + current: EligibilityToken, + }, + #[error("outpoint {0:?} is absent from the current fresh wallet snapshot")] + OutpointNotInFreshSnapshot(OutPoint), + #[error("outpoint {0:?} was not quoteable in the supplied eligible-inventory view")] + OutpointNotInEligibleView(OutPoint), +} + +#[cfg(test)] +#[path = "inventory_tests.rs"] +mod tests; diff --git a/crates/deadcat-rfq-provider/src/inventory_tests.rs b/crates/deadcat-rfq-provider/src/inventory_tests.rs new file mode 100644 index 0000000..93bcef6 --- /dev/null +++ b/crates/deadcat-rfq-provider/src/inventory_tests.rs @@ -0,0 +1,728 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Barrier, Mutex}; +use std::thread; + +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::{ + Address, AddressParams, AssetId, BlockHash, OutPoint, Script, TxOut, TxOutSecrets, + TxOutWitness, Txid, +}; +use tempfile::TempDir; +use thiserror::Error; + +use super::*; +use crate::model::{ + FeePolicy, FeeSizeMetric, IdempotencyKey, OwnerId, ProviderId, QuoteCommitment, + ReservationAccess, ReservationState, TransactionFee, WalletKeyLocator, +}; +use crate::wallet::{InventorySnapshot, WalletBoundaryError}; + +#[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 = Address::p2tr( + &secp, + internal_key, + None, + Some(blinding_public_key), + &AddressParams::ELEMENTS, + ) + .script_pubkey(); + Self { + internal_key, + blinding_public_key, + script_pubkey, + } + } + + fn owned_output(&self, marker: u8) -> WalletOwnedOutput { + let asset = asset(7); + let amount = 10_000 + u64::from(marker); + 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("locator"), + ) + .expect("owned output") + } +} + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +enum MockSourceError { + #[error("mock wallet unavailable")] + Unavailable, +} + +struct MockSource { + responses: Mutex>>, +} + +struct SequenceClock { + observations: Mutex>, +} + +impl SequenceClock { + fn new(observations: impl IntoIterator) -> Self { + Self { + observations: Mutex::new(observations.into_iter().collect()), + } + } +} + +impl Clock for SequenceClock { + fn now(&self) -> UnixMillis { + self.observations + .lock() + .expect("sequence clock lock") + .pop_front() + .expect("sequence clock exhausted") + } +} + +impl MockSource { + fn new( + responses: impl IntoIterator>, + ) -> Self { + Self { + responses: Mutex::new(responses.into_iter().collect()), + } + } +} + +impl InventorySource for MockSource { + type Error = MockSourceError; + + fn inventory_snapshot(&self) -> Result { + self.responses + .lock() + .expect("mock source lock") + .pop_front() + .unwrap_or(Err(MockSourceError::Unavailable)) + } +} + +enum ControlledResponse { + Immediate(InventorySnapshot), + Blocked(InventorySnapshot), +} + +struct ControlledSource { + responses: Mutex>, + entered: Arc, + release: Arc, +} + +impl InventorySource for ControlledSource { + type Error = MockSourceError; + + fn inventory_snapshot(&self) -> Result { + match self + .responses + .lock() + .expect("controlled source lock") + .pop_front() + .ok_or(MockSourceError::Unavailable)? + { + ControlledResponse::Immediate(snapshot) => Ok(snapshot), + ControlledResponse::Blocked(snapshot) => { + self.entered.wait(); + self.release.wait(); + Ok(snapshot) + } + } + } +} + +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 identity(marker: u8) -> ProviderIdentity { + ProviderIdentity::new( + ProviderId::new([marker; 32]), + BlockHash::from_byte_array([marker.wrapping_add(1); 32]), + asset(1), + ) +} + +fn snapshot( + identity: ProviderIdentity, + anchor_marker: u8, + outputs: Vec, +) -> Result { + InventorySnapshot::new( + identity, + WalletScanAnchor::new( + BlockHash::from_byte_array([anchor_marker; 32]), + u32::from(anchor_marker), + ), + outputs, + ) +} + +fn policy(max_age: u64, max_outputs: usize) -> InventoryFreshnessPolicy { + InventoryFreshnessPolicy::new(max_age, max_outputs).expect("freshness policy") +} + +fn fee_policy(identity: ProviderIdentity) -> FeePolicy { + FeePolicy::new( + identity.policy_asset(), + 2_000, + 50, + 4_000, + FeeSizeMetric::DiscountVbytes, + ) + .expect("fee policy") +} + +fn plan( + identity: ProviderIdentity, + owner_marker: u8, + request_marker: u8, + outpoints: Vec, +) -> ReservationPlan { + ReservationPlan::new( + OwnerId::new([owner_marker; 32]), + IdempotencyKey::new([request_marker; 32]), + QuoteCommitment::new([request_marker.wrapping_add(1); 32]), + outpoints, + UnixMillis::new(1_000), + fee_policy(identity), + ) + .expect("reservation plan") +} + +fn coordinator( + directory: &TempDir, + identity: ProviderIdentity, + source: MockSource, + policy: InventoryFreshnessPolicy, +) -> InventoryCoordinator { + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + InventoryCoordinator::new(book, source, policy) +} + +#[test] +fn quoteable_inventory_requires_a_fresh_scan_and_durable_availability() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(10); + let wallet = WalletFixture::new(2, 3); + let first = wallet.owned_output(11); + let second = wallet.owned_output(12); + let source = MockSource::new([Ok(snapshot( + identity, + 20, + vec![first.clone(), second.clone()], + ) + .expect("snapshot"))]); + let coordinator = coordinator(&directory, identity, source, policy(100, 4)); + + assert!(matches!( + coordinator.eligible(&UnixMillis::new(99)), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); + let eligible = coordinator.refresh(&UnixMillis::new(100)).expect("refresh"); + assert_eq!(eligible.outputs(), &[first.clone(), second.clone()]); + + let reservation = coordinator + .reserve( + &eligible, + &plan(identity, 1, 1, vec![first.outpoint()]), + &UnixMillis::new(101), + ) + .expect("reserve") + .reservation() + .clone(); + let while_reserved = coordinator + .eligible(&UnixMillis::new(102)) + .expect("eligible after reserve"); + assert_eq!(while_reserved.outputs(), std::slice::from_ref(&second)); + let current = coordinator + .current(&UnixMillis::new(102)) + .expect("current complete inventory"); + assert_eq!(current.token(), while_reserved.token()); + assert_eq!(current.outputs(), &[first.clone(), second.clone()]); + assert_eq!( + current + .output(first.outpoint()) + .expect("reserved output in current snapshot") + .confidential_input_opening(), + first.confidential_input_opening() + ); + + coordinator + .reservation_book() + .cancel( + ReservationAccess::new(reservation.id(), reservation.owner()), + &UnixMillis::new(103), + ) + .expect("cancel"); + assert!(matches!( + coordinator.reserve( + &while_reserved, + &plan(identity, 2, 2, vec![first.outpoint()]), + &UnixMillis::new(104), + ), + Err(InventoryCoordinatorError::OutpointNotInEligibleView(actual)) + if actual == first.outpoint() + )); + let after_release = coordinator + .eligible(&UnixMillis::new(104)) + .expect("eligible after release"); + assert_eq!(after_release.outputs(), &[first.clone(), second]); + assert!( + coordinator + .reserve( + &after_release, + &plan(identity, 2, 2, vec![first.outpoint()]), + &UnixMillis::new(104), + ) + .expect("reserve from refreshed eligible view") + .created() + ); +} + +#[test] +fn latest_complete_snapshot_replaces_membership_without_deleting_history() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(20); + let wallet = WalletFixture::new(4, 5); + let first = wallet.owned_output(21); + let second = wallet.owned_output(22); + let source = MockSource::new([ + Ok(snapshot(identity, 30, vec![first.clone()]).expect("first snapshot")), + Ok(snapshot(identity, 31, vec![second.clone()]).expect("second snapshot")), + ]); + let coordinator = coordinator(&directory, identity, source, policy(100, 4)); + let old = coordinator + .refresh(&UnixMillis::new(100)) + .expect("first refresh"); + let current = coordinator + .refresh(&UnixMillis::new(101)) + .expect("second refresh"); + assert_eq!(current.outputs(), &[second]); + assert_eq!( + coordinator + .reservation_book() + .inventory(first.outpoint()) + .expect("durable history") + .expect("first inventory") + .state(), + InventoryState::Available + ); + + assert!(matches!( + coordinator.reserve( + &old, + &plan(identity, 1, 1, vec![first.outpoint()]), + &UnixMillis::new(102), + ), + Err(InventoryCoordinatorError::SnapshotSuperseded { .. }) + )); + assert!(matches!( + coordinator.reserve( + ¤t, + &plan(identity, 2, 2, vec![first.outpoint()]), + &UnixMillis::new(102), + ), + Err(InventoryCoordinatorError::OutpointNotInFreshSnapshot(actual)) + if actual == first.outpoint() + )); +} + +#[test] +fn exact_reservation_retry_survives_snapshot_replacement() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(30); + let wallet = WalletFixture::new(6, 7); + let first = wallet.owned_output(31); + let second = wallet.owned_output(32); + let source = MockSource::new([ + Ok(snapshot(identity, 40, vec![first.clone()]).expect("first snapshot")), + Ok(snapshot(identity, 41, vec![second]).expect("second snapshot")), + ]); + let coordinator = coordinator(&directory, identity, source, policy(100, 4)); + let old = coordinator + .refresh(&UnixMillis::new(100)) + .expect("first refresh"); + let request = plan(identity, 1, 1, vec![first.outpoint()]); + let created = coordinator + .reserve(&old, &request, &UnixMillis::new(101)) + .expect("created reservation"); + assert!(created.created()); + coordinator + .refresh(&UnixMillis::new(102)) + .expect("replacement refresh"); + + let retry = coordinator + .reserve(&old, &request, &UnixMillis::new(103)) + .expect("idempotent retry"); + assert!(!retry.created()); + assert_eq!(retry.reservation(), created.reservation()); +} + +#[test] +fn refresh_replacing_membership_cannot_race_an_old_snapshot_reservation() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(35); + let wallet = WalletFixture::new(18, 19); + let first = wallet.owned_output(36); + let second = wallet.owned_output(37); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let source = ControlledSource { + responses: Mutex::new(VecDeque::from([ + ControlledResponse::Immediate( + snapshot(identity, 42, vec![first.clone()]).expect("first snapshot"), + ), + ControlledResponse::Blocked( + snapshot(identity, 43, vec![second]).expect("replacement snapshot"), + ), + ])), + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }; + let book = ReservationBook::open(directory.path().join("provider.redb"), identity) + .expect("reservation book"); + let coordinator = Arc::new(InventoryCoordinator::new(book, source, policy(100, 4))); + let old = coordinator + .refresh(&UnixMillis::new(100)) + .expect("first refresh"); + + let refresh_coordinator = Arc::clone(&coordinator); + let refresh = thread::spawn(move || refresh_coordinator.refresh(&UnixMillis::new(101))); + entered.wait(); + + let reserve_coordinator = Arc::clone(&coordinator); + let requested_outpoint = first.outpoint(); + let reserve = thread::spawn(move || { + reserve_coordinator.reserve( + &old, + &plan(identity, 1, 1, vec![requested_outpoint]), + &UnixMillis::new(102), + ) + }); + release.wait(); + refresh + .join() + .expect("refresh thread") + .expect("replacement refresh"); + assert!(matches!( + reserve.join().expect("reserve thread"), + Err(InventoryCoordinatorError::SnapshotSuperseded { .. }) + )); + assert_eq!( + coordinator + .reservation_book() + .inventory(first.outpoint()) + .expect("inventory") + .expect("first output") + .state(), + InventoryState::Available + ); +} + +#[test] +fn freshness_boundary_is_exclusive_and_clock_rollback_fails_closed() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(40); + let wallet = WalletFixture::new(8, 9); + let output = wallet.owned_output(41); + let source = MockSource::new([Ok(snapshot(identity, 50, vec![output]).expect("snapshot"))]); + let coordinator = coordinator(&directory, identity, source, policy(10, 4)); + coordinator.refresh(&UnixMillis::new(100)).expect("refresh"); + coordinator + .eligible(&UnixMillis::new(109)) + .expect("last fresh millisecond"); + assert!(matches!( + coordinator.eligible(&UnixMillis::new(110)), + Err(InventoryCoordinatorError::SnapshotStale { + observed_at, + now, + maximum_age_millis: 10, + }) if observed_at == UnixMillis::new(100) && now == UnixMillis::new(110) + )); + assert!(matches!( + coordinator.eligible(&UnixMillis::new(99)), + Err(InventoryCoordinatorError::Provider( + ProviderError::ClockRegression { previous, now } + )) if previous == UnixMillis::new(100) && now == UnixMillis::new(99) + )); +} + +#[test] +fn reservation_rechecks_snapshot_freshness_after_the_durable_writer_lock() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(45); + let wallet = WalletFixture::new(20, 21); + let output = wallet.owned_output(46); + let source = MockSource::new([Ok( + snapshot(identity, 55, vec![output.clone()]).expect("snapshot") + )]); + let coordinator = coordinator(&directory, identity, source, policy(10, 4)); + let eligible = coordinator.refresh(&UnixMillis::new(100)).expect("refresh"); + let clock = SequenceClock::new([UnixMillis::new(109), UnixMillis::new(110)]); + + assert!(matches!( + coordinator.reserve( + &eligible, + &plan(identity, 1, 1, vec![output.outpoint()]), + &clock, + ), + Err(InventoryCoordinatorError::Provider( + ProviderError::InventorySnapshotStale { + observed_at, + now, + maximum_age_millis: 10, + } + )) if observed_at == UnixMillis::new(100) && now == UnixMillis::new(110) + )); + assert_eq!( + coordinator + .reservation_book() + .inventory(output.outpoint()) + .expect("inventory") + .expect("known output") + .state(), + InventoryState::Available + ); +} + +#[test] +fn source_failure_retains_bounded_cache_but_rejected_new_scan_invalidates_it() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(50); + let wallet = WalletFixture::new(10, 11); + let first = wallet.owned_output(51); + let new_item = wallet.owned_output(52); + let conflicting_wallet = WalletFixture::new(12, 13); + let conflict = conflicting_wallet.owned_output(51); + let source = MockSource::new([ + Ok(snapshot(identity, 60, vec![first.clone()]).expect("first snapshot")), + Err(MockSourceError::Unavailable), + Ok(snapshot(identity, 61, vec![new_item.clone(), conflict]).expect("conflicting snapshot")), + ]); + let coordinator = coordinator(&directory, identity, source, policy(10, 4)); + let first_view = coordinator + .refresh(&UnixMillis::new(100)) + .expect("first refresh"); + + assert!(matches!( + coordinator.refresh(&UnixMillis::new(105)), + Err(InventoryCoordinatorError::Source( + MockSourceError::Unavailable + )) + )); + assert_eq!( + coordinator + .eligible(&UnixMillis::new(109)) + .expect("old snapshot remains fresh") + .token(), + first_view.token() + ); + + assert!(matches!( + coordinator.refresh(&UnixMillis::new(109)), + Err(InventoryCoordinatorError::Provider( + ProviderError::InventoryMetadataConflict { outpoint: actual } + )) if actual == first.outpoint() + )); + assert!( + coordinator + .reservation_book() + .inventory(new_item.outpoint()) + .expect("new item query") + .is_none(), + "metadata conflict must roll back every new item in the scan" + ); + assert!(matches!( + coordinator.eligible(&UnixMillis::new(109)), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); + assert!(matches!( + coordinator.current(&UnixMillis::new(109)), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); + assert!(matches!( + coordinator.reserve( + &first_view, + &plan(identity, 1, 1, vec![first.outpoint()]), + &UnixMillis::new(109), + ), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); +} + +#[test] +fn wrong_identity_and_oversized_snapshot_never_publish() { + let directory = TempDir::new().expect("tempdir"); + let expected = identity(60); + let wallet = WalletFixture::new(14, 15); + let first = wallet.owned_output(61); + let second = wallet.owned_output(62); + let source = MockSource::new([ + Ok(snapshot(identity(61), 70, vec![first.clone()]).expect("wrong identity snapshot")), + Ok(snapshot(expected, 71, vec![first.clone(), second.clone()]) + .expect("oversized snapshot")), + ]); + let coordinator = coordinator(&directory, expected, source, policy(100, 1)); + + assert!(matches!( + coordinator.refresh(&UnixMillis::new(100)), + Err(InventoryCoordinatorError::IdentityMismatch { .. }) + )); + assert!( + coordinator + .reservation_book() + .inventory(first.outpoint()) + .expect("wrong identity query") + .is_none() + ); + assert!(matches!( + coordinator.refresh(&UnixMillis::new(101)), + Err(InventoryCoordinatorError::SnapshotTooLarge { + maximum: 1, + actual: 2, + }) + )); + for output in [&first, &second] { + assert!( + coordinator + .reservation_book() + .inventory(output.outpoint()) + .expect("oversized query") + .is_none() + ); + } + assert!(matches!( + coordinator.eligible(&UnixMillis::new(101)), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); +} + +#[test] +fn restart_requires_rediscovery_and_committed_inventory_never_reopens() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(70); + let wallet = WalletFixture::new(16, 17); + let output = wallet.owned_output(71); + let reservation_id; + { + let source = MockSource::new([Ok( + snapshot(identity, 80, vec![output.clone()]).expect("snapshot") + )]); + let coordinator = coordinator(&directory, identity, source, policy(100, 4)); + let eligible = coordinator.refresh(&UnixMillis::new(100)).expect("refresh"); + let reservation = coordinator + .reserve( + &eligible, + &plan(identity, 1, 1, vec![output.outpoint()]), + &UnixMillis::new(101), + ) + .expect("reserve") + .reservation() + .clone(); + reservation_id = reservation.id(); + let fee = TransactionFee::new(identity.policy_asset(), 200, 800, 200, 100) + .expect("transaction fee"); + coordinator + .reservation_book() + .commit_before_sign( + ReservationAccess::new(reservation.id(), reservation.owner()), + vec![1, 2, 3], + fee, + &UnixMillis::new(102), + ) + .expect("commit"); + } + + let source = MockSource::new([Ok( + snapshot(identity, 81, vec![output.clone()]).expect("rediscovery snapshot") + )]); + let reopened = coordinator(&directory, identity, source, policy(100, 4)); + assert!(matches!( + reopened.eligible(&UnixMillis::new(103)), + Err(InventoryCoordinatorError::NoPublishedSnapshot) + )); + assert!(matches!( + reopened + .reservation_book() + .reservation(reservation_id) + .expect("reservation") + .expect("persisted reservation") + .state(), + ReservationState::Committed { .. } + )); + let eligible = reopened + .refresh(&UnixMillis::new(104)) + .expect("rediscovery"); + assert!( + eligible.outputs().is_empty(), + "fresh rediscovery must not make committed inventory quoteable" + ); + let current = reopened + .current(&UnixMillis::new(104)) + .expect("fresh current inventory"); + assert_eq!(current.token(), eligible.token()); + assert_eq!( + current + .output(output.outpoint()) + .expect("committed output remains available for recovery") + .confidential_input_opening(), + output.confidential_input_opening() + ); +} + +#[test] +fn policy_rejects_zero_limits() { + assert_eq!( + InventoryFreshnessPolicy::new(0, 1), + Err(InventoryPolicyError::ZeroMaximumSnapshotAge) + ); + assert_eq!( + InventoryFreshnessPolicy::new(1, 0), + Err(InventoryPolicyError::ZeroMaximumInventoryOutputs) + ); +} diff --git a/crates/deadcat-rfq-provider/src/lib.rs b/crates/deadcat-rfq-provider/src/lib.rs index 5fe0633..1eb4d8e 100644 --- a/crates/deadcat-rfq-provider/src/lib.rs +++ b/crates/deadcat-rfq-provider/src/lib.rs @@ -1,8 +1,9 @@ //! Transport-free durable state for a noncustodial RFQ provider. //! -//! This crate owns neither networking nor wallet keys. Its job is narrower: -//! make inventory allocation and the provider's signing point of no return -//! durable and auditable. The required ordering is: +//! This crate owns neither networking nor wallet keys. It defines the +//! backend-neutral wallet capabilities and makes inventory allocation and the +//! provider's signing point of no return durable and auditable. The required +//! ordering is: //! //! `validate -> commit exact payload -> sign -> persist signed bytes -> release` //! @@ -10,22 +11,38 @@ //! payload is committed, every reserved outpoint remains retired even across //! expiry, process restart, signer ambiguity, mempool eviction, or reorg. //! -//! This first state-only layer does not yet expose the commit or signed-result -//! transitions outside the crate. A later concrete transaction validator and -//! signer adapter must be the only producers of those transition inputs. +//! 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. +mod inventory; mod model; mod store; +mod wallet; +pub use inventory::{ + CurrentInventory, DEFAULT_MAX_INVENTORY_OUTPUTS, EligibilityToken, EligibleInventory, + InventoryCoordinator, InventoryCoordinatorError, InventoryFreshnessPolicy, + InventoryPolicyError, +}; pub use model::{ AuditEntry, AuditEvent, Clock, FeePolicy, FeePolicyViolation, FeeSizeMetric, IdempotencyKey, - InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS, MAX_SETTLEMENT_BYTES, - ModelError, OwnerId, ProviderId, ProviderIdentity, QuoteCommitment, RecoveryAction, - ReleaseReason, ReservationAccess, ReservationId, ReservationPlan, ReservationState, - ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment, SigningJob, - TransactionFee, UnixMillis, + InventoryBinding, InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS, + MAX_SETTLEMENT_BYTES, ModelError, OwnerId, ProviderId, ProviderIdentity, QuoteCommitment, + RecoveryAction, ReleaseReason, ReservationAccess, ReservationId, ReservationPlan, + ReservationState, ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment, + SigningJob, SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator, }; pub use store::{ CommitOutcome, MAX_EXPIRATION_BATCH, ProviderError, ReservationBook, ReserveOutcome, SCHEMA_VERSION, SignedOutcome, }; +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, +}; diff --git a/crates/deadcat-rfq-provider/src/model.rs b/crates/deadcat-rfq-provider/src/model.rs index 746a7cb..001b39c 100644 --- a/crates/deadcat-rfq-provider/src/model.rs +++ b/crates/deadcat-rfq-provider/src/model.rs @@ -1,3 +1,6 @@ +use core::fmt; + +use elements::secp256k1_zkp::XOnlyPublicKey; use elements::{AssetId, BlockHash, OutPoint}; use thiserror::Error; @@ -54,6 +57,37 @@ fixed_id!( /// Domain-separated commitment to the exact persisted signed response. SignedArtifactDigest ); +fixed_id!( + /// Commitment to the wallet-authenticated public and durable metadata for one output. + InventoryBinding +); + +/// Stable, non-secret handle used by the provider wallet or HSM to recover a key. +/// +/// The bytes are deliberately opaque to the state machine. They must not be a +/// private key, blinding factor, seed, or derivation path containing secrets. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct WalletKeyLocator([u8; 32]); + +impl WalletKeyLocator { + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(ModelError::InvalidWalletKeyLocator); + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl fmt::Debug for WalletKeyLocator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("WalletKeyLocator([opaque])") + } +} /// Absolute Unix time in milliseconds. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -125,10 +159,20 @@ pub struct InventoryItem { outpoint: OutPoint, asset: AssetId, amount: u64, + wallet_locator: WalletKeyLocator, + internal_key: XOnlyPublicKey, + binding: InventoryBinding, } impl InventoryItem { - pub fn new(outpoint: OutPoint, asset: AssetId, amount: u64) -> Result { + pub(crate) fn new( + outpoint: OutPoint, + asset: AssetId, + amount: u64, + wallet_locator: WalletKeyLocator, + internal_key: XOnlyPublicKey, + binding: InventoryBinding, + ) -> Result { if outpoint.is_null() || outpoint.vout & 0xc000_0000 != 0 { return Err(ModelError::InvalidInventoryOutpoint(outpoint)); } @@ -139,6 +183,9 @@ impl InventoryItem { outpoint, asset, amount, + wallet_locator, + internal_key, + binding, }) } @@ -156,6 +203,24 @@ impl InventoryItem { pub const fn amount(self) -> u64 { self.amount } + + /// Opaque, non-secret handle required to recover the provider signing key. + #[must_use] + pub const fn wallet_locator(self) -> WalletKeyLocator { + self.wallet_locator + } + + /// Untweaked key committed by the tree-less P2TR output. + #[must_use] + pub const fn internal_key(self) -> XOnlyPublicKey { + self.internal_key + } + + /// Commitment to the wallet-authenticated public prevout and durable metadata. + #[must_use] + pub const fn binding(self) -> InventoryBinding { + self.binding + } } /// Transaction size measure used by the provider's broadcasting node. @@ -513,6 +578,12 @@ impl ReservationView { } } +/// Durable allocation state only. +/// +/// `Available` means that no reservation owns the outpoint in redb. It does +/// not prove that the wallet still reports the output as unspent or that a +/// sufficiently fresh discovery snapshot exists. Quote construction must use +/// the wallet coordinator's eligible-inventory view instead. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InventoryState { Available, @@ -554,6 +625,7 @@ pub struct SigningJob { pub(crate) commitment: SigningCommitment, pub(crate) pre_sign_payload: Vec, pub(crate) fee: TransactionFee, + pub(crate) targets: Vec, } impl SigningJob { @@ -576,6 +648,47 @@ impl SigningJob { pub const fn fee(&self) -> TransactionFee { self.fee } + + /// Exact provider-owned inputs authorized by this durable signing job. + #[must_use] + pub fn targets(&self) -> &[SigningTarget] { + &self.targets + } +} + +/// Non-secret wallet authorization for one provider input in a durable job. +/// +/// The signing policy is fixed by the wallet boundary to tree-less P2TR key +/// path with explicit `SIGHASH_ALL`; it is intentionally not caller-selectable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SigningTarget { + pub(crate) outpoint: OutPoint, + pub(crate) wallet_locator: WalletKeyLocator, + pub(crate) internal_key: XOnlyPublicKey, + pub(crate) inventory_binding: InventoryBinding, +} + +impl SigningTarget { + #[must_use] + pub const fn outpoint(self) -> OutPoint { + self.outpoint + } + + #[must_use] + pub const fn wallet_locator(self) -> WalletKeyLocator { + self.wallet_locator + } + + #[must_use] + pub const fn internal_key(self) -> XOnlyPublicKey { + self.internal_key + } + + /// Commitment to the wallet-authenticated public prevout and durable metadata. + #[must_use] + pub const fn inventory_binding(self) -> InventoryBinding { + self.inventory_binding + } } /// Exact signed bytes persisted before any response or relay attempt. @@ -668,6 +781,8 @@ pub enum ModelError { InvalidInventoryOutpoint(OutPoint), #[error("inventory amount must be nonzero")] ZeroInventoryAmount, + #[error("wallet key locator must not be the all-zero reserved value")] + InvalidWalletKeyLocator, #[error("minimum fee rate must be nonzero")] ZeroMinimumFeeRate, #[error("maximum transaction weight must be nonzero")] diff --git a/crates/deadcat-rfq-provider/src/store.rs b/crates/deadcat-rfq-provider/src/store.rs index f30724e..e083074 100644 --- a/crates/deadcat-rfq-provider/src/store.rs +++ b/crates/deadcat-rfq-provider/src/store.rs @@ -4,6 +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::{AssetId, BlockHash, OutPoint}; use redb::{ Database, Durability, ReadableDatabase as _, ReadableTable as _, TableDefinition, @@ -16,11 +17,11 @@ use thiserror::Error; use crate::model::{ AuditEntry, AuditEvent, Clock, FeePolicy, FeePolicyViolation, FeeSizeMetric, IdempotencyKey, - InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS, MAX_SETTLEMENT_BYTES, - OwnerId, ProviderId, ProviderIdentity, QuoteCommitment, RecoveryAction, ReleaseReason, - ReservationAccess, ReservationId, ReservationPlan, ReservationState, ReservationView, - SignedArtifact, SignedArtifactDigest, SigningCommitment, SigningJob, TransactionFee, - UnixMillis, + InventoryBinding, InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS, + MAX_SETTLEMENT_BYTES, OwnerId, ProviderId, ProviderIdentity, QuoteCommitment, RecoveryAction, + ReleaseReason, ReservationAccess, ReservationId, ReservationPlan, ReservationState, + ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment, SigningJob, + SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator, }; pub const SCHEMA_VERSION: u32 = 1; @@ -160,34 +161,56 @@ impl ReservationBook { /// Add one wallet-discovered output without changing an existing record. /// Exact retries are idempotent; conflicting metadata is rejected. - pub fn import_inventory( + #[cfg(test)] + pub(crate) fn import_inventory( &self, item: InventoryItem, clock: &C, ) -> Result { + Ok(self.import_inventory_batch(&[item], clock)? == 1) + } + + /// Atomically import one complete wallet discovery set. Every item is + /// validated against existing immutable metadata before any item is added. + pub(crate) fn import_inventory_batch( + &self, + items: &[InventoryItem], + clock: &C, + ) -> Result { + let mut unique = BTreeSet::new(); + for item in items { + if !unique.insert(item.outpoint()) { + return Err(ProviderError::DuplicateInventoryOutpoint(item.outpoint())); + } + } let (_operation_guard, write, now) = self.begin_timed_write(clock)?; - let key = outpoint_key(item.outpoint()); - let stored = StoredInventoryItem::from(item); - if let Some(existing) = read_record_from_write(&write, INVENTORY, &key)? { - let existing: StoredInventoryItem = existing; - if existing != stored { - return Err(ProviderError::InventoryMetadataConflict { - outpoint: item.outpoint(), - }); + let mut pending = Vec::new(); + for item in items { + let key = outpoint_key(item.outpoint()); + let stored = StoredInventoryItem::from(*item); + if let Some(existing) = read_record_from_write(&write, INVENTORY, &key)? { + let existing: StoredInventoryItem = existing; + if existing != stored { + return Err(ProviderError::InventoryMetadataConflict { + outpoint: item.outpoint(), + }); + } + } else { + pending.push((key, stored)); } - self.commit_write(write)?; - return Ok(false); } - write_record(&write, INVENTORY, &key, &stored)?; - append_audit( - &write, - now, - StoredAuditEvent::InventoryImported { - outpoint: item.outpoint(), - }, - )?; + for (key, stored) in &pending { + write_record(&write, INVENTORY, key, stored)?; + append_audit( + &write, + now, + StoredAuditEvent::InventoryImported { + outpoint: stored.outpoint, + }, + )?; + } self.commit_write(write)?; - Ok(true) + Ok(pending.len()) } pub fn inventory(&self, outpoint: OutPoint) -> Result, ProviderError> { @@ -209,7 +232,7 @@ impl ReservationBook { Ok(Some(InventoryView::new(item.to_domain()?, state))) } - pub fn inventory_all(&self) -> Result, ProviderError> { + pub(crate) fn inventory_all(&self) -> Result, ProviderError> { self.ensure_healthy()?; let read = self.database.begin_read()?; let inventory = read.open_table(INVENTORY)?; @@ -228,14 +251,90 @@ impl ReservationBook { Ok(result) } + /// Whether this exact authenticated request already has a durable binding. + /// + /// The wallet coordinator uses this read-only check to preserve idempotent + /// 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. + pub(crate) fn has_matching_request( + &self, + plan: &ReservationPlan, + ) -> Result { + self.ensure_healthy()?; + if plan.fee_policy().policy_asset() != self.identity.policy_asset() { + return Err(ProviderError::WrongPolicyAsset { + expected: self.identity.policy_asset(), + 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()); + let Some(binding) = request_keys.get(key.as_slice())? else { + return Ok(false); + }; + let binding: StoredRequestBinding = decode_record(binding.value())?; + if binding.request_digest != expected_digest { + return Err(ProviderError::IdempotencyConflict { + owner: plan.owner(), + key: plan.idempotency_key(), + }); + } + drop(request_keys); + let reservations = read.open_table(RESERVATIONS)?; + let record = reservations + .get(binding.reservation_id.as_slice())? + .ok_or_else(|| { + ProviderError::CorruptState( + "idempotency binding references a missing reservation".to_owned(), + ) + })?; + let record: StoredReservation = decode_record(record.value())?; + if record.id != binding.reservation_id || record.request_digest != binding.request_digest { + return Err(ProviderError::CorruptState( + "idempotency binding disagrees with its reservation".to_owned(), + )); + } + record.validate()?; + Ok(true) + } + /// Atomically reserve every requested outpoint or none of them. /// /// 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. - pub fn reserve( + pub(crate) fn reserve( + &self, + plan: &ReservationPlan, + clock: &C, + ) -> Result { + self.reserve_inner(plan, clock, None) + } + + /// Reserve from one wallet snapshot, rechecking its exclusive freshness + /// deadline using the same post-writer-lock observation as the quote + /// deadline and durable allocation. + pub(crate) fn reserve_from_snapshot( + &self, + plan: &ReservationPlan, + snapshot_observed_at: UnixMillis, + maximum_snapshot_age_millis: u64, + clock: &C, + ) -> Result { + self.reserve_inner( + plan, + clock, + Some((snapshot_observed_at, maximum_snapshot_age_millis)), + ) + } + + fn reserve_inner( &self, plan: &ReservationPlan, clock: &C, + snapshot_freshness: Option<(UnixMillis, u64)>, ) -> Result { if plan.fee_policy().policy_asset() != self.identity.policy_asset() { return Err(ProviderError::WrongPolicyAsset { @@ -267,6 +366,21 @@ impl ReservationBook { }); } + if let Some((observed_at, maximum_age_millis)) = snapshot_freshness { + if now < observed_at { + self.commit_write(write)?; + return Err(ProviderError::InventorySnapshotObservedInFuture { observed_at, now }); + } + if now.value() - observed_at.value() >= maximum_age_millis { + self.commit_write(write)?; + return Err(ProviderError::InventorySnapshotStale { + observed_at, + now, + maximum_age_millis, + }); + } + } + if now >= plan.accept_before() { self.commit_write(write)?; return Err(ProviderError::ReservationDeadlineElapsed { @@ -486,7 +600,8 @@ impl ReservationBook { match &record.state { StoredReservationState::Committed { intent } => { - let proposed = signing_commitment(&record, &pre_sign_payload, fee)?; + let proposed = + signing_commitment(&record, &pre_sign_payload, fee, &intent.targets)?; if intent.commitment != proposed.to_bytes() || intent.pre_sign_payload != pre_sign_payload || intent.fee != StoredTransactionFee::from(fee) @@ -498,7 +613,8 @@ impl ReservationBook { return Ok(CommitOutcome::AlreadyCommitted(job)); } StoredReservationState::Signed { intent, artifact } => { - let proposed = signing_commitment(&record, &pre_sign_payload, fee)?; + let proposed = + signing_commitment(&record, &pre_sign_payload, fee, &intent.targets)?; if intent.commitment != proposed.to_bytes() || intent.pre_sign_payload != pre_sign_payload || intent.fee != StoredTransactionFee::from(fee) @@ -527,7 +643,8 @@ impl ReservationBook { let policy = record.fee_policy.to_domain()?; policy.validate(fee)?; - let commitment = signing_commitment(&record, &pre_sign_payload, fee)?; + let targets = signing_targets_for_reservation(&write, &record)?; + let commitment = signing_commitment(&record, &pre_sign_payload, fee, &targets)?; for outpoint in &record.outpoints { let key = outpoint_key(*outpoint); let allocation = read_record_from_write::(&write, ALLOCATIONS, &key)? @@ -552,6 +669,7 @@ impl ReservationBook { pre_sign_payload, fee: StoredTransactionFee::from(fee), committed_at: now.value(), + targets, }; for outpoint in &record.outpoints { write_record( @@ -1140,10 +1258,32 @@ fn read_request_binding( read_record_from_write(write, REQUEST_KEYS, &request_key(owner, key)) } +fn signing_targets_for_reservation( + write: &WriteTransaction, + reservation: &StoredReservation, +) -> Result, ProviderError> { + let mut targets = Vec::with_capacity(reservation.outpoints.len()); + for outpoint in &reservation.outpoints { + let key = outpoint_key(*outpoint); + let inventory = read_record_from_write::(write, INVENTORY, &key)? + .ok_or_else(|| { + ProviderError::CorruptState(format!( + "reserved outpoint {outpoint:?} has no inventory metadata" + )) + })?; + // Decode through the domain constructor before handing any persisted + // locator or key material to a signer. + inventory.to_domain()?; + targets.push(StoredSigningTarget::from_inventory(inventory)); + } + Ok(targets) +} + fn signing_commitment( reservation: &StoredReservation, pre_sign_payload: &[u8], fee: TransactionFee, + targets: &[StoredSigningTarget], ) -> Result { let transcript = StoredSigningTranscript { request_digest: reservation.request_digest, @@ -1152,6 +1292,7 @@ fn signing_commitment( quote_commitment: reservation.quote_commitment, fee_policy: reservation.fee_policy, fee: StoredTransactionFee::from(fee), + targets, pre_sign_payload, }; Ok(SigningCommitment::new(domain_digest( @@ -1443,14 +1584,14 @@ fn validate_store_integrity( } } - for outpoint in &record.outpoints { + for (target_index, outpoint) in record.outpoints.iter().enumerate() { let key = outpoint_key(*outpoint); - if !inventory.contains_key(&key) { - return Err(ProviderError::CorruptState(format!( + let inventory_item = inventory.get(&key).ok_or_else(|| { + ProviderError::CorruptState(format!( "reservation {:?} references missing inventory {outpoint:?}", record.id() - ))); - } + )) + })?; let allocation = allocations.get(&key); match &record.state { StoredReservationState::Reserved => { @@ -1467,6 +1608,13 @@ fn validate_store_integrity( } StoredReservationState::Committed { intent } | StoredReservationState::Signed { intent, .. } => { + let expected_target = StoredSigningTarget::from_inventory(*inventory_item); + if intent.targets.get(target_index) != Some(&expected_target) { + return Err(ProviderError::CorruptState(format!( + "committed reservation {:?} signing target disagrees with inventory {outpoint:?}", + record.id() + ))); + } if allocation != Some(&StoredAllocation::Committed { reservation_id: record.id, @@ -1995,6 +2143,9 @@ struct StoredInventoryItem { outpoint: OutPoint, asset: AssetId, amount: u64, + wallet_locator: [u8; 32], + internal_key: [u8; 32], + binding: [u8; 32], } impl From for StoredInventoryItem { @@ -2003,13 +2154,32 @@ impl From for StoredInventoryItem { outpoint: value.outpoint(), asset: value.asset(), amount: value.amount(), + wallet_locator: value.wallet_locator().to_bytes(), + internal_key: value.internal_key().serialize(), + binding: value.binding().to_bytes(), } } } impl StoredInventoryItem { fn to_domain(self) -> Result { - InventoryItem::new(self.outpoint, self.asset, self.amount).map_err(|error| { + let wallet_locator = WalletKeyLocator::new(self.wallet_locator).map_err(|error| { + ProviderError::CorruptState(format!("invalid persisted inventory: {error}")) + })?; + let internal_key = XOnlyPublicKey::from_slice(&self.internal_key).map_err(|error| { + ProviderError::CorruptState(format!( + "invalid persisted inventory internal key: {error}" + )) + })?; + InventoryItem::new( + self.outpoint, + self.asset, + self.amount, + wallet_locator, + internal_key, + InventoryBinding::new(self.binding), + ) + .map_err(|error| { ProviderError::CorruptState(format!("invalid persisted inventory: {error}")) }) } @@ -2166,12 +2336,47 @@ impl StoredReleaseReason { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct StoredSigningTarget { + outpoint: OutPoint, + wallet_locator: [u8; 32], + internal_key: [u8; 32], + inventory_binding: [u8; 32], +} + +impl StoredSigningTarget { + fn from_inventory(item: StoredInventoryItem) -> Self { + Self { + outpoint: item.outpoint, + wallet_locator: item.wallet_locator, + internal_key: item.internal_key, + inventory_binding: item.binding, + } + } + + fn to_domain(self) -> Result { + let wallet_locator = WalletKeyLocator::new(self.wallet_locator).map_err(|error| { + ProviderError::CorruptState(format!("invalid persisted signing locator: {error}")) + })?; + let internal_key = XOnlyPublicKey::from_slice(&self.internal_key).map_err(|error| { + ProviderError::CorruptState(format!("invalid persisted signing key: {error}")) + })?; + Ok(SigningTarget { + outpoint: self.outpoint, + wallet_locator, + internal_key, + inventory_binding: InventoryBinding::new(self.inventory_binding), + }) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct StoredSigningIntent { commitment: [u8; 32], pre_sign_payload: Vec, fee: StoredTransactionFee, committed_at: u64, + targets: Vec, } impl StoredSigningIntent { @@ -2184,6 +2389,12 @@ impl StoredSigningIntent { commitment: SigningCommitment::new(self.commitment), pre_sign_payload: self.pre_sign_payload.clone(), fee: self.fee.to_domain()?, + targets: self + .targets + .iter() + .copied() + .map(StoredSigningTarget::to_domain) + .collect::>()?, }) } } @@ -2302,7 +2513,22 @@ impl StoredReservation { "persisted signing fee violates its policy: {error}" )) })?; - let expected = signing_commitment(self, &intent.pre_sign_payload, fee)?; + if intent.targets.len() != self.outpoints.len() + || intent + .targets + .iter() + .zip(&self.outpoints) + .any(|(target, outpoint)| target.outpoint != *outpoint) + { + return Err(ProviderError::CorruptState( + "persisted signing targets do not match reservation outpoints".to_owned(), + )); + } + for target in &intent.targets { + target.to_domain()?; + } + let expected = + signing_commitment(self, &intent.pre_sign_payload, fee, &intent.targets)?; if expected.to_bytes() != intent.commitment { return Err(ProviderError::CorruptState( "persisted signing commitment does not match its transcript".to_owned(), @@ -2375,6 +2601,7 @@ struct StoredSigningTranscript<'a> { quote_commitment: [u8; 32], fee_policy: StoredFeePolicy, fee: StoredTransactionFee, + targets: &'a [StoredSigningTarget], pre_sign_payload: &'a [u8], } @@ -2503,6 +2730,19 @@ pub enum ProviderError { previous: UnixMillis, now: UnixMillis, }, + #[error("wallet snapshot observed at {observed_at:?} is in the future at {now:?}")] + InventorySnapshotObservedInFuture { + observed_at: UnixMillis, + now: UnixMillis, + }, + #[error( + "wallet snapshot observed at {observed_at:?} is stale at {now:?}; maximum age is {maximum_age_millis} ms" + )] + InventorySnapshotStale { + observed_at: UnixMillis, + now: UnixMillis, + maximum_age_millis: u64, + }, #[error("persisted audit sequence is corrupt")] CorruptAuditSequence, #[error("audit sequence overflowed")] @@ -2517,6 +2757,8 @@ pub enum ProviderError { UnknownInventory(OutPoint), #[error("inventory metadata conflicts at {outpoint:?}")] InventoryMetadataConflict { outpoint: OutPoint }, + #[error("wallet discovery contains duplicate inventory outpoint {0:?}")] + DuplicateInventoryOutpoint(OutPoint), #[error("outpoint {outpoint:?} is unavailable: {state:?}")] OutpointUnavailable { outpoint: OutPoint, diff --git a/crates/deadcat-rfq-provider/src/store/tests.rs b/crates/deadcat-rfq-provider/src/store/tests.rs index 439e0bc..f360031 100644 --- a/crates/deadcat-rfq-provider/src/store/tests.rs +++ b/crates/deadcat-rfq-provider/src/store/tests.rs @@ -2,6 +2,7 @@ use std::sync::{Arc, Barrier}; use std::thread; use elements::hashes::Hash as _; +use elements::secp256k1_zkp::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey}; use elements::{AssetId, BlockHash, OutPoint, Txid}; use tempfile::TempDir; @@ -43,7 +44,24 @@ fn transaction_fee(identity: ProviderIdentity, amount: u64) -> TransactionFee { } fn inventory(marker: u8) -> InventoryItem { - InventoryItem::new(outpoint(marker, 0), asset(2), 10_000).expect("inventory") + inventory_variant(marker, marker) +} + +fn inventory_variant(outpoint_marker: u8, metadata_marker: u8) -> InventoryItem { + let secp = Secp256k1::new(); + let metadata_marker = metadata_marker.max(1); + let secret_key = SecretKey::from_slice(&[metadata_marker; 32]).expect("secret key"); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let (internal_key, _) = XOnlyPublicKey::from_keypair(&keypair); + InventoryItem::new( + outpoint(outpoint_marker, 0), + asset(2), + 10_000, + WalletKeyLocator::new([metadata_marker; 32]).expect("wallet locator"), + internal_key, + InventoryBinding::new([metadata_marker.wrapping_add(1); 32]), + ) + .expect("inventory") } fn owner(marker: u8) -> OwnerId { @@ -126,6 +144,51 @@ fn fee_policy_uses_checked_ceiling_and_both_parties_bounds() { )); } +#[test] +fn wallet_inventory_batch_is_atomic_and_exact_rediscovery_is_idempotent() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(81); + let book = open_book(&directory, identity); + let existing = inventory(120); + let new_item = inventory(121); + let now = UnixMillis::new(100); + assert_eq!( + book.import_inventory_batch(&[existing], &now) + .expect("first import"), + 1 + ); + assert_eq!( + book.import_inventory_batch(&[existing], &now) + .expect("exact retry"), + 0 + ); + + let conflict = inventory_variant(120, 122); + assert!(matches!( + book.import_inventory_batch(&[new_item, conflict], &UnixMillis::new(101)), + Err(ProviderError::InventoryMetadataConflict { outpoint: actual }) + if actual == existing.outpoint() + )); + assert!( + book.inventory(new_item.outpoint()) + .expect("new inventory query") + .is_none(), + "a later conflict must roll back the entire discovery batch" + ); + assert_eq!(book.audit_log().expect("audit").len(), 1); + + assert!(matches!( + book.import_inventory_batch(&[new_item, new_item], &UnixMillis::new(102)), + Err(ProviderError::DuplicateInventoryOutpoint(actual)) + if actual == new_item.outpoint() + )); + assert!( + book.inventory(new_item.outpoint()) + .expect("duplicate inventory query") + .is_none() + ); +} + #[test] fn reservation_is_atomic_idempotent_and_owner_authenticated() { let directory = TempDir::new().expect("tempdir"); @@ -565,6 +628,60 @@ fn fee_policy_is_rechecked_before_the_irreversible_transition() { ); } +#[test] +fn signing_commitment_covers_every_durable_wallet_target_field() { + let identity = identity(18); + let request = plan(identity, owner(1), 1, 2, vec![outpoint(30, 0)], 1_000); + let reservation = StoredReservation { + id: derive_reservation_id(request.owner(), request.idempotency_key()).to_bytes(), + owner: request.owner().to_bytes(), + idempotency_key: request.idempotency_key().to_bytes(), + request_digest: request_digest(identity, &request).expect("request digest"), + quote_commitment: request.quote_commitment().to_bytes(), + outpoints: request.outpoints().to_vec(), + created_at: 100, + accept_before: request.accept_before().value(), + fee_policy: StoredFeePolicy::from(request.fee_policy()), + state: StoredReservationState::Reserved, + }; + let base = + StoredSigningTarget::from_inventory(StoredInventoryItem::from(inventory_variant(30, 30))); + let alternate = + StoredSigningTarget::from_inventory(StoredInventoryItem::from(inventory_variant(31, 31))); + let expected = signing_commitment( + &reservation, + &[1, 2, 3], + transaction_fee(identity, 200), + &[base], + ) + .expect("base commitment"); + + let mut changed_outpoint = base; + changed_outpoint.outpoint = alternate.outpoint; + let mut changed_locator = base; + changed_locator.wallet_locator = alternate.wallet_locator; + let mut changed_key = base; + changed_key.internal_key = alternate.internal_key; + let mut changed_binding = base; + changed_binding.inventory_binding = alternate.inventory_binding; + + for (field, target) in [ + ("outpoint", changed_outpoint), + ("wallet locator", changed_locator), + ("internal key", changed_key), + ("inventory binding", changed_binding), + ] { + let actual = signing_commitment( + &reservation, + &[1, 2, 3], + transaction_fee(identity, 200), + &[target], + ) + .expect("changed commitment"); + assert_ne!(expected, actual, "target {field} must be committed"); + } +} + #[test] fn committed_outpoints_never_reopen_after_deadline_cancel_or_restart() { let directory = TempDir::new().expect("tempdir"); @@ -618,6 +735,11 @@ fn committed_outpoints_never_reopen_after_deadline_cancel_or_restart() { if job.reservation_id() == reservation_id && job.commitment() == commitment && job.pre_sign_payload() == [9, 8, 7] + && job.targets().len() == 1 + && job.targets()[0].outpoint() == item.outpoint() + && job.targets()[0].wallet_locator() == item.wallet_locator() + && job.targets()[0].internal_key() == item.internal_key() + && job.targets()[0].inventory_binding() == item.binding() )); assert!(matches!( reopened.reserve( diff --git a/crates/deadcat-rfq-provider/src/wallet.rs b/crates/deadcat-rfq-provider/src/wallet.rs new file mode 100644 index 0000000..bba05a1 --- /dev/null +++ b/crates/deadcat-rfq-provider/src/wallet.rs @@ -0,0 +1,1291 @@ +//! Backend-neutral wallet discovery, destination, and signing capabilities. +//! +//! This module deliberately models capabilities rather than a particular +//! wallet RPC. Discovery authenticates the public transaction output against +//! its confidential opening and an exact tree-less P2TR key before producing +//! durable inventory metadata. The opening remains only in the redacted, +//! in-memory [`WalletOwnedOutput`] so collaborative blinding can consume it; +//! [`InventoryItem`] and the durable signing job never retain blinding factors. +//! +//! A signer receives only a durable [`SigningJob`]. It cannot be asked through +//! this interface to sign detached caller-supplied bytes, keys, or sighash +//! policies. Version one fixes provider inputs to P2TR key-path spends with an +//! explicitly serialized `SIGHASH_ALL` byte. + +use core::fmt; +use std::error::Error; + +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 sha2::{Digest as _, Sha256}; +use thiserror::Error; + +use crate::model::{ + InventoryBinding, InventoryItem, ModelError, ProviderIdentity, SigningCommitment, SigningJob, + WalletKeyLocator, +}; + +/// Serialized Schnorr signature length when `SIGHASH_ALL` is explicit. +pub const P2TR_SIGHASH_ALL_SIGNATURE_BYTES: usize = 65; +/// Serialized witness-stack length for one explicit-`SIGHASH_ALL` key-path signature. +/// +/// This is one compact-size stack count, one compact-size item length, and the +/// 65-byte signature. Transaction fee projection must account separately for +/// the surrounding Elements input-witness fields. +pub const P2TR_SIGHASH_ALL_SCRIPT_WITNESS_BYTES: usize = 67; + +const OUTPUT_BINDING_DOMAIN: &[u8] = b"deadcat/rfq/wallet-owned-output/v1"; +const SNAPSHOT_COMMITMENT_DOMAIN: &[u8] = b"deadcat/rfq/inventory-snapshot/v1"; + +/// Wallet-authenticated provider output suitable for version-one inventory. +/// +/// The full public prevout and its validated confidential opening are retained +/// for later collaborative blinding and validation. The custom `Debug` +/// implementation omits the opening, and conversion to [`InventoryItem`] +/// deliberately drops every blinding factor before persistence. +#[derive(Clone, PartialEq, Eq)] +pub struct WalletOwnedOutput { + txout: TxOut, + opening: ConfidentialInputOpening, + item: InventoryItem, +} + +/// Sensitive in-memory opening of one provider confidential input. +/// +/// This value exists only to let the provider blind its portion of a PSET. It +/// is deliberately absent from durable inventory, reservation records, +/// signing targets, raw-factor commitment transcripts, and debug output. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) struct ConfidentialInputOpening(TxOutSecrets); + +impl ConfidentialInputOpening { + /// Reveal the opening to the provider's collaborative-blinding adapter. + /// Callers must not log or persist the returned blinding factors. + // The concrete adapter is the next provider layer and will be this + // crate-private capability's only non-test consumer. + #[allow(dead_code)] + #[must_use] + pub(crate) const fn txout_secrets(self) -> TxOutSecrets { + self.0 + } +} + +impl fmt::Debug for ConfidentialInputOpening { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ConfidentialInputOpening([redacted])") + } +} + +impl WalletOwnedOutput { + /// Authenticate one wallet-discovered output. + /// + /// The caller is the wallet boundary: it is responsible for associating + /// `wallet_locator` with both the spend and blinding capabilities used to + /// discover this output. A surjection proof is required, but cannot be + /// verified in isolation because its input-generator domain belongs to the + /// output's creating transaction. The source must therefore authenticate + /// that the creating transaction passed its configured chain or mempool + /// validation policy. The later settlement validator rechecks this exact + /// prevout and validates the new transaction's confidential balance and + /// proofs; it cannot reconstruct the old proof's missing generator domain + /// from this isolated output alone. + pub fn new( + outpoint: OutPoint, + txout: TxOut, + opening: TxOutSecrets, + internal_key: XOnlyPublicKey, + wallet_locator: WalletKeyLocator, + ) -> Result { + if outpoint.is_null() || outpoint.vout & 0xc000_0000 != 0 { + return Err(ModelError::InvalidInventoryOutpoint(outpoint).into()); + } + if opening.value == 0 { + return Err(ModelError::ZeroInventoryAmount.into()); + } + if !txout.asset.is_confidential() { + return Err(WalletBoundaryError::NonConfidentialAsset); + } + if !txout.value.is_confidential() { + return Err(WalletBoundaryError::NonConfidentialValue); + } + if !txout.nonce.is_confidential() { + return Err(WalletBoundaryError::NonConfidentialNonce); + } + if txout.witness.surjection_proof.is_none() { + return Err(WalletBoundaryError::MissingSurjectionProof); + } + let Some(rangeproof) = txout.witness.rangeproof.as_deref() else { + return Err(WalletBoundaryError::MissingRangeproof); + }; + + let secp = Secp256k1::new(); + let expected_script = Script::new_v1_p2tr(&secp, internal_key, None); + if txout.script_pubkey != expected_script { + return Err(WalletBoundaryError::NotExactTreeLessP2tr); + } + + let expected_asset = Asset::new_confidential(&secp, opening.asset, opening.asset_bf); + if txout.asset != expected_asset { + return Err(WalletBoundaryError::AssetOpeningMismatch); + } + let expected_value = Value::new_confidential_from_assetid( + &secp, + opening.value, + opening.asset, + opening.value_bf, + opening.asset_bf, + ); + if txout.value != expected_value { + return Err(WalletBoundaryError::ValueOpeningMismatch); + } + + let value_commitment = txout + .value + .commitment() + .ok_or(WalletBoundaryError::NonConfidentialValue)?; + let asset_generator = txout + .asset + .commitment() + .ok_or(WalletBoundaryError::NonConfidentialAsset)?; + let proven_range = rangeproof + .verify( + &secp, + value_commitment, + txout.script_pubkey.as_bytes(), + asset_generator, + ) + .map_err(|_| WalletBoundaryError::InvalidRangeproof)?; + if !proven_range.contains(&opening.value) { + return Err(WalletBoundaryError::OpeningOutsideProvenRange); + } + + let binding = output_binding( + outpoint, + &txout, + opening.asset, + opening.value, + internal_key, + wallet_locator, + ); + let item = InventoryItem::new( + outpoint, + opening.asset, + opening.value, + wallet_locator, + internal_key, + binding, + )?; + + Ok(Self { + txout, + opening: ConfidentialInputOpening(opening), + item, + }) + } + + #[must_use] + pub const fn outpoint(&self) -> OutPoint { + self.item.outpoint() + } + + #[must_use] + pub const fn asset(&self) -> elements::AssetId { + self.item.asset() + } + + #[must_use] + pub const fn amount(&self) -> u64 { + self.item.amount() + } + + #[must_use] + pub const fn wallet_locator(&self) -> WalletKeyLocator { + self.item.wallet_locator() + } + + #[must_use] + pub const fn internal_key(&self) -> XOnlyPublicKey { + self.item.internal_key() + } + + #[must_use] + pub const fn binding(&self) -> InventoryBinding { + self.item.binding() + } + + #[must_use] + pub const fn inventory_item(&self) -> InventoryItem { + self.item + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + &self.txout + } + + /// Return the validated opening needed for provider-side non-last PSET + /// blinding. The returned factors must remain ephemeral. + // Opening access stays inside this crate so quote/pricing consumers cannot + // extract wallet input secrets from the public inventory view. + #[allow(dead_code)] + #[must_use] + pub(crate) const fn confidential_input_opening(&self) -> ConfidentialInputOpening { + self.opening + } +} + +impl fmt::Debug for WalletOwnedOutput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WalletOwnedOutput") + .field("outpoint", &self.outpoint()) + .field("asset", &self.asset()) + .field("amount", &self.amount()) + .field("wallet_locator", &self.wallet_locator()) + .field("internal_key", &self.internal_key()) + .field("binding", &self.binding()) + .finish_non_exhaustive() + } +} + +/// Chain point at which a complete wallet inventory scan was observed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WalletScanAnchor { + block_hash: BlockHash, + block_height: u32, +} + +impl WalletScanAnchor { + #[must_use] + pub const fn new(block_hash: BlockHash, block_height: u32) -> Self { + Self { + block_hash, + block_height, + } + } + + #[must_use] + pub const fn block_hash(self) -> BlockHash { + self.block_hash + } + + #[must_use] + pub const fn block_height(self) -> u32 { + self.block_height + } +} + +/// Deterministic commitment to one complete, canonically ordered scan. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InventorySnapshotCommitment([u8; 32]); + +impl InventorySnapshotCommitment { + #[must_use] + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +/// Complete provider-wallet inventory at one chain scan anchor. +/// +/// Outputs are sorted by outpoint, and duplicates are rejected. Consequently, +/// the commitment does not depend on backend iteration order. Implementations +/// of [`InventorySource`] must return the complete currently spendable set; +/// callers treat an output missing from a new snapshot as ineligible even when +/// an older durable inventory record still exists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InventorySnapshot { + identity: ProviderIdentity, + anchor: WalletScanAnchor, + outputs: Vec, + commitment: InventorySnapshotCommitment, +} + +impl InventorySnapshot { + pub fn new( + identity: ProviderIdentity, + anchor: WalletScanAnchor, + mut outputs: Vec, + ) -> Result { + outputs.sort_by_key(WalletOwnedOutput::outpoint); + if let Some(duplicate) = outputs + .windows(2) + .find(|pair| pair[0].outpoint() == pair[1].outpoint()) + .map(|pair| pair[0].outpoint()) + { + return Err(WalletBoundaryError::DuplicateSnapshotOutpoint(duplicate)); + } + let commitment = snapshot_commitment(identity, anchor, &outputs); + Ok(Self { + identity, + anchor, + outputs, + commitment, + }) + } + + #[must_use] + pub const fn identity(&self) -> ProviderIdentity { + self.identity + } + + #[must_use] + pub const fn anchor(&self) -> WalletScanAnchor { + self.anchor + } + + #[must_use] + pub fn outputs(&self) -> &[WalletOwnedOutput] { + &self.outputs + } + + #[must_use] + pub const fn commitment(&self) -> InventorySnapshotCommitment { + self.commitment + } +} + +/// Authoritative source of complete, fresh provider-wallet inventory scans. +pub trait InventorySource { + type Error: Error + Send + Sync + 'static; + + /// Return a newly observed, complete inventory snapshot. + /// + /// This call must not return a process-cached historical snapshot as fresh. + /// It must return one coherent view at the reported chain anchor—not a + /// delta or a streaming mixture of scan points—and must fail if the backend + /// cannot establish that view. The backend is also responsible for + /// returning only outputs whose creating transactions passed its configured + /// chain or mempool validation policy; this trait cannot prove that claim. + /// The coordinator independently verifies the returned provider/chain + /// identity and intersects its outputs with durable `Available` state. + fn inventory_snapshot(&self) -> Result; +} + +/// Why the provider needs a fresh confidential destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DestinationPurpose { + /// Receive the asset paid to the provider by a settlement. + SettlementReceive, + /// Receive provider change from a settlement. + SettlementChange, +} + +/// Fresh wallet destination with P2TR spend and confidential-blinding data. +#[derive(Clone, PartialEq, Eq)] +pub struct ConfidentialDestination { + script_pubkey: Script, + blinding_public_key: PublicKey, + internal_key: XOnlyPublicKey, + wallet_locator: WalletKeyLocator, +} + +impl ConfidentialDestination { + /// Validate that a wallet-returned script is the exact tree-less P2TR + /// output for its claimed internal key. + pub fn new( + script_pubkey: Script, + blinding_public_key: PublicKey, + internal_key: XOnlyPublicKey, + wallet_locator: WalletKeyLocator, + ) -> Result { + let expected = Script::new_v1_p2tr(&Secp256k1::new(), internal_key, None); + if script_pubkey != expected { + return Err(WalletBoundaryError::NotExactTreeLessP2tr); + } + Ok(Self { + script_pubkey, + blinding_public_key, + internal_key, + wallet_locator, + }) + } + + #[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 + } + + #[must_use] + pub const fn internal_key(&self) -> XOnlyPublicKey { + self.internal_key + } + + #[must_use] + pub const fn wallet_locator(&self) -> WalletKeyLocator { + self.wallet_locator + } +} + +impl fmt::Debug for ConfidentialDestination { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("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) + .finish() + } +} + +/// Source of non-reused provider receive and change destinations. +pub trait DestinationSource { + type Error: Error + Send + Sync + 'static; + + /// Return a destination never previously issued for this purpose. + /// + /// Non-reuse is a required backend guarantee; this interface cannot infer + /// wallet derivation history and therefore cannot enforce it itself. + fn fresh_confidential_destination( + &self, + purpose: DestinationPurpose, + ) -> Result; +} + +/// One explicit-`SIGHASH_ALL` P2TR key-path signature for a provider input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProviderInputSignature { + outpoint: OutPoint, + signature: SchnorrSig, + serialized: [u8; P2TR_SIGHASH_ALL_SIGNATURE_BYTES], +} + +impl ProviderInputSignature { + pub fn new(outpoint: OutPoint, signature: SchnorrSig) -> Result { + if signature.hash_ty != SchnorrSighashType::All { + return Err(WalletBoundaryError::NonExplicitSighashAll { + outpoint, + actual: signature.hash_ty, + }); + } + let encoded = signature.to_vec(); + let serialized: [u8; P2TR_SIGHASH_ALL_SIGNATURE_BYTES] = encoded + .try_into() + .map_err(|_| WalletBoundaryError::InvalidSighashAllEncoding(outpoint))?; + if serialized[P2TR_SIGHASH_ALL_SIGNATURE_BYTES - 1] != SchnorrSighashType::All as u8 { + return Err(WalletBoundaryError::InvalidSighashAllEncoding(outpoint)); + } + Ok(Self { + outpoint, + signature, + serialized, + }) + } + + #[must_use] + pub const fn outpoint(self) -> OutPoint { + self.outpoint + } + + #[must_use] + pub const fn signature(self) -> SchnorrSig { + self.signature + } + + #[must_use] + pub const fn serialized(&self) -> &[u8; P2TR_SIGHASH_ALL_SIGNATURE_BYTES] { + &self.serialized + } +} + +/// Shape-validated signatures for exactly one durable signing job. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SigningResponse { + commitment: SigningCommitment, + signatures: Vec, +} + +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. + pub fn new( + job: &SigningJob, + signatures: Vec, + ) -> Result { + if signatures.len() != job.targets().len() { + return Err(WalletBoundaryError::SignatureCountMismatch { + expected: job.targets().len(), + actual: signatures.len(), + }); + } + for (index, (target, signature)) in job.targets().iter().zip(&signatures).enumerate() { + if target.outpoint() != signature.outpoint() { + return Err(WalletBoundaryError::SignatureTargetMismatch { + index, + expected: target.outpoint(), + actual: signature.outpoint(), + }); + } + } + Ok(Self { + commitment: job.commitment(), + signatures, + }) + } + + #[must_use] + pub const fn commitment(&self) -> SigningCommitment { + self.commitment + } + + #[must_use] + pub fn signatures(&self) -> &[ProviderInputSignature] { + &self.signatures + } +} + +/// Provider wallet or HSM signer capability. +/// +/// The sole signing input is an unforgeable durable [`SigningJob`]. Concrete +/// implementations recover keys through each job target's opaque locator and +/// must verify that each locator resolves to the target's exact untweaked +/// public key before signing only the persisted PSET bytes with P2TR key path +/// `SIGHASH_ALL`. Resolution must use durable wallet ownership history rather +/// than only the current unspent-output list, because a committed input may +/// disappear after an ambiguous signing attempt. +pub trait ProviderSigner { + type Error: Error + Send + Sync + 'static; + + fn sign(&self, job: &SigningJob) -> Result; +} + +/// Validation failures at the provider wallet boundary. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum WalletBoundaryError { + #[error(transparent)] + Model(#[from] ModelError), + #[error("wallet output asset is not confidential")] + NonConfidentialAsset, + #[error("wallet output value is not confidential")] + NonConfidentialValue, + #[error("wallet output nonce is not confidential")] + NonConfidentialNonce, + #[error("wallet output is missing its asset surjection proof")] + MissingSurjectionProof, + #[error("wallet output is missing its value rangeproof")] + MissingRangeproof, + #[error("wallet output is not the exact tree-less P2TR script for its internal key")] + NotExactTreeLessP2tr, + #[error("wallet output asset commitment disagrees with its opening")] + AssetOpeningMismatch, + #[error("wallet output value commitment disagrees with its opening")] + ValueOpeningMismatch, + #[error("wallet output rangeproof does not verify")] + InvalidRangeproof, + #[error("wallet output opening lies outside its rangeproof's proven range")] + OpeningOutsideProvenRange, + #[error("wallet inventory snapshot contains duplicate outpoint {0:?}")] + DuplicateSnapshotOutpoint(OutPoint), + #[error("provider signature for {outpoint:?} is not explicit SIGHASH_ALL: {actual:?}")] + NonExplicitSighashAll { + outpoint: OutPoint, + actual: SchnorrSighashType, + }, + #[error("provider signature for {0:?} has a non-canonical SIGHASH_ALL encoding")] + InvalidSighashAllEncoding(OutPoint), + #[error("signer returned {actual} signatures for {expected} durable targets")] + SignatureCountMismatch { expected: usize, actual: usize }, + #[error("signer response target {index} is {actual:?}, expected durable target {expected:?}")] + SignatureTargetMismatch { + index: usize, + expected: OutPoint, + actual: OutPoint, + }, +} + +fn output_binding( + outpoint: OutPoint, + txout: &TxOut, + asset: elements::AssetId, + amount: u64, + internal_key: XOnlyPublicKey, + wallet_locator: WalletKeyLocator, +) -> InventoryBinding { + let mut hasher = Sha256::new(); + hash_frame(&mut hasher, OUTPUT_BINDING_DOMAIN); + hash_frame(&mut hasher, &serialize(&outpoint)); + hash_frame(&mut hasher, &serialize(txout)); + hash_frame(&mut hasher, &serialize(&txout.witness)); + hash_frame(&mut hasher, &asset.into_inner().to_byte_array()); + hash_frame(&mut hasher, &amount.to_be_bytes()); + hash_frame(&mut hasher, &internal_key.serialize()); + hash_frame(&mut hasher, &wallet_locator.to_bytes()); + InventoryBinding::new(hasher.finalize().into()) +} + +fn snapshot_commitment( + identity: ProviderIdentity, + anchor: WalletScanAnchor, + outputs: &[WalletOwnedOutput], +) -> InventorySnapshotCommitment { + let mut hasher = Sha256::new(); + hash_frame(&mut hasher, SNAPSHOT_COMMITMENT_DOMAIN); + hash_frame(&mut hasher, &identity.provider().to_bytes()); + hash_frame(&mut hasher, &identity.genesis_hash().to_byte_array()); + hash_frame( + &mut hasher, + &identity.policy_asset().into_inner().to_byte_array(), + ); + hash_frame(&mut hasher, &anchor.block_hash().to_byte_array()); + hash_frame(&mut hasher, &anchor.block_height().to_be_bytes()); + hash_frame( + &mut hasher, + &u64::try_from(outputs.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + for output in outputs { + hash_frame(&mut hasher, &serialize(&output.outpoint())); + hash_frame(&mut hasher, &output.binding().to_bytes()); + } + InventorySnapshotCommitment(hasher.finalize().into()) +} + +fn hash_frame(hasher: &mut Sha256, bytes: &[u8]) { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + hasher.update(length.to_be_bytes()); + hasher.update(bytes); +} + +#[cfg(test)] +mod tests { + use core::convert::Infallible; + + use elements::confidential::{AssetBlindingFactor, Nonce, ValueBlindingFactor}; + use elements::hashes::Hash as _; + use elements::secp256k1_zkp::rand::thread_rng; + use elements::secp256k1_zkp::{Keypair, Message, SecretKey}; + use elements::taproot::TapNodeHash; + use elements::{Address, AddressParams, AssetId, TxOutWitness, Txid}; + + use super::*; + use crate::model::{ProviderId, ReservationId, SigningTarget, TransactionFee}; + + #[derive(Clone)] + struct WalletFixture { + internal_key: XOnlyPublicKey, + spend_keypair: Keypair, + 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 = Address::p2tr( + &secp, + internal_key, + None, + Some(blinding_public_key), + &AddressParams::ELEMENTS, + ) + .script_pubkey(); + Self { + internal_key, + spend_keypair, + blinding_public_key, + script_pubkey, + } + } + + fn output( + &self, + marker: u8, + asset: AssetId, + amount: u64, + ) -> (OutPoint, TxOut, TxOutSecrets) { + 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"); + ( + outpoint(marker), + txout, + TxOutSecrets::new(asset, asset_bf, amount, value_bf), + ) + } + } + + fn outpoint(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), u32::from(marker)) + } + + fn asset(marker: u8) -> AssetId { + AssetId::from_byte_array([marker; 32]) + } + + fn locator(marker: u8) -> WalletKeyLocator { + WalletKeyLocator::new([marker; 32]).expect("locator") + } + + fn identity() -> ProviderIdentity { + ProviderIdentity::new( + ProviderId::new([1; 32]), + BlockHash::from_byte_array([2; 32]), + asset(3), + ) + } + + fn owned_output(wallet: &WalletFixture, marker: u8) -> WalletOwnedOutput { + let (outpoint, txout, opening) = wallet.output(marker, asset(7), 10_000); + WalletOwnedOutput::new( + outpoint, + txout, + opening, + wallet.internal_key, + locator(marker), + ) + .expect("authenticated wallet output") + } + + #[test] + fn authenticates_confidential_tree_less_p2tr_inventory() { + let wallet = WalletFixture::new(4, 5); + let (expected_outpoint, txout, expected_opening) = wallet.output(6, asset(7), 10_000); + let output = WalletOwnedOutput::new( + expected_outpoint, + txout, + expected_opening, + wallet.internal_key, + locator(6), + ) + .expect("authenticated wallet output"); + let opening = output.confidential_input_opening(); + + assert_eq!(output.outpoint(), outpoint(6)); + assert_eq!(output.asset(), asset(7)); + assert_eq!(output.amount(), 10_000); + assert_eq!(output.internal_key(), wallet.internal_key); + assert_eq!(output.wallet_locator(), locator(6)); + assert_eq!(output.txout().script_pubkey, wallet.script_pubkey); + assert!(output.txout().witness.rangeproof.is_some()); + assert!(output.txout().witness.surjection_proof.is_some()); + assert_eq!(opening.txout_secrets(), expected_opening); + assert_eq!( + format!("{opening:?}"), + "ConfidentialInputOpening([redacted])" + ); + assert!(!format!("{output:?}").contains("opening")); + } + + #[test] + fn wallet_locator_rejects_the_reserved_value_and_redacts_debug_output() { + assert_eq!( + WalletKeyLocator::new([0; 32]), + Err(ModelError::InvalidWalletKeyLocator) + ); + let locator = WalletKeyLocator::new([0xab; 32]).expect("locator"); + assert_eq!(format!("{locator:?}"), "WalletKeyLocator([opaque])"); + } + + #[test] + fn rejects_invalid_outpoints_and_zero_openings() { + let wallet = WalletFixture::new(8, 9); + let (_, txout, opening) = wallet.output(10, asset(11), 1); + assert_eq!( + WalletOwnedOutput::new( + OutPoint::null(), + txout.clone(), + opening, + wallet.internal_key, + locator(10), + ), + Err(WalletBoundaryError::Model( + ModelError::InvalidInventoryOutpoint(OutPoint::null()) + )) + ); + + let zero = TxOutSecrets::new(opening.asset, opening.asset_bf, 0, opening.value_bf); + assert_eq!( + WalletOwnedOutput::new(outpoint(10), txout, zero, wallet.internal_key, locator(10),), + Err(WalletBoundaryError::Model(ModelError::ZeroInventoryAmount)) + ); + } + + #[test] + fn requires_every_confidential_field_and_both_proofs() { + let wallet = WalletFixture::new(12, 13); + let (outpoint, txout, opening) = wallet.output(14, asset(15), 20); + + let mut explicit_asset = txout.clone(); + explicit_asset.asset = Asset::Explicit(opening.asset); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + explicit_asset, + opening, + wallet.internal_key, + locator(14), + ), + Err(WalletBoundaryError::NonConfidentialAsset) + ); + + let mut explicit_value = txout.clone(); + explicit_value.value = Value::Explicit(opening.value); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + explicit_value, + opening, + wallet.internal_key, + locator(14), + ), + Err(WalletBoundaryError::NonConfidentialValue) + ); + + let mut null_nonce = txout.clone(); + null_nonce.nonce = Nonce::Null; + assert_eq!( + WalletOwnedOutput::new( + outpoint, + null_nonce, + opening, + wallet.internal_key, + locator(14), + ), + Err(WalletBoundaryError::NonConfidentialNonce) + ); + + let mut no_surjection_proof = txout.clone(); + no_surjection_proof.witness.surjection_proof = None; + assert_eq!( + WalletOwnedOutput::new( + outpoint, + no_surjection_proof, + opening, + wallet.internal_key, + locator(14), + ), + Err(WalletBoundaryError::MissingSurjectionProof) + ); + + let mut no_rangeproof = txout; + no_rangeproof.witness.rangeproof = None; + assert_eq!( + WalletOwnedOutput::new( + outpoint, + no_rangeproof, + opening, + wallet.internal_key, + locator(14), + ), + Err(WalletBoundaryError::MissingRangeproof) + ); + } + + #[test] + fn verifies_opening_commitments_and_rangeproof() { + let wallet = WalletFixture::new(16, 17); + let (outpoint, txout, opening) = wallet.output(18, asset(19), 30); + + let wrong_asset = + TxOutSecrets::new(asset(20), opening.asset_bf, opening.value, opening.value_bf); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + txout.clone(), + wrong_asset, + wallet.internal_key, + locator(18), + ), + Err(WalletBoundaryError::AssetOpeningMismatch) + ); + + let wrong_value = TxOutSecrets::new( + opening.asset, + opening.asset_bf, + opening.value + 1, + opening.value_bf, + ); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + txout.clone(), + wrong_value, + wallet.internal_key, + locator(18), + ), + Err(WalletBoundaryError::ValueOpeningMismatch) + ); + + let (_, other_txout, _) = wallet.output(21, opening.asset, opening.value); + let mut wrong_rangeproof = txout; + wrong_rangeproof.witness.rangeproof = other_txout.witness.rangeproof; + assert_eq!( + WalletOwnedOutput::new( + outpoint, + wrong_rangeproof, + opening, + wallet.internal_key, + locator(18), + ), + Err(WalletBoundaryError::InvalidRangeproof) + ); + } + + #[test] + fn rejects_a_p2tr_output_not_bound_to_the_claimed_tree_less_key() { + let wallet = WalletFixture::new(22, 23); + let other_wallet = WalletFixture::new(24, 25); + let (outpoint, txout, opening) = wallet.output(26, asset(27), 40); + + assert_eq!( + WalletOwnedOutput::new( + outpoint, + txout.clone(), + opening, + other_wallet.internal_key, + locator(26), + ), + Err(WalletBoundaryError::NotExactTreeLessP2tr) + ); + + let mut non_p2tr = txout.clone(); + non_p2tr.script_pubkey = Script::new(); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + non_p2tr, + opening, + wallet.internal_key, + locator(26), + ), + Err(WalletBoundaryError::NotExactTreeLessP2tr) + ); + + let mut script_tree = txout; + script_tree.script_pubkey = Script::new_v1_p2tr( + &Secp256k1::new(), + wallet.internal_key, + Some(TapNodeHash::from_byte_array([1; 32])), + ); + assert_eq!( + WalletOwnedOutput::new( + outpoint, + script_tree, + opening, + wallet.internal_key, + locator(26), + ), + Err(WalletBoundaryError::NotExactTreeLessP2tr) + ); + } + + #[test] + fn output_binding_commits_to_the_opaque_wallet_locator() { + let wallet = WalletFixture::new(28, 29); + let (outpoint, txout, opening) = wallet.output(30, asset(31), 50); + let first = WalletOwnedOutput::new( + outpoint, + txout.clone(), + opening, + wallet.internal_key, + locator(30), + ) + .expect("first output"); + let second = + WalletOwnedOutput::new(outpoint, txout, opening, wallet.internal_key, locator(31)) + .expect("second output"); + + assert_ne!(first.binding(), second.binding()); + } + + #[test] + fn snapshots_sort_outputs_reject_duplicates_and_commit_deterministically() { + let wallet = WalletFixture::new(32, 33); + let first = owned_output(&wallet, 34); + let second = owned_output(&wallet, 35); + let anchor = WalletScanAnchor::new(BlockHash::from_byte_array([36; 32]), 42); + + let forward = + InventorySnapshot::new(identity(), anchor, vec![first.clone(), second.clone()]) + .expect("forward snapshot"); + let reverse = InventorySnapshot::new(identity(), anchor, vec![second, first.clone()]) + .expect("reverse snapshot"); + assert_eq!(forward.outputs(), reverse.outputs()); + assert_eq!(forward.commitment(), reverse.commitment()); + + let other_identity = ProviderIdentity::new( + ProviderId::new([37; 32]), + identity().genesis_hash(), + identity().policy_asset(), + ); + let identity_changed = + InventorySnapshot::new(other_identity, anchor, forward.outputs().to_vec()) + .expect("identity-changed snapshot"); + assert_ne!(forward.commitment(), identity_changed.commitment()); + + let other_anchor = WalletScanAnchor::new(BlockHash::from_byte_array([38; 32]), 43); + let anchor_changed = + InventorySnapshot::new(identity(), other_anchor, forward.outputs().to_vec()) + .expect("anchor-changed snapshot"); + assert_ne!(forward.commitment(), anchor_changed.commitment()); + + assert_eq!( + InventorySnapshot::new(identity(), anchor, vec![first.clone(), first]), + Err(WalletBoundaryError::DuplicateSnapshotOutpoint(outpoint(34))) + ); + } + + #[test] + fn confidential_destinations_require_the_claimed_tree_less_key() { + let wallet = WalletFixture::new(37, 38); + let destination = ConfidentialDestination::new( + wallet.script_pubkey.clone(), + wallet.blinding_public_key, + wallet.internal_key, + locator(39), + ) + .expect("destination"); + assert_eq!(destination.script_pubkey(), &wallet.script_pubkey); + assert_eq!( + destination.blinding_public_key(), + wallet.blinding_public_key + ); + + let other = WalletFixture::new(40, 41); + assert_eq!( + ConfidentialDestination::new( + wallet.script_pubkey, + wallet.blinding_public_key, + other.internal_key, + locator(39), + ), + Err(WalletBoundaryError::NotExactTreeLessP2tr) + ); + } + + fn signature( + wallet: &WalletFixture, + _outpoint: OutPoint, + hash_ty: SchnorrSighashType, + ) -> SchnorrSig { + let message = Message::from_digest([42; 32]); + SchnorrSig { + sig: Secp256k1::new().sign_schnorr(&message, &wallet.spend_keypair), + hash_ty, + } + } + + fn signing_job(wallet: &WalletFixture, targets: &[OutPoint]) -> SigningJob { + SigningJob { + reservation_id: ReservationId::new([43; 32]), + commitment: SigningCommitment::new([44; 32]), + pre_sign_payload: vec![45; 16], + fee: TransactionFee::new(asset(3), 1_000, 400, 100, 80).expect("fee"), + targets: targets + .iter() + .enumerate() + .map(|(index, outpoint)| SigningTarget { + outpoint: *outpoint, + wallet_locator: locator(u8::try_from(index + 1).expect("small test index")), + internal_key: wallet.internal_key, + inventory_binding: InventoryBinding::new( + [u8::try_from(index + 1).expect("small test index"); 32], + ), + }) + .collect(), + } + } + + #[test] + fn provider_signatures_require_explicit_sighash_all() { + let wallet = WalletFixture::new(46, 47); + let outpoint = outpoint(48); + let explicit = ProviderInputSignature::new( + outpoint, + signature(&wallet, outpoint, SchnorrSighashType::All), + ) + .expect("explicit SIGHASH_ALL"); + assert_eq!( + explicit.serialized().len(), + P2TR_SIGHASH_ALL_SIGNATURE_BYTES + ); + assert_eq!( + explicit.serialized()[P2TR_SIGHASH_ALL_SIGNATURE_BYTES - 1], + SchnorrSighashType::All as u8 + ); + assert_eq!( + serialize(&vec![explicit.serialized().to_vec()]).len(), + P2TR_SIGHASH_ALL_SCRIPT_WITNESS_BYTES + ); + + for hash_type in [ + SchnorrSighashType::Default, + SchnorrSighashType::None, + SchnorrSighashType::Single, + SchnorrSighashType::AllPlusAnyoneCanPay, + SchnorrSighashType::NonePlusAnyoneCanPay, + SchnorrSighashType::SinglePlusAnyoneCanPay, + SchnorrSighashType::Reserved, + ] { + assert_eq!( + ProviderInputSignature::new(outpoint, signature(&wallet, outpoint, hash_type),), + Err(WalletBoundaryError::NonExplicitSighashAll { + outpoint, + actual: hash_type, + }) + ); + } + } + + #[test] + fn signing_response_matches_every_durable_target_in_order() { + let wallet = WalletFixture::new(49, 50); + let first_outpoint = outpoint(51); + let second_outpoint = outpoint(52); + let job = signing_job(&wallet, &[first_outpoint, second_outpoint]); + let first = ProviderInputSignature::new( + first_outpoint, + signature(&wallet, first_outpoint, SchnorrSighashType::All), + ) + .expect("first signature"); + let second = ProviderInputSignature::new( + second_outpoint, + signature(&wallet, second_outpoint, SchnorrSighashType::All), + ) + .expect("second signature"); + + let response = SigningResponse::new(&job, vec![first, second]).expect("response"); + assert_eq!(response.commitment(), job.commitment()); + assert_eq!(response.signatures().len(), 2); + + assert_eq!( + SigningResponse::new(&job, vec![first]), + Err(WalletBoundaryError::SignatureCountMismatch { + expected: 2, + actual: 1, + }) + ); + assert_eq!( + SigningResponse::new(&job, vec![second, first]), + Err(WalletBoundaryError::SignatureTargetMismatch { + index: 0, + expected: first_outpoint, + actual: second_outpoint, + }) + ); + } + + struct MockInventorySource(InventorySnapshot); + + impl InventorySource for MockInventorySource { + type Error = Infallible; + + fn inventory_snapshot(&self) -> Result { + Ok(self.0.clone()) + } + } + + struct MockDestinationSource(ConfidentialDestination); + + impl DestinationSource for MockDestinationSource { + type Error = Infallible; + + fn fresh_confidential_destination( + &self, + _purpose: DestinationPurpose, + ) -> Result { + Ok(self.0.clone()) + } + } + + struct MockSigner { + wallet: WalletFixture, + } + + impl ProviderSigner for MockSigner { + type Error = WalletBoundaryError; + + fn sign(&self, job: &SigningJob) -> Result { + let signatures = job + .targets() + .iter() + .map(|target| { + ProviderInputSignature::new( + target.outpoint(), + signature(&self.wallet, target.outpoint(), SchnorrSighashType::All), + ) + }) + .collect::, _>>()?; + SigningResponse::new(job, signatures) + } + } + + #[test] + fn backend_traits_carry_only_validated_capabilities() { + let wallet = WalletFixture::new(53, 54); + let output = owned_output(&wallet, 55); + let snapshot = InventorySnapshot::new( + identity(), + WalletScanAnchor::new(BlockHash::from_byte_array([56; 32]), 9), + vec![output], + ) + .expect("snapshot"); + let source = MockInventorySource(snapshot.clone()); + assert_eq!(source.inventory_snapshot().expect("infallible"), snapshot); + + let destination = ConfidentialDestination::new( + wallet.script_pubkey.clone(), + wallet.blinding_public_key, + wallet.internal_key, + locator(57), + ) + .expect("destination"); + let destinations = MockDestinationSource(destination.clone()); + assert_eq!( + destinations + .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .expect("infallible"), + destination + ); + + let job = signing_job(&wallet, &[outpoint(58)]); + let response = MockSigner { wallet } + .sign(&job) + .expect("shape-valid signature response"); + assert_eq!(response.commitment(), job.commitment()); + assert_eq!(response.signatures()[0].outpoint(), outpoint(58)); + } + + #[test] + fn wallet_boundary_values_are_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } +} diff --git a/docs/adr/0006-rfq-first-liquidity-scope.md b/docs/adr/0006-rfq-first-liquidity-scope.md index a09415a..72ef39d 100644 --- a/docs/adr/0006-rfq-first-liquidity-scope.md +++ b/docs/adr/0006-rfq-first-liquidity-scope.md @@ -123,7 +123,8 @@ links apply only to that revision. 4. **Completed in PR #26 as a provisional client-local API:** add exact-in/exact-out aggregate intent, exact per-leg allocation, authenticated proposal binding, route-owned transaction composition, and no remote wire format. -5. **Provider core implemented under ADR 0007:** complete the separate wallet, - quoting, transaction-validation, signer, relay, and remote-service layers. +5. **Provider state core and wallet capability boundary implemented under ADR + 0007:** complete the concrete wallet backend, quoting, + transaction-validation, signer adapter, relay, and remote-service layers. 6. Add production-shaped process, crash-recovery, mutation, reorg, and operational acceptance gates. diff --git a/docs/adr/0007-rfq-provider-state-machine.md b/docs/adr/0007-rfq-provider-state-machine.md index 8defebf..e4f3c43 100644 --- a/docs/adr/0007-rfq-provider-state-machine.md +++ b/docs/adr/0007-rfq-provider-state-machine.md @@ -141,11 +141,94 @@ 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 state-only crate in this change 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, 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. + +### Wallet capability and quote-eligibility boundary + +The provider's first wallet boundary is backend-neutral: it defines complete +inventory discovery, fresh confidential receive/change destinations, and a +signer capability without selecting Elements RPC, a descriptor wallet, an HSM, +or another production backend. + +Version-one provider inventory has one fixed spend profile: + +- confidential asset, value, and nonce fields; +- present range and surjection proofs; +- an opening whose asset and value reconstruct the on-chain commitments; +- a valid rangeproof for the output script and commitments; +- an exact tree-less P2TR script for the wallet's untweaked internal key; and +- P2TR key-path signatures with an explicit `SIGHASH_ALL` byte. + +Surjection-proof verification needs the creating transaction's complete input +generator domain, so isolated discovery requires proof presence and relies on +the wallet/chain backend's guarantee that the creating transaction passed its +configured chain or mempool validation policy. The later final-transaction +validator rechecks the authoritative prevout and validates the new settlement's +proofs and balance; it cannot reconstruct the historical proof's missing +generator domain from an isolated prevout. + +Discovery returns a complete canonically ordered snapshot bound to the provider +identity and a chain anchor. After validating the complete discovery result, +the service stamps it with the same clock observation persisted by its atomic +inventory import. +The only inventory suitable for quote construction is: + +```text +fresh complete wallet snapshot + intersection +durable allocation state == Available +``` + +Durable `Available` by itself means only “not allocated in redb.” It never +means “currently unspent” or “fresh enough to quote.” A process restart has no +positive discovery cache and must scan again. A later complete snapshot +replaces membership without deleting durable inventory history; outputs absent +from it become ineligible, while reserved and committed outputs never re-enter +eligibility merely because the wallet rediscovers them. + +A wallet-source error may retain the last successful view only within its +original freshness window. Once the source returns a newer complete view, that +result supersedes the old observation even if identity, size, immutable +metadata, import, or reconciliation checks reject it: the coordinator clears +the positive cache and requires another successful scan. An authoritative +contradiction can therefore never fall back to older quoteable inventory. + +The coordinator serializes refresh, eligibility, and reservation. A reservation +must present the current in-process snapshot token and may name only outputs in +that exact eligible view. Token and membership are rechecked while the refresh +lock is held; snapshot freshness and the quote deadline are then sampled again +after acquiring the durable writer lock. This closes the local +list-then-reserve and queued-writer expiry races; authoritative prevouts must +still be rechecked before commitment because chain state can change immediately +after any scan. Exact idempotent reservation retries replay their durable result +even after the original snapshot has been superseded. + +Wallet blinding factors authenticate discovery and remain only in the redacted +in-memory complete snapshot so provider-side collaborative blinding can consume +them. That fresh complete view does not filter out reserved or committed +outputs, so they remain available for transaction construction when the wallet +source still reports them; the separate eligible view contains only its +durable-`Available` intersection. redb never retains blinding factors: it stores +the unblinded asset and amount, untweaked public key, a fixed-size opaque +non-secret wallet locator, and a commitment to the public discovery metadata. +The locator must resolve through wallet ownership history, not only the current +unspent set. When a reservation crosses the point of no return, its exact +locators, keys, outpoints, and inventory commitments become part of the durable +signing job and signing commitment. Signing recovery therefore does not depend +on a committed input continuing to appear in `listunspent` after an ambiguous +signing or broadcast attempt. Restarted pre-commit collaborative blinding does +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. ## Consequences @@ -156,9 +239,10 @@ detached caller assertions are not an admissible production trust boundary. replay. - Immediate provider relay and optional provider-funded CPFP reduce the time committed inventory remains unavailable; cooperative RBF is deferred. -- The state core stores no private keys and implements no pricing, inventory - discovery, transaction validation, signing, networking, relay, mempool, or - reorg policy. Those layers consume its transition-specific API. +- The persistence core stores no private keys and implements no pricing, + 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. @@ -169,7 +253,8 @@ detached caller assertions are not an admissible production trust boundary. ## Implementation and follow-up -The first implementation is the `deadcat-rfq-provider` library. It provides +The first implementation is the `deadcat-rfq-provider` library. Its durable +state layer provides 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 @@ -178,12 +263,21 @@ 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. -The next provider milestones are: - -1. choose the wallet/signer and inventory-discovery boundary; -2. add configurable inventory-aware quote construction; -3. validate a concrete final Liquid PSET and derive its exact fee metrics; -4. define a dedicated RFQ protocol, identity, and ALPN; -5. persist relay and chain-reconciliation observations without ever reopening +Its wallet layer now provides validated confidential tree-less P2TR discovery, +complete chain-anchored snapshots, atomic batch import followed by a +reserve-time-rechecked fresh-availability intersection, confidential input +openings kept only in redacted memory, destination and committed-job-only signer +capability interfaces, explicit `SIGHASH_ALL` response shape, durable non-secret +recovery locators, and adversarial restart, freshness, replacement, concurrency, +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. + +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 a committed outpoint; and -6. pass process-kill, signer ambiguity, mempool, confirmation, and reorg gates. +5. pass process-kill, signer ambiguity, mempool, confirmation, and reorg gates. diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index 5cca620..a0ffeb8 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -583,6 +583,17 @@ interface proposed here: proves two-wallet P2TR settlement, collaborative blinding, exact whole-transaction validation, and spendable recipient outputs on liquidregtest. +- The transport-free + [`deadcat-rfq-provider`](../crates/deadcat-rfq-provider/src/lib.rs) core + durably allocates exact inventory and enforces commit-before-sign recovery. + Its backend-neutral wallet boundary admits only authenticated confidential + tree-less P2TR inventory, intersects complete time-bounded wallet scans with + durable availability, retains confidential input openings only in redacted + memory for collaborative blinding, defines typed confidential receive/change + destination capabilities, and gives signers only exact durable jobs with + 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. - 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 @@ -614,8 +625,10 @@ interface proposed here: remain historical composition evidence, not production interfaces. Phase 1 has extracted and tested the smallest generic plan/composer seam from -these patterns without making the router depend on maker-specific types. The -API remains provisional until real remote RFQ evidence and a production signer +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. ### Symbolic transaction contributions @@ -953,8 +966,10 @@ three independently designed fragment layouts compose safely. network representation? - How are signed RFQ executions published without unnecessarily sacrificing trade privacy? -- Which sighash profiles are supported for each input type, what fields does - each profile commit, and how are omitted proofs or witnesses authenticated? +- Which sighash profiles are supported for future covenant venue inputs, what + fields does each profile commit, and how are omitted proofs or witnesses + authenticated? RFQ provider inventory version one is fixed to tree-less P2TR + key path with explicit `SIGHASH_ALL`. - ADR 0007 resolves reservation, commit-before-sign, signature persistence, and permanent input retirement. Exact relay, ambiguous-broadcast, and canonical outspend reconciliation remain to be specified with the service layer.