Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,37 @@ transaction construction. A future AMM or decentralized limit-order book can
implement the same venue boundary. [ADR 0007](docs/adr/0007-rfq-provider-state-machine.md)
defines the provider's durable reservation and commit-before-sign boundary.
The transport-free provider state core and backend-neutral wallet capability
boundary are implemented. A production wallet/RPC/HSM backend, pricing,
transaction validator, signer adapter, remote service, and relay remain future
work. Until the validator and signer adapter land, the safety-critical commit
and signed-result transitions are intentionally crate-internal. The RFQ
provider remains separate from `deadcat-node`; future AMM and DLOB protocols
are not implemented by this repository today.
boundary are implemented, along with configurable, inventory-aware firm-quote
construction for exact-in and exact-out trades. The quote engine applies exact
integer pricing, deterministically selects fresh available inventory, reserves
its exact outpoints, and durably replays the same symbolic transaction
contribution for an idempotent request. The provider now also validates a
concrete final Liquid PSET before the irreversible signing transition: it binds
the persisted RFQ leg inside a venue-neutral transaction, checks authoritative
unspent prevouts, finalized taker P2TR `SIGHASH_ALL` signatures, confidential
disclosures/proofs/balance and provider output recovery, and derives fee and
weight facts with the missing provider witnesses projected. Its `FirmQuote` is
still an internal, unauthenticated artifact, not yet a provider-signed network
quote. A production wallet/RPC/HSM backend, market-data pricing source, signer
adapter, authenticated remote protocol, and relay remain future work.
This initial validator accepts ordinary finalized tree-less P2TR
`SIGHASH_ALL` inputs outside the current RFQ leg; Simplicity covenant inputs
and a second interactive RFQ signer need a later authenticated venue/script
verification seam.
The eventual service must derive market assets from chain-validated canonical
parameters and add authenticated-owner rate limits plus bounded history
retention; the library's live-quote quotas only cap concurrent reservations.
The safety-critical commit is reachable only by consuming the validator's
opaque one-shot intent; the signed-result transition remains crate-internal
until the signer adapter lands. The RFQ provider remains separate from
`deadcat-node`; future AMM and DLOB protocols are not implemented by this
repository today.

The RFQ provider database is still clean-slate preproduction state. Its schema
and private record-layout versions intentionally remain `1` while the provider
core evolves; local databases created by earlier alpha builds must be deleted
and recreated rather than migrated. This exception must end before any provider
database is treated as production data.

## Assurance

Expand Down
2 changes: 2 additions & 0 deletions crates/deadcat-rfq-provider/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ publish.workspace = true
workspace = true

[dependencies]
deadcat-types.workspace = true
elements.workspace = true
postcard.workspace = true
redb.workspace = true
Expand All @@ -17,4 +18,5 @@ sha2.workspace = true
thiserror.workspace = true

[dev-dependencies]
deadcat-client.workspace = true
tempfile.workspace = true
141 changes: 134 additions & 7 deletions crates/deadcat-rfq-provider/src/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@
//! publishes only outputs present in a recent complete wallet snapshot *and*
//! durably unallocated, and it holds the snapshot lock while reserving them.

use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
#[cfg(test)]
use std::collections::BTreeSet;
use std::sync::{Mutex, MutexGuard};

use elements::OutPoint;
use elements::hashes::Hash as _;
use sha2::{Digest as _, Sha256};
use thiserror::Error;

use crate::model::{Clock, InventoryState, ProviderIdentity, ReservationPlan, UnixMillis};
use crate::store::{ProviderError, ReservationBook, ReserveOutcome};
#[cfg(test)]
use crate::model::ReservationPlan;
use crate::model::{Clock, InventoryState, ProviderIdentity, UnixMillis};
use crate::model::{IdempotencyKey, OwnerId, QuoteRequestDigest};
use crate::quote::{FirmQuoteDraft, FirmQuoteOutcome, QuoteEnginePolicy};
#[cfg(test)]
use crate::store::ReserveOutcome;
use crate::store::{ProviderError, ReservationBook};
use crate::wallet::{
InventorySnapshotCommitment, InventorySource, WalletOwnedOutput, WalletScanAnchor,
};

/// Conservative default upper bound for one complete wallet scan.
pub const DEFAULT_MAX_INVENTORY_OUTPUTS: usize = 10_000;
const ELIGIBLE_INVENTORY_DOMAIN: &[u8] = b"deadcat/rfq/eligible-inventory/v1";

/// Quote-admission policy for wallet inventory snapshots.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -98,6 +109,8 @@ impl EligibilityToken {
pub struct EligibleInventory {
token: EligibilityToken,
anchor: WalletScanAnchor,
allocation_revision: u64,
eligible_commitment: [u8; 32],
outputs: Vec<WalletOwnedOutput>,
}

Expand Down Expand Up @@ -152,6 +165,18 @@ impl EligibleInventory {
self.anchor
}

/// Monotonic durable revision of inventory allocation state.
#[must_use]
pub const fn allocation_revision(&self) -> u64 {
self.allocation_revision
}

/// Commitment to the exact eligible outpoints and wallet bindings.
#[must_use]
pub const fn eligible_commitment(&self) -> [u8; 32] {
self.eligible_commitment
}

#[must_use]
pub fn outputs(&self) -> &[WalletOwnedOutput] {
&self.outputs
Expand Down Expand Up @@ -308,7 +333,8 @@ where
///
/// Existing exact idempotent requests are replayed independently of the
/// old discovery token; they never allocate inventory a second time.
pub fn reserve<C: Clock>(
#[cfg(test)]
pub(crate) fn reserve<C: Clock>(
&self,
eligible: &EligibleInventory,
plan: &ReservationPlan,
Expand Down Expand Up @@ -376,13 +402,100 @@ where
.map_err(Into::into)
}

/// Atomically reserve the exact provider inputs and persist the complete
/// firm quote selected from `eligible`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn reserve_firm_quote<C: Clock>(
&self,
eligible: &EligibleInventory,
owner: OwnerId,
key: IdempotencyKey,
request_digest: QuoteRequestDigest,
draft: &FirmQuoteDraft,
policy: QuoteEnginePolicy,
clock: &C,
) -> Result<FirmQuoteOutcome, InventoryCoordinatorError<S::Error>> {
draft.validate().map_err(|_| {
InventoryCoordinatorError::Provider(ProviderError::FirmQuoteDraftInvalid)
})?;
let state = self.lock_state()?;
// Preflight runs before pricing so failed capacity checks cannot burn
// wallet destinations. Recheck only idempotency after taking the
// snapshot lock: a concurrent identical request may have won in the
// interval, and replay must not depend on the now-stale snapshot.
if let Some(replayed) = self
.book
.replay_firm_quote(owner, key, request_digest, clock)?
{
return Ok(replayed);
}
let now = clock.now();
let latest = self.require_fresh(&state, now)?;
if latest.token != eligible.token {
return Err(InventoryCoordinatorError::SnapshotSuperseded {
requested: eligible.token,
current: latest.token,
});
}
let current_eligible = self.eligible_from_snapshot(latest)?;
if draft.snapshot.allocation_revision() != current_eligible.allocation_revision
|| draft.snapshot.eligible_commitment() != current_eligible.eligible_commitment
{
return Err(InventoryCoordinatorError::Provider(
ProviderError::EligibleInventoryChanged,
));
}
if draft.snapshot.anchor() != latest.anchor
|| draft.snapshot.commitment() != latest.token.snapshot
{
return Err(InventoryCoordinatorError::Provider(
ProviderError::FirmQuoteSnapshotMismatch,
));
}
for quoted_input in draft.contribution.inputs() {
let Some(output) = current_eligible
.outputs
.iter()
.find(|output| output.outpoint() == quoted_input.outpoint())
else {
return Err(InventoryCoordinatorError::OutpointNotInEligibleView(
quoted_input.outpoint(),
));
};
if output.asset() != draft.selected_asset
|| output.txout() != quoted_input.witness_utxo()
|| output.binding() != quoted_input.inventory_binding()
{
return Err(InventoryCoordinatorError::Provider(
ProviderError::FirmQuoteInventoryMismatch(quoted_input.outpoint()),
));
}
}
self.book
.reserve_firm_quote_from_snapshot(
owner,
key,
request_digest,
draft,
policy,
latest.token.observed_at,
self.policy.max_snapshot_age_millis,
clock,
)
.map_err(Into::into)
}

fn eligible_from_snapshot(
&self,
latest: &PublishedSnapshot,
) -> Result<EligibleInventory, InventoryCoordinatorError<S::Error>> {
let durable = self
.book
.inventory_all()?
let outpoints = latest
.outputs
.iter()
.map(WalletOwnedOutput::outpoint)
.collect::<Vec<_>>();
let (durable, allocation_revision) = self.book.inventory_state_for(&outpoints)?;
let durable = durable
.into_iter()
.map(|view| (view.item().outpoint(), view))
.collect::<BTreeMap<_, _>>();
Expand All @@ -408,6 +521,8 @@ where
Ok(EligibleInventory {
token: latest.token,
anchor: latest.anchor,
allocation_revision,
eligible_commitment: eligible_commitment(&outputs),
outputs,
})
}
Expand Down Expand Up @@ -452,6 +567,18 @@ where
}
}

fn eligible_commitment(outputs: &[WalletOwnedOutput]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(ELIGIBLE_INVENTORY_DOMAIN);
hasher.update((outputs.len() as u64).to_be_bytes());
for output in outputs {
hasher.update(output.outpoint().txid.to_byte_array());
hasher.update(output.outpoint().vout.to_be_bytes());
hasher.update(output.binding().to_bytes());
}
hasher.finalize().into()
}

/// Fail-closed discovery, freshness, or durable-allocation error.
#[derive(Debug, Error)]
pub enum InventoryCoordinatorError<SourceError>
Expand Down
38 changes: 29 additions & 9 deletions crates/deadcat-rfq-provider/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
//!
//! Wallet discovery admits only confidential tree-less P2TR outputs and quote
//! eligibility is the intersection of a fresh complete scan with durable
//! unallocated state. Concrete wallet/RPC/HSM implementations remain outside
//! this crate. The commit and signed-result transitions also remain private
//! until the concrete transaction validator and signer adapter can be their
//! only producers.
//! unallocated state. The final-PSET validator is the sole production path to
//! the commit transition: it rechecks the durable quote, authoritative
//! prevouts, complete taker signatures, confidential proofs and openings, and
//! exact fee/weight facts before it emits a one-shot signing capability.
//! Concrete wallet/RPC/HSM implementations remain outside this crate. The
//! signed-result transition remains private until a concrete signer adapter
//! can be its only producer.

mod inventory;
mod model;
mod quote;
mod store;
mod wallet;

Expand All @@ -32,17 +36,33 @@ pub use model::{
AuditEntry, AuditEvent, Clock, FeePolicy, FeePolicyViolation, FeeSizeMetric, IdempotencyKey,
InventoryBinding, InventoryItem, InventoryState, InventoryView, MAX_RESERVATION_INPUTS,
MAX_SETTLEMENT_BYTES, ModelError, OwnerId, ProviderId, ProviderIdentity, QuoteCommitment,
RecoveryAction, ReleaseReason, ReservationAccess, ReservationId, ReservationPlan,
QuoteRequestDigest, RecoveryAction, ReleaseReason, ReservationAccess, ReservationId,
ReservationState, ReservationView, SignedArtifact, SignedArtifactDigest, SigningCommitment,
SigningJob, SigningTarget, TransactionFee, UnixMillis, WalletKeyLocator,
};
pub use quote::{
AmountRange, AssetAmount, BinaryMarketAssets, DEFAULT_MAX_LIVE_QUOTES_PER_OWNER,
DEFAULT_MAX_QUOTE_INPUTS, DEFAULT_QUOTE_LIFETIME_MILLIS, DEFAULT_SELECTION_SEARCH_NODE_BUDGET,
FirmQuote, FirmQuoteOutcome, FirmQuoteRequest, InventorySummary,
MAX_QUOTE_RECIPIENT_SCRIPT_BYTES, MarketQuoteConfig, PairLimits, PairRule, PricingDecision,
PricingPolicy, PricingPolicyId, PricingRequest, PricingRevision, PricingSide,
QuoteAdmissionError, QuoteBlinderRole, QuoteConfigurationError, QuoteContext,
QuoteContribution, QuoteEngine, QuoteEngineError, QuoteEnginePolicy, QuoteExecution,
QuoteInputId, QuoteKind, QuoteModelError, QuoteOutputId, QuoteOutputRole, QuoteRecipient,
QuoteSnapshotEvidence, QuotedOutput, QuotedProviderInput, RationalRate, StaticPricingError,
StaticRateRule, StaticRationalPricing,
};
pub use store::{
CommitOutcome, MAX_EXPIRATION_BATCH, ProviderError, ReservationBook, ReserveOutcome,
SCHEMA_VERSION, SignedOutcome,
AuthoritativePrevout, CommitOutcome, DEFAULT_MAX_SETTLEMENT_INPUTS,
DEFAULT_MAX_SETTLEMENT_OUTPUTS, MAX_EXPIRATION_BATCH, ProviderError,
ProviderSettlementValidator, ReservationBook, SCHEMA_VERSION, SettlementChainSource,
SettlementInputPlacement, SettlementLayout, SettlementLayoutError, SettlementLimitsError,
SettlementOutputPlacement, SettlementValidationError, SettlementValidationLimits,
SignedOutcome, ValidatedSigningIntent,
};
pub use wallet::{
ConfidentialDestination, DestinationPurpose, DestinationSource, InventorySnapshot,
InventorySnapshotCommitment, InventorySource, P2TR_SIGHASH_ALL_SCRIPT_WITNESS_BYTES,
P2TR_SIGHASH_ALL_SIGNATURE_BYTES, ProviderInputSignature, ProviderSigner, SigningResponse,
WalletBoundaryError, WalletOwnedOutput, WalletScanAnchor,
P2TR_SIGHASH_ALL_SIGNATURE_BYTES, ProviderInputSignature, ProviderOutputRecovery,
ProviderSigner, SigningResponse, WalletBoundaryError, WalletOwnedOutput, WalletScanAnchor,
};
Loading
Loading