diff --git a/crates/deadcat-client/Cargo.toml b/crates/deadcat-client/Cargo.toml index d26dc9a..709ca31 100644 --- a/crates/deadcat-client/Cargo.toml +++ b/crates/deadcat-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deadcat-client" -description = "Client-side Deadcat evidence verification and market transaction construction." +description = "Client-side Deadcat evidence verification, venue normalization, and transaction construction." version.workspace = true edition.workspace = true publish.workspace = true diff --git a/crates/deadcat-client/src/composition.rs b/crates/deadcat-client/src/composition.rs new file mode 100644 index 0000000..9520b4c --- /dev/null +++ b/crates/deadcat-client/src/composition.rs @@ -0,0 +1,1767 @@ +//! Provisional, venue-neutral transaction composition. +//! +//! Venue adapters contribute narrow symbolic input and output specifications, +//! never independently assembled PSETs. The composer allocates every global +//! position once, resolves blinder input references, appends the sole network +//! fee output, and returns an unblinded-structure manifest. +//! +//! The manifest freezes transaction-body fields and the clear asset/amount +//! metadata from which ordinary confidential outputs are blinded. It is not a +//! signing authorization check: after blinding, each signer must additionally +//! validate its sighash policy, commitment disclosures, consensus proofs, and +//! recipient openings. Venue-specific covenant finalizers remain responsible +//! for their own witnesses and proof domains. +//! +//! This first seam intentionally supports native-witness, non-issuing inputs, +//! exact exclusive outputs, trusted client-local covenant output templates, +//! absolute locktime requirements, and non-RBF sequences. Output aggregation, +//! relative timelocks, issuance, peg-ins, and arbitrary ordering constraints +//! require concrete venue designs and are not guessed here. + +use std::collections::BTreeMap; + +use elements::bitcoin::PublicKey; +use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTransaction}; +use elements::{AssetId, LockTime, OutPoint, Script, Sequence, TxOut}; +use thiserror::Error; + +/// Contribution-local symbolic identity for one transaction input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct InputId(u64); + +impl InputId { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +/// Contribution-local symbolic identity for one transaction output claim. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OutputId(u64); + +impl OutputId { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +/// Input responsible for blinding one ordinary confidential output. +/// +/// Local references are resolved within the output's own contribution. +/// External references use an exact outpoint, avoiding numeric-ID coordination +/// between independently prepared contributions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlinderRef { + Local(InputId), + External(OutPoint), +} + +/// Exact sequence profiles supported by the first composer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputSequence { + /// Final input; does not activate transaction locktime. + Final, + /// Non-final and non-RBF input used to activate absolute locktime. + LocktimeEnabled, +} + +impl InputSequence { + #[must_use] + pub const fn to_sequence(self) -> Sequence { + match self { + Self::Final => Sequence::MAX, + Self::LocktimeEnabled => Sequence(0xffff_fffe), + } + } +} + +/// Narrow specification for an ordinary input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InputSpec { + id: InputId, + outpoint: OutPoint, + witness_utxo: TxOut, + sequence: InputSequence, +} + +impl InputSpec { + #[must_use] + pub const fn new( + id: InputId, + outpoint: OutPoint, + witness_utxo: TxOut, + sequence: InputSequence, + ) -> Self { + Self { + id, + outpoint, + witness_utxo, + sequence, + } + } + + #[must_use] + pub const fn id(&self) -> InputId { + self.id + } + + #[must_use] + pub const fn outpoint(&self) -> OutPoint { + self.outpoint + } + + #[must_use] + pub const fn witness_utxo(&self) -> &TxOut { + &self.witness_utxo + } + + #[must_use] + pub const fn sequence(&self) -> InputSequence { + self.sequence + } +} + +/// Exact output kind supported by the first composer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutputSpec { + /// Confidential recipient output, assigned to one symbolic blinder input. + Confidential { + id: OutputId, + asset: AssetId, + amount: u64, + script_pubkey: Script, + blinding_key: PublicKey, + blinder: BlinderRef, + }, + /// Explicit non-fee output. Empty scripts are reserved for the sole fee. + Explicit { + id: OutputId, + asset: AssetId, + amount: u64, + script_pubkey: Script, + }, + /// Exact committed output produced only by a trusted client-local covenant + /// builder. The private template type prevents remote venue data from + /// entering the composer as an arbitrary PSET output. + Covenant { + id: OutputId, + template: CovenantOutputTemplate, + }, +} + +/// Exact covenant output body with no public constructor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CovenantOutputTemplate(TxOut); + +impl CovenantOutputTemplate { + pub(crate) fn trusted(txout: TxOut) -> Self { + Self(txout) + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + &self.0 + } +} + +impl OutputSpec { + #[must_use] + pub const fn confidential( + id: OutputId, + asset: AssetId, + amount: u64, + script_pubkey: Script, + blinding_key: PublicKey, + blinder: BlinderRef, + ) -> Self { + Self::Confidential { + id, + asset, + amount, + script_pubkey, + blinding_key, + blinder, + } + } + + #[must_use] + pub const fn explicit( + id: OutputId, + asset: AssetId, + amount: u64, + script_pubkey: Script, + ) -> Self { + Self::Explicit { + id, + asset, + amount, + script_pubkey, + } + } + + #[must_use] + pub const fn id(&self) -> OutputId { + match self { + Self::Confidential { id, .. } + | Self::Explicit { id, .. } + | Self::Covenant { id, .. } => *id, + } + } + + #[must_use] + pub const fn asset_amount(&self) -> Option<(AssetId, u64)> { + match self { + Self::Confidential { asset, amount, .. } | Self::Explicit { asset, amount, .. } => { + Some((*asset, *amount)) + } + Self::Covenant { .. } => None, + } + } + + #[must_use] + pub const fn confidential_recipient(&self) -> Option<(&Script, PublicKey)> { + match self { + Self::Confidential { + script_pubkey, + blinding_key, + .. + } => Some((script_pubkey, *blinding_key)), + Self::Explicit { .. } | Self::Covenant { .. } => None, + } + } + + #[must_use] + pub const fn blinder(&self) -> Option { + match self { + Self::Confidential { blinder, .. } => Some(*blinder), + Self::Explicit { .. } | Self::Covenant { .. } => None, + } + } + + pub(crate) fn covenant(id: OutputId, txout: TxOut) -> Self { + Self::Covenant { + id, + template: CovenantOutputTemplate::trusted(txout), + } + } +} + +/// Absolute transaction-locktime requirement contributed by one participant. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LockTimeConstraint { + #[default] + Unconstrained, + /// The final transaction locktime must be at least this value. + AtLeast(LockTime), + /// The final transaction locktime must equal this value. + Exact(LockTime), +} + +/// One deterministic, contiguous input/output contribution. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TransactionContribution { + inputs: Vec, + outputs: Vec, + locktime: LockTimeConstraint, +} + +impl TransactionContribution { + #[must_use] + pub fn new( + inputs: Vec, + outputs: Vec, + locktime: LockTimeConstraint, + ) -> Self { + Self { + inputs, + outputs, + locktime, + } + } + + #[must_use] + pub fn inputs(&self) -> &[InputSpec] { + &self.inputs + } + + #[must_use] + pub fn outputs(&self) -> &[OutputSpec] { + &self.outputs + } + + #[must_use] + pub const fn locktime(&self) -> LockTimeConstraint { + self.locktime + } +} + +/// Hard local bounds applied before proof generation or signing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CompositionLimits { + pub max_contributions: usize, + pub max_inputs: usize, + pub max_outputs: usize, + pub max_script_pubkey_bytes: usize, + pub max_unblinded_pset_bytes: usize, +} + +impl Default for CompositionLimits { + fn default() -> Self { + Self { + max_contributions: 8, + max_inputs: 32, + max_outputs: 32, + max_script_pubkey_bytes: 10_000, + max_unblinded_pset_bytes: 1_000_000, + } + } +} + +/// Exact explicit Liquid network fee created only by the composer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NetworkFee { + policy_asset: AssetId, + amount: u64, +} + +impl NetworkFee { + pub fn new(policy_asset: AssetId, amount: u64) -> Result { + if amount == 0 { + return Err(CompositionError::ZeroNetworkFee); + } + Ok(Self { + policy_asset, + amount, + }) + } + + #[must_use] + pub const fn policy_asset(self) -> AssetId { + self.policy_asset + } + + #[must_use] + pub const fn amount(self) -> u64 { + self.amount + } +} + +/// Opaque handle returned when a contribution is appended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ContributionHandle(usize); + +/// Final contiguous placement for one contribution. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ContributionPlacement { + input_base: usize, + input_count: usize, + output_base: usize, + output_count: usize, +} + +impl ContributionPlacement { + #[must_use] + pub const fn input_base(self) -> usize { + self.input_base + } + + #[must_use] + pub const fn input_count(self) -> usize { + self.input_count + } + + #[must_use] + pub const fn output_base(self) -> usize { + self.output_base + } + + #[must_use] + pub const fn output_count(self) -> usize { + self.output_count + } + + #[must_use] + pub fn input_index(self, local_index: usize) -> Option { + (local_index < self.input_count).then(|| self.input_base + local_index) + } + + #[must_use] + pub fn output_index(self, local_index: usize) -> Option { + (local_index < self.output_count).then(|| self.output_base + local_index) + } +} + +/// Symbolic-to-physical position map for a completed composition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompositionLayout { + input_indices: BTreeMap<(ContributionHandle, InputId), usize>, + output_indices: BTreeMap<(ContributionHandle, OutputId), usize>, + outpoint_indices: BTreeMap, + placements: Vec, + fee_output_index: usize, +} + +impl CompositionLayout { + #[must_use] + pub fn input_index(&self, handle: ContributionHandle, id: InputId) -> Option { + self.input_indices.get(&(handle, id)).copied() + } + + #[must_use] + pub fn output_index(&self, handle: ContributionHandle, id: OutputId) -> Option { + self.output_indices.get(&(handle, id)).copied() + } + + #[must_use] + pub fn outpoint_index(&self, outpoint: OutPoint) -> Option { + self.outpoint_indices.get(&outpoint).copied() + } + + #[must_use] + pub fn placement(&self, handle: ContributionHandle) -> Option { + self.placements.get(handle.0).copied() + } + + #[must_use] + pub const fn fee_output_index(&self) -> usize { + self.fee_output_index + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ManifestInput { + outpoint: OutPoint, + witness_utxo: TxOut, + sequence: Sequence, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ManifestOutput { + Confidential { + asset: AssetId, + amount: u64, + script_pubkey: Script, + blinding_key: PublicKey, + blinder_index: u32, + }, + Explicit { + asset: AssetId, + amount: u64, + script_pubkey: Script, + }, + Covenant(CovenantOutputTemplate), + Fee(NetworkFee), +} + +/// Frozen unblinded transaction-structure expectations. +/// +/// This deliberately ignores participant signing metadata, collaborative +/// blinding scalar state, and—for ordinary confidential outputs—post-blinding +/// commitments and proofs. It is therefore necessary but not sufficient before +/// signing. A signer must also run its own complete sighash, proof, and +/// recipient-opening validation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnblindedStructureManifest { + inputs: Vec, + outputs: Vec, + locktime: LockTime, +} + +impl UnblindedStructureManifest { + /// Revalidate the transaction body and original clear output declarations. + /// + /// This is not a signing-intent or post-blinding proof validator. + pub fn validate(&self, pset: &PartiallySignedTransaction) -> Result<(), CompositionError> { + if pset.global.version != 2 || pset.global.tx_data.version != 2 { + return Err(CompositionError::UnexpectedVersion); + } + if pset.global.tx_data.tx_modifiable.unwrap_or(0) != 0 + || pset.global.elements_tx_modifiable_flag.unwrap_or(0) != 0 + { + return Err(CompositionError::TransactionModifiable); + } + if !pset.global.xpub.is_empty() + || !pset.global.proprietary.is_empty() + || !pset.global.unknown.is_empty() + { + return Err(CompositionError::UnexpectedGlobalMetadata); + } + if pset + .global + .tx_data + .fallback_locktime + .unwrap_or(LockTime::ZERO) + != self.locktime + { + return Err(CompositionError::LockTimeMismatch); + } + if pset.inputs().len() != self.inputs.len() || pset.outputs().len() != self.outputs.len() { + return Err(CompositionError::ShapeMismatch); + } + + for (index, (actual, expected)) in pset.inputs().iter().zip(&self.inputs).enumerate() { + let actual_outpoint = OutPoint::new(actual.previous_txid, actual.previous_output_index); + if actual_outpoint != expected.outpoint { + return Err(CompositionError::InputMismatch { index }); + } + let Some(actual_utxo) = actual.witness_utxo.as_ref() else { + return Err(CompositionError::InputMismatch { index }); + }; + if !same_prevout_body(actual_utxo, &expected.witness_utxo) + || actual.in_utxo_rangeproof != expected.witness_utxo.witness.rangeproof + || actual.sequence.unwrap_or(Sequence::MAX) != expected.sequence + || actual.required_height_locktime.is_some() + || actual.required_time_locktime.is_some() + || actual.final_script_sig.is_some() + || has_issuance_metadata(actual) + || has_pegin(actual) + || !actual.proprietary.is_empty() + || !actual.unknown.is_empty() + { + return Err(CompositionError::InputMismatch { index }); + } + } + + for (index, (actual, expected)) in pset.outputs().iter().zip(&self.outputs).enumerate() { + if !actual.proprietary.is_empty() || !actual.unknown.is_empty() { + return Err(CompositionError::OutputMismatch { index }); + } + match expected { + ManifestOutput::Confidential { + asset, + amount, + script_pubkey, + blinding_key, + blinder_index, + } => { + if actual.asset != Some(*asset) + || actual.amount != Some(*amount) + || actual.script_pubkey != *script_pubkey + || actual.blinding_key != Some(*blinding_key) + || actual.blinder_index != Some(*blinder_index) + { + return Err(CompositionError::OutputMismatch { index }); + } + } + ManifestOutput::Explicit { + asset, + amount, + script_pubkey, + } => { + if !is_exact_explicit_output(actual, *asset, *amount, script_pubkey) { + return Err(CompositionError::OutputMismatch { index }); + } + } + ManifestOutput::Covenant(template) => { + if !matches_covenant_template(actual, template.txout()) { + return Err(CompositionError::OutputMismatch { index }); + } + } + ManifestOutput::Fee(fee) => { + if !is_exact_explicit_output( + actual, + fee.policy_asset, + fee.amount, + &Script::new(), + ) { + return Err(CompositionError::OutputMismatch { index }); + } + } + } + } + Ok(()) + } + + #[must_use] + pub const fn locktime(&self) -> LockTime { + self.locktime + } +} + +/// Complete unblinded PSET, its symbolic layout, and its frozen manifest. +#[derive(Clone, Debug)] +pub struct ComposedTransaction { + pset: PartiallySignedTransaction, + layout: CompositionLayout, + manifest: UnblindedStructureManifest, +} + +impl ComposedTransaction { + #[must_use] + pub const fn pset(&self) -> &PartiallySignedTransaction { + &self.pset + } + + #[must_use] + pub const fn layout(&self) -> &CompositionLayout { + &self.layout + } + + #[must_use] + pub const fn manifest(&self) -> &UnblindedStructureManifest { + &self.manifest + } + + #[must_use] + pub fn into_parts( + self, + ) -> ( + PartiallySignedTransaction, + CompositionLayout, + UnblindedStructureManifest, + ) { + (self.pset, self.layout, self.manifest) + } +} + +/// Deterministic append-only transaction composer. +#[derive(Clone, Debug)] +pub struct TransactionComposer { + limits: CompositionLimits, + fee: NetworkFee, + contributions: Vec, +} + +impl TransactionComposer { + #[must_use] + pub const fn new(limits: CompositionLimits, fee: NetworkFee) -> Self { + Self { + limits, + fee, + contributions: Vec::new(), + } + } + + pub fn push( + &mut self, + contribution: TransactionContribution, + ) -> Result { + if self.contributions.len() >= self.limits.max_contributions { + return Err(CompositionError::TooManyContributions); + } + let handle = ContributionHandle(self.contributions.len()); + self.contributions.push(contribution); + Ok(handle) + } + + pub fn finish(self) -> Result { + if self.contributions.is_empty() { + return Err(CompositionError::NoContributions); + } + let input_count = self + .contributions + .iter() + .try_fold(0_usize, |total, contribution| { + total.checked_add(contribution.inputs.len()) + }) + .ok_or(CompositionError::LimitOverflow)?; + let non_fee_output_count = self + .contributions + .iter() + .try_fold(0_usize, |total, contribution| { + total.checked_add(contribution.outputs.len()) + }) + .ok_or(CompositionError::LimitOverflow)?; + let output_count = non_fee_output_count + .checked_add(1) + .ok_or(CompositionError::LimitOverflow)?; + if input_count == 0 { + return Err(CompositionError::NoInputs); + } + if input_count > self.limits.max_inputs { + return Err(CompositionError::TooManyInputs); + } + if output_count > self.limits.max_outputs { + return Err(CompositionError::TooManyOutputs); + } + + let locktime = resolve_locktime( + self.contributions + .iter() + .map(TransactionContribution::locktime), + )?; + // Absolute nLockTime is activated transaction-wide: any non-final + // input is sufficient, even when a different contribution requires + // the lock. Checking a designated input would recreate H-1's bug. + let has_non_final = self + .contributions + .iter() + .flat_map(TransactionContribution::inputs) + .any(|input| input.sequence().to_sequence() != Sequence::MAX); + if locktime != LockTime::ZERO && !has_non_final { + return Err(CompositionError::InactiveLockTime); + } + + let mut pset = PartiallySignedTransaction::new_v2(); + pset.global.tx_data.fallback_locktime = Some(locktime); + pset.global.tx_data.tx_modifiable = Some(0); + pset.global.elements_tx_modifiable_flag = Some(0); + let mut input_indices = BTreeMap::new(); + let mut output_indices = BTreeMap::new(); + let mut outpoint_indices = BTreeMap::new(); + let mut placements = Vec::with_capacity(self.contributions.len()); + let mut manifest_inputs = Vec::with_capacity(input_count); + + for (contribution_index, contribution) in self.contributions.iter().enumerate() { + let handle = ContributionHandle(contribution_index); + let input_base = pset.inputs().len(); + for input in contribution.inputs() { + let input_index = pset.inputs().len(); + if input_indices + .insert((handle, input.id()), input_index) + .is_some() + { + return Err(CompositionError::DuplicateInputId(input.id())); + } + if input.outpoint().is_null() || input.outpoint().vout & 0xc000_0000 != 0 { + return Err(CompositionError::UnsupportedOutpoint(input.outpoint())); + } + if !input.witness_utxo().script_pubkey.is_witness_program() { + return Err(CompositionError::NonWitnessInput(input.outpoint())); + } + if input.witness_utxo().script_pubkey.len() > self.limits.max_script_pubkey_bytes { + return Err(CompositionError::ScriptPubkeyTooLarge); + } + if outpoint_indices + .insert(input.outpoint(), input_index) + .is_some() + { + return Err(CompositionError::DuplicateOutpoint(input.outpoint())); + } + let mut pset_input = PsetInput::from_prevout(input.outpoint()); + pset_input.witness_utxo = Some(input.witness_utxo().clone()); + // TxOut witnesses are not encoded inside PSET_IN_WITNESS_UTXO. + // Preserve the input rangeproof in its dedicated Elements + // field so a serialized handoff carries the complete proof. + pset_input.in_utxo_rangeproof = input.witness_utxo().witness.rangeproof.clone(); + pset_input.sequence = Some(input.sequence().to_sequence()); + pset.add_input(pset_input); + manifest_inputs.push(ManifestInput { + outpoint: input.outpoint(), + witness_utxo: input.witness_utxo().clone(), + sequence: input.sequence().to_sequence(), + }); + } + placements.push(ContributionPlacement { + input_base, + input_count: contribution.inputs().len(), + output_base: 0, + output_count: contribution.outputs().len(), + }); + } + + let mut manifest_outputs = Vec::with_capacity(output_count); + for (contribution_index, contribution) in self.contributions.iter().enumerate() { + let handle = ContributionHandle(contribution_index); + let output_base = pset.outputs().len(); + placements[contribution_index].output_base = output_base; + for output in contribution.outputs() { + let output_index = pset.outputs().len(); + if output_indices + .insert((handle, output.id()), output_index) + .is_some() + { + return Err(CompositionError::DuplicateOutputId(output.id())); + } + match output { + OutputSpec::Confidential { + asset, + amount, + script_pubkey, + blinding_key, + blinder, + .. + } => { + validate_ordinary_output( + *amount, + script_pubkey, + self.limits.max_script_pubkey_bytes, + )?; + let blinder_index = match blinder { + BlinderRef::Local(input_id) => input_indices + .get(&(handle, *input_id)) + .copied() + .ok_or(CompositionError::UnknownLocalBlinder(*input_id))?, + BlinderRef::External(outpoint) => outpoint_indices + .get(outpoint) + .copied() + .ok_or(CompositionError::UnknownExternalBlinder(*outpoint))?, + }; + let blinder_index = u32::try_from(blinder_index) + .map_err(|_| CompositionError::BlinderIndexOverflow)?; + let mut pset_output = PsetOutput::new_explicit( + script_pubkey.clone(), + *amount, + *asset, + Some(*blinding_key), + ); + pset_output.blinder_index = Some(blinder_index); + pset.add_output(pset_output); + manifest_outputs.push(ManifestOutput::Confidential { + asset: *asset, + amount: *amount, + script_pubkey: script_pubkey.clone(), + blinding_key: *blinding_key, + blinder_index, + }); + } + OutputSpec::Explicit { + asset, + amount, + script_pubkey, + .. + } => { + validate_ordinary_output( + *amount, + script_pubkey, + self.limits.max_script_pubkey_bytes, + )?; + pset.add_output(PsetOutput::new_explicit( + script_pubkey.clone(), + *amount, + *asset, + None, + )); + manifest_outputs.push(ManifestOutput::Explicit { + asset: *asset, + amount: *amount, + script_pubkey: script_pubkey.clone(), + }); + } + OutputSpec::Covenant { template, .. } => { + validate_covenant_template( + template.txout(), + self.limits.max_script_pubkey_bytes, + )?; + pset.add_output(PsetOutput::from_txout(template.txout().clone())); + manifest_outputs.push(ManifestOutput::Covenant(template.clone())); + } + } + } + } + + let fee_output_index = pset.outputs().len(); + pset.add_output(PsetOutput::from_txout(TxOut::new_fee( + self.fee.amount, + self.fee.policy_asset, + ))); + manifest_outputs.push(ManifestOutput::Fee(self.fee)); + let layout = CompositionLayout { + input_indices, + output_indices, + outpoint_indices, + placements, + fee_output_index, + }; + if elements::encode::serialize(&pset).len() > self.limits.max_unblinded_pset_bytes { + return Err(CompositionError::UnblindedPsetTooLarge); + } + let manifest = UnblindedStructureManifest { + inputs: manifest_inputs, + outputs: manifest_outputs, + locktime, + }; + manifest.validate(&pset)?; + Ok(ComposedTransaction { + pset, + layout, + manifest, + }) + } +} + +fn validate_ordinary_output( + amount: u64, + script_pubkey: &Script, + max_script_pubkey_bytes: usize, +) -> Result<(), CompositionError> { + if amount == 0 { + return Err(CompositionError::ZeroOutputAmount); + } + if script_pubkey.is_empty() { + return Err(CompositionError::ReservedFeeScript); + } + if script_pubkey.is_provably_unspendable() { + return Err(CompositionError::UnspendableOrdinaryOutput); + } + if script_pubkey.len() > max_script_pubkey_bytes { + return Err(CompositionError::ScriptPubkeyTooLarge); + } + Ok(()) +} + +fn validate_covenant_template( + template: &TxOut, + max_script_pubkey_bytes: usize, +) -> Result<(), CompositionError> { + if template.script_pubkey.is_empty() { + return Err(CompositionError::ReservedFeeScript); + } + if template.script_pubkey.len() > max_script_pubkey_bytes { + return Err(CompositionError::ScriptPubkeyTooLarge); + } + Ok(()) +} + +fn resolve_locktime( + constraints: impl IntoIterator, +) -> Result { + let mut minimum: Option = None; + let mut exact: Option = None; + for constraint in constraints { + match constraint { + LockTimeConstraint::Unconstrained => {} + LockTimeConstraint::AtLeast(candidate) => { + if candidate == LockTime::ZERO { + continue; + } + minimum = Some(match minimum { + None => candidate, + Some(current) if current.is_same_unit(candidate) => { + if current.to_consensus_u32() >= candidate.to_consensus_u32() { + current + } else { + candidate + } + } + Some(_) => return Err(CompositionError::IncompatibleLockTimeUnits), + }); + } + LockTimeConstraint::Exact(candidate) => { + if exact.is_some_and(|current| current != candidate) { + return Err(CompositionError::IncompatibleExactLockTimes); + } + exact = Some(candidate); + } + } + } + if let Some(exact) = exact { + if let Some(minimum) = minimum + && (!exact.is_same_unit(minimum) + || exact.to_consensus_u32() < minimum.to_consensus_u32()) + { + return Err(CompositionError::ExactLockTimeBelowMinimum); + } + return Ok(exact); + } + Ok(minimum.unwrap_or(LockTime::ZERO)) +} + +fn same_prevout_body(actual: &TxOut, expected: &TxOut) -> bool { + actual.asset == expected.asset + && actual.value == expected.value + && actual.nonce == expected.nonce + && actual.script_pubkey == expected.script_pubkey +} + +fn has_pegin(input: &PsetInput) -> bool { + input.is_pegin() + || input.pegin_tx.is_some() + || input.pegin_txout_proof.is_some() + || input.pegin_genesis_hash.is_some() + || input.pegin_claim_script.is_some() + || input.pegin_value.is_some() + || input.pegin_witness.is_some() +} + +fn has_issuance_metadata(input: &PsetInput) -> bool { + input.has_issuance() + || input.issuance_value_amount.is_some() + || input.issuance_value_comm.is_some() + || input.issuance_inflation_keys.is_some() + || input.issuance_inflation_keys_comm.is_some() + || input.issuance_value_rangeproof.is_some() + || input.issuance_keys_rangeproof.is_some() + || input.issuance_blinding_nonce.is_some() + || input.issuance_asset_entropy.is_some() + || input.in_issuance_blind_value_proof.is_some() + || input.in_issuance_blind_inflation_keys_proof.is_some() + || input.blinded_issuance.is_some() +} + +fn matches_covenant_template(output: &PsetOutput, expected: &TxOut) -> bool { + let actual = output.to_txout(); + actual.asset == expected.asset + && actual.value == expected.value + && actual.nonce == expected.nonce + && actual.script_pubkey == expected.script_pubkey + && actual.witness.rangeproof == expected.witness.rangeproof + && (expected.witness.surjection_proof.is_none() + || actual.witness.surjection_proof == expected.witness.surjection_proof) +} + +fn is_exact_explicit_output( + output: &PsetOutput, + asset: AssetId, + amount: u64, + script_pubkey: &Script, +) -> bool { + output.asset == Some(asset) + && output.amount == Some(amount) + && output.script_pubkey == *script_pubkey + && output.asset_comm.is_none() + && output.amount_comm.is_none() + && output.blinding_key.is_none() + && output.ecdh_pubkey.is_none() + && output.blinder_index.is_none() + && output.value_rangeproof.is_none() + && output.asset_surjection_proof.is_none() + && output.blind_value_proof.is_none() + && output.blind_asset_proof.is_none() +} + +/// Composition failures are fail-closed before any proof or signature work. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CompositionError { + #[error("the network fee must be positive")] + ZeroNetworkFee, + #[error("at least one contribution is required")] + NoContributions, + #[error("at least one input is required")] + NoInputs, + #[error("the contribution limit was exceeded")] + TooManyContributions, + #[error("the input limit was exceeded")] + TooManyInputs, + #[error("the output limit was exceeded")] + TooManyOutputs, + #[error("composition limit arithmetic overflowed")] + LimitOverflow, + #[error("duplicate symbolic input id {0:?}")] + DuplicateInputId(InputId), + #[error("duplicate transaction outpoint {0}")] + DuplicateOutpoint(OutPoint), + #[error("ordinary inputs cannot use null, peg-in, or issuance outpoint flags: {0}")] + UnsupportedOutpoint(OutPoint), + #[error("ordinary input {0} does not spend a native witness output")] + NonWitnessInput(OutPoint), + #[error("duplicate symbolic output id {0:?}")] + DuplicateOutputId(OutputId), + #[error("output refers to unknown local blinder input {0:?}")] + UnknownLocalBlinder(InputId), + #[error("output refers to missing external blinder outpoint {0}")] + UnknownExternalBlinder(OutPoint), + #[error("the resolved blinder index does not fit in PSET v2")] + BlinderIndexOverflow, + #[error("ordinary contribution outputs must have positive amounts")] + ZeroOutputAmount, + #[error("an empty script is reserved for the composer-created fee output")] + ReservedFeeScript, + #[error("ordinary outputs cannot use provably unspendable scripts")] + UnspendableOrdinaryOutput, + #[error("an output script exceeds the configured byte limit")] + ScriptPubkeyTooLarge, + #[error("the unblinded PSET exceeds the configured byte limit")] + UnblindedPsetTooLarge, + #[error("height and time locktime requirements cannot be combined")] + IncompatibleLockTimeUnits, + #[error("exact locktime requirements disagree")] + IncompatibleExactLockTimes, + #[error("the exact locktime does not satisfy every minimum")] + ExactLockTimeBelowMinimum, + #[error("nonzero locktime is ineffective because every input is final")] + InactiveLockTime, + #[error("unexpected PSET or transaction version")] + UnexpectedVersion, + #[error("the transaction remains marked modifiable")] + TransactionModifiable, + #[error("unexpected proprietary or unknown global metadata")] + UnexpectedGlobalMetadata, + #[error("the transaction locktime no longer matches the frozen manifest")] + LockTimeMismatch, + #[error("the transaction input/output shape no longer matches the frozen manifest")] + ShapeMismatch, + #[error("input {index} no longer matches the frozen manifest")] + InputMismatch { index: usize }, + #[error("output {index} no longer matches the frozen manifest")] + OutputMismatch { index: usize }, +} + +#[cfg(test)] +mod tests { + use elements::bitcoin::PublicKey as BitcoinPublicKey; + use elements::confidential::{Asset, Nonce, Value}; + use elements::hashes::Hash as _; + use elements::secp256k1_zkp::{PublicKey, Secp256k1, SecretKey}; + use elements::{AssetId, TxOutWitness, Txid}; + + use super::*; + + fn asset(byte: u8) -> AssetId { + AssetId::from_slice(&[byte; 32]).expect("asset") + } + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint::new(Txid::from_byte_array([byte; 32]), vout) + } + + fn script(byte: u8) -> Script { + let mut bytes = vec![0x00, 0x14]; + bytes.extend([byte; 20]); + Script::from(bytes) + } + + fn blinding_key(byte: u8) -> BitcoinPublicKey { + BitcoinPublicKey::new(PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[byte; 32]).expect("secret"), + )) + } + + fn explicit_utxo(asset: AssetId, amount: u64, script_pubkey: Script) -> TxOut { + TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(amount), + nonce: Nonce::Null, + script_pubkey, + witness: TxOutWitness::default(), + } + } + + fn input(id: u64, txid_byte: u8, vout: u32, sequence: InputSequence) -> InputSpec { + InputSpec::new( + InputId::new(id), + outpoint(txid_byte, vout), + explicit_utxo(asset(1), 10_000, script(txid_byte)), + sequence, + ) + } + + fn output(id: u64, amount: u64, blinder: u64) -> OutputSpec { + OutputSpec::confidential( + OutputId::new(id), + asset(2), + amount, + script(u8::try_from(id).expect("small test id")), + blinding_key(3), + BlinderRef::Local(InputId::new(blinder)), + ) + } + + fn fee() -> NetworkFee { + NetworkFee::new(asset(1), 100).expect("fee") + } + + #[test] + fn composition_is_deterministic_and_resolves_nonzero_symbolic_positions() { + let wallet = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Unconstrained, + ); + let venue = TransactionContribution::new( + vec![input(2, 20, 0, InputSequence::Final)], + vec![output(20, 2_000, 2), output(21, 3_000, 2)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + let wallet_handle = composer.push(wallet.clone()).expect("wallet contribution"); + let venue_handle = composer.push(venue.clone()).expect("venue contribution"); + let composed = composer.finish().expect("composition"); + let mut second = TransactionComposer::new(CompositionLimits::default(), fee()); + second.push(wallet).expect("wallet contribution"); + second.push(venue).expect("venue contribution"); + let second = second.finish().expect("second composition"); + assert_eq!(composed.pset(), second.pset()); + assert_eq!(composed.layout(), second.layout()); + assert_eq!(composed.manifest(), second.manifest()); + + assert_eq!( + composed + .layout() + .input_index(wallet_handle, InputId::new(1)), + Some(0) + ); + assert_eq!( + composed.layout().input_index(venue_handle, InputId::new(2)), + Some(1) + ); + assert_eq!( + composed + .layout() + .output_index(wallet_handle, OutputId::new(10)), + Some(0) + ); + assert_eq!( + composed + .layout() + .output_index(venue_handle, OutputId::new(20)), + Some(1) + ); + assert_eq!( + composed + .layout() + .output_index(venue_handle, OutputId::new(21)), + Some(2) + ); + assert_eq!(composed.layout().fee_output_index(), 3); + assert_eq!( + composed.layout().placement(wallet_handle), + Some(ContributionPlacement { + input_base: 0, + input_count: 1, + output_base: 0, + output_count: 1, + }) + ); + assert_eq!( + composed.layout().placement(venue_handle), + Some(ContributionPlacement { + input_base: 1, + input_count: 1, + output_base: 1, + output_count: 2, + }) + ); + assert_eq!(composed.pset().outputs()[1].blinder_index, Some(1)); + composed + .manifest() + .validate(composed.pset()) + .expect("manifest"); + } + + #[test] + fn duplicate_dependencies_and_symbolic_ids_fail_closed() { + let first = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Unconstrained, + ); + let duplicate_outpoint = TransactionContribution::new( + vec![input(2, 10, 0, InputSequence::Final)], + vec![output(20, 2_000, 2)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(first).expect("first"); + composer.push(duplicate_outpoint).expect("second"); + assert!(matches!( + composer.finish(), + Err(CompositionError::DuplicateOutpoint(_)) + )); + + let duplicate_input_id = TransactionContribution::new( + vec![ + input(1, 11, 0, InputSequence::Final), + input(1, 12, 0, InputSequence::Final), + ], + vec![output(20, 2_000, 1)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(duplicate_input_id).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::DuplicateInputId(InputId(1))) + )); + + let duplicate_output_id = TransactionContribution::new( + vec![input(2, 11, 0, InputSequence::Final)], + vec![output(10, 2_000, 2), output(10, 3_000, 2)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(duplicate_output_id).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::DuplicateOutputId(OutputId(10))) + )); + } + + #[test] + fn identical_exclusive_outputs_never_alias() { + let duplicate = OutputSpec::confidential( + OutputId::new(10), + asset(2), + 1_000, + script(10), + blinding_key(3), + BlinderRef::External(outpoint(10, 0)), + ); + let same_bytes_different_claim = match duplicate.clone() { + OutputSpec::Confidential { + asset, + amount, + script_pubkey, + blinding_key, + blinder, + .. + } => OutputSpec::confidential( + OutputId::new(11), + asset, + amount, + script_pubkey, + blinding_key, + blinder, + ), + OutputSpec::Explicit { .. } | OutputSpec::Covenant { .. } => unreachable!(), + }; + let first = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![duplicate], + LockTimeConstraint::Unconstrained, + ); + let second = TransactionContribution::new( + // Contribution-local IDs may safely repeat across independent + // fragments without any global namespace coordination. + vec![input(1, 20, 0, InputSequence::Final)], + vec![same_bytes_different_claim], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + let first_handle = composer.push(first).expect("first contribution"); + let second_handle = composer.push(second).expect("second contribution"); + let composed = composer.finish().expect("composition"); + let first = composed + .layout() + .output_index(first_handle, OutputId::new(10)) + .expect("first output"); + let second = composed + .layout() + .output_index(second_handle, OutputId::new(11)) + .expect("second output"); + assert_ne!(first, second); + assert_eq!(composed.pset().outputs()[0], composed.pset().outputs()[1]); + } + + #[test] + fn blinder_references_and_fee_ownership_are_closed_world() { + let missing_blinder = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 99)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(missing_blinder).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::UnknownLocalBlinder(InputId(99))) + )); + + let missing_outpoint = outpoint(99, 0); + let external_blinder = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![OutputSpec::confidential( + OutputId::new(10), + asset(2), + 1_000, + script(10), + blinding_key(3), + BlinderRef::External(missing_outpoint), + )], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(external_blinder).expect("contribution"); + assert_eq!( + composer.finish().expect_err("missing external blinder"), + CompositionError::UnknownExternalBlinder(missing_outpoint) + ); + + let fake_fee = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![OutputSpec::explicit( + OutputId::new(10), + asset(1), + 1, + Script::new(), + )], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(fake_fee).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::ReservedFeeScript) + )); + } + + #[test] + fn locktime_constraints_intersect_and_activate_globally() { + let required = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::AtLeast(LockTime::from_height(100).expect("height")), + ); + let unrelated_activator = TransactionContribution::new( + vec![input(2, 20, 0, InputSequence::LocktimeEnabled)], + vec![output(20, 1_000, 2)], + LockTimeConstraint::AtLeast(LockTime::from_height(120).expect("height")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(required).expect("required"); + composer.push(unrelated_activator).expect("activator"); + let composed = composer.finish().expect("composition"); + assert_eq!( + composed.manifest().locktime(), + LockTime::from_height(120).expect("height") + ); + assert_eq!(composed.pset().inputs()[0].sequence, Some(Sequence::MAX)); + assert_eq!( + composed.pset().inputs()[1].sequence, + Some(Sequence(0xffff_fffe)) + ); + } + + #[test] + fn incompatible_or_inactive_locktime_fails_closed() { + let all_final = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::AtLeast(LockTime::from_height(100).expect("height")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(all_final.clone()).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::InactiveLockTime) + )); + + let time = TransactionContribution::new( + vec![input(2, 20, 0, InputSequence::LocktimeEnabled)], + vec![output(20, 1_000, 2)], + LockTimeConstraint::AtLeast(LockTime::from_time(500_000_001).expect("time")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(all_final).expect("height"); + composer.push(time).expect("time"); + assert!(matches!( + composer.finish(), + Err(CompositionError::IncompatibleLockTimeUnits) + )); + } + + #[test] + fn exact_locktimes_intersect_or_fail_closed() { + let minimum = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::AtLeast(LockTime::from_height(100).expect("height")), + ); + let exact = TransactionContribution::new( + vec![input(1, 20, 0, InputSequence::LocktimeEnabled)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Exact(LockTime::from_height(120).expect("height")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(minimum.clone()).expect("minimum"); + composer.push(exact.clone()).expect("exact"); + assert_eq!( + composer.finish().expect("compatible").manifest().locktime(), + LockTime::from_height(120).expect("height") + ); + + let below = TransactionContribution::new( + vec![input(1, 30, 0, InputSequence::LocktimeEnabled)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Exact(LockTime::from_height(99).expect("height")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(minimum.clone()).expect("minimum"); + composer.push(below).expect("below"); + assert_eq!( + composer.finish().expect_err("below minimum"), + CompositionError::ExactLockTimeBelowMinimum + ); + + let conflicting = TransactionContribution::new( + vec![input(1, 40, 0, InputSequence::LocktimeEnabled)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Exact(LockTime::from_height(121).expect("height")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(exact).expect("exact"); + composer.push(conflicting).expect("conflicting"); + assert_eq!( + composer.finish().expect_err("conflicting exact values"), + CompositionError::IncompatibleExactLockTimes + ); + + let exact_time = TransactionContribution::new( + vec![input(1, 50, 0, InputSequence::LocktimeEnabled)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Exact(LockTime::from_time(500_000_001).expect("time")), + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(minimum).expect("minimum"); + composer.push(exact_time).expect("exact time"); + assert_eq!( + composer.finish().expect_err("mixed units"), + CompositionError::ExactLockTimeBelowMinimum + ); + } + + #[test] + fn manifest_detects_structural_mutations_after_handoff() { + let contribution = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(contribution).expect("contribution"); + let composed = composer.finish().expect("composition"); + + let mut wrong_output = composed.pset().clone(); + wrong_output.outputs_mut()[0].amount = Some(1_001); + assert_eq!( + composed.manifest().validate(&wrong_output), + Err(CompositionError::OutputMismatch { index: 0 }) + ); + + let mut wrong_outpoint = composed.pset().clone(); + wrong_outpoint.inputs_mut()[0].previous_output_index = 1; + assert_eq!( + composed.manifest().validate(&wrong_outpoint), + Err(CompositionError::InputMismatch { index: 0 }) + ); + + let mut wrong_prevout = composed.pset().clone(); + wrong_prevout.inputs_mut()[0] + .witness_utxo + .as_mut() + .expect("prevout") + .value = Value::Explicit(9_999); + assert_eq!( + composed.manifest().validate(&wrong_prevout), + Err(CompositionError::InputMismatch { index: 0 }) + ); + + let mut wrong_sequence = composed.pset().clone(); + wrong_sequence.inputs_mut()[0].sequence = Some(Sequence::ZERO); + assert_eq!( + composed.manifest().validate(&wrong_sequence), + Err(CompositionError::InputMismatch { index: 0 }) + ); + + let mut wrong_locktime = composed.pset().clone(); + wrong_locktime.global.tx_data.fallback_locktime = + Some(LockTime::from_height(1).expect("height")); + assert_eq!( + composed.manifest().validate(&wrong_locktime), + Err(CompositionError::LockTimeMismatch) + ); + + let mut modifiable = composed.pset().clone(); + modifiable.global.tx_data.tx_modifiable = Some(1); + assert_eq!( + composed.manifest().validate(&modifiable), + Err(CompositionError::TransactionModifiable) + ); + + let mut wrong_recipient = composed.pset().clone(); + wrong_recipient.outputs_mut()[0].script_pubkey = script(9); + assert_eq!( + composed.manifest().validate(&wrong_recipient), + Err(CompositionError::OutputMismatch { index: 0 }) + ); + + let mut wrong_blinder = composed.pset().clone(); + wrong_blinder.outputs_mut()[0].blinder_index = Some(9); + assert_eq!( + composed.manifest().validate(&wrong_blinder), + Err(CompositionError::OutputMismatch { index: 0 }) + ); + + let mut wrong_fee = composed.pset().clone(); + wrong_fee.outputs_mut()[1].amount = Some(101); + assert_eq!( + composed.manifest().validate(&wrong_fee), + Err(CompositionError::OutputMismatch { index: 1 }) + ); + + let mut extra_fee = composed.pset().clone(); + extra_fee.add_output(PsetOutput::from_txout(TxOut::new_fee(1, asset(1)))); + assert_eq!( + composed.manifest().validate(&extra_fee), + Err(CompositionError::ShapeMismatch) + ); + + let mut issuance = composed.pset().clone(); + issuance.inputs_mut()[0].issuance_value_amount = Some(1); + assert_eq!( + composed.manifest().validate(&issuance), + Err(CompositionError::InputMismatch { index: 0 }) + ); + + // Signing metadata is intentionally outside this structure-only + // manifest and must be authorized by the participant-specific signer. + let mut signing_metadata = composed.pset().clone(); + signing_metadata.inputs_mut()[0].sighash_type = + Some(elements::SchnorrSighashType::All.into()); + composed + .manifest() + .validate(&signing_metadata) + .expect("signing metadata is a separate validation layer"); + } + + #[test] + fn limits_apply_before_composition() { + let limits = CompositionLimits { + max_contributions: 1, + max_inputs: 1, + max_outputs: 2, + ..CompositionLimits::default() + }; + let contribution = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(10, 1_000, 1)], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(limits, fee()); + composer.push(contribution.clone()).expect("first"); + assert_eq!( + composer.push(contribution), + Err(CompositionError::TooManyContributions) + ); + } + + #[test] + fn empty_flagged_and_oversized_shapes_fail_closed() { + assert_eq!( + TransactionComposer::new(CompositionLimits::default(), fee()) + .finish() + .expect_err("empty composer"), + CompositionError::NoContributions + ); + + let no_inputs = TransactionContribution::new( + Vec::new(), + vec![OutputSpec::explicit( + OutputId::new(1), + asset(2), + 1, + script(1), + )], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(no_inputs).expect("contribution"); + assert_eq!( + composer.finish().expect_err("no inputs"), + CompositionError::NoInputs + ); + + let limits = CompositionLimits { + max_inputs: 1, + max_outputs: 1, + ..CompositionLimits::default() + }; + let mut too_many_inputs = TransactionComposer::new(limits, fee()); + too_many_inputs + .push(TransactionContribution::new( + vec![ + input(1, 10, 0, InputSequence::Final), + input(2, 11, 0, InputSequence::Final), + ], + Vec::new(), + LockTimeConstraint::Unconstrained, + )) + .expect("contribution"); + assert_eq!( + too_many_inputs.finish().expect_err("input limit"), + CompositionError::TooManyInputs + ); + + let mut fee_inclusive_outputs = TransactionComposer::new(limits, fee()); + fee_inclusive_outputs + .push(TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![output(1, 1, 1)], + LockTimeConstraint::Unconstrained, + )) + .expect("contribution"); + assert_eq!( + fee_inclusive_outputs + .finish() + .expect_err("fee counts toward output limit"), + CompositionError::TooManyOutputs + ); + + let flagged = TransactionContribution::new( + vec![input(1, 10, 1 << 30, InputSequence::Final)], + Vec::new(), + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer.push(flagged).expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::UnsupportedOutpoint(_)) + )); + + let legacy_input = InputSpec::new( + InputId::new(1), + outpoint(10, 0), + explicit_utxo(asset(1), 1_000, Script::from(vec![0x51])), + InputSequence::Final, + ); + let mut composer = TransactionComposer::new(CompositionLimits::default(), fee()); + composer + .push(TransactionContribution::new( + vec![legacy_input], + Vec::new(), + LockTimeConstraint::Unconstrained, + )) + .expect("contribution"); + assert!(matches!( + composer.finish(), + Err(CompositionError::NonWitnessInput(_)) + )); + + let script_limits = CompositionLimits { + max_script_pubkey_bytes: 10, + ..CompositionLimits::default() + }; + let mut oversized_input = TransactionComposer::new(script_limits, fee()); + oversized_input + .push(TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + Vec::new(), + LockTimeConstraint::Unconstrained, + )) + .expect("contribution"); + assert_eq!( + oversized_input.finish().expect_err("input script limit"), + CompositionError::ScriptPubkeyTooLarge + ); + + let oversized = TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + vec![OutputSpec::explicit( + OutputId::new(1), + asset(2), + 1, + Script::from(vec![0x51; 11]), + )], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new(script_limits, fee()); + composer.push(oversized).expect("contribution"); + assert_eq!( + composer.finish().expect_err("script limit"), + CompositionError::ScriptPubkeyTooLarge + ); + + let mut composer = TransactionComposer::new( + CompositionLimits { + max_unblinded_pset_bytes: 1, + ..CompositionLimits::default() + }, + fee(), + ); + composer + .push(TransactionContribution::new( + vec![input(1, 10, 0, InputSequence::Final)], + Vec::new(), + LockTimeConstraint::Unconstrained, + )) + .expect("contribution"); + assert_eq!( + composer.finish().expect_err("PSET byte limit"), + CompositionError::UnblindedPsetTooLarge + ); + } +} diff --git a/crates/deadcat-client/src/lib.rs b/crates/deadcat-client/src/lib.rs index caffcf2..7e4dd3f 100644 --- a/crates/deadcat-client/src/lib.rs +++ b/crates/deadcat-client/src/lib.rs @@ -1,4 +1,6 @@ -//! Transport-free client verification and construction logic. +//! Transport-free client verification, venue normalization, and construction logic. +pub mod composition; pub mod market_builder; pub mod validation; +pub mod venue; diff --git a/crates/deadcat-client/src/market_builder.rs b/crates/deadcat-client/src/market_builder.rs index d9dbe3f..9a6d5f4 100644 --- a/crates/deadcat-client/src/market_builder.rs +++ b/crates/deadcat-client/src/market_builder.rs @@ -41,6 +41,11 @@ use rand::SeedableRng as _; use rand::rngs::StdRng; use thiserror::Error; +use crate::composition::{ + InputId, InputSequence, InputSpec, LockTimeConstraint, OutputId, OutputSpec, + TransactionContribution, +}; + /// Network-known assets needed to verify a compact market recovery hint. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MarketCreationContext { @@ -455,6 +460,80 @@ impl BinaryMarketTransitionPlan { .collect() } + /// Convert a non-issuance lifecycle plan into one contiguous composer + /// contribution. The caller supplies authoritative witness UTXOs in the + /// plan's input-role order; this method verifies them before exposing the + /// narrow symbolic fragment. + /// + /// Issuance remains deliberately outside the first generic composer seam + /// because it requires typed reissuance fields, not a widened raw-PSET + /// adapter surface. + pub fn composition_contribution( + &self, + witness_utxos: Vec, + ) -> Result { + if matches!( + self.applied.transition, + BinaryMarketTransition::Issued { .. } + ) { + return Err(MarketBuilderError::CompositionIssuanceUnsupported); + } + let slots = self.input_slots(); + if witness_utxos.len() != slots.len() { + return Err(MarketBuilderError::CompositionInputCountMismatch); + } + let compiled = compile(self.params)?; + let sequence = if matches!( + self.path(), + BinaryMarketPath::ActiveExpiry | BinaryMarketPath::DormantExpiry + ) { + InputSequence::LocktimeEnabled + } else { + InputSequence::Final + }; + let mut scratch = PartiallySignedTransaction::new_v2(); + let mut inputs = Vec::with_capacity(slots.len()); + for (offset, (slot, witness_utxo)) in slots.iter().copied().zip(witness_utxos).enumerate() { + let outpoint = self.outpoint_for_slot(slot)?; + let mut pset_input = PsetInput::from_prevout(outpoint); + pset_input.witness_utxo = Some(witness_utxo.clone()); + pset_input.sequence = Some(sequence.to_sequence()); + scratch.add_input(pset_input); + inputs.push(InputSpec::new( + InputId::new(u64::try_from(offset).map_err(|_| MarketBuilderError::IndexOverflow)?), + outpoint, + witness_utxo, + sequence, + )); + } + self.verify_inputs(&compiled, &scratch, 0)?; + + let outputs = self + .output_templates + .iter() + .cloned() + .enumerate() + .map(|(offset, txout)| { + u64::try_from(offset) + .map(OutputId::new) + .map(|id| OutputSpec::covenant(id, txout)) + .map_err(|_| MarketBuilderError::IndexOverflow) + }) + .collect::, _>>()?; + let locktime = if matches!( + self.path(), + BinaryMarketPath::ActiveExpiry | BinaryMarketPath::DormantExpiry + ) { + LockTimeConstraint::AtLeast( + LockTime::from_height(self.params.expiry_height) + .map_err(|_| MarketBuilderError::InvalidExpiryHeight)?, + ) + } else { + LockTimeConstraint::Unconstrained + }; + Ok(TransactionContribution::new(inputs, outputs, locktime)) + } + /// Install the two exact explicit reissuances for an issuance plan. pub fn configure_reissuance_inputs( &self, @@ -676,26 +755,7 @@ impl BinaryMarketTransitionPlan { .inputs() .get(index) .ok_or(MarketBuilderError::InputIndexOutOfBounds)?; - let expected_outpoint = match slot { - BinaryMarketSlot::DormantYesRt | BinaryMarketSlot::UnresolvedYesRt => { - self.live - .yes_rt - .as_ref() - .ok_or(MarketBuilderError::MissingYesRt)? - .outpoint - } - BinaryMarketSlot::DormantNoRt | BinaryMarketSlot::UnresolvedNoRt => { - self.live - .no_rt - .as_ref() - .ok_or(MarketBuilderError::MissingNoRt)? - .outpoint - } - _ => self - .live - .collateral - .ok_or(MarketBuilderError::MissingCollateral)?, - }; + let expected_outpoint = self.outpoint_for_slot(slot)?; if pset_outpoint(input) != expected_outpoint { return Err(MarketBuilderError::WrongContractInput); } @@ -835,6 +895,27 @@ impl BinaryMarketTransitionPlan { .collect() } + fn outpoint_for_slot(&self, slot: BinaryMarketSlot) -> Result { + match slot { + BinaryMarketSlot::DormantYesRt | BinaryMarketSlot::UnresolvedYesRt => self + .live + .yes_rt + .as_ref() + .map(|input| input.outpoint) + .ok_or(MarketBuilderError::MissingYesRt), + BinaryMarketSlot::DormantNoRt | BinaryMarketSlot::UnresolvedNoRt => self + .live + .no_rt + .as_ref() + .map(|input| input.outpoint) + .ok_or(MarketBuilderError::MissingNoRt), + _ => self + .live + .collateral + .ok_or(MarketBuilderError::MissingCollateral), + } + } + fn contract_input_indices(&self, input_base: usize) -> Result, MarketBuilderError> { (0..self.input_slots().len()) .map(|offset| add_index(input_base, offset)) @@ -1446,6 +1527,10 @@ pub enum MarketBuilderError { CommitmentMismatch, #[error("this operation is not an issuance path")] NotIssuancePath, + #[error("the first generic composer seam does not yet model issuance fields")] + CompositionIssuanceUnsupported, + #[error("composer witness UTXOs do not match the plan's input count")] + CompositionInputCountMismatch, #[error("this operation is not an expiry path")] NotExpiryPath, #[error("invalid v1 expiry height")] @@ -1489,11 +1574,20 @@ mod tests { use elements::{Txid, confidential::AssetBlindingFactor}; use super::*; + use crate::composition::{ + CompositionError, CompositionLimits, NetworkFee, TransactionComposer, + }; fn asset(byte: u8) -> AssetId { AssetId::from_slice(&[byte; 32]).expect("asset") } + fn native_witness_script(byte: u8) -> Script { + let mut bytes = vec![0x00, 0x14]; + bytes.extend([byte; 20]); + Script::from(bytes) + } + fn oracle_keypair() -> Keypair { Keypair::from_seckey_slice(&Secp256k1::new(), &[0x31; 32]).expect("oracle key") } @@ -1775,6 +1869,150 @@ mod tests { pset } + #[test] + fn non_issuance_market_plans_compose_and_finalize_at_nonzero_bases() { + let params = params(); + let cases = [ + ( + BinaryMarketState::Trading { + outstanding_pairs: 5, + }, + BinaryMarketAction::Cancel { pairs: 2 }, + false, + 0x81, + ), + ( + BinaryMarketState::Trading { + outstanding_pairs: 3, + }, + BinaryMarketAction::Expire, + true, + 0x83, + ), + ]; + for (before, action, is_expiry, wallet_tag) in cases { + let plan = BinaryMarketTransitionPlan::new( + params, + before, + action, + live_for_state(before), + None, + ) + .expect("transition plan"); + let standalone = pset_for_plan(&plan, 0, 0); + let witness_utxos = standalone + .inputs() + .iter() + .map(|input| input.witness_utxo.clone().expect("witness UTXO")) + .collect(); + let market = plan + .composition_contribution(witness_utxos) + .expect("market contribution"); + let market_input_count = market.inputs().len(); + + let policy_asset = params.collateral_asset_id; + let wallet = TransactionContribution::new( + vec![InputSpec::new( + InputId::new(0), + OutPoint::new(Txid::from_byte_array([wallet_tag; 32]), 0), + explicit_txout(policy_asset, 1_000, native_witness_script(wallet_tag)), + InputSequence::Final, + )], + vec![OutputSpec::explicit( + OutputId::new(0), + policy_asset, + 900, + native_witness_script(wallet_tag.wrapping_add(1)), + )], + LockTimeConstraint::Unconstrained, + ); + let mut composer = TransactionComposer::new( + CompositionLimits::default(), + NetworkFee::new(policy_asset, 100).expect("fee"), + ); + composer.push(wallet).expect("wallet contribution"); + let market_handle = composer.push(market).expect("market contribution"); + let composed = composer.finish().expect("composition"); + let placement = composed + .layout() + .placement(market_handle) + .expect("market placement"); + assert!(placement.input_base() > 0); + assert!(placement.output_base() > 0); + if is_expiry { + assert_eq!( + composed.manifest().locktime(), + LockTime::from_height(params.expiry_height).expect("expiry height") + ); + for input in &composed.pset().inputs() + [placement.input_base()..placement.input_base() + market_input_count] + { + assert_eq!(input.sequence, Some(Sequence(0xffff_fffe))); + } + } + + let (mut pset, _, manifest) = composed.into_parts(); + plan.finalize( + &mut pset, + placement.input_base(), + placement.output_base(), + &SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }, + ) + .expect("finalize composed covenant"); + manifest + .validate(&pset) + .expect("covenant finalization preserves structure"); + + let mut tampered = pset.clone(); + tampered.outputs_mut()[placement.output_base()].script_pubkey = + native_witness_script(0x91); + assert_eq!( + manifest.validate(&tampered), + Err(CompositionError::OutputMismatch { + index: placement.output_base(), + }) + ); + } + } + + #[test] + fn composer_conversion_rejects_issuance_and_wrong_witness_count() { + let params = params(); + let issuance_state = BinaryMarketState::Trading { + outstanding_pairs: 0, + }; + let issuance = BinaryMarketTransitionPlan::new( + params, + issuance_state, + BinaryMarketAction::Issue { pairs: 2 }, + live_for_state(issuance_state), + None, + ) + .expect("issuance plan"); + assert!(matches!( + issuance.composition_contribution(Vec::new()), + Err(MarketBuilderError::CompositionIssuanceUnsupported) + )); + + let cancellation_state = BinaryMarketState::Trading { + outstanding_pairs: 5, + }; + let cancellation = BinaryMarketTransitionPlan::new( + params, + cancellation_state, + BinaryMarketAction::Cancel { pairs: 2 }, + live_for_state(cancellation_state), + None, + ) + .expect("cancellation plan"); + assert!(matches!( + cancellation.composition_contribution(Vec::new()), + Err(MarketBuilderError::CompositionInputCountMismatch) + )); + } + #[test] fn every_market_path_finalizes_real_simplicity_witnesses() { let params = params(); diff --git a/crates/deadcat-client/src/venue.rs b/crates/deadcat-client/src/venue.rs new file mode 100644 index 0000000..ac0b4a6 --- /dev/null +++ b/crates/deadcat-client/src/venue.rs @@ -0,0 +1,1945 @@ +//! Provisional client-local venue normalization. +//! +//! A venue-specific adapter authenticates its quote, reservation, or chain +//! evidence and proposes one selected fill. A client-created per-leg request +//! then binds that proposal to exact payment and recipient outputs before it +//! becomes a [`PreparedLeg`]. Aggregate validation returns an owning +//! [`ValidatedRoute`], which is the only route type that can compose those exact +//! legs and the already-authorized network fee. +//! +//! These types are deliberately transport-free and do not derive +//! serialization: untrusted wire data must never deserialize directly into a +//! client-authorized leg. This initial binding supports ordinary confidential +//! payment/receipt outputs. Future AMM or DLOB adapters will need typed +//! covenant-specific bindings rather than raw PSET maps. +//! +//! Leg input amounts are the user's gross trade-asset debit, including any +//! same-asset venue fee. Leg output amounts are the user's net receipt. The +//! Liquid network fee is authorized and accounted separately. +//! +//! A prepared leg or validated route authorizes only these normalized trade +//! claims and their named ordinary outputs. It does not validate wallet change, +//! ancillary-output net effects, per-asset transaction balance, proofs, or +//! sighash policy. Every participant must still authorize the complete blinded +//! transaction before signing. + +use std::collections::{BTreeMap, BTreeSet}; + +use deadcat_types::{ChainIdentity, ContractId}; +use elements::bitcoin::PublicKey; +use elements::{AssetId, OutPoint, Script}; +use thiserror::Error; + +use crate::composition::{ + BlinderRef, ComposedTransaction, CompositionError, CompositionLimits, ContributionHandle, + InputSpec, NetworkFee, OutputId, OutputSpec, TransactionComposer, TransactionContribution, +}; + +/// Exact amount of one Liquid asset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AssetAmount { + asset: AssetId, + amount: u64, +} + +/// Exact confidential destination authorized by the user for every route leg. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfidentialRecipient { + script_pubkey: Script, + blinding_key: PublicKey, +} + +impl ConfidentialRecipient { + pub fn new(script_pubkey: Script, blinding_key: PublicKey) -> Result { + if script_pubkey.is_empty() || script_pubkey.is_provably_unspendable() { + return Err(ExecutionError::InvalidRecipientScript); + } + Ok(Self { + script_pubkey, + blinding_key, + }) + } + + #[must_use] + pub const fn script_pubkey(&self) -> &Script { + &self.script_pubkey + } + + #[must_use] + pub const fn blinding_key(&self) -> PublicKey { + self.blinding_key + } +} + +impl AssetAmount { + pub fn new(asset: AssetId, amount: u64) -> Result { + if amount == 0 { + return Err(ExecutionError::ZeroAmount); + } + Ok(Self { asset, amount }) + } + + #[must_use] + pub const fn asset(self) -> AssetId { + self.asset + } + + #[must_use] + pub const fn amount(self) -> u64 { + self.amount + } +} + +/// Exact chain, market, and policy-asset context shared by every route leg. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VenueContext { + pub chain: ChainIdentity, + pub market: ContractId, + pub policy_asset: AssetId, +} + +/// User-authorized trade amount semantics. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExecutionKind { + ExactIn { + input: AssetAmount, + output_asset: AssetId, + minimum_output: u64, + }, + ExactOut { + input_asset: AssetId, + maximum_input: u64, + output: AssetAmount, + }, +} + +impl ExecutionKind { + fn pair(self) -> (AssetId, AssetId) { + match self { + Self::ExactIn { + input, + output_asset, + .. + } => (input.asset, output_asset), + Self::ExactOut { + input_asset, + output, + .. + } => (input_asset, output.asset), + } + } +} + +/// Venue-neutral request and the user's hard fee bounds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExecutionRequest { + context: VenueContext, + kind: ExecutionKind, + recipient: ConfidentialRecipient, + venue_fee_limits: BTreeMap, + max_network_fee: u64, +} + +impl ExecutionRequest { + pub fn exact_in( + context: VenueContext, + input: AssetAmount, + output_asset: AssetId, + minimum_output: u64, + recipient: ConfidentialRecipient, + venue_fee_limits: BTreeMap, + max_network_fee: u64, + ) -> Result { + validate_pair_and_amounts(input.asset, input.amount, output_asset, minimum_output)?; + validate_request_limits( + input.asset, + input.amount, + &venue_fee_limits, + max_network_fee, + )?; + Ok(Self { + context, + kind: ExecutionKind::ExactIn { + input, + output_asset, + minimum_output, + }, + recipient, + venue_fee_limits, + max_network_fee, + }) + } + + pub fn exact_out( + context: VenueContext, + input_asset: AssetId, + maximum_input: u64, + output: AssetAmount, + recipient: ConfidentialRecipient, + venue_fee_limits: BTreeMap, + max_network_fee: u64, + ) -> Result { + validate_pair_and_amounts(input_asset, maximum_input, output.asset, output.amount)?; + validate_request_limits( + input_asset, + maximum_input, + &venue_fee_limits, + max_network_fee, + )?; + Ok(Self { + context, + kind: ExecutionKind::ExactOut { + input_asset, + maximum_input, + output, + }, + recipient, + venue_fee_limits, + max_network_fee, + }) + } + + #[must_use] + pub const fn context(&self) -> VenueContext { + self.context + } + + #[must_use] + pub const fn kind(&self) -> ExecutionKind { + self.kind + } + + #[must_use] + pub const fn recipient(&self) -> &ConfidentialRecipient { + &self.recipient + } + + #[must_use] + pub const fn max_network_fee(&self) -> u64 { + self.max_network_fee + } + + /// Allocate an exact-input portion to one venue adapter. + pub fn exact_in_leg( + &self, + id: LegId, + input_amount: u64, + payer_blinder: OutPoint, + ) -> Result { + let ExecutionKind::ExactIn { + input, + output_asset, + .. + } = self.kind + else { + return Err(ExecutionError::WrongLegRequestKind); + }; + Ok(LegPreparationRequest { + id, + context: self.context, + kind: LegExecutionKind::ExactIn { + input: AssetAmount::new(input.asset, input_amount)?, + output_asset, + }, + recipient: self.recipient.clone(), + payer_blinder, + }) + } + + /// Allocate an exact-output portion to one venue adapter. + pub fn exact_out_leg( + &self, + id: LegId, + output_amount: u64, + payer_blinder: OutPoint, + ) -> Result { + let ExecutionKind::ExactOut { + input_asset, + output, + .. + } = self.kind + else { + return Err(ExecutionError::WrongLegRequestKind); + }; + Ok(LegPreparationRequest { + id, + context: self.context, + kind: LegExecutionKind::ExactOut { + input_asset, + output: AssetAmount::new(output.asset, output_amount)?, + }, + recipient: self.recipient.clone(), + payer_blinder, + }) + } + + /// Validate checked aggregate economics and retain ownership of the exact + /// legs and fee that will be composed. + pub fn validate_route( + self, + legs: Vec, + network_fee: NetworkFee, + ) -> Result { + if legs.is_empty() { + return Err(ExecutionError::NoLegs); + } + if network_fee.policy_asset() != self.context.policy_asset { + return Err(ExecutionError::WrongPolicyAsset); + } + if network_fee.amount() > self.max_network_fee { + return Err(ExecutionError::NetworkFeeExceeded { + maximum: self.max_network_fee, + actual: network_fee.amount(), + }); + } + + let expected_pair = self.kind.pair(); + let mut leg_ids = BTreeSet::new(); + let mut total_input = 0_u64; + let mut total_output = 0_u64; + let mut fees = BTreeMap::::new(); + for leg in &legs { + if !leg_ids.insert(leg.id()) { + return Err(ExecutionError::DuplicateLegId(leg.id())); + } + if leg.request.context != self.context || leg.request.recipient != self.recipient { + return Err(ExecutionError::ContextMismatch); + } + if !matches!( + (self.kind, leg.request.kind), + ( + ExecutionKind::ExactIn { .. }, + LegExecutionKind::ExactIn { .. } + ) | ( + ExecutionKind::ExactOut { .. }, + LegExecutionKind::ExactOut { .. } + ) + ) { + return Err(ExecutionError::WrongLegRequestKind); + } + if leg.request.kind.pair() != expected_pair + || (leg.execution.input.asset, leg.execution.output.asset) != expected_pair + { + return Err(ExecutionError::AssetDirectionMismatch); + } + total_input = total_input + .checked_add(leg.execution.input.amount) + .ok_or(ExecutionError::AmountOverflow)?; + total_output = total_output + .checked_add(leg.execution.output.amount) + .ok_or(ExecutionError::AmountOverflow)?; + for (&asset, &amount) in &leg.venue_fees { + let total = fees.entry(asset).or_default(); + *total = total + .checked_add(amount) + .ok_or(ExecutionError::AmountOverflow)?; + } + } + + for (&asset, &actual) in &fees { + let maximum = self.venue_fee_limits.get(&asset).copied().unwrap_or(0); + if actual > maximum { + return Err(ExecutionError::VenueFeeExceeded { + asset, + maximum, + actual, + }); + } + } + + match self.kind { + ExecutionKind::ExactIn { + input, + minimum_output, + .. + } => { + if total_input != input.amount { + return Err(ExecutionError::ExactInputMismatch { + expected: input.amount, + actual: total_input, + }); + } + if total_output < minimum_output { + return Err(ExecutionError::MinimumOutputNotMet { + minimum: minimum_output, + actual: total_output, + }); + } + } + ExecutionKind::ExactOut { + maximum_input, + output, + .. + } => { + if total_output != output.amount { + return Err(ExecutionError::ExactOutputMismatch { + expected: output.amount, + actual: total_output, + }); + } + if total_input > maximum_input { + return Err(ExecutionError::MaximumInputExceeded { + maximum: maximum_input, + actual: total_input, + }); + } + } + } + + let summary = RouteSummary { + execution: ExactExecution { + input: AssetAmount { + asset: expected_pair.0, + amount: total_input, + }, + output: AssetAmount { + asset: expected_pair.1, + amount: total_output, + }, + }, + venue_fees: fees, + network_fee, + }; + Ok(ValidatedRoute { + request: self, + legs, + summary, + }) + } +} + +/// Exact gross input and net output for one prepared leg or complete route. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExactExecution { + input: AssetAmount, + output: AssetAmount, +} + +impl ExactExecution { + pub fn new(input: AssetAmount, output: AssetAmount) -> Result { + validate_pair_and_amounts(input.asset, input.amount, output.asset, output.amount)?; + Ok(Self { input, output }) + } + + #[must_use] + pub const fn input(self) -> AssetAmount { + self.input + } + + #[must_use] + pub const fn output(self) -> AssetAmount { + self.output + } +} + +/// Route-local identifier for one independently prepared venue leg. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LegId(u64); + +impl LegId { + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +/// Per-leg allocation created by the client/router before adapter preparation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegPreparationRequest { + id: LegId, + context: VenueContext, + kind: LegExecutionKind, + recipient: ConfidentialRecipient, + payer_blinder: OutPoint, +} + +impl LegPreparationRequest { + #[must_use] + pub const fn id(&self) -> LegId { + self.id + } + + #[must_use] + pub const fn context(&self) -> VenueContext { + self.context + } + + #[must_use] + pub const fn kind(&self) -> LegExecutionKind { + self.kind + } + + #[must_use] + pub const fn recipient(&self) -> &ConfidentialRecipient { + &self.recipient + } + + #[must_use] + pub const fn payer_blinder(&self) -> OutPoint { + self.payer_blinder + } + + /// Authorize one adapter proposal against this exact allocation and the + /// user's exact recipient/blinding requirements. + pub fn authorize(self, proposal: ProposedLeg) -> Result { + validate_leg_execution(self.kind, proposal.execution)?; + validate_initial_fees(proposal.execution, &proposal.venue_fees)?; + if proposal.contribution.inputs().is_empty() || proposal.contribution.outputs().is_empty() { + return Err(ExecutionError::EmptyContribution); + } + if proposal.payment_output == proposal.receive_output { + return Err(ExecutionError::ReusedEconomicOutput); + } + + let payment = unique_output(&proposal.contribution, proposal.payment_output)?; + validate_claimed_confidential_output( + payment, + proposal.execution.input, + None, + BlinderRef::External(self.payer_blinder), + )?; + let receive = unique_output(&proposal.contribution, proposal.receive_output)?; + validate_claimed_confidential_output( + receive, + proposal.execution.output, + Some(&self.recipient), + receive + .blinder() + .ok_or(ExecutionError::EconomicOutputNotConfidential)?, + )?; + let Some(BlinderRef::Local(receive_blinder)) = receive.blinder() else { + return Err(ExecutionError::ReceiveOutputNotVenueBlinded); + }; + if unique_input(&proposal.contribution, receive_blinder).is_err() { + return Err(ExecutionError::ReceiveOutputNotVenueBlinded); + } + + for output in proposal.contribution.outputs() { + if let Some(BlinderRef::External(outpoint)) = output.blinder() + && (output.id() != proposal.payment_output || outpoint != self.payer_blinder) + { + return Err(ExecutionError::UnauthorizedExternalBlinder); + } + } + if proposal + .contribution + .inputs() + .iter() + .any(|input| input.outpoint() == self.payer_blinder) + { + return Err(ExecutionError::VenueClaimsPayerInput); + } + + Ok(PreparedLeg { + request: self, + execution: proposal.execution, + venue_fees: proposal.venue_fees, + contribution: proposal.contribution, + payment_output: proposal.payment_output, + receive_output: proposal.receive_output, + }) + } +} + +/// Exact side allocated to one venue before it returns a quote/fill. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LegExecutionKind { + ExactIn { + input: AssetAmount, + output_asset: AssetId, + }, + ExactOut { + input_asset: AssetId, + output: AssetAmount, + }, +} + +impl LegExecutionKind { + fn pair(self) -> (AssetId, AssetId) { + match self { + Self::ExactIn { + input, + output_asset, + } => (input.asset, output_asset), + Self::ExactOut { + input_asset, + output, + } => (input_asset, output.asset), + } + } +} + +/// Adapter-produced fill proposal. It is not authorized until the originating +/// [`LegPreparationRequest`] consumes and validates it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProposedLeg { + execution: ExactExecution, + venue_fees: BTreeMap, + contribution: TransactionContribution, + payment_output: OutputId, + receive_output: OutputId, +} + +impl ProposedLeg { + pub fn new( + execution: ExactExecution, + venue_fees: BTreeMap, + contribution: TransactionContribution, + payment_output: OutputId, + receive_output: OutputId, + ) -> Result { + if venue_fees.values().any(|amount| *amount == 0) { + return Err(ExecutionError::ZeroFeeEntry); + } + Ok(Self { + execution, + venue_fees, + contribution, + payment_output, + receive_output, + }) + } +} + +/// Exact venue execution authenticated and bound to physical output claims by +/// a client-created per-leg request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreparedLeg { + request: LegPreparationRequest, + execution: ExactExecution, + venue_fees: BTreeMap, + contribution: TransactionContribution, + payment_output: OutputId, + receive_output: OutputId, +} + +impl PreparedLeg { + #[must_use] + pub const fn id(&self) -> LegId { + self.request.id + } + + #[must_use] + pub const fn context(&self) -> VenueContext { + self.request.context + } + + #[must_use] + pub const fn execution(&self) -> ExactExecution { + self.execution + } + + #[must_use] + pub const fn contribution(&self) -> &TransactionContribution { + &self.contribution + } + + #[must_use] + pub fn venue_fees(&self) -> &BTreeMap { + &self.venue_fees + } + + #[must_use] + pub const fn payment_output(&self) -> OutputId { + self.payment_output + } + + #[must_use] + pub const fn receive_output(&self) -> OutputId { + self.receive_output + } +} + +/// Pure local adapter boundary. Network I/O and evidence acquisition happen +/// before this method; the implementation authenticates and proposes a fill. +pub trait VenueAdapter { + type Evidence; + type Error; + + fn prepare( + &self, + request: &LegPreparationRequest, + evidence: &Self::Evidence, + ) -> Result; +} + +/// Normalized aggregate result for final wallet review. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteSummary { + execution: ExactExecution, + venue_fees: BTreeMap, + network_fee: NetworkFee, +} + +impl RouteSummary { + #[must_use] + pub const fn execution(&self) -> ExactExecution { + self.execution + } + + #[must_use] + pub fn venue_fees(&self) -> &BTreeMap { + &self.venue_fees + } + + #[must_use] + pub const fn network_fee(&self) -> NetworkFee { + self.network_fee + } +} + +/// Aggregate-validated route that owns the exact legs and network fee that +/// will be composed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ValidatedRoute { + request: ExecutionRequest, + legs: Vec, + summary: RouteSummary, +} + +impl ValidatedRoute { + #[must_use] + pub const fn request(&self) -> &ExecutionRequest { + &self.request + } + + #[must_use] + pub fn legs(&self) -> &[PreparedLeg] { + &self.legs + } + + #[must_use] + pub const fn summary(&self) -> &RouteSummary { + &self.summary + } + + /// Compose the exact owned legs after a wallet contribution. The same fee + /// that passed aggregate validation is used; callers cannot substitute a + /// different contribution or fee after validation. + pub fn compose( + self, + limits: CompositionLimits, + wallet: TransactionContribution, + ) -> Result { + let wallet_outpoints = wallet + .inputs() + .iter() + .map(InputSpec::outpoint) + .collect::>(); + for payer_leg in &self.legs { + let payer_blinder = payer_leg.request.payer_blinder; + if !wallet_outpoints.contains(&payer_blinder) { + return Err(RouteCompositionError::PayerBlinderNotInWallet { + leg: payer_leg.id(), + outpoint: payer_blinder, + }); + } + if let Some(claiming_leg) = self.legs.iter().find(|candidate| { + candidate + .contribution + .inputs() + .iter() + .any(|input| input.outpoint() == payer_blinder) + }) { + return Err(RouteCompositionError::PayerBlinderClaimedByVenue { + payer_leg: payer_leg.id(), + claiming_leg: claiming_leg.id(), + outpoint: payer_blinder, + }); + } + } + let mut composer = TransactionComposer::new(limits, self.summary.network_fee); + let wallet_handle = composer.push(wallet)?; + let mut leg_handles = BTreeMap::new(); + for leg in &self.legs { + let handle = composer.push(leg.contribution.clone())?; + if leg_handles.insert(leg.id(), handle).is_some() { + return Err(RouteCompositionError::DuplicateLegId(leg.id())); + } + } + let transaction = composer.finish()?; + Ok(ComposedRoute { + transaction, + authorization: RouteAuthorization { + request: self.request, + legs: self.legs, + layout: RouteLayout { + wallet: wallet_handle, + legs: leg_handles, + }, + summary: self.summary, + }, + }) + } +} + +/// Contribution handles allocated to the wallet and each selected venue leg. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteLayout { + wallet: ContributionHandle, + legs: BTreeMap, +} + +impl RouteLayout { + #[must_use] + pub const fn wallet(&self) -> ContributionHandle { + self.wallet + } + + #[must_use] + pub fn leg(&self, id: LegId) -> Option { + self.legs.get(&id).copied() + } +} + +/// Route-level authorization retained for final signer-specific validation and +/// user review; it is not by itself authorization to sign the transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteAuthorization { + request: ExecutionRequest, + legs: Vec, + layout: RouteLayout, + summary: RouteSummary, +} + +impl RouteAuthorization { + #[must_use] + pub const fn request(&self) -> &ExecutionRequest { + &self.request + } + + #[must_use] + pub fn legs(&self) -> &[PreparedLeg] { + &self.legs + } + + #[must_use] + pub const fn layout(&self) -> &RouteLayout { + &self.layout + } + + #[must_use] + pub const fn summary(&self) -> &RouteSummary { + &self.summary + } +} + +/// Transaction assembled from validated legs, pending each participant's +/// signer-specific whole-transaction authorization. +#[derive(Clone, Debug)] +pub struct ComposedRoute { + transaction: ComposedTransaction, + authorization: RouteAuthorization, +} + +impl ComposedRoute { + #[must_use] + pub const fn transaction(&self) -> &ComposedTransaction { + &self.transaction + } + + #[must_use] + pub const fn layout(&self) -> &RouteLayout { + self.authorization.layout() + } + + #[must_use] + pub const fn summary(&self) -> &RouteSummary { + self.authorization.summary() + } + + #[must_use] + pub const fn authorization(&self) -> &RouteAuthorization { + &self.authorization + } + + #[must_use] + pub fn into_parts(self) -> (ComposedTransaction, RouteAuthorization) { + (self.transaction, self.authorization) + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum RouteCompositionError { + #[error(transparent)] + Composition(#[from] CompositionError), + #[error("duplicate route leg id {0:?} reached composition")] + DuplicateLegId(LegId), + #[error("route leg {leg:?} assigns blinding to non-wallet input {outpoint}")] + PayerBlinderNotInWallet { leg: LegId, outpoint: OutPoint }, + #[error( + "route leg {payer_leg:?} assigns blinding to wallet input {outpoint}, but venue leg {claiming_leg:?} also claims that input" + )] + PayerBlinderClaimedByVenue { + payer_leg: LegId, + claiming_leg: LegId, + outpoint: OutPoint, + }, +} + +fn validate_pair_and_amounts( + input_asset: AssetId, + input_amount: u64, + output_asset: AssetId, + output_amount: u64, +) -> Result<(), ExecutionError> { + if input_asset == output_asset { + return Err(ExecutionError::SameAssetPair); + } + if input_amount == 0 || output_amount == 0 { + return Err(ExecutionError::ZeroAmount); + } + Ok(()) +} + +fn validate_request_limits( + input_asset: AssetId, + maximum_input: u64, + venue_fee_limits: &BTreeMap, + max_network_fee: u64, +) -> Result<(), ExecutionError> { + if max_network_fee == 0 { + return Err(ExecutionError::ZeroNetworkFeeLimit); + } + for (&asset, &amount) in venue_fee_limits { + if amount == 0 { + return Err(ExecutionError::ZeroFeeEntry); + } + if asset != input_asset { + return Err(ExecutionError::UnsupportedFeeAsset(asset)); + } + if amount > maximum_input { + return Err(ExecutionError::VenueFeeExceedsGrossInput); + } + } + Ok(()) +} + +fn validate_leg_execution( + requested: LegExecutionKind, + execution: ExactExecution, +) -> Result<(), ExecutionError> { + if requested.pair() != (execution.input.asset, execution.output.asset) { + return Err(ExecutionError::AssetDirectionMismatch); + } + match requested { + LegExecutionKind::ExactIn { input, .. } if execution.input != input => { + Err(ExecutionError::LegExactInputMismatch) + } + LegExecutionKind::ExactOut { output, .. } if execution.output != output => { + Err(ExecutionError::LegExactOutputMismatch) + } + _ => Ok(()), + } +} + +fn validate_initial_fees( + execution: ExactExecution, + fees: &BTreeMap, +) -> Result<(), ExecutionError> { + for (&asset, &amount) in fees { + if amount == 0 { + return Err(ExecutionError::ZeroFeeEntry); + } + if asset != execution.input.asset { + return Err(ExecutionError::UnsupportedFeeAsset(asset)); + } + if amount > execution.input.amount { + return Err(ExecutionError::VenueFeeExceedsGrossInput); + } + } + Ok(()) +} + +fn unique_output( + contribution: &TransactionContribution, + id: OutputId, +) -> Result<&OutputSpec, ExecutionError> { + let mut matches = contribution + .outputs() + .iter() + .filter(|output| output.id() == id); + let output = matches + .next() + .ok_or(ExecutionError::MissingEconomicOutput(id))?; + if matches.next().is_some() { + return Err(ExecutionError::AmbiguousEconomicOutput(id)); + } + Ok(output) +} + +fn unique_input( + contribution: &TransactionContribution, + id: crate::composition::InputId, +) -> Result<(), ExecutionError> { + let count = contribution + .inputs() + .iter() + .filter(|input| input.id() == id) + .count(); + if count == 1 { + Ok(()) + } else { + Err(ExecutionError::ReceiveOutputNotVenueBlinded) + } +} + +fn validate_claimed_confidential_output( + output: &OutputSpec, + expected: AssetAmount, + recipient: Option<&ConfidentialRecipient>, + expected_blinder: BlinderRef, +) -> Result<(), ExecutionError> { + if output.asset_amount() != Some((expected.asset, expected.amount)) { + return Err(ExecutionError::EconomicOutputMismatch(output.id())); + } + let Some((script_pubkey, blinding_key)) = output.confidential_recipient() else { + return Err(ExecutionError::EconomicOutputNotConfidential); + }; + if output.blinder() != Some(expected_blinder) { + return Err(ExecutionError::EconomicOutputBlinderMismatch(output.id())); + } + if let Some(recipient) = recipient + && (script_pubkey != recipient.script_pubkey() || blinding_key != recipient.blinding_key()) + { + return Err(ExecutionError::RecipientMismatch); + } + Ok(()) +} + +/// Venue normalization and aggregate-intent failures. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ExecutionError { + #[error("trade amounts must be positive")] + ZeroAmount, + #[error("zero-value venue fee entries must be omitted")] + ZeroFeeEntry, + #[error("the network-fee limit must be positive")] + ZeroNetworkFeeLimit, + #[error("the initial venue binding supports fees only in the trade input asset: {0}")] + UnsupportedFeeAsset(AssetId), + #[error("a venue fee cannot exceed the gross trade input")] + VenueFeeExceedsGrossInput, + #[error("the recipient script must be spendable and nonempty")] + InvalidRecipientScript, + #[error("input and output assets must differ")] + SameAssetPair, + #[error("at least one prepared leg is required")] + NoLegs, + #[error("duplicate prepared leg id {0:?}")] + DuplicateLegId(LegId), + #[error("the requested leg allocation uses the wrong exact-in/exact-out mode")] + WrongLegRequestKind, + #[error("a leg targets a different chain, market, or policy asset")] + ContextMismatch, + #[error("a leg uses the wrong asset direction")] + AssetDirectionMismatch, + #[error("checked route amount arithmetic overflowed")] + AmountOverflow, + #[error("the venue proposal does not use the exact allocated leg input")] + LegExactInputMismatch, + #[error("the venue proposal does not use the exact allocated leg output")] + LegExactOutputMismatch, + #[error("a prepared venue leg must contribute at least one input and output")] + EmptyContribution, + #[error("payment and receipt cannot claim the same output")] + ReusedEconomicOutput, + #[error("the contribution is missing economic output {0:?}")] + MissingEconomicOutput(OutputId), + #[error("economic output {0:?} is ambiguous within its contribution")] + AmbiguousEconomicOutput(OutputId), + #[error("economic output {0:?} does not match its declared asset and amount")] + EconomicOutputMismatch(OutputId), + #[error("economic payment and receipt outputs must be confidential")] + EconomicOutputNotConfidential, + #[error("economic output {0:?} uses the wrong blinding input")] + EconomicOutputBlinderMismatch(OutputId), + #[error("the receive output does not match the user-authorized destination")] + RecipientMismatch, + #[error("the receive output must be blinded by a local venue input")] + ReceiveOutputNotVenueBlinded, + #[error("only the claimed payment output may use the payer's external blinder")] + UnauthorizedExternalBlinder, + #[error("a venue contribution cannot claim the payer's wallet input")] + VenueClaimsPayerInput, + #[error("the network fee uses the wrong policy asset")] + WrongPolicyAsset, + #[error("network fee {actual} exceeds maximum {maximum}")] + NetworkFeeExceeded { maximum: u64, actual: u64 }, + #[error("venue fee for {asset} is {actual}, exceeding maximum {maximum}")] + VenueFeeExceeded { + asset: AssetId, + maximum: u64, + actual: u64, + }, + #[error("exact input is {actual}, expected {expected}")] + ExactInputMismatch { expected: u64, actual: u64 }, + #[error("output is {actual}, below minimum {minimum}")] + MinimumOutputNotMet { minimum: u64, actual: u64 }, + #[error("exact output is {actual}, expected {expected}")] + ExactOutputMismatch { expected: u64, actual: u64 }, + #[error("input is {actual}, above maximum {maximum}")] + MaximumInputExceeded { maximum: u64, actual: u64 }, +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use deadcat_types::{ChainIdentity, ContractId, LiquidNetwork}; + use elements::bitcoin::PublicKey as BitcoinPublicKey; + use elements::confidential::{Asset, Nonce, Value}; + use elements::hashes::Hash as _; + use elements::secp256k1_zkp::{PublicKey, Secp256k1, SecretKey}; + use elements::{BlockHash, OutPoint, Script, TxOut, TxOutWitness, Txid}; + + use super::*; + use crate::composition::{ + BlinderRef, CompositionLimits, InputId, InputSequence, InputSpec, LockTimeConstraint, + OutputId, OutputSpec, + }; + + fn asset(byte: u8) -> AssetId { + AssetId::from_slice(&[byte; 32]).expect("asset") + } + + fn test_context(byte: u8) -> VenueContext { + VenueContext { + chain: ChainIdentity { + network: LiquidNetwork::ElementsRegtest, + genesis_hash: BlockHash::from_byte_array([byte; 32]), + }, + market: ContractId::new(OutPoint::new( + Txid::from_byte_array([byte.wrapping_add(1); 32]), + 0, + )), + policy_asset: asset(1), + } + } + + fn amount(asset: AssetId, amount: u64) -> AssetAmount { + AssetAmount::new(asset, amount).expect("amount") + } + + fn script(byte: u8) -> Script { + let mut bytes = vec![0x00, 0x14]; + bytes.extend([byte; 20]); + Script::from(bytes) + } + + fn blinding_key(byte: u8) -> BitcoinPublicKey { + BitcoinPublicKey::new(PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[byte; 32]).expect("secret"), + )) + } + + fn recipient() -> ConfidentialRecipient { + ConfidentialRecipient::new(script(90), blinding_key(90)).expect("recipient") + } + + fn payer_outpoint() -> OutPoint { + OutPoint::new(Txid::from_byte_array([20; 32]), 0) + } + + fn input_spec(id: u64, byte: u8, input_asset: AssetId, input_amount: u64) -> InputSpec { + InputSpec::new( + InputId::new(id), + OutPoint::new(Txid::from_byte_array([byte; 32]), 0), + TxOut { + asset: Asset::Explicit(input_asset), + value: Value::Explicit(input_amount), + nonce: Nonce::Null, + script_pubkey: script(byte), + witness: TxOutWitness::default(), + }, + InputSequence::Final, + ) + } + + fn proposed_leg( + id: u64, + asset_in: AssetId, + amount_in: u64, + asset_out: AssetId, + amount_out: u64, + fees: BTreeMap, + ) -> ProposedLeg { + ProposedLeg::new( + ExactExecution::new(amount(asset_in, amount_in), amount(asset_out, amount_out)) + .expect("execution"), + fees, + TransactionContribution::new( + vec![input_spec(1, 30 + id as u8, asset_out, amount_out)], + vec![ + OutputSpec::confidential( + OutputId::new(1), + asset_in, + amount_in, + script(70 + id as u8), + blinding_key(70 + id as u8), + BlinderRef::External(payer_outpoint()), + ), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + amount_out, + recipient().script_pubkey().clone(), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(1)), + ), + ], + LockTimeConstraint::Unconstrained, + ), + OutputId::new(1), + OutputId::new(2), + ) + .expect("proposal") + } + + struct StaticAdapter(ProposedLeg); + + impl VenueAdapter for StaticAdapter { + type Evidence = (); + type Error = ExecutionError; + + fn prepare( + &self, + _request: &LegPreparationRequest, + _evidence: &Self::Evidence, + ) -> Result { + Ok(self.0.clone()) + } + } + + fn exact_in_leg( + request: &ExecutionRequest, + id: u64, + amount_in: u64, + amount_out: u64, + fees: BTreeMap, + ) -> PreparedLeg { + let leg_request = request + .exact_in_leg(LegId::new(id), amount_in, payer_outpoint()) + .expect("leg request"); + let (asset_in, asset_out) = request.kind().pair(); + let adapter = StaticAdapter(proposed_leg( + id, asset_in, amount_in, asset_out, amount_out, fees, + )); + let proposal = adapter + .prepare(&leg_request, &()) + .expect("adapter proposal"); + leg_request.authorize(proposal).expect("prepared leg") + } + + fn exact_out_leg( + request: &ExecutionRequest, + id: u64, + amount_in: u64, + amount_out: u64, + ) -> PreparedLeg { + let leg_request = request + .exact_out_leg(LegId::new(id), amount_out, payer_outpoint()) + .expect("leg request"); + let (asset_in, asset_out) = request.kind().pair(); + let adapter = StaticAdapter(proposed_leg( + id, + asset_in, + amount_in, + asset_out, + amount_out, + BTreeMap::new(), + )); + let proposal = adapter + .prepare(&leg_request, &()) + .expect("adapter proposal"); + leg_request.authorize(proposal).expect("prepared leg") + } + + #[test] + fn exact_in_aggregates_multiple_legs_with_fee_inclusive_debits() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 100), + asset_out, + 95, + recipient(), + BTreeMap::from([(asset_in, 5)]), + 1_000, + ) + .expect("request"); + let first = exact_in_leg(&request, 1, 40, 39, BTreeMap::from([(asset_in, 2)])); + let second = exact_in_leg(&request, 2, 60, 58, BTreeMap::from([(asset_in, 3)])); + let network_fee = NetworkFee::new(context.policy_asset, 900).expect("fee"); + let route = request + .validate_route(vec![first, second], network_fee) + .expect("route"); + let summary = route.summary(); + + assert_eq!(summary.execution().input, amount(asset_in, 100)); + assert_eq!(summary.execution().output, amount(asset_out, 97)); + assert_eq!(summary.venue_fees().get(&asset_in), Some(&5)); + assert_eq!(summary.network_fee(), network_fee); + } + + #[test] + fn multiple_exact_legs_feed_one_atomic_composition_without_output_aliasing() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let network_fee = NetworkFee::new(context.policy_asset, 100).expect("fee"); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 100), + asset_out, + 95, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("request"); + let first = exact_in_leg(&request, 1, 40, 40, BTreeMap::new()); + let second = exact_in_leg(&request, 2, 60, 55, BTreeMap::new()); + let route = request + .validate_route(vec![first, second], network_fee) + .expect("aggregate exact-in route"); + + let wallet = TransactionContribution::new( + vec![ + input_spec(1, 10, context.policy_asset, 1_000), + InputSpec::new( + InputId::new(2), + payer_outpoint(), + TxOut { + asset: Asset::Explicit(asset_in), + value: Value::Explicit(100), + nonce: Nonce::Null, + script_pubkey: script(20), + witness: TxOutWitness::default(), + }, + InputSequence::Final, + ), + ], + vec![OutputSpec::confidential( + OutputId::new(1), + context.policy_asset, + 900, + script(80), + blinding_key(80), + BlinderRef::Local(InputId::new(1)), + )], + LockTimeConstraint::Unconstrained, + ); + let composed_route = route + .compose(CompositionLimits::default(), wallet) + .expect("atomic composition"); + let first_handle = composed_route + .layout() + .leg(LegId::new(1)) + .expect("first leg"); + let second_handle = composed_route + .layout() + .leg(LegId::new(2)) + .expect("second leg"); + let composed = composed_route.transaction(); + + assert_eq!(composed.pset().inputs().len(), 4); + assert_eq!(composed.pset().outputs().len(), 6); + let first_receive = composed + .layout() + .output_index(first_handle, OutputId::new(2)) + .expect("first receive"); + let second_receive = composed + .layout() + .output_index(second_handle, OutputId::new(2)) + .expect("second receive"); + let payer_input = composed + .layout() + .input_index(composed_route.layout().wallet(), InputId::new(2)) + .expect("payer input"); + let first_inventory = composed + .layout() + .input_index(first_handle, InputId::new(1)) + .expect("first inventory input"); + let second_inventory = composed + .layout() + .input_index(second_handle, InputId::new(1)) + .expect("second inventory input"); + let first_payment = composed + .layout() + .output_index(first_handle, OutputId::new(1)) + .expect("first payment"); + let second_payment = composed + .layout() + .output_index(second_handle, OutputId::new(1)) + .expect("second payment"); + assert_ne!(first_receive, second_receive); + assert_eq!(composed.pset().outputs()[first_receive].amount, Some(40)); + assert_eq!(composed.pset().outputs()[second_receive].amount, Some(55)); + assert_eq!( + composed.pset().outputs()[first_payment].blinder_index, + Some(u32::try_from(payer_input).expect("PSET index")) + ); + assert_eq!( + composed.pset().outputs()[second_payment].blinder_index, + Some(u32::try_from(payer_input).expect("PSET index")) + ); + assert_eq!( + composed.pset().outputs()[first_receive].blinder_index, + Some(u32::try_from(first_inventory).expect("PSET index")) + ); + assert_eq!( + composed.pset().outputs()[second_receive].blinder_index, + Some(u32::try_from(second_inventory).expect("PSET index")) + ); + composed + .manifest() + .validate(composed.pset()) + .expect("frozen multi-leg manifest"); + } + + #[test] + fn exact_out_requires_exact_receipt_and_bounded_input() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_out( + context, + asset_in, + 105, + amount(asset_out, 100), + recipient(), + BTreeMap::new(), + 1_000, + ) + .expect("request"); + let first = exact_out_leg(&request, 1, 50, 40); + let second = exact_out_leg(&request, 2, 54, 60); + request + .clone() + .validate_route( + vec![first.clone(), second.clone()], + NetworkFee::new(context.policy_asset, 1_000).expect("fee"), + ) + .expect("within maximum"); + + let too_expensive = exact_out_leg(&request, 3, 56, 60); + assert!(matches!( + request.clone().validate_route( + vec![first, too_expensive], + NetworkFee::new(context.policy_asset, 1_000).expect("fee"), + ), + Err(ExecutionError::MaximumInputExceeded { .. }) + )); + + let wrong_output = exact_out_leg(&request, 4, 54, 59); + assert!(matches!( + request.clone().validate_route( + vec![second, wrong_output], + NetworkFee::new(context.policy_asset, 1_000).expect("fee"), + ), + Err(ExecutionError::ExactOutputMismatch { .. }) + )); + + let allocation = request + .exact_out_leg(LegId::new(5), 60, payer_outpoint()) + .expect("allocation"); + assert_eq!( + allocation.authorize(proposed_leg( + 5, + asset_in, + 54, + asset_out, + 59, + BTreeMap::new(), + )), + Err(ExecutionError::LegExactOutputMismatch) + ); + } + + #[test] + fn context_direction_fee_and_id_mismatches_fail_closed() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 10), + asset_out, + 9, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("request"); + let valid = exact_in_leg(&request, 1, 10, 9, BTreeMap::new()); + + assert_eq!( + request.clone().validate_route( + vec![valid.clone(), valid.clone()], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::DuplicateLegId(LegId::new(1))) + ); + let other_context_request = ExecutionRequest::exact_in( + test_context(11), + amount(asset_in, 10), + asset_out, + 9, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("other context request"); + assert_eq!( + request.clone().validate_route( + vec![exact_in_leg( + &other_context_request, + 2, + 10, + 9, + BTreeMap::new(), + )], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::ContextMismatch) + ); + let reverse_request = ExecutionRequest::exact_in( + context, + amount(asset_out, 10), + asset_in, + 9, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("reverse request"); + assert_eq!( + request.clone().validate_route( + vec![exact_in_leg(&reverse_request, 2, 10, 9, BTreeMap::new(),)], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::AssetDirectionMismatch) + ); + assert_eq!( + request.validate_route(vec![valid], NetworkFee::new(asset(9), 100).expect("fee"),), + Err(ExecutionError::WrongPolicyAsset) + ); + } + + #[test] + fn checked_sums_and_fee_limits_fail_closed() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, u64::MAX), + asset_out, + 1, + recipient(), + BTreeMap::from([(asset_in, 2)]), + 100, + ) + .expect("request"); + let first = exact_in_leg(&request, 1, u64::MAX, 1, BTreeMap::new()); + let second = exact_in_leg(&request, 2, 1, 1, BTreeMap::new()); + assert_eq!( + request.clone().validate_route( + vec![first, second], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::AmountOverflow) + ); + + let fee_leg = exact_in_leg(&request, 3, u64::MAX, 1, BTreeMap::from([(asset_in, 3)])); + assert!(matches!( + request.validate_route( + vec![fee_leg], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::VenueFeeExceeded { .. }) + )); + } + + #[test] + fn exact_in_empty_fee_input_and_output_bounds_fail_closed() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 100), + asset_out, + 95, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("request"); + assert_eq!( + request.clone().validate_route( + Vec::new(), + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::NoLegs) + ); + assert_eq!( + request.clone().validate_route( + vec![exact_in_leg(&request, 1, 99, 95, BTreeMap::new())], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::ExactInputMismatch { + expected: 100, + actual: 99, + }) + ); + assert_eq!( + request.clone().validate_route( + vec![exact_in_leg(&request, 1, 100, 94, BTreeMap::new())], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ), + Err(ExecutionError::MinimumOutputNotMet { + minimum: 95, + actual: 94, + }) + ); + assert_eq!( + request.clone().validate_route( + vec![exact_in_leg(&request, 1, 100, 95, BTreeMap::new())], + NetworkFee::new(context.policy_asset, 101).expect("fee"), + ), + Err(ExecutionError::NetworkFeeExceeded { + maximum: 100, + actual: 101, + }) + ); + + let route = request + .clone() + .validate_route( + vec![exact_in_leg(&request, 1, 100, 95, BTreeMap::new())], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ) + .expect("route"); + let wrong_wallet = TransactionContribution::new( + vec![input_spec(1, 10, context.policy_asset, 1_000)], + Vec::new(), + LockTimeConstraint::Unconstrained, + ); + assert!(matches!( + route.compose(CompositionLimits::default(), wrong_wallet), + Err(RouteCompositionError::PayerBlinderNotInWallet { .. }) + )); + } + + #[test] + fn payer_blinder_must_not_be_claimed_by_another_venue_leg() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 100), + asset_out, + 95, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("request"); + let first = exact_in_leg(&request, 1, 40, 40, BTreeMap::new()); + let second_payer = OutPoint::new(Txid::from_byte_array([21; 32]), 0); + let second_request = request + .exact_in_leg(LegId::new(2), 60, second_payer) + .expect("second allocation"); + let second = second_request + .authorize( + ProposedLeg::new( + ExactExecution::new(amount(asset_in, 60), amount(asset_out, 55)) + .expect("execution"), + BTreeMap::new(), + TransactionContribution::new( + vec![input_spec(1, 20, asset_out, 55)], + vec![ + OutputSpec::confidential( + OutputId::new(1), + asset_in, + 60, + script(72), + blinding_key(72), + BlinderRef::External(second_payer), + ), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + 55, + recipient().script_pubkey().clone(), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(1)), + ), + ], + LockTimeConstraint::Unconstrained, + ), + OutputId::new(1), + OutputId::new(2), + ) + .expect("proposal"), + ) + .expect("prepared leg"); + let route = request + .validate_route( + vec![first, second], + NetworkFee::new(context.policy_asset, 100).expect("fee"), + ) + .expect("route"); + let wallet = TransactionContribution::new( + vec![ + input_spec(1, 20, asset_in, 40), + InputSpec::new( + InputId::new(2), + second_payer, + TxOut { + asset: Asset::Explicit(asset_in), + value: Value::Explicit(60), + nonce: Nonce::Null, + script_pubkey: script(21), + witness: TxOutWitness::default(), + }, + InputSequence::Final, + ), + ], + Vec::new(), + LockTimeConstraint::Unconstrained, + ); + + assert_eq!( + route + .compose(CompositionLimits::default(), wallet) + .expect_err("cross-leg payer-input claim must fail"), + RouteCompositionError::PayerBlinderClaimedByVenue { + payer_leg: LegId::new(1), + claiming_leg: LegId::new(2), + outpoint: payer_outpoint(), + } + ); + } + + #[test] + fn proposal_claims_bind_amount_recipient_and_blinding_roles() { + let context = test_context(10); + let asset_in = asset(2); + let asset_out = asset(3); + let request = ExecutionRequest::exact_in( + context, + amount(asset_in, 10), + asset_out, + 9, + recipient(), + BTreeMap::new(), + 100, + ) + .expect("request"); + + let wrong_amount_request = request + .exact_in_leg(LegId::new(1), 10, payer_outpoint()) + .expect("leg request"); + let wrong_amount = ProposedLeg::new( + ExactExecution::new(amount(asset_in, 10), amount(asset_out, 9)).expect("execution"), + BTreeMap::new(), + TransactionContribution::new( + vec![input_spec(1, 31, asset_out, 9)], + vec![ + OutputSpec::confidential( + OutputId::new(1), + asset_in, + 11, + script(71), + blinding_key(71), + BlinderRef::External(payer_outpoint()), + ), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + 9, + recipient().script_pubkey().clone(), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(1)), + ), + ], + LockTimeConstraint::Unconstrained, + ), + OutputId::new(1), + OutputId::new(2), + ) + .expect("proposal"); + assert_eq!( + wrong_amount_request.authorize(wrong_amount), + Err(ExecutionError::EconomicOutputMismatch(OutputId::new(1))) + ); + + let wrong_recipient_request = request + .exact_in_leg(LegId::new(2), 10, payer_outpoint()) + .expect("leg request"); + let mut wrong_recipient = proposed_leg(2, asset_in, 10, asset_out, 9, BTreeMap::new()); + wrong_recipient.contribution = TransactionContribution::new( + vec![input_spec(1, 32, asset_out, 9)], + vec![ + OutputSpec::confidential( + OutputId::new(1), + asset_in, + 10, + script(72), + blinding_key(72), + BlinderRef::External(payer_outpoint()), + ), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + 9, + script(91), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(1)), + ), + ], + LockTimeConstraint::Unconstrained, + ); + assert_eq!( + wrong_recipient_request.authorize(wrong_recipient), + Err(ExecutionError::RecipientMismatch) + ); + + let extra_external_request = request + .exact_in_leg(LegId::new(3), 10, payer_outpoint()) + .expect("leg request"); + let mut extra_external = proposed_leg(3, asset_in, 10, asset_out, 9, BTreeMap::new()); + extra_external.contribution = TransactionContribution::new( + extra_external.contribution.inputs().to_vec(), + [ + extra_external.contribution.outputs().to_vec(), + vec![OutputSpec::confidential( + OutputId::new(3), + asset_out, + 1, + script(92), + blinding_key(92), + BlinderRef::External(payer_outpoint()), + )], + ] + .concat(), + LockTimeConstraint::Unconstrained, + ); + assert_eq!( + extra_external_request.authorize(extra_external), + Err(ExecutionError::UnauthorizedExternalBlinder) + ); + + let allocation_mismatch_request = request + .exact_in_leg(LegId::new(4), 10, payer_outpoint()) + .expect("leg request"); + assert_eq!( + allocation_mismatch_request.authorize(proposed_leg( + 4, + asset_in, + 9, + asset_out, + 9, + BTreeMap::new(), + )), + Err(ExecutionError::LegExactInputMismatch) + ); + + let reused_output_request = request + .exact_in_leg(LegId::new(5), 10, payer_outpoint()) + .expect("leg request"); + let mut reused_output = proposed_leg(5, asset_in, 10, asset_out, 9, BTreeMap::new()); + reused_output.receive_output = reused_output.payment_output; + assert_eq!( + reused_output_request.authorize(reused_output), + Err(ExecutionError::ReusedEconomicOutput) + ); + + let explicit_payment_request = request + .exact_in_leg(LegId::new(6), 10, payer_outpoint()) + .expect("leg request"); + let explicit_payment = ProposedLeg::new( + ExactExecution::new(amount(asset_in, 10), amount(asset_out, 9)).expect("execution"), + BTreeMap::new(), + TransactionContribution::new( + vec![input_spec(1, 36, asset_out, 9)], + vec![ + OutputSpec::explicit(OutputId::new(1), asset_in, 10, script(76)), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + 9, + recipient().script_pubkey().clone(), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(1)), + ), + ], + LockTimeConstraint::Unconstrained, + ), + OutputId::new(1), + OutputId::new(2), + ) + .expect("proposal"); + assert_eq!( + explicit_payment_request.authorize(explicit_payment), + Err(ExecutionError::EconomicOutputNotConfidential) + ); + + let missing_receive_blinder_request = request + .exact_in_leg(LegId::new(7), 10, payer_outpoint()) + .expect("leg request"); + let mut missing_receive_blinder = + proposed_leg(7, asset_in, 10, asset_out, 9, BTreeMap::new()); + missing_receive_blinder.contribution = TransactionContribution::new( + missing_receive_blinder.contribution.inputs().to_vec(), + vec![ + missing_receive_blinder.contribution.outputs()[0].clone(), + OutputSpec::confidential( + OutputId::new(2), + asset_out, + 9, + recipient().script_pubkey().clone(), + recipient().blinding_key(), + BlinderRef::Local(InputId::new(99)), + ), + ], + LockTimeConstraint::Unconstrained, + ); + assert_eq!( + missing_receive_blinder_request.authorize(missing_receive_blinder), + Err(ExecutionError::ReceiveOutputNotVenueBlinded) + ); + + let payer_claim_request = request + .exact_in_leg(LegId::new(8), 10, payer_outpoint()) + .expect("leg request"); + let mut payer_claim = proposed_leg(8, asset_in, 10, asset_out, 9, BTreeMap::new()); + payer_claim.contribution = TransactionContribution::new( + vec![InputSpec::new( + InputId::new(1), + payer_outpoint(), + TxOut { + asset: Asset::Explicit(asset_out), + value: Value::Explicit(9), + nonce: Nonce::Null, + script_pubkey: script(20), + witness: TxOutWitness::default(), + }, + InputSequence::Final, + )], + payer_claim.contribution.outputs().to_vec(), + LockTimeConstraint::Unconstrained, + ); + assert_eq!( + payer_claim_request.authorize(payer_claim), + Err(ExecutionError::VenueClaimsPayerInput) + ); + } +} diff --git a/crates/deadcat-client/tests/rfq_regtest.rs b/crates/deadcat-client/tests/rfq_regtest.rs index ed41a1c..8499141 100644 --- a/crates/deadcat-client/tests/rfq_regtest.rs +++ b/crates/deadcat-client/tests/rfq_regtest.rs @@ -4,14 +4,25 @@ //! synthetic UTXOs. The ignored live test repeats it against liquidregtest, //! broadcasts the settlement, and spends both parties' received outputs. //! -//! This is deliberately test-local. It proves the transaction and wallet -//! primitives before Deadcat freezes a remote RFQ protocol or stable venue API. +//! The wallet and collaborative-signing harness remains test-local, while the +//! settlement body is built through Deadcat's provisional production venue and +//! composition seam. No remote RFQ protocol or stable wire API is frozen here. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::str::FromStr as _; use bitcoincore_rpc::{Client, RpcApi}; +use deadcat_client::composition::{ + BlinderRef, CompositionLayout, CompositionLimits, ContributionHandle, InputId, InputSequence, + InputSpec, LockTimeConstraint, NetworkFee, OutputId, OutputSpec, TransactionContribution, + UnblindedStructureManifest, +}; +use deadcat_client::venue::{ + AssetAmount, ConfidentialRecipient, ExactExecution, ExecutionError, ExecutionRequest, LegId, + LegPreparationRequest, ProposedLeg, RouteAuthorization, VenueAdapter, VenueContext, +}; use deadcat_contracts::SimplicityNetwork; +use deadcat_types::{ChainIdentity, ContractId, LiquidNetwork}; use elements::bitcoin::PublicKey as BitcoinPublicKey; use elements::confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}; use elements::encode::{deserialize, serialize}; @@ -39,6 +50,15 @@ const PROVIDER_INVENTORY_VALUE: u64 = 5; const PROVIDER_PAYMENT_VALUE: u64 = 20_000; const USER_RECEIVE_VALUE: u64 = 2; +const FEE_INPUT_ID: InputId = InputId::new(1); +const PAYMENT_INPUT_ID: InputId = InputId::new(2); +const INVENTORY_INPUT_ID: InputId = InputId::new(1); +const FEE_CHANGE_OUTPUT_ID: OutputId = OutputId::new(1); +const USER_PAYMENT_CHANGE_OUTPUT_ID: OutputId = OutputId::new(2); +const PROVIDER_PAYMENT_OUTPUT_ID: OutputId = OutputId::new(1); +const PROVIDER_INVENTORY_CHANGE_OUTPUT_ID: OutputId = OutputId::new(2); +const USER_RECEIVE_OUTPUT_ID: OutputId = OutputId::new(3); + #[derive(Clone, Copy)] struct SettlementAssets { policy: AssetId, @@ -80,9 +100,13 @@ impl P2trWallet { fn input(&self, utxo: &OwnedUtxo) -> PsetInput { let mut input = PsetInput::from_prevout(utxo.outpoint); input.witness_utxo = Some(utxo.txout.clone()); + self.configure_input(&mut input); + input + } + + fn configure_input(&self, input: &mut PsetInput) { input.sighash_type = Some(SchnorrSighashType::All.into()); input.tap_internal_key = Some(self.internal_key); - input } fn confidential_output( @@ -102,6 +126,26 @@ impl P2trWallet { output } + fn confidential_output_spec( + &self, + id: OutputId, + amount: u64, + asset: AssetId, + blinder: BlinderRef, + ) -> OutputSpec { + OutputSpec::confidential( + id, + asset, + amount, + self.address.script_pubkey(), + self.address + .blinding_pubkey + .map(BitcoinPublicKey::new) + .expect("confidential address"), + blinder, + ) + } + fn unblind(&self, txout: &TxOut) -> TxOutSecrets { self.try_unblind(txout) .expect("wallet can unblind its output") @@ -168,19 +212,37 @@ struct SettlementLayout { } impl SettlementLayout { - const fn composed() -> Self { + fn from_composition( + layout: &CompositionLayout, + wallet: ContributionHandle, + venue: ContributionHandle, + ) -> Self { Self { - // Input zero and output zero represent transaction-global wallet - // funding, so the RFQ leg is valid away from the first slots. - fee_input: 0, - payment_input: 1, - inventory_input: 2, - fee_change: 0, - provider_payment: 1, - provider_inventory_change: 2, - user_payment_change: 3, - user_receive: 4, - fee: 5, + fee_input: layout + .input_index(wallet, FEE_INPUT_ID) + .expect("fee input placement"), + payment_input: layout + .input_index(wallet, PAYMENT_INPUT_ID) + .expect("payment input placement"), + inventory_input: layout + .input_index(venue, INVENTORY_INPUT_ID) + .expect("inventory input placement"), + fee_change: layout + .output_index(wallet, FEE_CHANGE_OUTPUT_ID) + .expect("fee change placement"), + provider_payment: layout + .output_index(venue, PROVIDER_PAYMENT_OUTPUT_ID) + .expect("provider payment placement"), + provider_inventory_change: layout + .output_index(venue, PROVIDER_INVENTORY_CHANGE_OUTPUT_ID) + .expect("provider inventory change placement"), + user_payment_change: layout + .output_index(wallet, USER_PAYMENT_CHANGE_OUTPUT_ID) + .expect("user payment change placement"), + user_receive: layout + .output_index(venue, USER_RECEIVE_OUTPUT_ID) + .expect("user receive placement"), + fee: layout.fee_output_index(), } } } @@ -188,6 +250,8 @@ impl SettlementLayout { #[derive(Clone)] struct SettlementFixture { pset: PartiallySignedTransaction, + manifest: UnblindedStructureManifest, + route_authorization: RouteAuthorization, layout: SettlementLayout, prevouts: Vec, user: P2trWallet, @@ -242,6 +306,73 @@ fn add_fee_output(pset: &mut PartiallySignedTransaction, amount: u64, policy_ass pset.add_output(PsetOutput::from_txout(TxOut::new_fee(amount, policy_asset))); } +#[derive(Clone)] +struct TestRfqEvidence { + context: VenueContext, + provider: P2trWallet, + inventory_input: OwnedUtxo, + assets: SettlementAssets, +} + +struct TestRfqAdapter; + +impl VenueAdapter for TestRfqAdapter { + type Evidence = TestRfqEvidence; + type Error = ExecutionError; + + fn prepare( + &self, + request: &LegPreparationRequest, + evidence: &Self::Evidence, + ) -> Result { + if request.context() != evidence.context { + return Err(ExecutionError::ContextMismatch); + } + let execution = ExactExecution::new( + AssetAmount::new(evidence.assets.payment, PROVIDER_PAYMENT_VALUE)?, + AssetAmount::new(evidence.assets.outcome, USER_RECEIVE_VALUE)?, + )?; + let contribution = TransactionContribution::new( + vec![InputSpec::new( + INVENTORY_INPUT_ID, + evidence.inventory_input.outpoint, + evidence.inventory_input.txout.clone(), + InputSequence::Final, + )], + vec![ + evidence.provider.confidential_output_spec( + PROVIDER_PAYMENT_OUTPUT_ID, + PROVIDER_PAYMENT_VALUE, + evidence.assets.payment, + BlinderRef::External(request.payer_blinder()), + ), + evidence.provider.confidential_output_spec( + PROVIDER_INVENTORY_CHANGE_OUTPUT_ID, + PROVIDER_INVENTORY_VALUE - USER_RECEIVE_VALUE, + evidence.assets.outcome, + BlinderRef::Local(INVENTORY_INPUT_ID), + ), + OutputSpec::confidential( + USER_RECEIVE_OUTPUT_ID, + evidence.assets.outcome, + USER_RECEIVE_VALUE, + request.recipient().script_pubkey().clone(), + request.recipient().blinding_key(), + BlinderRef::Local(INVENTORY_INPUT_ID), + ), + ], + LockTimeConstraint::Unconstrained, + ); + ProposedLeg::new( + execution, + BTreeMap::new(), + contribution, + PROVIDER_PAYMENT_OUTPUT_ID, + USER_RECEIVE_OUTPUT_ID, + ) + } +} + fn build_settlement( user: P2trWallet, provider: P2trWallet, @@ -249,47 +380,133 @@ fn build_settlement( payment_input: OwnedUtxo, inventory_input: OwnedUtxo, assets: SettlementAssets, + genesis_hash: BlockHash, ) -> SettlementFixture { - let layout = SettlementLayout::composed(); - let prevouts = vec![ - fee_input.txout.clone(), - payment_input.txout.clone(), - inventory_input.txout.clone(), - ]; - let mut pset = PartiallySignedTransaction::new_v2(); - pset.add_input(user.input(&fee_input)); - pset.add_input(user.input(&payment_input)); - pset.add_input(provider.input(&inventory_input)); - - pset.add_output(user.confidential_output( - USER_FEE_INPUT_VALUE - NETWORK_FEE, - assets.policy, - layout.fee_input, - )); - pset.add_output(provider.confidential_output( - PROVIDER_PAYMENT_VALUE, - assets.payment, - layout.payment_input, - )); - pset.add_output(provider.confidential_output( - PROVIDER_INVENTORY_VALUE - USER_RECEIVE_VALUE, + let context = VenueContext { + chain: ChainIdentity { + network: LiquidNetwork::ElementsRegtest, + genesis_hash, + }, + market: ContractId::new(inventory_input.outpoint), + policy_asset: assets.policy, + }; + let network_fee = NetworkFee::new(assets.policy, NETWORK_FEE).expect("network fee"); + let recipient = ConfidentialRecipient::new( + user.address.script_pubkey(), + user.address + .blinding_pubkey + .map(BitcoinPublicKey::new) + .expect("confidential user address"), + ) + .expect("user recipient"); + let request = ExecutionRequest::exact_in( + context, + AssetAmount::new(assets.payment, PROVIDER_PAYMENT_VALUE).expect("exact input"), assets.outcome, - layout.inventory_input, - )); - pset.add_output(user.confidential_output( - USER_PAYMENT_INPUT_VALUE - PROVIDER_PAYMENT_VALUE, - assets.payment, - layout.payment_input, - )); - pset.add_output(user.confidential_output( USER_RECEIVE_VALUE, - assets.outcome, - layout.inventory_input, - )); - add_fee_output(&mut pset, NETWORK_FEE, assets.policy); + recipient, + BTreeMap::new(), + NETWORK_FEE, + ) + .expect("exact-in request"); + let evidence = TestRfqEvidence { + context, + provider: provider.clone(), + inventory_input: inventory_input.clone(), + assets, + }; + let leg_request = request + .exact_in_leg( + LegId::new(1), + PROVIDER_PAYMENT_VALUE, + payment_input.outpoint, + ) + .expect("single-leg allocation"); + let proposal = TestRfqAdapter + .prepare(&leg_request, &evidence) + .expect("client-local RFQ adapter"); + let leg = leg_request + .authorize(proposal) + .expect("proposal matches exact allocation and recipient"); + let route = request + .validate_route(vec![leg], network_fee) + .expect("prepared leg satisfies exact-in intent"); + + let wallet = TransactionContribution::new( + vec![ + InputSpec::new( + FEE_INPUT_ID, + fee_input.outpoint, + fee_input.txout.clone(), + InputSequence::Final, + ), + InputSpec::new( + PAYMENT_INPUT_ID, + payment_input.outpoint, + payment_input.txout.clone(), + InputSequence::Final, + ), + ], + vec![ + user.confidential_output_spec( + FEE_CHANGE_OUTPUT_ID, + USER_FEE_INPUT_VALUE - NETWORK_FEE, + assets.policy, + BlinderRef::Local(FEE_INPUT_ID), + ), + user.confidential_output_spec( + USER_PAYMENT_CHANGE_OUTPUT_ID, + USER_PAYMENT_INPUT_VALUE - PROVIDER_PAYMENT_VALUE, + assets.payment, + BlinderRef::Local(PAYMENT_INPUT_ID), + ), + ], + LockTimeConstraint::Unconstrained, + ); + let composed_route = route + .compose(CompositionLimits::default(), wallet) + .expect("complete route composition"); + let wallet_handle = composed_route.layout().wallet(); + let venue_handle = composed_route + .layout() + .leg(LegId::new(1)) + .expect("RFQ placement"); + let (composed, route_authorization) = composed_route.into_parts(); + assert_eq!( + composed + .layout() + .placement(wallet_handle) + .expect("wallet placement") + .input_base(), + 0 + ); + assert!( + composed + .layout() + .placement(venue_handle) + .expect("venue placement") + .input_base() + > 0, + "the venue contribution must not rely on global input zero" + ); + let layout = SettlementLayout::from_composition(composed.layout(), wallet_handle, venue_handle); + let (mut pset, _, manifest) = composed.into_parts(); + user.configure_input(&mut pset.inputs_mut()[layout.fee_input]); + user.configure_input(&mut pset.inputs_mut()[layout.payment_input]); + provider.configure_input(&mut pset.inputs_mut()[layout.inventory_input]); + manifest + .validate(&pset) + .expect("signing metadata preserves the frozen manifest"); + let prevouts = pset + .inputs() + .iter() + .map(|input| input.witness_utxo.clone().expect("composed prevout")) + .collect(); SettlementFixture { pset, + manifest, + route_authorization, layout, prevouts, user, @@ -390,18 +607,52 @@ fn expect_output( if output.amount != Some(amount) { return Err(format!("wrong amount at output {index}")); } + if output.redeem_script.is_some() + || output.witness_script.is_some() + || !output.bip32_derivation.is_empty() + || output.tap_internal_key.is_some() + || output.tap_tree.is_some() + || !output.tap_key_origins.is_empty() + || !output.proprietary.is_empty() + || !output.unknown.is_empty() + { + return Err(format!("unexpected wallet metadata at output {index}")); + } Ok(()) } fn validate_settlement_intent(fixture: &SettlementFixture) -> Result<(), String> { let pset = &fixture.pset; let layout = fixture.layout; + let route_summary = fixture.route_authorization.summary(); + fixture + .manifest + .validate(pset) + .map_err(|error| error.to_string())?; + if route_summary.execution().input() + != AssetAmount::new(fixture.assets.payment, PROVIDER_PAYMENT_VALUE) + .map_err(|error| error.to_string())? + || route_summary.execution().output() + != AssetAmount::new(fixture.assets.outcome, USER_RECEIVE_VALUE) + .map_err(|error| error.to_string())? + || !route_summary.venue_fees().is_empty() + || route_summary.network_fee().policy_asset() != fixture.assets.policy + || route_summary.network_fee().amount() != NETWORK_FEE + { + return Err("normalized route summary no longer matches settlement intent".into()); + } if pset.inputs().len() != 3 || pset.outputs().len() != 6 { return Err("unexpected input or output count".into()); } if pset.global.version != 2 || pset.global.tx_data.version != 2 { return Err("unexpected PSET or transaction version".into()); } + if !pset.global.xpub.is_empty() + || !pset.global.proprietary.is_empty() + || !pset.global.unknown.is_empty() + { + return Err("unexpected global wallet metadata".into()); + } let expected_inputs = [ (layout.fee_input, &fixture.fee_input, &fixture.user), (layout.payment_input, &fixture.payment_input, &fixture.user), @@ -423,6 +674,7 @@ fn validate_settlement_intent(fixture: &SettlementFixture) -> Result<(), String> .witness_utxo .as_ref() .is_some_and(|actual| same_prevout_body(actual, &expected.txout)) + || input.in_utxo_rangeproof != expected.txout.witness.rangeproof { return Err(format!("wrong witness UTXO at input {index}")); } @@ -431,9 +683,19 @@ fn validate_settlement_intent(fixture: &SettlementFixture) -> Result<(), String> || input.tap_merkle_root.is_some() || !input.tap_script_sigs.is_empty() || !input.tap_scripts.is_empty() + || !input.tap_key_origins.is_empty() + || input.non_witness_utxo.is_some() + || !input.partial_sigs.is_empty() + || !input.bip32_derivation.is_empty() + || !input.ripemd160_preimages.is_empty() + || !input.sha256_preimages.is_empty() + || !input.hash160_preimages.is_empty() + || !input.hash256_preimages.is_empty() || input.final_script_sig.is_some() || input.redeem_script.is_some() || input.witness_script.is_some() + || !input.proprietary.is_empty() + || !input.unknown.is_empty() { return Err(format!("wrong Taproot signing policy at input {index}")); } @@ -541,6 +803,14 @@ fn validate_settlement_intent(fixture: &SettlementFixture) -> Result<(), String> || fee.blinder_index.is_some() || fee.value_rangeproof.is_some() || fee.asset_surjection_proof.is_some() + || fee.redeem_script.is_some() + || fee.witness_script.is_some() + || !fee.bip32_derivation.is_empty() + || fee.tap_internal_key.is_some() + || fee.tap_tree.is_some() + || !fee.tap_key_origins.is_empty() + || !fee.proprietary.is_empty() + || !fee.unknown.is_empty() { return Err("fee output is not exact and explicit".into()); } @@ -758,6 +1028,7 @@ fn offline_fixture() -> SettlementFixture { payment: payment_asset, outcome: outcome_asset, }, + BlockHash::from_byte_array([0x71; 32]), ) } @@ -1058,6 +1329,18 @@ fn settlement_intent_and_disclosure_validation_fail_closed() { let fixture = offline_fixture(); validate_settlement_intent(&fixture).expect("baseline intent"); + let round_trip: PartiallySignedTransaction = + deserialize(&serialize(&fixture.pset)).expect("PSET round trip"); + assert_eq!( + round_trip.inputs()[fixture.layout.payment_input].in_utxo_rangeproof, + fixture.payment_input.txout.witness.rangeproof, + "a confidential input rangeproof must survive a PSET handoff" + ); + fixture + .manifest + .validate(&round_trip) + .expect("round-tripped input proof remains authorized"); + let mut mutated = fixture.clone(); mutated.pset.global.tx_data.version = 3; assert!(validate_settlement_intent(&mutated).is_err()); @@ -1104,6 +1387,10 @@ fn settlement_intent_and_disclosure_validation_fail_closed() { Some(mutated.inventory_input.txout.clone()); assert!(validate_settlement_intent(&mutated).is_err()); + mutated = fixture.clone(); + mutated.pset.inputs_mut()[mutated.layout.payment_input].in_utxo_rangeproof = None; + assert!(validate_settlement_intent(&mutated).is_err()); + mutated = fixture.clone(); mutated.pset.inputs_mut()[mutated.layout.payment_input].sighash_type = None; assert!(validate_settlement_intent(&mutated).is_err()); @@ -1354,6 +1641,7 @@ fn two_wallet_confidential_p2tr_rfq_settlement_is_accepted_and_spendable() { payment: payment_asset, outcome: outcome_asset, }, + genesis_hash, ); validate_settlement_intent(&fixture).expect("exact RFQ intent before blinding"); blind_settlement(&mut fixture); diff --git a/docs/adr/0006-rfq-first-liquidity-scope.md b/docs/adr/0006-rfq-first-liquidity-scope.md index 8a98948..8593457 100644 --- a/docs/adr/0006-rfq-first-liquidity-scope.md +++ b/docs/adr/0006-rfq-first-liquidity-scope.md @@ -5,7 +5,7 @@ - Supersedes: ADR 0002's release-scope decision - Retires as historical: ADR 0003 - Amends: ADR 0001's node-side advisory-routing responsibility -- Implementation status updated: 2026-07-30 +- Implementation status updated: 2026-08-10 ## Context @@ -118,10 +118,11 @@ links apply only to that revision. 2. **Completed in PR #16:** remove `MakerOrderV1` through every active code, storage, wire, CLI, fixture, test, and normative-document surface without changing version constants. -3. Prove a two-wallet confidential RFQ settlement on liquidregtest before - freezing a remote RFQ protocol. -4. Add the smallest client-local exact-in/exact-out venue adapter and - transaction-composition seam. +3. **Completed in PR #25:** prove a two-wallet confidential RFQ settlement on + liquidregtest before freezing a remote RFQ protocol. +4. **Implemented 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. Build the RFQ provider as a separate inventory-bearing service. 6. Add production-shaped process, crash-recovery, mutation, reorg, and operational acceptance gates. diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index aaeb064..5cf2bd9 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -414,6 +414,10 @@ ExecutableLeg { } ``` +The provisional ordinary-output API uses the narrower name `PreparedLeg`: its +economics and output claims are authorized, but venue-specific completion and +the final signer checks still have to succeed before it is executable on chain. + For an RFQ leg, preparation reserves exact provider inventory and returns a signed short-lived commitment. @@ -569,14 +573,44 @@ interface proposed here: separate authority boundary from node indexing. - The [live multi-market fixture](../crates/deadcat-client/tests/market_regtest.rs) composes two market transitions and proves transaction-atomic behavior. +- The [confidential RFQ fixture](../crates/deadcat-client/tests/rfq_regtest.rs) + proves two-wallet P2TR settlement, collaborative blinding, exact + whole-transaction validation, and spendable recipient outputs on + liquidregtest. +- 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 + authenticated venue proposal to real payment/receipt outputs and the user's + exact confidential destination, and allocate contribution-local symbolic + fragments without defining a remote wire format. A validated route owns the + exact legs and network fee consumed by composition, so validation and + assembly cannot silently diverge. This route validation does not infer wallet + change, validate ancillary-output net effects or per-asset transaction + balance, or authorize signing; each participant's final whole-transaction + validator remains a separate boundary. +- Cross-contribution blinding roles refer to exact outpoints rather than a + shared numeric namespace. A venue may assign only its claimed payment output + to the payer's external blinder; its user receipt and ancillary confidential + outputs remain assigned to inputs local to that venue contribution. +- The composer's `UnblindedStructureManifest` is intentionally not signing + authorization. It freezes transaction-body fields and clear output intent, + while participant-specific validation must still authorize sighash and spend + policy, verify confidential commitments and proofs, and rewind owned outputs. +- The initial generic venue binding supports ordinary confidential exclusive + payment and receipt outputs. Trusted client-local covenant builders have a + separate private template path; a non-issuance binary-market transition is + tested at nonzero composer offsets. Issuance fields and future AMM/DLOB + economic-delta bindings remain deliberately deferred. - The retired [`MakerFillPlan`](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/src/maker_builder.rs) and [heterogeneous live fixture](https://github.com/Resolvr-io/deadcat-node/blob/d7be35b27a020a61333e471b2ded5f59e3a0a039/crates/deadcat-client/tests/market_regtest.rs) remain historical composition evidence, not production interfaces. -Phase 1 should extract and test the smallest generic plan/composer seam from -these patterns instead of making the router depend on maker-specific types. +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 +exercise it. ### Symbolic transaction contributions @@ -590,10 +624,11 @@ Each venue adapter instead contributes a symbolic fragment containing: - input ordering or adjacency constraints; - mandatory output templates; - output ordering or adjacency constraints; -- net asset deltas; +- gross unsigned user spends and receives, with fees itemized separately; - explicit or confidential output policy; - global locktime and sequence requirements; -- mergeability rules for user-facing outputs; +- an exclusive output-claim policy; any future aggregation requires explicit + compatibility and a proven collaborative-blinding construction; - a local covenant finalizer or remote signer role; and - a conservative resource estimate.