From 797150f6d4e8385d54e42e9fd9e223546e8fb71e Mon Sep 17 00:00:00 2001 From: bennyhodl Date: Tue, 9 Jun 2026 15:13:31 -0400 Subject: [PATCH] feat: stateless contract construction --- ddk-manager/src/contract/mod.rs | 8 + ddk/examples/common/stateless.rs | 197 +++++ ddk/examples/stateless_descriptor.rs | 91 ++ ddk/examples/stateless_external_psbt.rs | 139 +++ ddk/examples/stateless_wallet.rs | 177 ++++ ddk/examples/stateless_xpriv.rs | 84 ++ ddk/src/contract/accept.rs | 107 +++ ddk/src/contract/advanced.rs | 155 ++++ ddk/src/contract/context.rs | 438 ++++++++++ ddk/src/contract/create.rs | 116 +++ ddk/src/contract/error.rs | 68 ++ ddk/src/contract/finalize.rs | 111 +++ ddk/src/contract/mod.rs | 113 +++ ddk/src/contract/psbt.rs | 250 ++++++ ddk/src/contract/sign.rs | 112 +++ ddk/src/contract/signing.rs | 285 +++++++ ddk/src/contract/tests.rs | 225 +++++ ddk/src/contract/types.rs | 203 +++++ ddk/src/lib.rs | 2 + ddk/tests/stateless.rs | 1044 +++++++++++++++++++++++ 20 files changed, 3925 insertions(+) create mode 100644 ddk/examples/common/stateless.rs create mode 100644 ddk/examples/stateless_descriptor.rs create mode 100644 ddk/examples/stateless_external_psbt.rs create mode 100644 ddk/examples/stateless_wallet.rs create mode 100644 ddk/examples/stateless_xpriv.rs create mode 100644 ddk/src/contract/accept.rs create mode 100644 ddk/src/contract/advanced.rs create mode 100644 ddk/src/contract/context.rs create mode 100644 ddk/src/contract/create.rs create mode 100644 ddk/src/contract/error.rs create mode 100644 ddk/src/contract/finalize.rs create mode 100644 ddk/src/contract/mod.rs create mode 100644 ddk/src/contract/psbt.rs create mode 100644 ddk/src/contract/sign.rs create mode 100644 ddk/src/contract/signing.rs create mode 100644 ddk/src/contract/tests.rs create mode 100644 ddk/src/contract/types.rs create mode 100644 ddk/tests/stateless.rs diff --git a/ddk-manager/src/contract/mod.rs b/ddk-manager/src/contract/mod.rs index 7833214b..20e41ae5 100644 --- a/ddk-manager/src/contract/mod.rs +++ b/ddk-manager/src/contract/mod.rs @@ -27,6 +27,14 @@ pub mod ser; pub mod signed_contract; pub(crate) mod utils; +/// Converts wire-level contract information into the execution information +/// required to construct CETs and adaptor signatures. +pub fn execution_contract_infos( + contract_info: &ddk_messages::contract_msgs::ContractInfo, +) -> Result, Error> { + Ok(crate::conversion_utils::get_contract_info_and_announcements(contract_info)?) +} + #[derive(Clone)] /// Enum representing the possible states of a DLC. pub enum Contract { diff --git a/ddk/examples/common/stateless.rs b/ddk/examples/common/stateless.rs new file mode 100644 index 00000000..e9f9122b --- /dev/null +++ b/ddk/examples/common/stateless.rs @@ -0,0 +1,197 @@ +// Shared scaffolding for the stateless contract examples: deterministic +// keys, a dummy funding UTXO per party, and a simple enum contract. Nothing +// here touches a chain, storage backend, or contract manager. + +use bitcoin::absolute::LockTime; +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::transaction::Version; +use bitcoin::{ + Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, +}; +use ddk::contract::{ + chain_hash_from_network, funding_input, CreateOfferParams, InputDerivation, PartyParams, +}; +use ddk_dlc::secp256k1_zkp::{All, Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use ddk_messages::contract_msgs::{ + ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, + EnumeratedContractDescriptor, SingleContractInfo, +}; +use ddk_messages::oracle_msgs::{ + tagged_announcement_msg, EnumEventDescriptor, EventDescriptor, OracleAnnouncement, + OracleEvent, OracleInfo, SingleOracleInfo, +}; +use ddk_messages::FundingInput; +use std::str::FromStr; + +pub const TOTAL_COLLATERAL: Amount = Amount::from_sat(100_000); + +/// One side of a contract: a DLC funding key plus a BIP84 wallet key +/// controlling a single funding UTXO. +pub struct PartySetup { + pub funding_secret_key: SecretKey, + pub xpriv: Xpriv, + pub derivation_path: DerivationPath, + pub funding_input: FundingInput, +} + +impl PartySetup { + pub fn new(secp: &Secp256k1, seed_byte: u8, network: Network, utxo_value: Amount) -> Self { + let funding_secret_key = SecretKey::from_slice(&[seed_byte; 32]).unwrap(); + let xpriv = Xpriv::new_master(network, &[seed_byte.wrapping_add(100); 64]).unwrap(); + let coin_type = if network == Network::Bitcoin { 0 } else { 1 }; + let derivation_path = + DerivationPath::from_str(&format!("84h/{coin_type}h/0h/0/0")).unwrap(); + let script_pubkey = p2wpkh_script(secp, &xpriv, &derivation_path); + let funding_input = funding_input( + &previous_transaction(utxo_value, script_pubkey), + 0, + Some(seed_byte as u64), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + Self { + funding_secret_key, + xpriv, + derivation_path, + funding_input, + } + } + + pub fn funding_pubkey(&self, secp: &Secp256k1) -> PublicKey { + self.funding_secret_key.public_key(secp) + } + + pub fn payout_script(&self, secp: &Secp256k1) -> ScriptBuf { + p2wpkh_script(secp, &self.xpriv, &self.derivation_path) + } + + pub fn party_params(&self, secp: &Secp256k1) -> PartyParams { + self.party_params_with_inputs(secp, vec![self.funding_input.clone()]) + } + + pub fn party_params_with_inputs( + &self, + secp: &Secp256k1, + funding_inputs: Vec, + ) -> PartyParams { + PartyParams { + funding_pubkey: self.funding_pubkey(secp), + funding_inputs, + payout_spk: self.payout_script(secp), + payout_serial_id: None, + change_spk: self.payout_script(secp), + change_serial_id: None, + } + } + + pub fn derivations(&self) -> Vec { + vec![InputDerivation { + input_serial_id: self.funding_input.input_serial_id, + derivation_path: self.derivation_path.clone(), + }] + } +} + +pub fn p2wpkh_script(secp: &Secp256k1, xpriv: &Xpriv, path: &DerivationPath) -> ScriptBuf { + let public_key = xpriv + .derive_priv(secp, path) + .unwrap() + .to_priv() + .public_key(secp); + ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()) +} + +/// A fake confirmed transaction paying `value` to `script_pubkey`. +pub fn previous_transaction(value: Amount, script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value, + script_pubkey, + }], + } +} + +/// A two-outcome enum contract with a locally signed oracle announcement. +pub fn enum_contract_info(total_collateral: Amount) -> ContractInfo { + let secp = Secp256k1::new(); + let oracle_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[88; 32]).unwrap()); + let nonce_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[90; 32]).unwrap()); + let oracle_event = OracleEvent { + oracle_nonces: vec![XOnlyPublicKey::from_keypair(&nonce_key).0], + event_maturity_epoch: 750, + event_descriptor: EventDescriptor::EnumEvent(EnumEventDescriptor { + outcomes: vec!["up".to_string(), "down".to_string()], + }), + event_id: "stateless-example".to_string(), + }; + let announcement = OracleAnnouncement { + announcement_signature: secp + .sign_schnorr(&tagged_announcement_msg(&oracle_event), &oracle_key), + oracle_public_key: XOnlyPublicKey::from_keypair(&oracle_key).0, + oracle_event, + }; + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral, + contract_info: ContractInfoInner { + contract_descriptor: ContractDescriptor::EnumeratedContractDescriptor( + EnumeratedContractDescriptor { + payouts: vec![ + ContractOutcome { + outcome: "up".to_string(), + offer_payout: total_collateral, + }, + ContractOutcome { + outcome: "down".to_string(), + offer_payout: Amount::ZERO, + }, + ], + }, + ), + oracle_info: OracleInfo::Single(SingleOracleInfo { + oracle_announcement: announcement, + }), + }, + }) +} + +pub fn offer_params( + secp: &Secp256k1, + offerer: &PartySetup, + offer_collateral: Amount, + network: Network, +) -> CreateOfferParams { + offer_params_with_party( + offerer.party_params(secp), + offer_collateral, + network, + ) +} + +pub fn offer_params_with_party( + party: PartyParams, + offer_collateral: Amount, + network: Network, +) -> CreateOfferParams { + CreateOfferParams { + chain_hash: chain_hash_from_network(network), + temporary_contract_id: None, + contract_info: enum_contract_info(TOTAL_COLLATERAL), + offer_collateral, + party, + fund_output_serial_id: None, + fee_rate_per_vb: 2, + cet_locktime: 500, + refund_locktime: 1_000, + contract_flags: 0, + } +} diff --git a/ddk/examples/stateless_descriptor.rs b/ddk/examples/stateless_descriptor.rs new file mode 100644 index 00000000..2a534373 --- /dev/null +++ b/ddk/examples/stateless_descriptor.rs @@ -0,0 +1,91 @@ +//! Stateless DLC lifecycle with funding inputs signed by a private output +//! descriptor. +//! +//! The lifecycle is identical to `stateless_xpriv`; only the signing source +//! changes. Watch-only descriptors are rejected, and only `wpkh()` and +//! `sh(wpkh())` descriptors are supported. +//! +//! Run with `cargo run --example stateless_descriptor`. + +#[allow(dead_code)] +mod util { + include!("common/stateless.rs"); +} + +use bitcoin::{Amount, Network}; +use ddk::contract::{ + accept_offer, create_funding_psbt, create_offer, finalize_sign, sign_accept, signing, + AcceptOfferParams, DescriptorInput, +}; +use ddk_dlc::secp256k1_zkp::Secp256k1; +use util::PartySetup; + +fn main() { + let secp = Secp256k1::new(); + let network = Network::Regtest; + + let offerer = PartySetup::new(&secp, 1, network, Amount::from_sat(150_000)); + let accepter = PartySetup::new(&secp, 2, network, Amount::from_sat(150_000)); + + // Private wildcard descriptors over the same BIP84 tree the UTXOs use. + let offer_descriptor = format!("wpkh({}/84h/1h/0h/0/*)", offerer.xpriv); + let accept_descriptor = format!("wpkh({}/84h/1h/0h/0/*)", accepter.xpriv); + + let offer = create_offer(util::offer_params( + &secp, + &offerer, + Amount::from_sat(50_000), + network, + )) + .expect("valid offer"); + + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter.funding_secret_key, + ) + .expect("valid accept"); + let accept = accept_result.accept; + + // Offer party signs with its descriptor; inputs are addressed by funding + // input serial id plus the descriptor's wildcard index. + let mut offer_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_descriptor( + &offer, + &accept, + &mut offer_psbt, + &offer_descriptor, + &[DescriptorInput { + input_serial_id: offerer.funding_input.input_serial_id, + derivation_index: 0, + }], + ) + .expect("offer descriptor signing"); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).expect("sign"); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_descriptor( + &offer, + &accept, + &mut accept_psbt, + &accept_descriptor, + &[DescriptorInput { + input_serial_id: accepter.funding_input.input_serial_id, + derivation_index: 0, + }], + ) + .expect("accept descriptor signing"); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).expect("finalize"); + + println!( + "completed funding transaction {} with {} signed inputs", + funding_transaction.compute_txid(), + funding_transaction.input.len() + ); +} diff --git a/ddk/examples/stateless_external_psbt.rs b/ddk/examples/stateless_external_psbt.rs new file mode 100644 index 00000000..6d48d02a --- /dev/null +++ b/ddk/examples/stateless_external_psbt.rs @@ -0,0 +1,139 @@ +//! Stateless DLC lifecycle with funding inputs signed by an external wallet. +//! +//! External signers (hardware wallets, remote services, other software) need +//! no DDK-specific code: +//! +//! ```text +//! create_funding_psbt -> serialize PSBT -> external wallet signs and +//! finalizes its own inputs -> deserialize PSBT -> sign_accept / finalize_sign +//! ``` +//! +//! The lifecycle functions verify that the returned PSBT spends exactly the +//! funding transaction rebuilt from the wire messages, so a signer cannot +//! mutate outputs, locktimes, sequences, or outpoints. +//! +//! Run with `cargo run --example stateless_external_psbt`. + +#[allow(dead_code)] +mod util { + include!("common/stateless.rs"); +} + +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::psbt::Psbt; +use bitcoin::{Amount, Network, ScriptBuf, Witness}; +use ddk::contract::{ + accept_offer, create_funding_psbt, create_offer, finalize_sign, sign_accept, signing, + AcceptOfferParams, +}; +use ddk_dlc::secp256k1_zkp::{All, Secp256k1}; +use util::PartySetup; + +fn main() { + let secp = Secp256k1::new(); + let network = Network::Regtest; + + let offerer = PartySetup::new(&secp, 1, network, Amount::from_sat(150_000)); + // The accept party's UTXO lives in an "external wallet" that only speaks PSBT. + let accepter = PartySetup::new(&secp, 2, network, Amount::from_sat(150_000)); + + let offer = create_offer(util::offer_params( + &secp, + &offerer, + Amount::from_sat(50_000), + network, + )) + .expect("valid offer"); + + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter.funding_secret_key, + ) + .expect("valid accept"); + let accept = accept_result.accept; + + // The offer party signs with whatever source it prefers (xpriv here). + let mut offer_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .expect("offer xpriv signing"); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).expect("sign"); + + // The accept party serializes the PSBT and hands it to the external wallet. + let psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + let serialized = psbt.serialize(); + let returned_bytes = external_wallet_sign( + serialized, + &accepter.xpriv, + &accepter.derivation_path, + &secp, + ); + let returned = Psbt::deserialize(&returned_bytes).expect("external wallet returned a PSBT"); + + // Inputs belonging to the offer party are still unsigned in `returned`; + // finalize_sign only requires the accept party's inputs to be finalized. + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &returned).expect("finalize"); + + println!( + "completed funding transaction {} with {} signed inputs", + funding_transaction.compute_txid(), + funding_transaction.input.len() + ); +} + +/// Stands in for an external wallet: signs and finalizes only the inputs it +/// owns, using nothing but rust-bitcoin. +fn external_wallet_sign( + serialized_psbt: Vec, + xpriv: &Xpriv, + path: &DerivationPath, + secp: &Secp256k1, +) -> Vec { + let mut psbt = Psbt::deserialize(&serialized_psbt).unwrap(); + let private_key = xpriv.derive_priv(secp, path).unwrap().to_priv(); + let public_key = private_key.public_key(secp); + let owned_script = ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()); + let fingerprint = xpriv.fingerprint(secp); + + for input in &mut psbt.inputs { + let owns_input = input + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey == owned_script) + .unwrap_or(false); + if owns_input { + input + .bip32_derivation + .insert(public_key.inner, (fingerprint, path.clone())); + } + } + psbt.sign(xpriv, secp).unwrap(); + for input in &mut psbt.inputs { + let Some((public_key, signature)) = input + .partial_sigs + .iter() + .map(|(pk, sig)| (*pk, *sig)) + .next() + else { + continue; + }; + input.final_script_witness = Some(Witness::from_slice(&[ + signature.to_vec(), + public_key.to_bytes(), + ])); + input.partial_sigs.clear(); + } + psbt.serialize() +} diff --git a/ddk/examples/stateless_wallet.rs b/ddk/examples/stateless_wallet.rs new file mode 100644 index 00000000..a1f4b671 --- /dev/null +++ b/ddk/examples/stateless_wallet.rs @@ -0,0 +1,177 @@ +//! Stateless DLC lifecycle with funding inputs signed by a wallet +//! implementing [`ddk_manager::Wallet`]. +//! +//! The wallet only ever sees the funding PSBT — no contract manager, signer +//! provider, or storage trait is involved. Any wallet that can sign PSBT +//! inputs works, including [`ddk::wallet::DlcDevKitWallet`]; this example uses +//! a minimal in-memory BDK wallet. +//! +//! Run with `cargo run --example stateless_wallet`. + +#[allow(dead_code)] +mod util { + include!("common/stateless.rs"); +} + +use bitcoin::bip32::Xpriv; +use bitcoin::psbt::Psbt; +use bitcoin::{Amount, Network, OutPoint, ScriptBuf}; +use ddk::contract::{ + accept_offer, create_funding_psbt, create_offer, finalize_sign, funding_input, sign_accept, + signing, AcceptOfferParams, Party, +}; +use ddk_dlc::secp256k1_zkp::Secp256k1; +use util::PartySetup; + +/// A minimal wallet implementing [`ddk_manager::Wallet`] over an in-memory +/// BDK wallet. Only `sign_psbt_input` is used by the stateless API. +struct ExampleWallet { + wallet: std::sync::Mutex, + script_pubkey: ScriptBuf, +} + +impl ExampleWallet { + fn new(network: Network, seed_byte: u8) -> Self { + let xpriv = Xpriv::new_master(network, &[seed_byte; 64]).unwrap(); + let descriptor = format!("wpkh({xpriv}/84h/1h/0h/0/*)"); + let mut wallet = bdk_wallet::Wallet::create_single(descriptor) + .network(network) + .create_wallet_no_persist() + .unwrap(); + let address = wallet.reveal_next_address(bdk_wallet::KeychainKind::External); + Self { + wallet: std::sync::Mutex::new(wallet), + script_pubkey: address.address.script_pubkey(), + } + } +} + +#[async_trait::async_trait] +impl ddk_manager::Wallet for ExampleWallet { + async fn get_new_address(&self) -> Result { + unimplemented!("not needed for PSBT signing") + } + async fn get_new_change_address(&self) -> Result { + unimplemented!("not needed for PSBT signing") + } + async fn get_utxos_for_amount( + &self, + _amount: Amount, + _fee_rate: u64, + _lock_utxos: bool, + ) -> Result, ddk_manager::error::Error> { + unimplemented!("not needed for PSBT signing") + } + async fn sign_psbt_input( + &self, + psbt: &mut Psbt, + input_index: usize, + ) -> Result<(), ddk_manager::error::Error> { + let wallet = self.wallet.lock().unwrap(); + let mut signed = psbt.clone(); + let options = bdk_wallet::SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + wallet + .sign(&mut signed, options) + .map_err(|e| ddk_manager::error::Error::WalletError(Box::new(e)))?; + psbt.inputs[input_index] = signed.inputs[input_index].clone(); + Ok(()) + } + fn import_address(&self, _address: &bitcoin::Address) -> Result<(), ddk_manager::error::Error> { + Ok(()) + } + fn unreserve_utxos(&self, _outpoints: &[OutPoint]) -> Result<(), ddk_manager::error::Error> { + Ok(()) + } +} + +#[tokio::main] +async fn main() { + let secp = Secp256k1::new(); + let network = Network::Regtest; + + let offerer_wallet = ExampleWallet::new(network, 71); + let accepter_wallet = ExampleWallet::new(network, 72); + + // DLC funding keys stay with the application; the wallets only control + // the UTXOs spent into the funding transaction. + let offerer = PartySetup::new(&secp, 1, network, Amount::from_sat(150_000)); + let accepter = PartySetup::new(&secp, 2, network, Amount::from_sat(150_000)); + let offer_input = funding_input( + &util::previous_transaction( + Amount::from_sat(150_000), + offerer_wallet.script_pubkey.clone(), + ), + 0, + Some(1), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + let accept_input = funding_input( + &util::previous_transaction( + Amount::from_sat(150_000), + accepter_wallet.script_pubkey.clone(), + ), + 0, + Some(2), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + + let offer = create_offer(util::offer_params_with_party( + offerer.party_params_with_inputs(&secp, vec![offer_input]), + Amount::from_sat(50_000), + network, + )) + .expect("valid offer"); + + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params_with_inputs(&secp, vec![accept_input]), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter.funding_secret_key, + ) + .expect("valid accept"); + let accept = accept_result.accept; + + let mut offer_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_wallet( + &offer, + &accept, + &mut offer_psbt, + &offerer_wallet, + Party::Offer, + ) + .await + .expect("offer wallet signing"); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).expect("sign"); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_wallet( + &offer, + &accept, + &mut accept_psbt, + &accepter_wallet, + Party::Accept, + ) + .await + .expect("accept wallet signing"); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).expect("finalize"); + + println!( + "completed funding transaction {} with {} signed inputs", + funding_transaction.compute_txid(), + funding_transaction.input.len() + ); +} diff --git a/ddk/examples/stateless_xpriv.rs b/ddk/examples/stateless_xpriv.rs new file mode 100644 index 00000000..685f28d2 --- /dev/null +++ b/ddk/examples/stateless_xpriv.rs @@ -0,0 +1,84 @@ +//! Stateless DLC lifecycle with funding inputs signed by a raw BIP32 xpriv. +//! +//! ```text +//! create_offer -> accept_offer -> create_funding_psbt +//! -> signing::sign_funding_psbt_with_xpriv (both parties) +//! -> sign_accept -> finalize_sign -> broadcast (caller's chain client) +//! ``` +//! +//! Run with `cargo run --example stateless_xpriv`. + +#[allow(dead_code)] +mod util { + include!("common/stateless.rs"); +} + +use bitcoin::{Amount, Network}; +use ddk::contract::{ + accept_offer, create_funding_psbt, create_offer, finalize_sign, sign_accept, signing, + AcceptOfferParams, +}; +use ddk_dlc::secp256k1_zkp::Secp256k1; +use util::PartySetup; + +fn main() { + let secp = Secp256k1::new(); + let network = Network::Regtest; + + // Each party holds a DLC funding key and a wallet xpriv with one UTXO. + let offerer = PartySetup::new(&secp, 1, network, Amount::from_sat(150_000)); + let accepter = PartySetup::new(&secp, 2, network, Amount::from_sat(150_000)); + + let offer = create_offer(util::offer_params( + &secp, + &offerer, + Amount::from_sat(50_000), + network, + )) + .expect("valid offer"); + + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter.funding_secret_key, + ) + .expect("valid accept"); + let accept = accept_result.accept; + + // Offer party: sign its funding input with the xpriv and create the sign message. + let mut offer_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .expect("offer xpriv signing"); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).expect("sign"); + + // Accept party: sign its funding input and complete the funding transaction. + let mut accept_psbt = create_funding_psbt(&offer, &accept).expect("funding psbt"); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .expect("accept xpriv signing"); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).expect("finalize"); + + // Broadcasting stays with the caller, e.g. `chain.send_transaction(&funding_transaction)`. + println!( + "completed funding transaction {} with {} signed inputs", + funding_transaction.compute_txid(), + funding_transaction.input.len() + ); +} diff --git a/ddk/src/contract/accept.rs b/ddk/src/contract/accept.rs new file mode 100644 index 00000000..54ebc07e --- /dev/null +++ b/ddk/src/contract/accept.rs @@ -0,0 +1,107 @@ +//! Offer acceptance and transaction reconstruction. + +use ddk_dlc::secp256k1_zkp::{PublicKey, Secp256k1, SecretKey}; +use ddk_dlc::DlcTransactions; +use ddk_messages::{AcceptDlc, CetAdaptorSignatures, OfferDlc}; + +use super::context::{ + build_context, context_from_messages, create_adaptor_signatures, create_refund_signature, + dlc_party_params, ensure_no_dlc_inputs, ensure_unique_input_serial_ids, +}; +use super::create::validate_offer; +use super::error::ContractError; +use super::psbt::build_funding_psbt; +use super::types::{random_serial_id, AcceptOfferParams, AcceptResult}; + +/// Validates an offer and creates the accepting party's wire message. +/// +/// The accept collateral is the offer's total collateral minus the offer +/// collateral. The returned [`AcceptResult`] carries the accept message to +/// send back, the rebuilt contract transactions, and a funding PSBT ready for +/// the PSBT signing layer. Serial ids are randomly generated when omitted. +/// +/// `funding_secret_key` is the accepting party's DLC funding key, used here to +/// produce CET adaptor signatures and the refund signature. It must match +/// `params.party.funding_pubkey`. +pub fn accept_offer( + offer: &OfferDlc, + params: AcceptOfferParams, + funding_secret_key: &SecretKey, +) -> Result { + let AcceptOfferParams { + party, + min_timeout_interval, + max_timeout_interval, + } = params; + + validate_offer(offer, min_timeout_interval, max_timeout_interval)?; + ensure_no_dlc_inputs(&party.funding_inputs)?; + + let secp = Secp256k1::new(); + if PublicKey::from_secret_key(&secp, funding_secret_key) != party.funding_pubkey { + return Err(ContractError::InvalidAccept( + "funding secret key does not match the accept party funding public key".to_string(), + )); + } + let accept_collateral = offer + .get_total_collateral() + .checked_sub(offer.offer_collateral) + .ok_or_else(|| { + ContractError::InvalidOffer("offer collateral exceeds total collateral".to_string()) + })?; + + let payout_serial_id = party.payout_serial_id.unwrap_or_else(random_serial_id); + let change_serial_id = party.change_serial_id.unwrap_or_else(random_serial_id); + let accept_params = dlc_party_params( + party.funding_pubkey, + party.payout_spk.clone(), + payout_serial_id, + party.change_spk.clone(), + change_serial_id, + accept_collateral, + &party.funding_inputs, + )?; + let context = build_context(offer, &accept_params)?; + let adaptor_signatures = create_adaptor_signatures( + &secp, + &context, + funding_secret_key, + offer.get_total_collateral(), + )?; + let refund_signature = create_refund_signature(&secp, &context, funding_secret_key)?; + + let accept = AcceptDlc { + protocol_version: offer.protocol_version, + temporary_contract_id: offer.temporary_contract_id, + accept_collateral, + funding_pubkey: party.funding_pubkey, + payout_spk: party.payout_spk, + payout_serial_id, + funding_inputs: party.funding_inputs, + change_spk: party.change_spk, + change_serial_id, + cet_adaptor_signatures: CetAdaptorSignatures::from(adaptor_signatures.as_slice()), + refund_signature, + negotiation_fields: None, + }; + ensure_unique_input_serial_ids(offer, &accept)?; + + let funding_psbt = build_funding_psbt(offer, &accept, context.transactions.fund.clone())?; + Ok(AcceptResult { + accept, + transactions: context.transactions, + funding_psbt, + }) +} + +/// Rebuilds the unsigned funding, CET, and refund transactions from wire messages. +/// +/// The result is deterministic: both parties rebuild identical transactions +/// from the same offer and accept messages, so neither has to trust +/// transaction data supplied by the other. +pub fn create_dlc_transactions( + offer: &OfferDlc, + accept: &AcceptDlc, +) -> Result { + Ok(context_from_messages(offer, accept)?.transactions) +} diff --git a/ddk/src/contract/advanced.rs b/ddk/src/contract/advanced.rs new file mode 100644 index 00000000..c65afa23 --- /dev/null +++ b/ddk/src/contract/advanced.rs @@ -0,0 +1,155 @@ +//! Low-level building blocks for advanced integrations. +//! +//! Most consumers should use the primary lifecycle functions in +//! [`ddk::contract`](super) together with the [`signing`](super::signing) +//! sources. The functions here expose the raw adaptor-signature and witness +//! plumbing for integrations that interoperate with other DLC implementations +//! or produce funding witnesses outside of a PSBT. + +use bitcoin::psbt::Psbt; +use bitcoin::sighash::EcdsaSighashType; +use bitcoin::{Amount, Transaction, Witness}; +use ddk_dlc::secp256k1_zkp::{EcdsaAdaptorSignature, Secp256k1, SecretKey}; +use ddk_messages::{ + AcceptDlc, CetAdaptorSignatures, FundingSignature, FundingSignatures, OfferDlc, SignDlc, +}; + +use super::context::{self, context_from_messages}; +use super::error::ContractError; +use super::psbt; +use super::types::{Party, SignResult}; + +/// Converts a Bitcoin witness into a wire funding signature. +pub fn funding_signature_from_witness(witness: Witness) -> FundingSignature { + psbt::funding_signature_from_witness(witness) +} + +/// Converts Bitcoin witnesses into wire funding signatures. +/// +/// The witnesses must be ordered like the party's funding inputs in its wire +/// message. +pub fn funding_signatures_from_witnesses(witnesses: Vec) -> FundingSignatures { + FundingSignatures { + funding_signatures: witnesses + .into_iter() + .map(psbt::funding_signature_from_witness) + .collect(), + } +} + +/// Signs one native P2WPKH funding input and returns its wire-format witness. +pub fn sign_p2wpkh_funding_input( + funding_transaction: &Transaction, + input_index: usize, + prevout_value: Amount, + secret_key: &SecretKey, +) -> Result { + let secp = Secp256k1::new(); + let witness = ddk_dlc::util::get_witness_for_p2wpkh_input( + &secp, + secret_key, + funding_transaction, + input_index, + EcdsaSighashType::All, + prevout_value, + )?; + Ok(psbt::funding_signature_from_witness(witness)) +} + +/// Creates one party's CET adaptor signatures over all contract outcomes. +pub fn create_cet_adaptor_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, +) -> Result, ContractError> { + let secp = Secp256k1::new(); + let context = context_from_messages(offer, accept)?; + context::create_adaptor_signatures( + &secp, + &context, + funding_secret_key, + offer.get_total_collateral(), + ) +} + +/// Verifies one party's refund and CET adaptor signatures. +/// +/// `party` names the party that produced the signatures. +pub fn verify_cet_adaptor_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + party: Party, + refund_signature: &ddk_dlc::secp256k1_zkp::ecdsa::Signature, + adaptor_signatures: &CetAdaptorSignatures, +) -> Result<(), ContractError> { + let secp = Secp256k1::new(); + let context = context_from_messages(offer, accept)?; + let (funding_pubkey, error): (_, fn(String) -> ContractError) = match party { + Party::Offer => (offer.funding_pubkey, ContractError::InvalidSign), + Party::Accept => (accept.funding_pubkey, ContractError::InvalidAccept), + }; + context::verify_counterparty_signatures( + &secp, + &context, + offer.get_total_collateral(), + funding_pubkey, + refund_signature, + adaptor_signatures, + error, + ) +} + +/// Extracts one party's finalized funding witnesses from a funding PSBT. +/// +/// The PSBT is first verified against the funding transaction rebuilt from +/// the messages. +pub fn funding_signatures_from_psbt( + offer: &OfferDlc, + accept: &AcceptDlc, + party: Party, + psbt: &Psbt, +) -> Result { + psbt::ensure_matching_psbt(offer, accept, psbt)?; + psbt::extract_funding_signatures(offer, accept, party, psbt) +} + +/// Computes the contract id from the offer and accept messages. +pub fn compute_contract_id( + offer: &OfferDlc, + accept: &AcceptDlc, +) -> Result<[u8; 32], ContractError> { + let context = context_from_messages(offer, accept)?; + Ok(context::contract_id_from_transactions( + &context.transactions, + &offer.temporary_contract_id, + )) +} + +/// Creates the sign message from externally produced offer-side funding witnesses. +/// +/// Prefer [`sign_accept`](super::sign_accept) with a PSBT; this variant exists +/// for integrations that already hold raw witnesses. `funding_signatures` must +/// contain one witness per offer funding input, in message order. +pub fn sign_accept_with_funding_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, + funding_signatures: FundingSignatures, +) -> Result { + super::sign::sign_accept_internal(offer, accept, funding_secret_key, funding_signatures) +} + +/// Completes the funding transaction from externally produced accept-side witnesses. +/// +/// Prefer [`finalize_sign`](super::finalize_sign) with a PSBT; this variant +/// exists for integrations that already hold raw witnesses. +/// `funding_signatures` must contain one witness per accept funding input, in +/// message order. +pub fn finalize_sign_with_funding_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_signatures: FundingSignatures, +) -> Result { + super::finalize::finalize_sign_internal(offer, accept, sign, funding_signatures) +} diff --git a/ddk/src/contract/context.rs b/ddk/src/contract/context.rs new file mode 100644 index 00000000..755df96f --- /dev/null +++ b/ddk/src/contract/context.rs @@ -0,0 +1,438 @@ +//! Internal reconstruction and validation of contract state from wire messages. +//! +//! Nothing in this module is persisted. Every lifecycle operation rebuilds the +//! transactions it needs from the offer and accept messages so that callers +//! never have to supply, store, or trust intermediate transaction data. + +use bitcoin::consensus::Decodable; +use bitcoin::{Amount, ScriptBuf, Transaction, Witness}; +use ddk_dlc::secp256k1_zkp::{All, EcdsaAdaptorSignature, PublicKey, Secp256k1, SecretKey}; +use ddk_dlc::{DlcTransactions, PartyParams as DlcPartyParams, TxInputInfo}; +use ddk_manager::contract::contract_info::ContractInfo as ExecutionContractInfo; +use ddk_messages::{AcceptDlc, CetAdaptorSignatures, FundingInput, FundingSignatures, OfferDlc}; + +use super::error::ContractError; +use super::types::Party; +use super::PROTOCOL_VERSION; + +/// Contract data rebuilt from the offer and accept messages. +pub(crate) struct ContractContext { + pub execution_infos: Vec, + pub cet_ranges: Vec>, + pub transactions: DlcTransactions, +} + +/// Validates an offer/accept pair and rebuilds the contract transactions. +pub(crate) fn context_from_messages( + offer: &OfferDlc, + accept: &AcceptDlc, +) -> Result { + ensure_protocol_version(offer.protocol_version, ContractError::InvalidOffer)?; + ensure_protocol_version(accept.protocol_version, ContractError::InvalidAccept)?; + if offer.protocol_version != accept.protocol_version { + return Err(ContractError::InvalidAccept( + "offer and accept protocol versions differ".to_string(), + )); + } + if offer.temporary_contract_id != accept.temporary_contract_id { + return Err(ContractError::InvalidAccept( + "accept message references a different temporary contract id".to_string(), + )); + } + if accept.negotiation_fields.is_some() { + return Err(ContractError::InvalidAccept( + "negotiation fields are not supported by the stateless API".to_string(), + )); + } + ensure_no_dlc_inputs(&offer.funding_inputs)?; + ensure_no_dlc_inputs(&accept.funding_inputs)?; + ensure_unique_input_serial_ids(offer, accept)?; + + let accept_params = dlc_party_params( + accept.funding_pubkey, + accept.payout_spk.clone(), + accept.payout_serial_id, + accept.change_spk.clone(), + accept.change_serial_id, + accept.accept_collateral, + &accept.funding_inputs, + )?; + build_context(offer, &accept_params) +} + +/// Rebuilds the contract transactions from an offer and the accepting party's +/// parameters. Used by [`context_from_messages`] and by accept-message creation +/// before the accept message exists. +pub(crate) fn build_context( + offer: &OfferDlc, + accept_params: &DlcPartyParams, +) -> Result { + let total_collateral = offer.get_total_collateral(); + if offer.offer_collateral + accept_params.collateral != total_collateral { + return Err(ContractError::InvalidAccept( + "offer and accept collateral do not equal total collateral".to_string(), + )); + } + let offer_params = dlc_party_params( + offer.funding_pubkey, + offer.payout_spk.clone(), + offer.payout_serial_id, + offer.change_spk.clone(), + offer.change_serial_id, + offer.offer_collateral, + &offer.funding_inputs, + )?; + let execution_infos = ddk_manager::contract::execution_contract_infos(&offer.contract_info)?; + if execution_infos.is_empty() { + return Err(ContractError::InvalidOffer( + "contract does not contain execution information".to_string(), + )); + } + for info in &execution_infos { + info.validate()?; + } + + let mut transactions = ddk_dlc::create_dlc_transactions( + &offer_params, + accept_params, + &execution_infos[0].get_payouts(total_collateral)?, + offer.refund_locktime, + offer.fee_rate_per_vb, + 0, + offer.cet_locktime, + offer.fund_output_serial_id, + offer.contract_flags, + )?; + let mut cet_ranges = Vec::with_capacity(execution_infos.len()); + cet_ranges.push(0..transactions.cets.len()); + let cet_input = transactions + .cets + .first() + .ok_or_else(|| ContractError::InvalidOffer("contract has no CETs".to_string()))? + .input[0] + .clone(); + + for info in execution_infos.iter().skip(1) { + let start = transactions.cets.len(); + transactions.cets.extend(ddk_dlc::create_cets( + &cet_input, + &offer_params.payout_script_pubkey, + offer_params.payout_serial_id, + &accept_params.payout_script_pubkey, + accept_params.payout_serial_id, + &info.get_payouts(total_collateral)?, + 0, + )); + cet_ranges.push(start..transactions.cets.len()); + } + + Ok(ContractContext { + execution_infos, + cet_ranges, + transactions, + }) +} + +pub(crate) fn dlc_party_params( + funding_pubkey: PublicKey, + payout_script_pubkey: ScriptBuf, + payout_serial_id: u64, + change_script_pubkey: ScriptBuf, + change_serial_id: u64, + collateral: Amount, + funding_inputs: &[FundingInput], +) -> Result { + let (inputs, input_amount) = tx_input_infos(funding_inputs)?; + Ok(DlcPartyParams { + fund_pubkey: funding_pubkey, + change_script_pubkey, + change_serial_id, + payout_script_pubkey, + payout_serial_id, + inputs, + dlc_inputs: vec![], + input_amount, + collateral, + }) +} + +fn tx_input_infos( + funding_inputs: &[FundingInput], +) -> Result<(Vec, Amount), ContractError> { + let mut input_amount = Amount::ZERO; + let mut inputs = Vec::with_capacity(funding_inputs.len()); + for input in funding_inputs { + let previous_transaction = decode_previous_transaction(input)?; + let prevout = previous_transaction + .output + .get(input.prev_tx_vout as usize) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "previous output {} does not exist", + input.prev_tx_vout + )) + })?; + input_amount += prevout.value; + inputs.push(TxInputInfo { + outpoint: bitcoin::OutPoint { + txid: previous_transaction.compute_txid(), + vout: input.prev_tx_vout, + }, + max_witness_len: input.max_witness_len as usize, + redeem_script: input.redeem_script.clone(), + serial_id: input.input_serial_id, + }); + } + Ok((inputs, input_amount)) +} + +/// Creates this party's CET adaptor signatures and groups them per execution info. +pub(crate) fn create_adaptor_signatures( + secp: &Secp256k1, + context: &ContractContext, + funding_secret_key: &SecretKey, + total_collateral: Amount, +) -> Result, ContractError> { + let mut signatures = Vec::new(); + for (info, range) in context.execution_infos.iter().zip(&context.cet_ranges) { + let (_, mut info_signatures) = info.get_adaptor_info( + secp, + total_collateral, + funding_secret_key, + &context.transactions.funding_script_pubkey, + context.transactions.get_fund_output().value, + &context.transactions.cets[range.clone()], + signatures.len(), + )?; + signatures.append(&mut info_signatures); + } + Ok(signatures) +} + +/// Creates this party's refund transaction signature. +pub(crate) fn create_refund_signature( + secp: &Secp256k1, + context: &ContractContext, + funding_secret_key: &SecretKey, +) -> Result { + Ok(ddk_dlc::util::get_raw_sig_for_tx_input( + secp, + &context.transactions.refund, + 0, + &context.transactions.funding_script_pubkey, + context.transactions.get_fund_output().value, + funding_secret_key, + )?) +} + +/// Verifies the counterparty's refund and CET adaptor signatures. +/// +/// `error` attributes failures to the message that carried the signatures +/// (accept or sign). +pub(crate) fn verify_counterparty_signatures( + secp: &Secp256k1, + context: &ContractContext, + total_collateral: Amount, + counterparty_funding_pubkey: PublicKey, + refund_signature: &ddk_dlc::secp256k1_zkp::ecdsa::Signature, + adaptor_signatures: &CetAdaptorSignatures, + error: fn(String) -> ContractError, +) -> Result<(), ContractError> { + let funding_value = context.transactions.get_fund_output().value; + ddk_dlc::verify_tx_input_sig( + secp, + refund_signature, + &context.transactions.refund, + 0, + &context.transactions.funding_script_pubkey, + funding_value, + &counterparty_funding_pubkey, + ) + .map_err(|e| error(format!("invalid refund signature: {e}")))?; + + let signatures: Vec = adaptor_signatures.into(); + let mut signature_index = 0; + for (info, range) in context.execution_infos.iter().zip(&context.cet_ranges) { + let (_, next_index) = info + .verify_and_get_adaptor_info( + secp, + total_collateral, + &counterparty_funding_pubkey, + &context.transactions.funding_script_pubkey, + funding_value, + &context.transactions.cets[range.clone()], + &signatures, + signature_index, + ) + .map_err(|e| error(format!("invalid CET adaptor signatures: {e}")))?; + signature_index = next_index; + } + if signature_index != signatures.len() { + return Err(error(format!( + "received {} adaptor signatures but used {}", + signatures.len(), + signature_index + ))); + } + Ok(()) +} + +/// Applies one party's funding witnesses to the funding transaction. +pub(crate) fn apply_funding_signatures( + transaction: &mut Transaction, + offer: &OfferDlc, + accept: &AcceptDlc, + party: Party, + signatures: &FundingSignatures, +) -> Result<(), ContractError> { + let inputs = party_funding_inputs(offer, accept, party); + if inputs.len() != signatures.funding_signatures.len() { + return Err(ContractError::InvalidFundingInput(format!( + "expected {} funding signatures, received {}", + inputs.len(), + signatures.funding_signatures.len() + ))); + } + for (input, signature) in inputs.iter().zip(&signatures.funding_signatures) { + if signature.witness_elements.is_empty() { + return Err(ContractError::InvalidFundingInput(format!( + "funding signature for input serial id {} has no witness elements", + input.input_serial_id + ))); + } + let index = funding_input_index(offer, accept, input.input_serial_id)?; + transaction.input[index].witness = Witness::from_slice( + &signature + .witness_elements + .iter() + .map(|element| element.witness.clone()) + .collect::>(), + ); + } + Ok(()) +} + +/// Maps a funding input serial id to its index in the funding transaction. +/// +/// Funding inputs are ordered by ascending serial id across both parties. +pub(crate) fn funding_input_index( + offer: &OfferDlc, + accept: &AcceptDlc, + input_serial_id: u64, +) -> Result { + let mut serial_ids = offer + .funding_inputs + .iter() + .chain(&accept.funding_inputs) + .map(|input| input.input_serial_id) + .collect::>(); + if serial_ids + .iter() + .filter(|id| **id == input_serial_id) + .count() + != 1 + { + return Err(ContractError::InvalidFundingInput(format!( + "funding input serial id {input_serial_id} is not unique" + ))); + } + serial_ids.sort_unstable(); + serial_ids + .iter() + .position(|id| *id == input_serial_id) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "funding input serial id {input_serial_id} was not found" + )) + }) +} + +pub(crate) fn party_funding_inputs<'a>( + offer: &'a OfferDlc, + accept: &'a AcceptDlc, + party: Party, +) -> &'a [FundingInput] { + match party { + Party::Offer => &offer.funding_inputs, + Party::Accept => &accept.funding_inputs, + } +} + +pub(crate) fn ensure_protocol_version( + version: u32, + error: fn(String) -> ContractError, +) -> Result<(), ContractError> { + if version != PROTOCOL_VERSION { + return Err(error(format!("unsupported DLC protocol version {version}"))); + } + Ok(()) +} + +pub(crate) fn ensure_funding_key( + secp: &Secp256k1, + secret_key: &SecretKey, + expected_public_key: &PublicKey, + error: fn(String) -> ContractError, +) -> Result<(), ContractError> { + if PublicKey::from_secret_key(secp, secret_key) != *expected_public_key { + return Err(error( + "funding secret key does not match the funding public key".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn ensure_no_dlc_inputs(funding_inputs: &[FundingInput]) -> Result<(), ContractError> { + if funding_inputs.iter().any(|input| input.dlc_input.is_some()) { + return Err(ContractError::InvalidFundingInput( + "DLC inputs and splicing require persisted previous-contract state".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn ensure_unique_input_serial_ids( + offer: &OfferDlc, + accept: &AcceptDlc, +) -> Result<(), ContractError> { + let mut serial_ids = offer + .funding_inputs + .iter() + .chain(&accept.funding_inputs) + .map(|input| input.input_serial_id) + .collect::>(); + serial_ids.sort_unstable(); + if serial_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(ContractError::InvalidFundingInput( + "funding input serial ids are not unique".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn decode_previous_transaction( + input: &FundingInput, +) -> Result { + Transaction::consensus_decode(&mut input.prev_tx.as_slice()).map_err(|e| { + ContractError::InvalidFundingInput(format!( + "could not decode the previous transaction of funding input serial id {}: {e}", + input.input_serial_id + )) + }) +} + +/// Computes the contract id from the funding transaction and temporary contract id. +pub(crate) fn contract_id_from_transactions( + transactions: &DlcTransactions, + temporary_contract_id: &[u8; 32], +) -> [u8; 32] { + let fund_txid = transactions.fund.compute_txid(); + let fund_output_index = transactions.get_fund_output_index() as u16; + let mut contract_id = [0; 32]; + for i in 0..32 { + contract_id[i] = fund_txid[31 - i] ^ temporary_contract_id[i]; + } + contract_id[30] ^= ((fund_output_index >> 8) & 0xff) as u8; + contract_id[31] ^= (fund_output_index & 0xff) as u8; + contract_id +} diff --git a/ddk/src/contract/create.rs b/ddk/src/contract/create.rs new file mode 100644 index 00000000..5fa72c85 --- /dev/null +++ b/ddk/src/contract/create.rs @@ -0,0 +1,116 @@ +//! Offer creation and validation. + +use ddk_dlc::secp256k1_zkp::Secp256k1; +use ddk_messages::OfferDlc; + +use super::context::{ensure_no_dlc_inputs, ensure_protocol_version}; +use super::error::ContractError; +use super::types::{random_serial_id, random_temporary_contract_id, CreateOfferParams}; +use super::PROTOCOL_VERSION; + +/// Creates an offer message from explicit contract and Bitcoin data. +/// +/// No secret key is required: the offer carries the offering party's DLC +/// funding *public* key, and funding inputs are signed later through the PSBT +/// signing layer. Serial ids and the temporary contract id are randomly +/// generated when omitted from `params`. +pub fn create_offer(params: CreateOfferParams) -> Result { + let CreateOfferParams { + chain_hash, + temporary_contract_id, + contract_info, + offer_collateral, + party, + fund_output_serial_id, + fee_rate_per_vb, + cet_locktime, + refund_locktime, + contract_flags, + } = params; + + ensure_no_dlc_inputs(&party.funding_inputs)?; + ddk_dlc::util::validate_fee_rate(fee_rate_per_vb) + .map_err(|e| ContractError::InvalidOffer(format!("invalid fee rate: {e}")))?; + if cet_locktime >= refund_locktime { + return Err(ContractError::InvalidOffer( + "refund locktime must be after the CET locktime".to_string(), + )); + } + let mut input_serial_ids = party + .funding_inputs + .iter() + .map(|input| input.input_serial_id) + .collect::>(); + input_serial_ids.sort_unstable(); + if input_serial_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(ContractError::InvalidFundingInput( + "funding input serial ids are not unique".to_string(), + )); + } + + let offer = OfferDlc { + protocol_version: PROTOCOL_VERSION, + contract_flags, + chain_hash, + temporary_contract_id: temporary_contract_id.unwrap_or_else(random_temporary_contract_id), + contract_info, + funding_pubkey: party.funding_pubkey, + payout_spk: party.payout_spk, + payout_serial_id: party.payout_serial_id.unwrap_or_else(random_serial_id), + offer_collateral, + funding_inputs: party.funding_inputs, + change_spk: party.change_spk, + change_serial_id: party.change_serial_id.unwrap_or_else(random_serial_id), + fund_output_serial_id: fund_output_serial_id.unwrap_or_else(random_serial_id), + fee_rate_per_vb, + cet_locktime, + refund_locktime, + }; + + if offer.offer_collateral > offer.get_total_collateral() { + return Err(ContractError::InvalidOffer( + "offer collateral exceeds total collateral".to_string(), + )); + } + // Catch malformed payout or oracle data before the offer leaves this party. + let execution_infos = ddk_manager::contract::execution_contract_infos(&offer.contract_info)?; + if execution_infos.is_empty() { + return Err(ContractError::InvalidOffer( + "contract does not contain execution information".to_string(), + )); + } + for info in &execution_infos { + info.validate()?; + } + + Ok(offer) +} + +/// Validates an incoming offer's structure, oracle announcements, and timeout policy. +/// +/// `min_timeout_interval` and `max_timeout_interval` bound the distance between +/// the oracle event maturity and the offer's refund locktime, and are the +/// accepting party's local policy. +pub fn validate_offer( + offer: &OfferDlc, + min_timeout_interval: u32, + max_timeout_interval: u32, +) -> Result<(), ContractError> { + ensure_protocol_version(offer.protocol_version, ContractError::InvalidOffer)?; + ensure_no_dlc_inputs(&offer.funding_inputs)?; + ddk_dlc::util::validate_fee_rate(offer.fee_rate_per_vb) + .map_err(|e| ContractError::InvalidOffer(format!("invalid fee rate: {e}")))?; + if offer.offer_collateral > offer.get_total_collateral() { + return Err(ContractError::InvalidOffer( + "offer collateral exceeds total collateral".to_string(), + )); + } + offer + .validate( + &Secp256k1::verification_only(), + min_timeout_interval, + max_timeout_interval, + ) + .map_err(|e| ContractError::InvalidOffer(e.to_string()))?; + Ok(()) +} diff --git a/ddk/src/contract/error.rs b/ddk/src/contract/error.rs new file mode 100644 index 00000000..c45b1394 --- /dev/null +++ b/ddk/src/contract/error.rs @@ -0,0 +1,68 @@ +//! Errors returned by the stateless contract API. + +use thiserror::Error; + +/// Errors returned by the stateless contract functions. +/// +/// Variants carry plain strings so they can cross FFI and binding boundaries +/// without exposing internal library error types. +#[derive(Debug, Error)] +pub enum ContractError { + /// The offer message, or data used to build one, is invalid. + #[error("invalid offer: {0}")] + InvalidOffer(String), + /// The accept message, or data used to build one, is invalid. + #[error("invalid accept: {0}")] + InvalidAccept(String), + /// The sign message, or data used to build one, is invalid. + #[error("invalid sign: {0}")] + InvalidSign(String), + /// A funding input is malformed or references missing data. + #[error("invalid funding input: {0}")] + InvalidFundingInput(String), + /// The PSBT does not match the funding transaction rebuilt from the wire messages. + #[error("PSBT mismatch: {0}")] + PsbtMismatch(String), + /// A PSBT input that must be signed does not have a finalized witness. + #[error("PSBT input {input_index} does not have a finalized witness")] + MissingFinalizedInput { + /// The index of the input in the funding transaction. + input_index: usize, + }, + /// The script type of a funding input is not supported for signing. + #[error("PSBT input {input_index} has an unsupported script type")] + UnsupportedScriptType { + /// The index of the input in the funding transaction. + input_index: usize, + }, + /// Descriptor parsing, derivation, or signing failed. + #[error("descriptor error: {0}")] + Descriptor(String), + /// A wallet implementation failed to sign a funding input. + #[error("wallet error: {0}")] + Wallet(String), + /// BIP32 key derivation failed. + #[error("BIP32 error: {0}")] + Bip32(String), + /// A DLC transaction or signature operation failed. + #[error("DLC error: {0}")] + Dlc(String), +} + +impl From for ContractError { + fn from(error: bitcoin::bip32::Error) -> Self { + ContractError::Bip32(error.to_string()) + } +} + +impl From for ContractError { + fn from(error: ddk_dlc::Error) -> Self { + ContractError::Dlc(error.to_string()) + } +} + +impl From for ContractError { + fn from(error: ddk_manager::error::Error) -> Self { + ContractError::Dlc(error.to_string()) + } +} diff --git a/ddk/src/contract/finalize.rs b/ddk/src/contract/finalize.rs new file mode 100644 index 00000000..e60eb341 --- /dev/null +++ b/ddk/src/contract/finalize.rs @@ -0,0 +1,111 @@ +//! Funding transaction completion by the accepting party. + +use bitcoin::psbt::Psbt; +use bitcoin::Transaction; +use ddk_dlc::secp256k1_zkp::Secp256k1; +use ddk_messages::{AcceptDlc, FundingSignatures, OfferDlc, SignDlc}; + +use super::context::{ + apply_funding_signatures, context_from_messages, contract_id_from_transactions, + ensure_protocol_version, verify_counterparty_signatures, ContractContext, +}; +use super::error::ContractError; +use super::psbt::{ensure_psbt_matches_funding_transaction, extract_funding_signatures}; +use super::types::Party; + +/// Verifies the sign message and completes the funding transaction. +/// +/// `signed_funding_psbt` must contain finalized witnesses for every +/// accept-side funding input; for single-funded contracts with no accept-side +/// inputs the unsigned funding PSBT is sufficient. The returned transaction is +/// fully signed and ready to broadcast through the caller's blockchain client +/// (for example [`ddk_manager::Blockchain::send_transaction`]); this function +/// performs no network access. +pub fn finalize_sign( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + signed_funding_psbt: &Psbt, +) -> Result { + let context = context_from_messages(offer, accept)?; + ensure_psbt_matches_funding_transaction(signed_funding_psbt, &context.transactions.fund)?; + let funding_signatures = + extract_funding_signatures(offer, accept, Party::Accept, signed_funding_psbt)?; + finalize_with_context(offer, accept, sign, funding_signatures, context) +} + +/// Completes the funding transaction from already extracted accept-side witnesses. +pub(crate) fn finalize_sign_internal( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_signatures: FundingSignatures, +) -> Result { + let context = context_from_messages(offer, accept)?; + finalize_with_context(offer, accept, sign, funding_signatures, context) +} + +fn finalize_with_context( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_signatures: FundingSignatures, + context: ContractContext, +) -> Result { + if funding_signatures.funding_signatures.len() != accept.funding_inputs.len() { + return Err(ContractError::InvalidFundingInput(format!( + "expected {} accept funding signatures, received {}", + accept.funding_inputs.len(), + funding_signatures.funding_signatures.len() + ))); + } + ensure_protocol_version(sign.protocol_version, ContractError::InvalidSign)?; + if sign.protocol_version != offer.protocol_version { + return Err(ContractError::InvalidSign( + "offer and sign protocol versions differ".to_string(), + )); + } + if sign.funding_signatures.funding_signatures.len() != offer.funding_inputs.len() { + return Err(ContractError::InvalidSign(format!( + "sign message carries {} funding signatures but the offer has {} funding inputs", + sign.funding_signatures.funding_signatures.len(), + offer.funding_inputs.len() + ))); + } + + let expected_contract_id = + contract_id_from_transactions(&context.transactions, &offer.temporary_contract_id); + if sign.contract_id != expected_contract_id { + return Err(ContractError::InvalidSign( + "sign message contract id does not match the rebuilt funding transaction".to_string(), + )); + } + let secp = Secp256k1::new(); + verify_counterparty_signatures( + &secp, + &context, + offer.get_total_collateral(), + offer.funding_pubkey, + &sign.refund_signature, + &sign.cet_adaptor_signatures, + ContractError::InvalidSign, + )?; + + let mut funding_transaction = context.transactions.fund; + apply_funding_signatures( + &mut funding_transaction, + offer, + accept, + Party::Offer, + &sign.funding_signatures, + )?; + apply_funding_signatures( + &mut funding_transaction, + offer, + accept, + Party::Accept, + &funding_signatures, + )?; + + Ok(funding_transaction) +} diff --git a/ddk/src/contract/mod.rs b/ddk/src/contract/mod.rs new file mode 100644 index 00000000..e64c5fb4 --- /dev/null +++ b/ddk/src/contract/mod.rs @@ -0,0 +1,113 @@ +//! Stateless DLC contract lifecycle. +//! +//! This module completes a DLC using only wire messages, explicit party data, +//! and PSBTs. There is no contract manager, no persisted contract state, no +//! storage backend, and no blockchain client: every operation rebuilds and +//! validates what it needs from the [`OfferDlc`](ddk_messages::OfferDlc) and +//! [`AcceptDlc`](ddk_messages::AcceptDlc) messages, which are the +//! authoritative state. +//! +//! # Lifecycle +//! +//! ```text +//! offer party accept party +//! ----------- ------------ +//! create_offer ──────────── OfferDlc ──────► accept_offer ─┐ +//! │ AcceptResult +//! ┌──────────────────────── AcceptDlc ◄─────────────────────┘ +//! │ create_funding_psbt +//! │ sign own inputs (signing::*) +//! │ sign_accept ──────────── SignDlc ──────► create_funding_psbt +//! │ sign own inputs (signing::*) +//! │ finalize_sign ──► Transaction +//! │ broadcast via chain client +//! ``` +//! +//! Between messages each party only needs to retain: +//! +//! | Party | After | Must retain | +//! |-------|-------|-------------| +//! | offer | `create_offer` | the `OfferDlc`, its DLC funding secret key, and access to the keys of its funding inputs | +//! | accept | `accept_offer` | the `OfferDlc`, the `AcceptDlc`, its DLC funding secret key, and access to the keys of its funding inputs | +//! | offer | `sign_accept` | nothing further; the CETs and refund transaction are rebuilt from the messages whenever needed | +//! +//! # PSBT as the signing boundary +//! +//! Funding inputs are regular wallet UTXOs, and wallets speak PSBT. The +//! funding PSBT built by [`create_funding_psbt`](crate::contract::create_funding_psbt) carries everything a signer +//! needs (`witness_utxo`, `non_witness_utxo`, redeem scripts, sighash type) +//! and never contains private key material. [`sign_accept`](crate::contract::sign_accept) and +//! [`finalize_sign`](crate::contract::finalize_sign) verify that a returned PSBT spends exactly the funding +//! transaction rebuilt from the messages — input count, outpoints, outputs, +//! locktime, and sequences — before extracting witnesses, so a signer cannot +//! mutate the transaction. +//! +//! Four funding sources produce those witnesses through the same lifecycle +//! (see [`signing`](crate::contract::signing)): +//! +//! | Source | How | +//! |--------|-----| +//! | DDK wallet | [`signing::sign_funding_psbt_with_wallet`](crate::contract::signing::sign_funding_psbt_with_wallet) with any [`ddk_manager::Wallet`] | +//! | Raw xpriv | [`signing::sign_funding_psbt_with_xpriv`](crate::contract::signing::sign_funding_psbt_with_xpriv) with per-input BIP32 paths | +//! | Private descriptor | [`signing::sign_funding_psbt_with_descriptor`](crate::contract::signing::sign_funding_psbt_with_descriptor) with per-input indexes | +//! | External / hardware signer | serialize the PSBT, sign and finalize externally, deserialize | +//! +//! # DLC funding keys versus wallet input keys +//! +//! Each party uses two kinds of keys. The *DLC funding key* +//! ([`PartyParams::funding_pubkey`](crate::contract::PartyParams::funding_pubkey) and the `funding_secret_key` arguments) is +//! a single secp256k1 key that controls the 2-of-2 funding output, the CET +//! adaptor signatures, and the refund signature. The *wallet input keys* +//! control the UTXOs spent into the funding transaction and never touch DLC +//! cryptography — they only sign the funding PSBT. A hardware wallet can hold +//! the input keys (PSBT exchange) while the application holds the DLC funding +//! key. +//! +//! # Script support +//! +//! Built-in signers support native P2WPKH and P2SH-P2WPKH funding inputs; +//! descriptor signing supports `wpkh()` and `sh(wpkh())`, with or without a +//! wildcard. Unsupported script types fail with +//! [`ContractError::UnsupportedScriptType`](crate::contract::ContractError::UnsupportedScriptType) rather than producing incomplete +//! signatures. External signers can fund with any script type they can +//! finalize themselves. +//! +//! # Broadcasting and storage stay with the caller +//! +//! [`finalize_sign`](crate::contract::finalize_sign) returns a fully signed [`bitcoin::Transaction`]; +//! broadcast it with the chain client of your choice (for example +//! [`ddk_manager::Blockchain::send_transaction`] implemented by +//! [`crate::chain::EsploraClient`]). Persisting messages for later execution +//! is likewise the caller's responsibility. +//! +//! Lower-level operations (raw witnesses, adaptor signatures, contract ids) +//! live in [`advanced`](crate::contract::advanced). + +pub mod advanced; +pub mod signing; + +mod accept; +mod context; +mod create; +mod error; +mod finalize; +mod psbt; +mod sign; +mod types; + +#[cfg(test)] +mod tests; + +pub use accept::{accept_offer, create_dlc_transactions}; +pub use create::{create_offer, validate_offer}; +pub use error::ContractError; +pub use finalize::finalize_sign; +pub use psbt::create_funding_psbt; +pub use sign::sign_accept; +pub use types::{ + chain_hash_from_network, funding_input, AcceptOfferParams, AcceptResult, CreateOfferParams, + DescriptorInput, InputDerivation, Party, PartyParams, SignResult, +}; + +/// The current DLC protocol version used by DDK. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/ddk/src/contract/psbt.rs b/ddk/src/contract/psbt.rs new file mode 100644 index 00000000..9b47cfca --- /dev/null +++ b/ddk/src/contract/psbt.rs @@ -0,0 +1,250 @@ +//! Funding PSBT construction, validation, finalization, and witness extraction. +//! +//! The PSBT is the universal signing boundary for funding inputs: every +//! signing source (wallet, xpriv, descriptor, or external signer) produces +//! finalized witnesses inside a PSBT, and the lifecycle functions extract wire +//! [`FundingSignatures`] from it. PSBTs never contain private key material. + +use bitcoin::psbt::Psbt; +use bitcoin::script::PushBytesBuf; +use bitcoin::sighash::EcdsaSighashType; +use bitcoin::{ScriptBuf, Transaction, Witness}; +use ddk_messages::{AcceptDlc, FundingSignature, FundingSignatures, OfferDlc, WitnessElement}; + +use super::context::{ + context_from_messages, decode_previous_transaction, funding_input_index, party_funding_inputs, +}; +use super::error::ContractError; +use super::types::Party; + +/// Builds the funding PSBT from the offer and accept messages. +/// +/// The PSBT contains, for every funding input: the `witness_utxo` (for SegWit +/// inputs), the `non_witness_utxo` (the full previous transaction, which some +/// signers require), the redeem script for P2SH-wrapped inputs, and the +/// `SIGHASH_ALL` sighash type. Input order follows ascending funding input +/// serial ids, matching the funding transaction. +pub fn create_funding_psbt(offer: &OfferDlc, accept: &AcceptDlc) -> Result { + let transactions = context_from_messages(offer, accept)?.transactions; + build_funding_psbt(offer, accept, transactions.fund) +} + +/// Builds the funding PSBT from an already rebuilt funding transaction. +pub(crate) fn build_funding_psbt( + offer: &OfferDlc, + accept: &AcceptDlc, + mut funding_transaction: Transaction, +) -> Result { + // PSBT unsigned transactions must have empty script sigs; the P2SH-P2WPKH + // redeem script push is restored by the input finalizer. + for input in &mut funding_transaction.input { + input.script_sig = ScriptBuf::new(); + } + let mut psbt = Psbt::from_unsigned_tx(funding_transaction) + .map_err(|e| ContractError::PsbtMismatch(format!("could not create PSBT: {e}")))?; + + for input in offer.funding_inputs.iter().chain(&accept.funding_inputs) { + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + let previous_transaction = decode_previous_transaction(input)?; + let outpoint = psbt.unsigned_tx.input[input_index].previous_output; + if outpoint.txid != previous_transaction.compute_txid() + || outpoint.vout != input.prev_tx_vout + { + return Err(ContractError::InvalidFundingInput(format!( + "funding input serial id {} does not match the funding transaction outpoint", + input.input_serial_id + ))); + } + let prevout = previous_transaction + .output + .get(input.prev_tx_vout as usize) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "previous output {} does not exist", + input.prev_tx_vout + )) + })?; + + let script_pubkey = &prevout.script_pubkey; + if script_pubkey.is_p2sh() { + if input.redeem_script.is_empty() { + return Err(ContractError::InvalidFundingInput(format!( + "funding input serial id {} is P2SH but has no redeem script", + input.input_serial_id + ))); + } + if ScriptBuf::new_p2sh(&input.redeem_script.script_hash()) != *script_pubkey { + return Err(ContractError::InvalidFundingInput(format!( + "funding input serial id {} redeem script does not match the script pubkey", + input.input_serial_id + ))); + } + psbt.inputs[input_index].redeem_script = Some(input.redeem_script.clone()); + } else if !input.redeem_script.is_empty() { + return Err(ContractError::InvalidFundingInput(format!( + "funding input serial id {} has a redeem script for a non-P2SH output", + input.input_serial_id + ))); + } + + let is_segwit = script_pubkey.is_witness_program() + || (script_pubkey.is_p2sh() && input.redeem_script.is_witness_program()); + if is_segwit { + psbt.inputs[input_index].witness_utxo = Some(prevout.clone()); + } + psbt.inputs[input_index].non_witness_utxo = Some(previous_transaction); + psbt.inputs[input_index].sighash_type = Some(EcdsaSighashType::All.into()); + } + + Ok(psbt) +} + +/// Verifies that a PSBT spends exactly the rebuilt funding transaction. +pub(crate) fn ensure_psbt_matches_funding_transaction( + psbt: &Psbt, + funding_transaction: &Transaction, +) -> Result<(), ContractError> { + let mut expected = funding_transaction.clone(); + for input in &mut expected.input { + input.script_sig = ScriptBuf::new(); + } + if psbt.unsigned_tx != expected { + return Err(ContractError::PsbtMismatch( + "PSBT unsigned transaction does not match the funding transaction rebuilt from the \ + offer and accept messages" + .to_string(), + )); + } + if psbt.inputs.len() != expected.input.len() { + return Err(ContractError::PsbtMismatch(format!( + "PSBT has {} inputs but the funding transaction has {}", + psbt.inputs.len(), + expected.input.len() + ))); + } + Ok(()) +} + +/// Rebuilds the funding transaction from the messages and verifies the PSBT +/// against it. +pub(crate) fn ensure_matching_psbt( + offer: &OfferDlc, + accept: &AcceptDlc, + psbt: &Psbt, +) -> Result<(), ContractError> { + let transactions = context_from_messages(offer, accept)?.transactions; + ensure_psbt_matches_funding_transaction(psbt, &transactions.fund) +} + +/// Extracts one party's finalized funding witnesses from a PSBT. +/// +/// The PSBT must already be verified against the rebuilt funding transaction. +pub(crate) fn extract_funding_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + party: Party, + psbt: &Psbt, +) -> Result { + let funding_signatures = party_funding_inputs(offer, accept, party) + .iter() + .map(|input| { + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + let witness = psbt.inputs[input_index] + .final_script_witness + .clone() + .filter(|witness| !witness.is_empty()) + .ok_or(ContractError::MissingFinalizedInput { input_index })?; + Ok(funding_signature_from_witness(witness)) + }) + .collect::, ContractError>>()?; + + Ok(FundingSignatures { funding_signatures }) +} + +/// Finalizes a signed P2WPKH or P2SH-P2WPKH PSBT input. +/// +/// Looks for a partial signature matching the input's script and converts it +/// into a finalized witness. Other script types return +/// [`ContractError::UnsupportedScriptType`]. +pub(crate) fn finalize_segwit_input( + psbt: &mut Psbt, + input_index: usize, +) -> Result<(), ContractError> { + let input = psbt.inputs.get_mut(input_index).ok_or_else(|| { + ContractError::PsbtMismatch(format!("PSBT input {input_index} does not exist")) + })?; + if input.final_script_witness.is_some() { + return Ok(()); + } + + let script_pubkey = input + .witness_utxo + .as_ref() + .ok_or_else(|| { + ContractError::PsbtMismatch(format!( + "PSBT input {input_index} is missing its witness UTXO" + )) + })? + .script_pubkey + .clone(); + + // Resolve the P2WPKH program, whether native or P2SH-wrapped. + let (witness_script_pubkey, redeem_script) = if script_pubkey.is_p2wpkh() { + (script_pubkey, None) + } else if script_pubkey.is_p2sh() { + let redeem_script = input.redeem_script.clone().ok_or_else(|| { + ContractError::PsbtMismatch(format!( + "PSBT input {input_index} is P2SH but has no redeem script" + )) + })?; + if !redeem_script.is_p2wpkh() { + return Err(ContractError::UnsupportedScriptType { input_index }); + } + (redeem_script.clone(), Some(redeem_script)) + } else { + return Err(ContractError::UnsupportedScriptType { input_index }); + }; + + let (public_key, signature) = input + .partial_sigs + .iter() + .find_map(|(public_key, signature)| { + public_key + .wpubkey_hash() + .ok() + .filter(|hash| ScriptBuf::new_p2wpkh(hash) == witness_script_pubkey) + .map(|_| (*public_key, *signature)) + }) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "PSBT input {input_index} does not have a signature matching its script" + )) + })?; + + input.final_script_witness = Some(Witness::from_slice(&[ + signature.to_vec(), + public_key.to_bytes(), + ])); + if let Some(redeem_script) = redeem_script { + let push = PushBytesBuf::try_from(redeem_script.into_bytes()).map_err(|_| { + ContractError::InvalidFundingInput(format!( + "PSBT input {input_index} redeem script is too long" + )) + })?; + input.final_script_sig = Some(ScriptBuf::builder().push_slice(push).into_script()); + } + input.partial_sigs.clear(); + Ok(()) +} + +/// Converts a Bitcoin witness into a wire funding signature. +pub(crate) fn funding_signature_from_witness(witness: Witness) -> FundingSignature { + FundingSignature { + witness_elements: witness + .iter() + .map(|element| WitnessElement { + witness: element.to_vec(), + }) + .collect(), + } +} diff --git a/ddk/src/contract/sign.rs b/ddk/src/contract/sign.rs new file mode 100644 index 00000000..8a917b6f --- /dev/null +++ b/ddk/src/contract/sign.rs @@ -0,0 +1,112 @@ +//! Sign message creation by the offering party. + +use bitcoin::psbt::Psbt; +use ddk_dlc::secp256k1_zkp::{Secp256k1, SecretKey}; +use ddk_messages::{AcceptDlc, CetAdaptorSignatures, FundingSignatures, OfferDlc, SignDlc}; + +use super::context::{ + context_from_messages, contract_id_from_transactions, create_adaptor_signatures, + create_refund_signature, ensure_funding_key, verify_counterparty_signatures, ContractContext, +}; +use super::error::ContractError; +use super::psbt::{ensure_psbt_matches_funding_transaction, extract_funding_signatures}; +use super::types::{Party, SignResult}; + +/// Verifies the accept message and creates the offering party's sign message. +/// +/// `signed_funding_psbt` must contain finalized witnesses for every offer-side +/// funding input; how they got there (wallet, xpriv, descriptor, or an +/// external signer) does not matter. The PSBT is verified against the funding +/// transaction rebuilt from the messages before any signature is extracted. +/// +/// `funding_secret_key` is the offering party's DLC funding key, used to +/// produce CET adaptor signatures and the refund signature. +pub fn sign_accept( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, + signed_funding_psbt: &Psbt, +) -> Result { + let context = context_from_messages(offer, accept)?; + ensure_psbt_matches_funding_transaction(signed_funding_psbt, &context.transactions.fund)?; + let funding_signatures = + extract_funding_signatures(offer, accept, Party::Offer, signed_funding_psbt)?; + sign_with_context( + offer, + accept, + funding_secret_key, + funding_signatures, + context, + ) +} + +/// Creates the sign message from already extracted offer-side funding witnesses. +pub(crate) fn sign_accept_internal( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, + funding_signatures: FundingSignatures, +) -> Result { + let context = context_from_messages(offer, accept)?; + sign_with_context( + offer, + accept, + funding_secret_key, + funding_signatures, + context, + ) +} + +fn sign_with_context( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, + funding_signatures: FundingSignatures, + context: ContractContext, +) -> Result { + if funding_signatures.funding_signatures.len() != offer.funding_inputs.len() { + return Err(ContractError::InvalidFundingInput(format!( + "expected {} offer funding signatures, received {}", + offer.funding_inputs.len(), + funding_signatures.funding_signatures.len() + ))); + } + let secp = Secp256k1::new(); + ensure_funding_key( + &secp, + funding_secret_key, + &offer.funding_pubkey, + ContractError::InvalidOffer, + )?; + verify_counterparty_signatures( + &secp, + &context, + offer.get_total_collateral(), + accept.funding_pubkey, + &accept.refund_signature, + &accept.cet_adaptor_signatures, + ContractError::InvalidAccept, + )?; + let adaptor_signatures = create_adaptor_signatures( + &secp, + &context, + funding_secret_key, + offer.get_total_collateral(), + )?; + let refund_signature = create_refund_signature(&secp, &context, funding_secret_key)?; + + let sign = SignDlc { + protocol_version: offer.protocol_version, + contract_id: contract_id_from_transactions( + &context.transactions, + &offer.temporary_contract_id, + ), + cet_adaptor_signatures: CetAdaptorSignatures::from(adaptor_signatures.as_slice()), + refund_signature, + funding_signatures, + }; + Ok(SignResult { + sign, + transactions: context.transactions, + }) +} diff --git a/ddk/src/contract/signing.rs b/ddk/src/contract/signing.rs new file mode 100644 index 00000000..b99a09aa --- /dev/null +++ b/ddk/src/contract/signing.rs @@ -0,0 +1,285 @@ +//! Funding sources for signing the funding PSBT. +//! +//! Every funding source produces finalized witnesses inside the funding PSBT; +//! the lifecycle functions ([`sign_accept`](super::sign_accept) and +//! [`finalize_sign`](super::finalize_sign)) then extract those witnesses. The +//! core DLC algorithms never branch on where a signature came from. +//! +//! | Source | Function | Notes | +//! |--------|----------|-------| +//! | DDK wallet | [`sign_funding_psbt_with_wallet`] | Any [`ddk_manager::Wallet`] implementation | +//! | Raw xpriv | [`sign_funding_psbt_with_xpriv`] | Caller supplies BIP32 paths per input | +//! | Private descriptor | [`sign_funding_psbt_with_descriptor`] | `wpkh()` and `sh(wpkh())` descriptors | +//! | External signer | none required | Serialize the PSBT, sign elsewhere, deserialize | +//! +//! External signers need no DDK-specific code: serialize the PSBT produced by +//! [`create_funding_psbt`](super::create_funding_psbt), let the external +//! wallet sign and finalize its own inputs, then pass the PSBT back to the +//! lifecycle functions. Inputs belonging to the other party may remain +//! unsigned. +//! +//! Inputs are identified by funding input serial id, so any subset of inputs +//! can be signed regardless of transaction position or which party owns them. + +use bdk_wallet::miniscript::descriptor::{ + Descriptor, DescriptorPublicKey, DescriptorSecretKey, KeyMap, ShInner, Wildcard, +}; +use bitcoin::bip32::{ChildNumber, Xpriv}; +use bitcoin::psbt::Psbt; +use bitcoin::sighash::SighashCache; +use bitcoin::{NetworkKind, PrivateKey, ScriptBuf}; +use ddk_dlc::secp256k1_zkp::{All, Secp256k1}; +use ddk_messages::{AcceptDlc, OfferDlc}; + +use super::context::funding_input_index; +use super::error::ContractError; +use super::psbt::{ensure_matching_psbt, finalize_segwit_input}; +use super::types::{network_from_chain_hash, DescriptorInput, InputDerivation, Party}; + +/// Signs and finalizes one party's funding inputs with a wallet. +/// +/// Works with any [`ddk_manager::Wallet`] implementation capable of signing +/// PSBT inputs, such as [`crate::wallet::DlcDevKitWallet`]. The wallet only +/// sees the funding PSBT; no manager, signer provider, or storage trait is +/// involved. +pub async fn sign_funding_psbt_with_wallet( + offer: &OfferDlc, + accept: &AcceptDlc, + psbt: &mut Psbt, + wallet: &W, + party: Party, +) -> Result<(), ContractError> +where + W: ddk_manager::Wallet + ?Sized, +{ + ensure_matching_psbt(offer, accept, psbt)?; + let inputs = match party { + Party::Offer => &offer.funding_inputs, + Party::Accept => &accept.funding_inputs, + }; + for input in inputs { + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + wallet + .sign_psbt_input(psbt, input_index) + .await + .map_err(|e| ContractError::Wallet(e.to_string()))?; + if psbt.inputs[input_index].final_script_witness.is_none() { + finalize_segwit_input(psbt, input_index).map_err(|e| match e { + ContractError::InvalidFundingInput(_) => ContractError::Wallet(format!( + "the wallet did not produce a signature for input {input_index}" + )), + other => other, + })?; + } + } + Ok(()) +} + +/// Signs and finalizes funding inputs with a BIP32 extended private key. +/// +/// Each [`InputDerivation`] names a funding input by serial id and the path, +/// relative to `xpriv`, of the key controlling it. Inputs not listed are left +/// untouched. Native P2WPKH and P2SH-P2WPKH inputs are supported. +pub fn sign_funding_psbt_with_xpriv( + offer: &OfferDlc, + accept: &AcceptDlc, + psbt: &mut Psbt, + xpriv: &Xpriv, + derivations: &[InputDerivation], +) -> Result<(), ContractError> { + ensure_matching_psbt(offer, accept, psbt)?; + let secp = Secp256k1::new(); + for derivation in derivations { + let input_index = funding_input_index(offer, accept, derivation.input_serial_id)?; + let derived = xpriv.derive_priv(&secp, &derivation.derivation_path)?; + sign_input_with_key(psbt, input_index, &derived.to_priv(), &secp)?; + } + Ok(()) +} + +/// Signs and finalizes funding inputs with a private output descriptor. +/// +/// `wpkh()` and `sh(wpkh())` descriptors are supported, with or without a +/// wildcard; each [`DescriptorInput`] names a funding input by serial id and +/// the wildcard derivation index of its script. Watch-only descriptors (no +/// private keys) and multipath descriptors are rejected. The descriptor key +/// network is validated against the offer's chain hash when the chain is +/// recognized. +pub fn sign_funding_psbt_with_descriptor( + offer: &OfferDlc, + accept: &AcceptDlc, + psbt: &mut Psbt, + descriptor: &str, + inputs: &[DescriptorInput], +) -> Result<(), ContractError> { + let secp = Secp256k1::new(); + let (descriptor, key_map) = + Descriptor::::parse_descriptor(&secp, descriptor) + .map_err(|e| ContractError::Descriptor(e.to_string()))?; + if key_map.is_empty() { + return Err(ContractError::Descriptor( + "watch-only descriptor: signing requires a descriptor with private keys".to_string(), + )); + } + match &descriptor { + Descriptor::Wpkh(_) => {} + Descriptor::Sh(sh) if matches!(sh.as_inner(), ShInner::Wpkh(_)) => {} + _ => { + return Err(ContractError::Descriptor( + "only wpkh() and sh(wpkh()) descriptors are supported".to_string(), + )) + } + } + if let Some(network) = network_from_chain_hash(offer.chain_hash) { + let network_kind = NetworkKind::from(network); + for secret in key_map.values() { + if let DescriptorSecretKey::XPrv(xkey) = secret { + if xkey.xkey.network != network_kind { + return Err(ContractError::Descriptor(format!( + "descriptor key network does not match the offer chain ({network})" + ))); + } + } + } + } + ensure_matching_psbt(offer, accept, psbt)?; + + for input in inputs { + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + let definite = descriptor + .at_derivation_index(input.derivation_index) + .map_err(|e| ContractError::Descriptor(e.to_string()))?; + let expected_script_pubkey = definite.script_pubkey(); + let input_script_pubkey = psbt.inputs[input_index] + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey.clone()) + .ok_or_else(|| { + ContractError::PsbtMismatch(format!( + "PSBT input {input_index} is missing its witness UTXO" + )) + })?; + if expected_script_pubkey != input_script_pubkey { + return Err(ContractError::Descriptor(format!( + "descriptor does not derive the script of input serial id {} at index {}", + input.input_serial_id, input.derivation_index + ))); + } + let private_key = derive_descriptor_private_key( + &key_map, + input.derivation_index, + &expected_script_pubkey, + &secp, + ) + .ok_or_else(|| { + ContractError::Descriptor(format!( + "descriptor private keys do not derive the script of input serial id {}", + input.input_serial_id + )) + })?; + sign_input_with_key(psbt, input_index, &private_key, &secp)?; + } + Ok(()) +} + +fn derive_descriptor_private_key( + key_map: &KeyMap, + derivation_index: u32, + expected_script_pubkey: &ScriptBuf, + secp: &Secp256k1, +) -> Option { + key_map + .values() + .filter_map(|secret| candidate_private_key(secret, derivation_index, secp)) + .find(|candidate| { + let Ok(hash) = candidate.public_key(secp).wpubkey_hash() else { + return false; + }; + let native = ScriptBuf::new_p2wpkh(&hash); + *expected_script_pubkey == native + || *expected_script_pubkey == ScriptBuf::new_p2sh(&native.script_hash()) + }) +} + +fn candidate_private_key( + secret: &DescriptorSecretKey, + derivation_index: u32, + secp: &Secp256k1, +) -> Option { + match secret { + DescriptorSecretKey::Single(single) => Some(single.key), + DescriptorSecretKey::XPrv(xkey) => { + let path = match xkey.wildcard { + Wildcard::None => xkey.derivation_path.clone(), + Wildcard::Unhardened => xkey + .derivation_path + .child(ChildNumber::from_normal_idx(derivation_index).ok()?), + Wildcard::Hardened => xkey + .derivation_path + .child(ChildNumber::from_hardened_idx(derivation_index).ok()?), + }; + Some(xkey.xkey.derive_priv(secp, &path).ok()?.to_priv()) + } + DescriptorSecretKey::MultiXPrv(_) => None, + } +} + +/// Signs one P2WPKH or P2SH-P2WPKH PSBT input with a concrete key and +/// finalizes it. +fn sign_input_with_key( + psbt: &mut Psbt, + input_index: usize, + private_key: &PrivateKey, + secp: &Secp256k1, +) -> Result<(), ContractError> { + let public_key = private_key.public_key(secp); + let wpubkey_hash = public_key.wpubkey_hash().map_err(|_| { + ContractError::InvalidFundingInput(format!( + "input {input_index} cannot be signed with an uncompressed key" + )) + })?; + let input = psbt.inputs.get(input_index).ok_or_else(|| { + ContractError::PsbtMismatch(format!("PSBT input {input_index} does not exist")) + })?; + let script_pubkey = input + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey.clone()) + .ok_or_else(|| { + ContractError::PsbtMismatch(format!( + "PSBT input {input_index} is missing its witness UTXO" + )) + })?; + + let native = ScriptBuf::new_p2wpkh(&wpubkey_hash); + let controls_input = if script_pubkey.is_p2wpkh() { + script_pubkey == native + } else if script_pubkey.is_p2sh() { + input.redeem_script.as_ref() == Some(&native) + } else { + return Err(ContractError::UnsupportedScriptType { input_index }); + }; + if !controls_input { + return Err(ContractError::InvalidFundingInput(format!( + "the derived key does not control the script of input {input_index}; \ + check the derivation path or index" + ))); + } + + let (message, sighash_type) = { + let mut cache = SighashCache::new(&psbt.unsigned_tx); + psbt.sighash_ecdsa(input_index, &mut cache).map_err(|e| { + ContractError::InvalidFundingInput(format!( + "could not compute the sighash for input {input_index}: {e}" + )) + })? + }; + let signature = bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(&message, &private_key.inner), + sighash_type, + }; + psbt.inputs[input_index] + .partial_sigs + .insert(public_key, signature); + finalize_segwit_input(psbt, input_index) +} diff --git a/ddk/src/contract/tests.rs b/ddk/src/contract/tests.rs new file mode 100644 index 00000000..8372c6e5 --- /dev/null +++ b/ddk/src/contract/tests.rs @@ -0,0 +1,225 @@ +//! Unit tests for internal contract helpers. The full lifecycle scenarios +//! live in `ddk/tests/stateless.rs` and exercise only the public API. + +use bitcoin::absolute::LockTime; +use bitcoin::hashes::Hash; +use bitcoin::psbt::Psbt; +use bitcoin::transaction::Version; +use bitcoin::{Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; +use ddk_dlc::secp256k1_zkp::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey}; +use ddk_messages::contract_msgs::{ + ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, + EnumeratedContractDescriptor, SingleContractInfo, +}; +use ddk_messages::oracle_msgs::{ + tagged_announcement_msg, EnumEventDescriptor, EventDescriptor, OracleAnnouncement, OracleEvent, + OracleInfo, SingleOracleInfo, +}; +use ddk_messages::{AcceptDlc, CetAdaptorSignatures, FundingInput, OfferDlc}; + +use super::context::funding_input_index; +use super::psbt::finalize_segwit_input; +use super::types::{funding_input, network_from_chain_hash, random_serial_id}; +use super::*; + +fn dummy_transaction(value: Amount, script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value, + script_pubkey, + }], + } +} + +fn dummy_funding_input(serial_id: u64) -> FundingInput { + let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([7; 20])); + funding_input( + &dummy_transaction(Amount::from_sat(10_000), script), + 0, + Some(serial_id), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap() +} + +fn enum_contract_info() -> ContractInfo { + let secp = Secp256k1::new(); + let oracle_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[8; 32]).unwrap()); + let nonce_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[9; 32]).unwrap()); + let oracle_event = OracleEvent { + oracle_nonces: vec![XOnlyPublicKey::from_keypair(&nonce_key).0], + event_maturity_epoch: 750, + event_descriptor: EventDescriptor::EnumEvent(EnumEventDescriptor { + outcomes: vec!["up".to_string(), "down".to_string()], + }), + event_id: "unit-test".to_string(), + }; + let announcement = OracleAnnouncement { + announcement_signature: secp + .sign_schnorr(&tagged_announcement_msg(&oracle_event), &oracle_key), + oracle_public_key: XOnlyPublicKey::from_keypair(&oracle_key).0, + oracle_event, + }; + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral: Amount::from_sat(100_000), + contract_info: ContractInfoInner { + contract_descriptor: ContractDescriptor::EnumeratedContractDescriptor( + EnumeratedContractDescriptor { + payouts: vec![ + ContractOutcome { + outcome: "up".to_string(), + offer_payout: Amount::from_sat(100_000), + }, + ContractOutcome { + outcome: "down".to_string(), + offer_payout: Amount::ZERO, + }, + ], + }, + ), + oracle_info: OracleInfo::Single(SingleOracleInfo { + oracle_announcement: announcement, + }), + }, + }) +} + +fn messages_with_serial_ids(offer_ids: &[u64], accept_ids: &[u64]) -> (OfferDlc, AcceptDlc) { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let public_key = secret_key.public_key(&secp); + let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([7; 20])); + let signature = secp.sign_ecdsa(&Message::from_digest([1; 32]), &secret_key); + let offer = OfferDlc { + protocol_version: PROTOCOL_VERSION, + contract_flags: 0, + chain_hash: chain_hash_from_network(Network::Regtest), + temporary_contract_id: [42; 32], + contract_info: enum_contract_info(), + funding_pubkey: public_key, + payout_spk: script.clone(), + payout_serial_id: 1, + offer_collateral: Amount::from_sat(50_000), + funding_inputs: offer_ids + .iter() + .map(|id| dummy_funding_input(*id)) + .collect(), + change_spk: script.clone(), + change_serial_id: 2, + fund_output_serial_id: 3, + fee_rate_per_vb: 2, + cet_locktime: 500, + refund_locktime: 1_000, + }; + let accept = AcceptDlc { + protocol_version: PROTOCOL_VERSION, + temporary_contract_id: [42; 32], + accept_collateral: Amount::from_sat(50_000), + funding_pubkey: public_key, + payout_spk: script.clone(), + payout_serial_id: 4, + funding_inputs: accept_ids + .iter() + .map(|id| dummy_funding_input(*id)) + .collect(), + change_spk: script, + change_serial_id: 5, + cet_adaptor_signatures: CetAdaptorSignatures::from(&[][..]), + refund_signature: signature, + negotiation_fields: None, + }; + (offer, accept) +} + +#[test] +fn funding_input_index_orders_by_serial_id() { + let (offer, accept) = messages_with_serial_ids(&[50, 3], &[12]); + assert_eq!(funding_input_index(&offer, &accept, 3).unwrap(), 0); + assert_eq!(funding_input_index(&offer, &accept, 12).unwrap(), 1); + assert_eq!(funding_input_index(&offer, &accept, 50).unwrap(), 2); +} + +#[test] +fn funding_input_index_rejects_duplicates_and_unknown_ids() { + let (offer, accept) = messages_with_serial_ids(&[5, 5], &[]); + assert!(matches!( + funding_input_index(&offer, &accept, 5), + Err(ContractError::InvalidFundingInput(_)) + )); + let (offer, accept) = messages_with_serial_ids(&[1], &[2]); + assert!(matches!( + funding_input_index(&offer, &accept, 9), + Err(ContractError::InvalidFundingInput(_)) + )); +} + +#[test] +fn network_round_trips_through_chain_hash() { + for network in [ + Network::Bitcoin, + Network::Testnet, + Network::Signet, + Network::Regtest, + ] { + assert_eq!( + network_from_chain_hash(chain_hash_from_network(network)), + Some(network) + ); + } + assert_eq!(network_from_chain_hash([0; 32]), None); +} + +#[test] +fn funding_input_rejects_missing_vout_and_bad_redeem_script() { + let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([7; 20])); + let transaction = dummy_transaction(Amount::from_sat(1_000), script.clone()); + assert!(matches!( + funding_input(&transaction, 4, None, u32::MAX, 108, ScriptBuf::new()), + Err(ContractError::InvalidFundingInput(_)) + )); + // Redeem script for a non-P2SH output. + assert!(matches!( + funding_input(&transaction, 0, None, u32::MAX, 108, script), + Err(ContractError::InvalidFundingInput(_)) + )); +} + +#[test] +fn random_serial_ids_differ() { + assert_ne!(random_serial_id(), random_serial_id()); +} + +#[test] +fn finalize_rejects_unsupported_script_types() { + let script_pubkey = ScriptBuf::new_p2wsh(&bitcoin::WScriptHash::from_byte_array([9; 32])); + let unsigned = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(unsigned).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(1_000), + script_pubkey, + }); + assert!(matches!( + finalize_segwit_input(&mut psbt, 0), + Err(ContractError::UnsupportedScriptType { input_index: 0 }) + )); +} diff --git a/ddk/src/contract/types.rs b/ddk/src/contract/types.rs new file mode 100644 index 00000000..6c427d03 --- /dev/null +++ b/ddk/src/contract/types.rs @@ -0,0 +1,203 @@ +//! Parameter and result types for the stateless contract API. + +use bitcoin::blockdata::constants::ChainHash; +use bitcoin::key::rand::{thread_rng, Rng}; +use bitcoin::psbt::Psbt; +use bitcoin::{Amount, Network, ScriptBuf, Transaction}; +use ddk_dlc::secp256k1_zkp::PublicKey; +use ddk_dlc::DlcTransactions; +use ddk_messages::contract_msgs::ContractInfo; +use ddk_messages::{AcceptDlc, FundingInput, SignDlc}; + +use super::error::ContractError; + +/// Identifies which party's funding inputs an operation applies to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Party { + /// The party that created the offer. + Offer, + /// The party that accepted the offer. + Accept, +} + +/// One party's Bitcoin-level contract data. +/// +/// The funding public key is the DLC funding key used for the multisig funding +/// output, adaptor signatures, and the refund signature. It is distinct from +/// the keys controlling `funding_inputs`, which are regular wallet UTXOs and +/// are signed through the PSBT signing layer. +#[derive(Clone, Debug)] +pub struct PartyParams { + /// The DLC funding public key of this party. + pub funding_pubkey: PublicKey, + /// The wallet UTXOs this party contributes to the funding transaction. + pub funding_inputs: Vec, + /// The script pubkey CET and refund payouts are sent to. + pub payout_spk: ScriptBuf, + /// Serial id ordering the payout output. Randomly generated when `None`. + pub payout_serial_id: Option, + /// The script pubkey funding change is sent to. + pub change_spk: ScriptBuf, + /// Serial id ordering the change output. Randomly generated when `None`. + pub change_serial_id: Option, +} + +/// Parameters for [`create_offer`](super::create_offer). +#[derive(Clone, Debug)] +pub struct CreateOfferParams { + /// The chain the contract settles on. See [`chain_hash_from_network`]. + pub chain_hash: [u8; 32], + /// Identifies the contract before it is funded. Randomly generated when `None`. + pub temporary_contract_id: Option<[u8; 32]>, + /// The contract payout and oracle information. + pub contract_info: ContractInfo, + /// The collateral contributed by the offering party. + pub offer_collateral: Amount, + /// The offering party's Bitcoin-level contract data. + pub party: PartyParams, + /// Serial id ordering the funding output. Randomly generated when `None`. + pub fund_output_serial_id: Option, + /// The fee rate, in satoshis per virtual byte, for the funding transaction and CETs. + pub fee_rate_per_vb: u64, + /// The earliest time CETs can be broadcast. + pub cet_locktime: u32, + /// The time after which the refund transaction can be broadcast. + pub refund_locktime: u32, + /// Contract feature flags. Use `0` unless a protocol extension requires otherwise. + pub contract_flags: u8, +} + +/// Parameters for [`accept_offer`](super::accept_offer). +#[derive(Clone, Debug)] +pub struct AcceptOfferParams { + /// The accepting party's Bitcoin-level contract data. + /// + /// `party.funding_pubkey` must match the public key of the DLC funding + /// secret key passed to [`accept_offer`](super::accept_offer). + pub party: PartyParams, + /// The minimum accepted interval between the oracle event maturity and the + /// refund locktime. + pub min_timeout_interval: u32, + /// The maximum accepted interval between the oracle event maturity and the + /// refund locktime. + pub max_timeout_interval: u32, +} + +/// The result of [`accept_offer`](super::accept_offer). +/// +/// This is an operation result, not persisted contract state. The accept +/// message is the authoritative artifact; the transactions and PSBT can be +/// deterministically rebuilt from the offer and accept messages at any time. +pub struct AcceptResult { + /// The accept message to send to the offering party. + pub accept: AcceptDlc, + /// The unsigned funding, CET, and refund transactions. + pub transactions: DlcTransactions, + /// The funding PSBT ready to be signed by either party's funding source. + pub funding_psbt: Psbt, +} + +/// The result of [`sign_accept`](super::sign_accept). +pub struct SignResult { + /// The sign message to send to the accepting party. + pub sign: SignDlc, + /// The unsigned funding, CET, and refund transactions. + pub transactions: DlcTransactions, +} + +/// Identifies a funding input and the BIP32 path that derives its key. +/// +/// Inputs are identified by their funding input serial id, not by transaction +/// position, so derivations remain stable regardless of input ordering. +#[derive(Clone, Debug)] +pub struct InputDerivation { + /// The serial id of the funding input to sign. + pub input_serial_id: u64, + /// The derivation path of the key controlling the input, relative to the + /// extended private key passed to + /// [`sign_funding_psbt_with_xpriv`](super::signing::sign_funding_psbt_with_xpriv). + pub derivation_path: bitcoin::bip32::DerivationPath, +} + +/// Identifies a funding input and the descriptor derivation index for its script. +#[derive(Clone, Debug)] +pub struct DescriptorInput { + /// The serial id of the funding input to sign. + pub input_serial_id: u64, + /// The wildcard derivation index of the input's script. Ignored for + /// descriptors without a wildcard. + pub derivation_index: u32, +} + +/// Creates a funding input from a previous transaction and output index. +/// +/// A random serial id is generated when `input_serial_id` is `None`. For +/// P2SH-wrapped SegWit inputs, `redeem_script` must contain the witness +/// program; for native SegWit inputs it must be empty. +pub fn funding_input( + previous_transaction: &Transaction, + vout: u32, + input_serial_id: Option, + sequence: u32, + max_witness_len: u16, + redeem_script: ScriptBuf, +) -> Result { + let prevout = previous_transaction + .output + .get(vout as usize) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!("previous output {vout} does not exist")) + })?; + if prevout.script_pubkey.is_p2sh() { + if redeem_script.is_empty() { + return Err(ContractError::InvalidFundingInput( + "P2SH input requires a redeem script".to_string(), + )); + } + if ScriptBuf::new_p2sh(&redeem_script.script_hash()) != prevout.script_pubkey { + return Err(ContractError::InvalidFundingInput( + "redeem script does not match the P2SH script pubkey".to_string(), + )); + } + } else if !redeem_script.is_empty() { + return Err(ContractError::InvalidFundingInput( + "redeem script provided for a non-P2SH input".to_string(), + )); + } + Ok(FundingInput { + input_serial_id: input_serial_id.unwrap_or_else(random_serial_id), + prev_tx: bitcoin::consensus::serialize(previous_transaction), + prev_tx_vout: vout, + sequence, + max_witness_len, + redeem_script, + dlc_input: None, + }) +} + +/// Returns the DLC chain hash for a network, suitable for +/// [`CreateOfferParams::chain_hash`]. +pub fn chain_hash_from_network(network: Network) -> [u8; 32] { + ChainHash::using_genesis_block_const(network).to_bytes() +} + +pub(crate) fn network_from_chain_hash(chain_hash: [u8; 32]) -> Option { + [ + Network::Bitcoin, + Network::Testnet, + Network::Signet, + Network::Regtest, + ] + .into_iter() + .find(|network| chain_hash_from_network(*network) == chain_hash) +} + +pub(crate) fn random_serial_id() -> u64 { + thread_rng().gen() +} + +pub(crate) fn random_temporary_contract_id() -> [u8; 32] { + let mut id = [0u8; 32]; + thread_rng().fill(&mut id); + id +} diff --git a/ddk/src/lib.rs b/ddk/src/lib.rs index 11528af0..b41aa18f 100644 --- a/ddk/src/lib.rs +++ b/ddk/src/lib.rs @@ -6,6 +6,8 @@ pub mod builder; /// Working with the bitcoin chain. pub mod chain; +/// Stateless DLC contract operations. +pub mod contract; mod ddk; /// DDK error types pub mod error; diff --git a/ddk/tests/stateless.rs b/ddk/tests/stateless.rs new file mode 100644 index 00000000..8453f0ca --- /dev/null +++ b/ddk/tests/stateless.rs @@ -0,0 +1,1044 @@ +//! Lifecycle tests for the stateless contract API. +//! +//! Every test completes (or rejects) a DLC using only wire messages, explicit +//! party data, and PSBTs — no storage backend, contract manager, or +//! blockchain client is constructed anywhere in this file. + +use bitcoin::absolute::LockTime; +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::psbt::Psbt; +use bitcoin::transaction::Version; +use bitcoin::{Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; +use ddk::contract::{ + accept_offer, chain_hash_from_network, create_dlc_transactions, create_funding_psbt, + create_offer, finalize_sign, funding_input, sign_accept, signing, AcceptOfferParams, + ContractError, CreateOfferParams, DescriptorInput, InputDerivation, Party, PartyParams, +}; +use ddk_dlc::secp256k1_zkp::{All, Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey}; +use ddk_messages::contract_msgs::{ + ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, + EnumeratedContractDescriptor, NumericOutcomeContractDescriptor, SingleContractInfo, +}; +use ddk_messages::oracle_msgs::{ + tagged_announcement_msg, DigitDecompositionEventDescriptor, EnumEventDescriptor, + EventDescriptor, OracleAnnouncement, OracleEvent, OracleInfo, SingleOracleInfo, +}; +use ddk_messages::{AcceptDlc, FundingInput, OfferDlc}; +use std::str::FromStr; + +const NETWORK: Network = Network::Regtest; +const MIN_TIMEOUT: u32 = 100; +const MAX_TIMEOUT: u32 = 500; +const TOTAL_COLLATERAL: Amount = Amount::from_sat(100_000); + +/// One side of a contract: a DLC funding key plus a BIP84 wallet key +/// controlling a single funding UTXO. +struct PartySetup { + funding_secret_key: SecretKey, + xpriv: Xpriv, + derivation_path: DerivationPath, + funding_input: FundingInput, +} + +impl PartySetup { + fn new( + secp: &Secp256k1, + seed_byte: u8, + network: Network, + utxo_value: Amount, + input_serial_id: u64, + ) -> Self { + let funding_secret_key = SecretKey::from_slice(&[seed_byte; 32]).unwrap(); + let xpriv = Xpriv::new_master(network, &[seed_byte.wrapping_add(100); 64]).unwrap(); + let coin_type = if network == Network::Bitcoin { 0 } else { 1 }; + let derivation_path = + DerivationPath::from_str(&format!("84h/{coin_type}h/0h/0/0")).unwrap(); + let script_pubkey = p2wpkh_script(secp, &xpriv, &derivation_path); + let previous_transaction = previous_transaction(utxo_value, script_pubkey); + let funding_input = funding_input( + &previous_transaction, + 0, + Some(input_serial_id), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + Self { + funding_secret_key, + xpriv, + derivation_path, + funding_input, + } + } + + fn funding_pubkey(&self, secp: &Secp256k1) -> PublicKey { + self.funding_secret_key.public_key(secp) + } + + fn payout_script(&self, secp: &Secp256k1) -> ScriptBuf { + p2wpkh_script(secp, &self.xpriv, &self.derivation_path) + } + + fn party_params( + &self, + secp: &Secp256k1, + funding_inputs: Vec, + ) -> PartyParams { + PartyParams { + funding_pubkey: self.funding_pubkey(secp), + funding_inputs, + payout_spk: self.payout_script(secp), + payout_serial_id: None, + change_spk: self.payout_script(secp), + change_serial_id: None, + } + } + + fn derivations(&self) -> Vec { + vec![InputDerivation { + input_serial_id: self.funding_input.input_serial_id, + derivation_path: self.derivation_path.clone(), + }] + } +} + +fn p2wpkh_script(secp: &Secp256k1, xpriv: &Xpriv, path: &DerivationPath) -> ScriptBuf { + let public_key = xpriv + .derive_priv(secp, path) + .unwrap() + .to_priv() + .public_key(secp); + ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()) +} + +fn previous_transaction(value: Amount, script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value, + script_pubkey, + }], + } +} + +fn oracle_announcement( + event_descriptor: EventDescriptor, + nonce_count: usize, +) -> OracleAnnouncement { + let secp = Secp256k1::new(); + let oracle_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[88; 32]).unwrap()); + let oracle_nonces = (0..nonce_count) + .map(|index| { + let nonce_key = Keypair::from_secret_key( + &secp, + &SecretKey::from_slice(&[90 + index as u8; 32]).unwrap(), + ); + XOnlyPublicKey::from_keypair(&nonce_key).0 + }) + .collect(); + let oracle_event = OracleEvent { + oracle_nonces, + event_maturity_epoch: 750, + event_descriptor, + event_id: "stateless-test".to_string(), + }; + OracleAnnouncement { + announcement_signature: secp + .sign_schnorr(&tagged_announcement_msg(&oracle_event), &oracle_key), + oracle_public_key: XOnlyPublicKey::from_keypair(&oracle_key).0, + oracle_event, + } +} + +fn enum_contract_info(total_collateral: Amount) -> ContractInfo { + let announcement = oracle_announcement( + EventDescriptor::EnumEvent(EnumEventDescriptor { + outcomes: vec!["up".to_string(), "down".to_string()], + }), + 1, + ); + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral, + contract_info: ContractInfoInner { + contract_descriptor: ContractDescriptor::EnumeratedContractDescriptor( + EnumeratedContractDescriptor { + payouts: vec![ + ContractOutcome { + outcome: "up".to_string(), + offer_payout: total_collateral, + }, + ContractOutcome { + outcome: "down".to_string(), + offer_payout: Amount::ZERO, + }, + ], + }, + ), + oracle_info: OracleInfo::Single(SingleOracleInfo { + oracle_announcement: announcement, + }), + }, + }) +} + +fn numerical_contract_info(offer_collateral: Amount, accept_collateral: Amount) -> ContractInfo { + let nb_digits = 10u16; + let max_value = (1u64 << nb_digits) - 1; + let payout_function = ddk_payouts::generate_payout_curve( + 0, + 900, + offer_collateral, + accept_collateral, + 5, + max_value, + ) + .unwrap(); + let numerical = ddk_manager::contract::numerical_descriptor::NumericalDescriptor { + payout_function, + rounding_intervals: ddk_manager::payout_curve::RoundingIntervals { + intervals: vec![ddk_manager::payout_curve::RoundingInterval { + begin_interval: 0, + rounding_mod: 1, + }], + }, + difference_params: None, + oracle_numeric_infos: ddk_trie::OracleNumericInfo { + base: 2, + nb_digits: vec![nb_digits as usize], + }, + }; + let announcement = oracle_announcement( + EventDescriptor::DigitDecompositionEvent(DigitDecompositionEventDescriptor { + base: 2, + is_signed: false, + unit: "sats".to_string(), + precision: 0, + nb_digits, + }), + nb_digits as usize, + ); + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral: offer_collateral + accept_collateral, + contract_info: ContractInfoInner { + contract_descriptor: ContractDescriptor::NumericOutcomeContractDescriptor( + NumericOutcomeContractDescriptor::from(&numerical), + ), + oracle_info: OracleInfo::Single(SingleOracleInfo { + oracle_announcement: announcement, + }), + }, + }) +} + +fn offer_params( + secp: &Secp256k1, + offerer: &PartySetup, + contract_info: ContractInfo, + offer_collateral: Amount, + network: Network, + funding_inputs: Vec, +) -> CreateOfferParams { + CreateOfferParams { + chain_hash: chain_hash_from_network(network), + temporary_contract_id: None, + contract_info, + offer_collateral, + party: offerer.party_params(secp, funding_inputs), + fund_output_serial_id: None, + fee_rate_per_vb: 2, + cet_locktime: 500, + refund_locktime: 1_000, + contract_flags: 0, + } +} + +/// Builds an enum contract offer/accept pair with one funding input per party. +fn enum_contract( + secp: &Secp256k1, + network: Network, +) -> (PartySetup, PartySetup, OfferDlc, AcceptDlc) { + let offerer = PartySetup::new(secp, 1, network, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(secp, 2, network, Amount::from_sat(150_000), 2); + let offer = create_offer(offer_params( + secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + Amount::from_sat(50_000), + network, + vec![offerer.funding_input.clone()], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(secp, vec![accepter.funding_input.clone()]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + (offerer, accepter, offer, accept_result.accept) +} + +/// Runs sign_accept and finalize_sign with xpriv-signed PSBTs and checks the +/// completed funding transaction. +fn complete_with_xpriv( + _secp: &Secp256k1, + offerer: &PartySetup, + accepter: &PartySetup, + offer: &OfferDlc, + accept: &AcceptDlc, +) -> Transaction { + let mut offer_psbt = create_funding_psbt(offer, accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + offer, + accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + let sign_result = sign_accept(offer, accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + let mut accept_psbt = create_funding_psbt(offer, accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + offer, + accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + let funding_transaction = + finalize_sign(offer, accept, &sign_result.sign, &accept_psbt).unwrap(); + + assert_funding_transaction_complete(&funding_transaction, offer, accept); + funding_transaction +} + +/// Checks that every funding input carries a witness whose public key matches +/// the previous output it spends. +fn assert_funding_transaction_complete( + funding_transaction: &Transaction, + offer: &OfferDlc, + accept: &AcceptDlc, +) { + let transactions = create_dlc_transactions(offer, accept).unwrap(); + assert_eq!( + funding_transaction.compute_txid(), + transactions.fund.compute_txid() + ); + let prevouts: Vec<(OutPoint, TxOut)> = offer + .funding_inputs + .iter() + .chain(&accept.funding_inputs) + .map(|input| { + let transaction: Transaction = bitcoin::consensus::deserialize(&input.prev_tx).unwrap(); + ( + OutPoint { + txid: transaction.compute_txid(), + vout: input.prev_tx_vout, + }, + transaction.output[input.prev_tx_vout as usize].clone(), + ) + }) + .collect(); + for tx_input in &funding_transaction.input { + let (_, prevout) = prevouts + .iter() + .find(|(outpoint, _)| *outpoint == tx_input.previous_output) + .expect("funding transaction spends an unknown outpoint"); + assert_eq!(tx_input.witness.len(), 2, "expected P2WPKH witness"); + let public_key = bitcoin::PublicKey::from_slice(&tx_input.witness[1]).unwrap(); + assert_eq!( + prevout.script_pubkey, + ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()), + "witness key does not control the spent output" + ); + } +} + +#[test] +fn enum_lifecycle_with_xpriv_signing() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + complete_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); +} + +#[test] +fn numerical_lifecycle_with_xpriv_signing() { + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 11, NETWORK, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(&secp, 12, NETWORK, Amount::from_sat(150_000), 2); + let offer = create_offer(offer_params( + &secp, + &offerer, + numerical_contract_info(Amount::from_sat(50_000), Amount::from_sat(50_000)), + Amount::from_sat(50_000), + NETWORK, + vec![offerer.funding_input.clone()], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![accepter.funding_input.clone()]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + complete_with_xpriv(&secp, &offerer, &accepter, &offer, &accept_result.accept); +} + +#[test] +fn mainnet_bip32_paths_complete_the_lifecycle() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, Network::Bitcoin); + assert_eq!(offer.chain_hash, chain_hash_from_network(Network::Bitcoin)); + complete_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); +} + +#[test] +fn descriptor_signing_completes_the_lifecycle() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + + // The offer party signs with a private wildcard descriptor. + let descriptor = format!("wpkh({}/84h/1h/0h/0/*)", offerer.xpriv); + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_descriptor( + &offer, + &accept, + &mut offer_psbt, + &descriptor, + &[DescriptorInput { + input_serial_id: offerer.funding_input.input_serial_id, + derivation_index: 0, + }], + ) + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).unwrap(); + assert_funding_transaction_complete(&funding_transaction, &offer, &accept); +} + +#[test] +fn watch_only_descriptor_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, accept) = enum_contract(&secp, NETWORK); + let account = offerer + .xpriv + .derive_priv(&secp, &DerivationPath::from_str("84h/1h/0h").unwrap()) + .unwrap(); + let xpub = bitcoin::bip32::Xpub::from_priv(&secp, &account); + let descriptor = format!("wpkh({xpub}/0/*)"); + let mut psbt = create_funding_psbt(&offer, &accept).unwrap(); + let result = signing::sign_funding_psbt_with_descriptor( + &offer, + &accept, + &mut psbt, + &descriptor, + &[DescriptorInput { + input_serial_id: offerer.funding_input.input_serial_id, + derivation_index: 0, + }], + ); + assert!(matches!(result, Err(ContractError::Descriptor(_)))); +} + +#[test] +fn wrong_descriptor_index_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, accept) = enum_contract(&secp, NETWORK); + let descriptor = format!("wpkh({}/84h/1h/0h/0/*)", offerer.xpriv); + let mut psbt = create_funding_psbt(&offer, &accept).unwrap(); + let result = signing::sign_funding_psbt_with_descriptor( + &offer, + &accept, + &mut psbt, + &descriptor, + &[DescriptorInput { + input_serial_id: offerer.funding_input.input_serial_id, + derivation_index: 7, + }], + ); + assert!(matches!(result, Err(ContractError::Descriptor(_)))); +} + +#[test] +fn incorrect_derivation_path_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, accept) = enum_contract(&secp, NETWORK); + let mut psbt = create_funding_psbt(&offer, &accept).unwrap(); + let result = signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut psbt, + &offerer.xpriv, + &[InputDerivation { + input_serial_id: offerer.funding_input.input_serial_id, + derivation_path: DerivationPath::from_str("84h/1h/0h/0/9").unwrap(), + }], + ); + assert!(matches!(result, Err(ContractError::InvalidFundingInput(_)))); +} + +#[tokio::test] +async fn wallet_interface_signs_the_funding_psbt() { + let secp = Secp256k1::new(); + let offerer_wallet = TestWallet::new(1); + let accepter_wallet = TestWallet::new(2); + + let offerer = PartySetup::new(&secp, 21, NETWORK, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(&secp, 22, NETWORK, Amount::from_sat(150_000), 2); + // Fund each party from its wallet's first address instead of the xpriv key. + let offer_input = funding_input( + &previous_transaction(Amount::from_sat(150_000), offerer_wallet.script_pubkey()), + 0, + Some(1), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + let accept_input = funding_input( + &previous_transaction(Amount::from_sat(150_000), accepter_wallet.script_pubkey()), + 0, + Some(2), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + + let offer = create_offer(offer_params( + &secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + Amount::from_sat(50_000), + NETWORK, + vec![offer_input], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![accept_input]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + let accept = accept_result.accept; + + let mut offer_psbt = accept_result.funding_psbt.clone(); + signing::sign_funding_psbt_with_wallet( + &offer, + &accept, + &mut offer_psbt, + &offerer_wallet, + Party::Offer, + ) + .await + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_wallet( + &offer, + &accept, + &mut accept_psbt, + &accepter_wallet, + Party::Accept, + ) + .await + .unwrap(); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).unwrap(); + assert_funding_transaction_complete(&funding_transaction, &offer, &accept); +} + +#[test] +fn externally_finalized_psbt_completes_the_lifecycle() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + // The accept party hands the PSBT to an "external wallet": the PSBT is + // serialized, signed and finalized with plain rust-bitcoin, and returned. + let psbt = create_funding_psbt(&offer, &accept).unwrap(); + let serialized = psbt.serialize(); + let externally_signed = external_wallet_sign( + serialized, + &accepter.xpriv, + &accepter.derivation_path, + &secp, + ); + let returned = Psbt::deserialize(&externally_signed).unwrap(); + + let funding_transaction = finalize_sign(&offer, &accept, &sign_result.sign, &returned).unwrap(); + assert_funding_transaction_complete(&funding_transaction, &offer, &accept); +} + +/// Simulates an external wallet: signs and finalizes only the inputs it owns +/// using nothing but rust-bitcoin. +fn external_wallet_sign( + serialized_psbt: Vec, + xpriv: &Xpriv, + path: &DerivationPath, + secp: &Secp256k1, +) -> Vec { + let mut psbt = Psbt::deserialize(&serialized_psbt).unwrap(); + let private_key = xpriv.derive_priv(secp, path).unwrap().to_priv(); + let public_key = private_key.public_key(secp); + let owned_script = ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap()); + let fingerprint = xpriv.fingerprint(secp); + for index in 0..psbt.inputs.len() { + let owns_input = psbt.inputs[index] + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey == owned_script) + .unwrap_or(false); + if !owns_input { + continue; + } + psbt.inputs[index] + .bip32_derivation + .insert(public_key.inner, (fingerprint, path.clone())); + } + psbt.sign(xpriv, secp).unwrap(); + for index in 0..psbt.inputs.len() { + let Some((public_key, signature)) = psbt.inputs[index] + .partial_sigs + .iter() + .map(|(pk, sig)| (*pk, *sig)) + .next() + else { + continue; + }; + psbt.inputs[index].final_script_witness = Some(Witness::from_slice(&[ + signature.to_vec(), + public_key.to_bytes(), + ])); + psbt.inputs[index].partial_sigs.clear(); + } + psbt.serialize() +} + +#[test] +fn single_funded_contract_with_no_accept_inputs() { + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 31, NETWORK, Amount::from_sat(250_000), 1); + let accepter = PartySetup::new(&secp, 32, NETWORK, Amount::from_sat(150_000), 2); + let offer = create_offer(offer_params( + &secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + TOTAL_COLLATERAL, + NETWORK, + vec![offerer.funding_input.clone()], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + let accept = accept_result.accept; + assert_eq!(accept.accept_collateral, Amount::ZERO); + assert!(accept.funding_inputs.is_empty()); + + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + // No accept-side inputs to sign: the unsigned PSBT is sufficient. + let unsigned_psbt = create_funding_psbt(&offer, &accept).unwrap(); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &unsigned_psbt).unwrap(); + assert_eq!(funding_transaction.input.len(), 1); + assert_funding_transaction_complete(&funding_transaction, &offer, &accept); +} + +#[test] +fn shuffled_serial_ids_map_witnesses_to_the_right_inputs() { + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 41, NETWORK, Amount::from_sat(75_000), 900); + let accepter = PartySetup::new(&secp, 42, NETWORK, Amount::from_sat(150_000), 37); + // Second offer input with a serial id sorting before the accept input. + let second_path = DerivationPath::from_str("84h/1h/0h/0/1").unwrap(); + let second_input = funding_input( + &previous_transaction( + Amount::from_sat(75_000), + p2wpkh_script(&secp, &offerer.xpriv, &second_path), + ), + 0, + Some(5), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap(); + + let offer = create_offer(offer_params( + &secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + Amount::from_sat(50_000), + NETWORK, + vec![offerer.funding_input.clone(), second_input], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![accepter.funding_input.clone()]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + let accept = accept_result.accept; + + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &[ + InputDerivation { + input_serial_id: 900, + derivation_path: offerer.derivation_path.clone(), + }, + InputDerivation { + input_serial_id: 5, + derivation_path: second_path, + }, + ], + ) + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + let funding_transaction = + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt).unwrap(); + assert_eq!(funding_transaction.input.len(), 3); + assert_funding_transaction_complete(&funding_transaction, &offer, &accept); +} + +#[test] +fn mutated_psbt_transactions_are_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, accept) = enum_contract(&secp, NETWORK); + + let sign_with = |psbt: &Psbt| sign_accept(&offer, &accept, &offerer.funding_secret_key, psbt); + let signed_psbt = { + let mut psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + psbt + }; + + // Modified output value. + let mut mutated = signed_psbt.clone(); + mutated.unsigned_tx.output[0].value += Amount::from_sat(1); + assert!(matches!( + sign_with(&mutated), + Err(ContractError::PsbtMismatch(_)) + )); + + // Modified locktime. + let mut mutated = signed_psbt.clone(); + mutated.unsigned_tx.lock_time = LockTime::from_consensus(777); + assert!(matches!( + sign_with(&mutated), + Err(ContractError::PsbtMismatch(_)) + )); + + // Modified sequence. + let mut mutated = signed_psbt.clone(); + mutated.unsigned_tx.input[0].sequence = Sequence::ZERO; + assert!(matches!( + sign_with(&mutated), + Err(ContractError::PsbtMismatch(_)) + )); + + // Modified outpoint. + let mut mutated = signed_psbt.clone(); + mutated.unsigned_tx.input[0].previous_output.vout = 9; + assert!(matches!( + sign_with(&mutated), + Err(ContractError::PsbtMismatch(_)) + )); + + // The signing sources reject mutated PSBTs too. + let mut mutated = create_funding_psbt(&offer, &accept).unwrap(); + mutated.unsigned_tx.output[0].value += Amount::from_sat(1); + assert!(matches!( + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut mutated, + &offerer.xpriv, + &offerer.derivations(), + ), + Err(ContractError::PsbtMismatch(_)) + )); +} + +#[test] +fn missing_finalized_witness_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + + let unsigned_psbt = create_funding_psbt(&offer, &accept).unwrap(); + assert!(matches!( + sign_accept(&offer, &accept, &offerer.funding_secret_key, &unsigned_psbt), + Err(ContractError::MissingFinalizedInput { .. }) + )); + + // finalize_sign requires the accept-side witness even when the offer side + // already signed. + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + let sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + let _ = accepter; + assert!(matches!( + finalize_sign(&offer, &accept, &sign_result.sign, &unsigned_psbt), + Err(ContractError::MissingFinalizedInput { .. }) + )); +} + +#[test] +fn invalid_counterparty_adaptor_signatures_are_rejected() { + let secp = Secp256k1::new(); + let (offerer, _, offer, mut accept) = enum_contract(&secp, NETWORK); + accept + .cet_adaptor_signatures + .ecdsa_adaptor_signatures + .reverse(); + + let mut psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + assert!(matches!( + sign_accept(&offer, &accept, &offerer.funding_secret_key, &psbt), + Err(ContractError::InvalidAccept(_)) + )); +} + +#[test] +fn incorrect_contract_id_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut offer_psbt, + &offerer.xpriv, + &offerer.derivations(), + ) + .unwrap(); + let mut sign_result = + sign_accept(&offer, &accept, &offerer.funding_secret_key, &offer_psbt).unwrap(); + sign_result.sign.contract_id[0] ^= 0xff; + + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + signing::sign_funding_psbt_with_xpriv( + &offer, + &accept, + &mut accept_psbt, + &accepter.xpriv, + &accepter.derivations(), + ) + .unwrap(); + assert!(matches!( + finalize_sign(&offer, &accept, &sign_result.sign, &accept_psbt), + Err(ContractError::InvalidSign(_)) + )); +} + +#[test] +fn accept_result_psbt_matches_create_funding_psbt() { + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 51, NETWORK, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(&secp, 52, NETWORK, Amount::from_sat(150_000), 2); + let offer = create_offer(offer_params( + &secp, + &offerer, + enum_contract_info(TOTAL_COLLATERAL), + Amount::from_sat(50_000), + NETWORK, + vec![offerer.funding_input.clone()], + )) + .unwrap(); + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(&secp, vec![accepter.funding_input.clone()]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter.funding_secret_key, + ) + .unwrap(); + let rebuilt = create_funding_psbt(&offer, &accept_result.accept).unwrap(); + assert_eq!(accept_result.funding_psbt.serialize(), rebuilt.serialize()); + assert_eq!( + accept_result.transactions.fund.compute_txid(), + create_dlc_transactions(&offer, &accept_result.accept) + .unwrap() + .fund + .compute_txid() + ); +} + +/// A minimal wallet implementing [`ddk_manager::Wallet`] over an in-memory +/// BDK wallet. Only PSBT signing is exercised by the stateless API. +struct TestWallet { + wallet: std::sync::Mutex, + script_pubkey: ScriptBuf, +} + +impl TestWallet { + fn new(seed_byte: u8) -> Self { + let xpriv = Xpriv::new_master(NETWORK, &[seed_byte; 64]).unwrap(); + let descriptor = format!("wpkh({xpriv}/84h/1h/0h/0/*)"); + let mut wallet = bdk_wallet::Wallet::create_single(descriptor) + .network(NETWORK) + .create_wallet_no_persist() + .unwrap(); + let address = wallet.reveal_next_address(bdk_wallet::KeychainKind::External); + Self { + wallet: std::sync::Mutex::new(wallet), + script_pubkey: address.address.script_pubkey(), + } + } + + fn script_pubkey(&self) -> ScriptBuf { + self.script_pubkey.clone() + } +} + +#[async_trait::async_trait] +impl ddk_manager::Wallet for TestWallet { + async fn get_new_address(&self) -> Result { + unimplemented!("not needed for PSBT signing") + } + async fn get_new_change_address(&self) -> Result { + unimplemented!("not needed for PSBT signing") + } + async fn get_utxos_for_amount( + &self, + _amount: Amount, + _fee_rate: u64, + _lock_utxos: bool, + ) -> Result, ddk_manager::error::Error> { + unimplemented!("not needed for PSBT signing") + } + async fn sign_psbt_input( + &self, + psbt: &mut Psbt, + input_index: usize, + ) -> Result<(), ddk_manager::error::Error> { + let wallet = self.wallet.lock().unwrap(); + let mut signed = psbt.clone(); + let options = bdk_wallet::SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + wallet + .sign(&mut signed, options) + .map_err(|e| ddk_manager::error::Error::WalletError(Box::new(e)))?; + psbt.inputs[input_index] = signed.inputs[input_index].clone(); + Ok(()) + } + fn import_address(&self, _address: &bitcoin::Address) -> Result<(), ddk_manager::error::Error> { + Ok(()) + } + fn unreserve_utxos(&self, _outpoints: &[OutPoint]) -> Result<(), ddk_manager::error::Error> { + Ok(()) + } +}