diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 39215d85..fd013ddb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -103,7 +103,7 @@ jobs: target/debug/build key: test-cache-${{ github.run_id }}-${{ github.run_number }} - id: set-matrix - run: cargo test --no-run --all-features && echo "matrix=$(testconfig/scripts/get_test_list.sh manager_execution manager_tests contract_updater)" >> "$GITHUB_OUTPUT" + run: cargo test --no-run --all-features && echo "matrix=$(testconfig/scripts/get_test_list.sh manager_execution manager_tests contract_updater stateless_execution)" >> "$GITHUB_OUTPUT" integration_tests: name: integration tests needs: integration_tests_prepare diff --git a/Cargo.lock b/Cargo.lock index 0c992e14..20aee291 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1202,7 +1202,7 @@ dependencies = [ [[package]] name = "ddk-testenv" -version = "1.1.1" +version = "1.1.2" dependencies = [ "bitcoin", "bitcoincore-rpc", diff --git a/ddk-manager/Cargo.toml b/ddk-manager/Cargo.toml index 9c00b48d..d4b952a7 100644 --- a/ddk-manager/Cargo.toml +++ b/ddk-manager/Cargo.toml @@ -9,8 +9,13 @@ version.workspace = true edition.workspace = true [features] -default = ["std"] +default = ["std", "manager"] std = ["ddk-dlc/std", "ddk-messages/std", "ddk-trie/std", "bitcoin/std", "lightning/std"] +# The async DLC application layer: the `Manager` and channel updater. This pulls +# tokio (`sync`). Turn it OFF (`default-features = false`) for FFI/mobile +# consumers that only need the pure protocol core — the `contract` types, the +# `ContractSigner`/`ContractSignerProvider`/`Wallet` traits, and `error::Error`. +manager = ["std", "dep:tokio"] fuzztarget = ["rand_chacha"] parallel = ["ddk-trie/parallel"] use-serde = ["serde", "ddk-dlc/use-serde", "ddk-messages/use-serde", "ddk-trie/use-serde"] @@ -30,11 +35,11 @@ once_cell = "1.21.3" rand_chacha = { version = "0.3.1", optional = true } secp256k1-zkp = { workspace = true } serde = { workspace = true, optional = true } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["sync"], optional = true } tracing = { workspace = true } [dev-dependencies] -ddk = { workspace = true } +ddk = { workspace = true, features = ["manager"] } ddk-testenv = { workspace = true } bitcoincore-rpc = { workspace = true } bitcoincore-rpc-json = { workspace = true } diff --git a/ddk-manager/src/channel/offered_channel.rs b/ddk-manager/src/channel/offered_channel.rs index 5c7e5054..4021223f 100644 --- a/ddk-manager/src/channel/offered_channel.rs +++ b/ddk-manager/src/channel/offered_channel.rs @@ -67,7 +67,7 @@ impl OfferedChannel { refund_locktime: offered_contract.refund_locktime, fee_rate_per_vb: offered_contract.fee_rate_per_vb, fund_output_serial_id: offered_contract.fund_output_serial_id, - cet_nsequence: crate::manager::CET_NSEQUENCE, + cet_nsequence: crate::CET_NSEQUENCE, } } 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-manager/src/lib.rs b/ddk-manager/src/lib.rs index 1e08fae3..09dae64f 100644 --- a/ddk-manager/src/lib.rs +++ b/ddk-manager/src/lib.rs @@ -11,18 +11,28 @@ #![deny(dead_code)] #![deny(unused_imports)] #![deny(missing_docs)] +// Without the `manager` feature the async application layer (`manager` / +// `channel_updater`) is compiled out, leaving some protocol helpers with no +// callers in this subset build. The default build compiles the superset and +// keeps `deny`, so genuinely-dead code is still caught there. +#![cfg_attr( + not(feature = "manager"), + allow(dead_code, unused_imports, unused_macros) +)] #[macro_use] extern crate ddk_messages; pub mod chain_monitor; pub mod channel; +#[cfg(feature = "manager")] pub mod channel_updater; pub mod contract; pub mod contract_updater; mod conversion_utils; mod dlc_input; pub mod error; +#[cfg(feature = "manager")] pub mod manager; pub mod payout_curve; mod utils; @@ -56,6 +66,9 @@ pub type KeysId = [u8; 32]; /// Type alias for a channel id. pub type ChannelId = [u8; 32]; +/// The nSequence value used for CETs in DLC channels. +pub const CET_NSEQUENCE: u32 = 288; + /// Time trait to provide current unix time. Mainly defined to facilitate testing. pub trait Time { /// Must return the unix epoch corresponding to the current time. diff --git a/ddk-manager/src/manager.rs b/ddk-manager/src/manager.rs index 5b9fcf86..e3cae882 100644 --- a/ddk-manager/src/manager.rs +++ b/ddk-manager/src/manager.rs @@ -18,7 +18,7 @@ use crate::contract::{ use crate::contract_updater::{accept_contract, verify_accepted_and_sign_contract}; use crate::error::Error; use crate::utils::get_object_in_state; -use crate::{ChannelId, ContractId, ContractSignerProvider}; +use crate::{ChannelId, ContractId, ContractSignerProvider, CET_NSEQUENCE}; use bitcoin::absolute::Height; use bitcoin::consensus::encode::serialize_hex; use bitcoin::consensus::Decodable; @@ -70,8 +70,6 @@ static AUTOMATIC_REFUND: Lazy = Lazy::new(|| match std::env::var("AUTOMATI /// The delay to set the refund value to. pub const REFUND_DELAY: u32 = 86400 * 7; -/// The nSequence value used for CETs in DLC channels -pub const CET_NSEQUENCE: u32 = 288; /// Timeout in seconds when waiting for a peer's reply, after which a DLC channel /// is forced closed. pub const PEER_TIMEOUT: u64 = 3600; diff --git a/ddk/Cargo.toml b/ddk/Cargo.toml index 035b97b6..cf1012d9 100644 --- a/ddk/Cargo.toml +++ b/ddk/Cargo.toml @@ -10,39 +10,60 @@ version.workspace = true edition.workspace = true [features] +default = ["manager"] + +# The full DLC manager application: builder, chain sync, wallet, storage, +# oracle, and transport services. This pulls the async/networked stack +# (tokio, zeromq, bdk_esplora, ...). Turn it OFF (`default-features = false`) +# for FFI/mobile consumers that only need the stateless `contract` module and +# `ContractKeyProvider`, which depend on nothing heavier than `ddk-manager`. +manager = [ + "ddk-manager/manager", + "dep:kormir", + "dep:bdk_esplora", + "dep:tokio", + "dep:zeromq", + "dep:uuid", + "dep:chrono", + "dep:serde_json", + "dep:async-trait", + "dep:hmac", + "dep:sha2", +] + # transport features -nostr = ["dep:nostr-rs", "dep:nostr-sdk", "dep:base64"] -lightning = ["dep:lightning-net-tokio"] +nostr = ["manager", "dep:nostr-rs", "dep:nostr-sdk", "dep:base64"] +lightning = ["manager", "dep:lightning-net-tokio"] # oracle features -kormir = ["dep:reqwest"] -p2pderivatives = ["dep:reqwest"] +kormir = ["manager", "dep:reqwest"] +p2pderivatives = ["manager", "dep:reqwest"] nostr-oracle = ["dep:nostr-database", "nostr", "kormir", "kormir/nostr"] # storage features -sled = ["dep:sled"] -postgres = ["dep:sqlx", "sqlx/postgres"] +sled = ["manager", "dep:sled"] +postgres = ["manager", "dep:sqlx", "sqlx/postgres"] [dependencies] ddk-manager = { workspace = true, features = ["std", "use-serde"] } ddk-dlc = { workspace = true, features = ["std", "use-serde"] } ddk-messages = { workspace = true, features = ["std", "use-serde"] } ddk-trie = { workspace = true, features = ["std", "use-serde"] } -kormir = { workspace = true } +kormir = { workspace = true, optional = true } bitcoin = { workspace = true, features = ["std", "rand", "serde"] } -bdk_esplora = { version = "0.22.2", default-features = false, features = ["std", "async-https", "tokio"] } +bdk_esplora = { version = "0.22.2", default-features = false, features = ["std", "async-https", "tokio"], optional = true } bdk_wallet = "3.0.0" bdk_chain = "0.23.3" lightning = { workspace = true, features = ["std", "grind_signatures"] } serde = { workspace = true, features = ["std", "derive"] } -serde_json = { workspace = true } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["full"] } +tokio = { workspace = true, features = ["full"], optional = true } tracing = { workspace = true } -uuid = { version = "1.8.0", features = ["v4"] } -chrono = { workspace = true, features = ["serde"] } -async-trait = { workspace = true } +uuid = { version = "1.8.0", features = ["v4"], optional = true } +chrono = { workspace = true, features = ["serde"], optional = true } +async-trait = { workspace = true, optional = true } hex = { workspace = true } # storage features @@ -59,13 +80,13 @@ lightning-net-tokio = { version = "0.2.0", optional = true } # oracle feature reqwest = { version = "0.13.3", features = ["json"], optional = true } -hmac = "0.13.0" -sha2 = "0.11.0" +hmac = { version = "0.13.0", optional = true } +sha2 = { version = "0.11.0", optional = true } nostr-database = { version = "0.44.0", optional = true } bip39 = "2.2.0" # zmq feature -zeromq = "0.6.0" +zeromq = { version = "0.6.0", optional = true } [dev-dependencies] test-log = { version = "0.2.16", features = ["trace"] } diff --git a/ddk/examples/common/stateless.rs b/ddk/examples/common/stateless.rs new file mode 100644 index 00000000..d41d5c2a --- /dev/null +++ b/ddk/examples/common/stateless.rs @@ -0,0 +1,209 @@ +// 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, ContractKeyProvider, 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); + +/// The contract's temporary id. Each party's DLC funding key is derived +/// deterministically from this via its [`ContractKeyProvider`], so the key can +/// be recomputed later (for example to splice) without being stored. +pub const CONTRACT_TEMP_ID: [u8; 32] = [0x5c; 32]; + +/// One side of a contract: a deterministic contract-key provider (which yields +/// the DLC funding key) plus a BIP84 wallet key controlling a single funding +/// UTXO. +pub struct PartySetup { + pub contract_keys: ContractKeyProvider, + 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 xpriv = Xpriv::new_master(network, &[seed_byte.wrapping_add(100); 64]).unwrap(); + // The DLC funding key is derived from the contract's temporary id, so it + // is recomputable on demand rather than stored. + let contract_keys = ContractKeyProvider::from_xprv(xpriv); + let funding_secret_key = contract_keys.funding_secret_key(CONTRACT_TEMP_ID).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 { + contract_keys, + 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: Some(CONTRACT_TEMP_ID), + 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_splice.rs b/ddk/examples/stateless_splice.rs new file mode 100644 index 00000000..5ec3417f --- /dev/null +++ b/ddk/examples/stateless_splice.rs @@ -0,0 +1,318 @@ +//! Stateless DLC *splicing* — spending a previous contract's 2-of-2 funding +//! output as an input to a new contract (this is how rollovers and collateral +//! changes are expressed). +//! +//! Two things this example shows: +//! +//! 1. **Funding UTXOs are signed through the [`ddk_manager::Wallet`] trait**, exactly +//! like `stateless_wallet.rs`. The wallet only ever sees the funding PSBT. +//! +//! 2. **Contract funding keys come from a [`ContractKeyProvider`]** — the +//! deterministic "key generator". A contract's funding key is a pure function +//! of its temporary id, so the caller never stores it: when splicing, each +//! party re-derives the *previous* contract's funding key with +//! [`ContractKeyProvider::dlc_input_signing_key`] and the prior temporary id. +//! A [`ContractKeyProvider`] can be built from an xpriv, a seed, a BIP39 +//! mnemonic, or a private descriptor; in production +//! [`ddk::wallet::DlcDevKitWallet`] is itself a provider. +//! +//! The splice is offer-only: the offering party (e.g. a borrower rolling a loan +//! over) contributes the splice input; the accepting party contributes no new +//! funds but still co-signs the prior 2-of-2 with its own recovered prior key. +//! +//! Run with `cargo run --example stateless_splice`. + +#[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, chain_hash_from_network, create_dlc_splice_input, create_funding_psbt, + create_offer, finalize_sign, finalize_sign_spliced, funding_input, sign_accept, + sign_accept_spliced, signing, AcceptOfferParams, ContractKeyProvider, CreateOfferParams, Party, + PartyParams, DLC_INPUT_MAX_WITNESS_LEN, +}; +use ddk_dlc::secp256k1_zkp::PublicKey; +use ddk_messages::FundingInput; + +/// A minimal wallet implementing [`ddk_manager::Wallet`] over an in-memory BDK +/// wallet; only `sign_psbt_input` is exercised. +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(), + } + } + + fn utxo(&self, value: Amount, serial_id: u64) -> FundingInput { + funding_input( + &util::previous_transaction(value, self.script_pubkey.clone()), + 0, + Some(serial_id), + u32::MAX, + 108, + ScriptBuf::new(), + ) + .unwrap() + } +} + +#[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(()) + } +} + +fn party_params( + funding_pubkey: PublicKey, + script_pubkey: ScriptBuf, + funding_inputs: Vec, +) -> PartyParams { + PartyParams { + funding_pubkey, + funding_inputs, + payout_spk: script_pubkey.clone(), + payout_serial_id: None, + change_spk: script_pubkey, + change_serial_id: None, + } +} + +#[tokio::main] +async fn main() { + let network = Network::Regtest; + + // Each party owns a wallet (signs funding UTXOs via the ddk_manager::Wallet + // trait) and a contract-key provider (derives DLC funding keys, recoverably). + let offerer_wallet = ExampleWallet::new(network, 71); + let accepter_wallet = ExampleWallet::new(network, 72); + let offerer_keys = ContractKeyProvider::from_seed(&[1u8; 64], network).unwrap(); + let accepter_keys = ContractKeyProvider::from_seed(&[2u8; 64], network).unwrap(); + + // ----- Contract A: an ordinary dual-funded contract, fully signed. ----- + let temp_id_a = [0xA1; 32]; + let offer_a = create_offer(CreateOfferParams { + chain_hash: chain_hash_from_network(network), + temporary_contract_id: Some(temp_id_a), + contract_info: util::enum_contract_info(util::TOTAL_COLLATERAL), + offer_collateral: Amount::from_sat(50_000), + party: party_params( + offerer_keys.funding_pubkey(temp_id_a).unwrap(), + offerer_wallet.script_pubkey.clone(), + vec![offerer_wallet.utxo(Amount::from_sat(150_000), 1)], + ), + fund_output_serial_id: None, + fee_rate_per_vb: 2, + cet_locktime: 500, + refund_locktime: 1_000, + contract_flags: 0, + }) + .expect("offer A"); + + let accept_a = accept_offer( + &offer_a, + AcceptOfferParams { + party: party_params( + accepter_keys.funding_pubkey(temp_id_a).unwrap(), + accepter_wallet.script_pubkey.clone(), + vec![accepter_wallet.utxo(Amount::from_sat(150_000), 2)], + ), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter_keys.funding_secret_key(temp_id_a).unwrap(), + ) + .expect("accept A") + .accept; + + let mut offer_a_psbt = create_funding_psbt(&offer_a, &accept_a).unwrap(); + signing::sign_funding_psbt_with_wallet( + &offer_a, + &accept_a, + &mut offer_a_psbt, + &offerer_wallet, + Party::Offer, + ) + .await + .expect("offer A wallet signing"); + let sign_a = sign_accept( + &offer_a, + &accept_a, + &offerer_keys.funding_secret_key(temp_id_a).unwrap(), + &offer_a_psbt, + ) + .expect("sign A"); + + let mut accept_a_psbt = create_funding_psbt(&offer_a, &accept_a).unwrap(); + signing::sign_funding_psbt_with_wallet( + &offer_a, + &accept_a, + &mut accept_a_psbt, + &accepter_wallet, + Party::Accept, + ) + .await + .expect("accept A wallet signing"); + let funding_tx_a = + finalize_sign(&offer_a, &accept_a, &sign_a.sign, &accept_a_psbt).expect("finalize A"); + + // ----- Contract B: splice A's funding output into a new contract. ----- + let splice_serial = 900; + let splice_input = create_dlc_splice_input( + &offer_a, + &accept_a, + Party::Offer, + Some(splice_serial), + DLC_INPUT_MAX_WITNESS_LEN, + ) + .expect("splice input"); + + // Single-funded: the offering party rolls the old collateral in (the splice + // input) plus a wallet UTXO of added collateral; the accepting party + // contributes no new funds. + let temp_id_b = [0xB2; 32]; + let splice_amount = Amount::from_sat(40_000); + let offer_collateral_b = util::TOTAL_COLLATERAL + splice_amount; + let offer_b = create_offer(CreateOfferParams { + chain_hash: chain_hash_from_network(network), + temporary_contract_id: Some(temp_id_b), + contract_info: util::enum_contract_info(offer_collateral_b), + offer_collateral: offer_collateral_b, + party: party_params( + offerer_keys.funding_pubkey(temp_id_b).unwrap(), + offerer_wallet.script_pubkey.clone(), + vec![ + splice_input, + offerer_wallet.utxo(Amount::from_sat(200_000), 10), + ], + ), + fund_output_serial_id: None, + fee_rate_per_vb: 2, + cet_locktime: 500, + refund_locktime: 1_000, + contract_flags: 0, + }) + .expect("offer B"); + + let accept_b = accept_offer( + &offer_b, + AcceptOfferParams { + party: party_params( + accepter_keys.funding_pubkey(temp_id_b).unwrap(), + accepter_wallet.script_pubkey.clone(), + vec![], + ), + min_timeout_interval: 100, + max_timeout_interval: 500, + }, + &accepter_keys.funding_secret_key(temp_id_b).unwrap(), + ) + .expect("accept B") + .accept; + + // Offer side: sign the new wallet UTXO, then produce this party's half of the + // prior 2-of-2. The prior funding key is RE-DERIVED from `temp_id_a` — not + // stored — via the provider's `dlc_input_signing_key` helper. + let mut offer_b_psbt = create_funding_psbt(&offer_b, &accept_b).unwrap(); + signing::sign_funding_psbt_with_wallet( + &offer_b, + &accept_b, + &mut offer_b_psbt, + &offerer_wallet, + Party::Offer, + ) + .await + .expect("offer B wallet signing"); + let offer_prior_key = offerer_keys + .dlc_input_signing_key(temp_id_a, splice_serial) + .expect("recover offer prior key"); + let sign_b = sign_accept_spliced( + &offer_b, + &accept_b, + &offerer_keys.funding_secret_key(temp_id_b).unwrap(), + &offer_b_psbt, + std::slice::from_ref(&offer_prior_key), + ) + .expect("sign B"); + + // Accept side: no new inputs; contribute the other half of the prior 2-of-2, + // again from the RE-DERIVED prior funding key. + let accept_b_psbt = create_funding_psbt(&offer_b, &accept_b).unwrap(); + let accept_prior_key = accepter_keys + .dlc_input_signing_key(temp_id_a, splice_serial) + .expect("recover accept prior key"); + let funding_tx_b = finalize_sign_spliced( + &offer_b, + &accept_b, + &sign_b.sign, + &accept_b_psbt, + std::slice::from_ref(&accept_prior_key), + ) + .expect("finalize B"); + + let spends_prior = funding_tx_b + .input + .iter() + .any(|input| input.previous_output.txid == funding_tx_a.compute_txid()); + + println!("prior funding tx {}", funding_tx_a.compute_txid()); + println!( + "splice funding tx {} ({} inputs, spends prior funding output: {})", + funding_tx_b.compute_txid(), + funding_tx_b.input.len(), + spends_prior, + ); + assert!(spends_prior, "splice must spend the prior funding output"); +} 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..69a5f657 --- /dev/null +++ b/ddk/src/contract/advanced.rs @@ -0,0 +1,171 @@ +//! 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::{DlcInputSigningKey, 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, &[]) +} + +/// Splice-aware variant of [`finalize_sign_with_funding_signatures`]. +/// +/// `dlc_input_keys` supplies this (accepting) party's previous contract funding +/// secret key for each DLC (splice) funding input in the offer, matched by +/// serial id. The offering party's DLC-input half signatures must already be +/// present in `sign.funding_signatures`. +pub fn finalize_sign_spliced_with_funding_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_signatures: FundingSignatures, + dlc_input_keys: &[DlcInputSigningKey], +) -> Result { + super::finalize::finalize_sign_internal(offer, accept, sign, funding_signatures, dlc_input_keys) +} diff --git a/ddk/src/contract/context.rs b/ddk/src/contract/context.rs new file mode 100644 index 00000000..f1f608cc --- /dev/null +++ b/ddk/src/contract/context.rs @@ -0,0 +1,541 @@ +//! 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(), + )); + } + validate_offer_funding_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 payouts = execution_infos[0].get_payouts(total_collateral)?; + // A splice input carries a `dlc_input`; when present the funding transaction + // spends the previous contract's 2-of-2 output and must be built through the + // spliced constructor. Only the offer side may contribute DLC inputs. + let has_dlc_inputs = + !offer_params.dlc_inputs.is_empty() || !accept_params.dlc_inputs.is_empty(); + let mut transactions = if has_dlc_inputs { + ddk_dlc::create_spliced_dlc_transactions( + &offer_params, + accept_params, + &payouts, + offer.refund_locktime, + offer.fee_rate_per_vb, + 0, + offer.cet_locktime, + offer.fund_output_serial_id, + offer.contract_flags, + )? + } else { + ddk_dlc::create_dlc_transactions( + &offer_params, + accept_params, + &payouts, + 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: funding_inputs + .iter() + .filter(|input| input.dlc_input.is_some()) + .map(|input| input.into()) + .collect(), + 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) { + // DLC (splice) inputs are 2-of-2 and completed by the combine step in + // `finalize`; their positional signature slot is consumed but skipped here. + if input.dlc_input.is_some() { + continue; + } + 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(()) +} + +/// Checks that a sign message belongs to the contract rebuilt from the offer +/// and accept messages. +pub(crate) fn ensure_sign_message( + offer: &OfferDlc, + sign: &ddk_messages::SignDlc, + context: &ContractContext, +) -> Result<(), ContractError> { + 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(), + )); + } + 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(), + )); + } + 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(()) +} + +/// Rejects DLC (splice) inputs. Used for accept-side funding inputs, which must +/// always be ordinary wallet UTXOs. +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 (splicing) are only supported on the offer side".to_string(), + )); + } + Ok(()) +} + +/// Validates the offering party's funding inputs, permitting DLC (splice) inputs. +/// +/// Ordinary wallet inputs pass through untouched. Each DLC input is checked for +/// consistency: a large-enough witness length, no redeem script, an in-range +/// previous output, and a previous output that is the 2-of-2 funding output of +/// the two funding public keys the input names. +pub(crate) fn validate_offer_funding_inputs( + funding_inputs: &[FundingInput], +) -> Result<(), ContractError> { + for input in funding_inputs { + let Some(dlc_input) = &input.dlc_input else { + continue; + }; + if input.max_witness_len as usize <= 108 { + return Err(ContractError::InvalidFundingInput( + "DLC input max witness length must be greater than 108".to_string(), + )); + } + if !input.redeem_script.is_empty() { + return Err(ContractError::InvalidFundingInput( + "DLC input must not carry a redeem script".to_string(), + )); + } + 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!( + "DLC input previous output {} does not exist", + input.prev_tx_vout + )) + })?; + let expected_script_pubkey = ddk_dlc::make_funding_redeemscript( + &dlc_input.local_fund_pubkey, + &dlc_input.remote_fund_pubkey, + ) + .to_p2wsh(); + if prevout.script_pubkey != expected_script_pubkey { + return Err(ContractError::InvalidFundingInput( + "DLC input previous output is not the 2-of-2 funding output of its funding public \ + keys" + .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..f9a794e3 --- /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_protocol_version, validate_offer_funding_inputs}; +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; + + validate_offer_funding_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)?; + validate_offer_funding_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..048e94d0 --- /dev/null +++ b/ddk/src/contract/error.rs @@ -0,0 +1,80 @@ +//! 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, + }, + /// An oracle attestation is malformed, forged, or does not correspond to + /// the announcement of the oracle it claims to come from. + #[error("invalid attestation: {0}")] + InvalidAttestation(String), + /// No contract outcome corresponds to the supplied oracle attestations, so + /// there is no CET to sign. + #[error("no contract outcome matches the given attestations")] + NoMatchingOutcome, + /// 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), + /// Contract funding-key derivation failed (bad mnemonic, missing private + /// key, or an invalid derived key). + #[error("contract key error: {0}")] + Key(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..f838fbd3 --- /dev/null +++ b/ddk/src/contract/finalize.rs @@ -0,0 +1,230 @@ +//! Funding transaction completion by the accepting party. + +use bitcoin::psbt::Psbt; +use bitcoin::Transaction; +use ddk_dlc::dlc_input::DlcInputInfo; +use ddk_dlc::secp256k1_zkp::{PublicKey, Secp256k1}; +use ddk_messages::{AcceptDlc, FundingSignatures, OfferDlc, SignDlc}; + +use super::context::{ + apply_funding_signatures, context_from_messages, ensure_sign_message, funding_input_index, + verify_counterparty_signatures, ContractContext, +}; +use super::error::ContractError; +use super::psbt::{ensure_psbt_matches_funding_transaction, extract_funding_signatures}; +use super::types::{DlcInputSigningKey, 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 { + finalize_sign_spliced(offer, accept, sign, signed_funding_psbt, &[]) +} + +/// Verifies the sign message and completes the funding transaction, including +/// any splice (DLC) funding inputs. +/// +/// Behaves like [`finalize_sign`] for ordinary funding inputs. For each DLC +/// (splice) input in the offer, `dlc_input_keys` must supply this (accepting) +/// party's previous contract funding secret key (matched by serial id). The +/// offering party's half signature is verified before this party's half is +/// produced and the two are combined into the input's final 2-of-2 witness. +/// Pass an empty slice when the contract has no splice inputs. +pub fn finalize_sign_spliced( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + signed_funding_psbt: &Psbt, + dlc_input_keys: &[DlcInputSigningKey], +) -> 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, + dlc_input_keys, + ) +} + +/// 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, + dlc_input_keys: &[DlcInputSigningKey], +) -> Result { + let context = context_from_messages(offer, accept)?; + finalize_with_context( + offer, + accept, + sign, + funding_signatures, + context, + dlc_input_keys, + ) +} + +fn finalize_with_context( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_signatures: FundingSignatures, + context: ContractContext, + dlc_input_keys: &[DlcInputSigningKey], +) -> 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_sign_message(offer, sign, &context)?; + 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 secp = Secp256k1::new(); + verify_counterparty_signatures( + &secp, + &context, + offer.get_total_collateral(), + offer.funding_pubkey, + &sign.refund_signature, + &sign.cet_adaptor_signatures, + ContractError::InvalidSign, + )?; + + // Splice inputs' 2-of-2 signatures are computed over the unsigned funding + // transaction (the SegWit sighash does not commit to other inputs' witnesses), + // matching what the offering party signed. + let unsigned_funding_transaction = context.transactions.fund.clone(); + 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, + )?; + complete_dlc_input_witnesses( + &mut funding_transaction, + &unsigned_funding_transaction, + offer, + accept, + sign, + dlc_input_keys, + )?; + + Ok(funding_transaction) +} + +/// Verifies the offering party's DLC-input half signatures and combines them +/// with this (accepting) party's half to complete each splice input's 2-of-2 +/// witness on the funding transaction. +fn complete_dlc_input_witnesses( + funding_transaction: &mut Transaction, + unsigned_funding_transaction: &Transaction, + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + dlc_input_keys: &[DlcInputSigningKey], +) -> Result<(), ContractError> { + let secp = Secp256k1::new(); + for (input, offer_signature) in offer + .funding_inputs + .iter() + .zip(&sign.funding_signatures.funding_signatures) + { + let Some(dlc_input) = &input.dlc_input else { + continue; + }; + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + let dlc_input_info: DlcInputInfo = input.into(); + let offer_half = offer_signature + .witness_elements + .first() + .ok_or_else(|| { + ContractError::InvalidSign(format!( + "DLC input serial id {} funding signature is empty", + input.input_serial_id + )) + })? + .witness + .clone(); + ddk_dlc::dlc_input::verify_dlc_funding_input_signature( + &secp, + unsigned_funding_transaction, + input_index, + &dlc_input_info, + offer_half.clone(), + &dlc_input.local_fund_pubkey, + ) + .map_err(|e| { + ContractError::InvalidSign(format!( + "invalid DLC input signature for serial id {}: {e}", + input.input_serial_id + )) + })?; + let signing_key = dlc_input_keys + .iter() + .find(|key| key.input_serial_id == input.input_serial_id) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "missing prior funding secret key for DLC input serial id {}", + input.input_serial_id + )) + })?; + if PublicKey::from_secret_key(&secp, &signing_key.prior_funding_secret_key) + != dlc_input.remote_fund_pubkey + { + return Err(ContractError::InvalidFundingInput( + "prior funding secret key does not match the DLC input remote funding public key" + .to_string(), + )); + } + let accept_half = ddk_dlc::dlc_input::create_dlc_funding_input_signature( + &secp, + unsigned_funding_transaction, + input_index, + &dlc_input_info, + &signing_key.prior_funding_secret_key, + )?; + funding_transaction.input[input_index].witness = + ddk_dlc::dlc_input::combine_dlc_input_signatures( + &dlc_input_info, + &accept_half, + &offer_half, + &dlc_input.remote_fund_pubkey, + &dlc_input.local_fund_pubkey, + ); + } + Ok(()) +} diff --git a/ddk/src/contract/keys.rs b/ddk/src/contract/keys.rs new file mode 100644 index 00000000..406d4eab --- /dev/null +++ b/ddk/src/contract/keys.rs @@ -0,0 +1,402 @@ +//! Deterministic contract funding-key derivation. +//! +//! A DLC contract's funding key (the key controlling its 2-of-2 output, its CET +//! adaptor signatures, and its refund signature) is a pure, deterministic +//! function of a `keys_id`, which is itself a pure function of the contract's +//! temporary id. Nothing is stored: given a master extended private key and a +//! contract's temporary id, the exact funding key is recomputed on demand. +//! +//! This is the mechanism the stateless splice API needs. To spend a previous +//! contract's funding output the caller must supply that contract's funding +//! secret key ([`DlcInputSigningKey`]); [`ContractKeyProvider::dlc_input_signing_key`] +//! re-derives it from the previous contract's temporary id. +//! +//! [`ContractKeyProvider`] is the standalone form of the derivation implemented +//! by [`crate::wallet::DlcDevKitWallet`], which delegates to it — so keys are +//! interchangeable between the stateful manager path and the stateless API. +//! It implements [`ddk_manager::ContractSignerProvider`], so it can also drive a +//! manager directly. + +use std::str::FromStr; + +use bdk_wallet::miniscript::descriptor::{Descriptor, DescriptorPublicKey, DescriptorSecretKey}; +use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint, Xpriv}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::{All, PublicKey, Secp256k1, SecretKey}; +use bitcoin::Network; +use ddk_manager::SimpleSigner; + +use super::error::ContractError; +use super::types::DlcInputSigningKey; + +/// Range of child numbers per hierarchical level. `3400^3 ≈ 39.3` billion paths +/// — large enough to avoid collisions across millions of contracts, small +/// enough for practical disaster recovery. +const CHILD_NUMBER_RANGE: u32 = 3_400; + +/// Base derivation path for contract keys. +const DLC_BASE_PATH: &str = "m/420'/0'/0'"; + +/// Domain-separation tag for the keys-id hash. Must stay in lockstep with +/// [`crate::wallet::DlcDevKitWallet`] or keys derived by one will not match the +/// other. +const KEYS_ID_TAG: &[u8] = b"CONTRACT_SIGNER_KEY_ID_V0"; + +/// Deterministically derives DLC contract funding keys from a master extended +/// private key. +/// +/// Construct one from whatever key material the consumer holds — a raw +/// [`Xpriv`](bitcoin::bip32::Xpriv), a BIP39 mnemonic, a seed, or an output +/// descriptor carrying a private key — and the funding keys for every contract +/// follow deterministically. The secret keys never leave this type; callers ask +/// for a [`funding_pubkey`](Self::funding_pubkey) to publish, or a +/// [`dlc_input_signing_key`](Self::dlc_input_signing_key) to splice. +#[derive(Clone)] +pub struct ContractKeyProvider { + xprv: Xpriv, + fingerprint: Fingerprint, + secp: Secp256k1, + dlc_path: DerivationPath, +} + +impl ContractKeyProvider { + /// Builds a provider from a master extended private key. + pub fn from_xprv(xprv: Xpriv) -> Self { + let secp = Secp256k1::new(); + let fingerprint = xprv.fingerprint(&secp); + let dlc_path = DerivationPath::from_str(DLC_BASE_PATH).expect("valid base path"); + Self { + xprv, + fingerprint, + secp, + dlc_path, + } + } + + /// Builds a provider from a raw seed (for example the 64 bytes produced by + /// [`convert_mnemonic_to_seed`](Self::from_mnemonic)). + pub fn from_seed(seed: &[u8], network: Network) -> Result { + let xprv = Xpriv::new_master(network, seed) + .map_err(|e| ContractError::Key(format!("invalid seed: {e}")))?; + Ok(Self::from_xprv(xprv)) + } + + /// Builds a provider from a BIP39 mnemonic (with optional passphrase). + pub fn from_mnemonic( + mnemonic: &str, + passphrase: Option<&str>, + network: Network, + ) -> Result { + let mnemonic = bip39::Mnemonic::from_str(mnemonic) + .map_err(|e| ContractError::Key(format!("invalid mnemonic: {e}")))?; + let seed = mnemonic.to_seed(passphrase.unwrap_or("")); + Self::from_seed(&seed, network) + } + + /// Builds a provider from an output descriptor that carries an extended + /// private key (for example `wpkh(xprv.../84h/1h/0h/0/*)`). The descriptor's + /// extended private key is used as the master key; the descriptor's own path + /// and wildcard are not applied to contract-key derivation. Watch-only + /// descriptors are rejected. + pub fn from_descriptor(descriptor: &str) -> Result { + let secp = Secp256k1::new(); + let (_, key_map) = Descriptor::::parse_descriptor(&secp, descriptor) + .map_err(|e| ContractError::Descriptor(e.to_string()))?; + let xprv = key_map + .values() + .find_map(|secret| match secret { + DescriptorSecretKey::XPrv(xkey) => Some(xkey.xkey), + _ => None, + }) + .ok_or_else(|| { + ContractError::Descriptor( + "descriptor does not contain an extended private key".to_string(), + ) + })?; + Ok(Self::from_xprv(xprv)) + } + + /// The `keys_id` for a contract, a deterministic function of its temporary id. + pub fn keys_id(&self, temporary_contract_id: [u8; 32]) -> [u8; 32] { + let mut input = Vec::with_capacity(4 + 32 + KEYS_ID_TAG.len()); + input.extend_from_slice(self.fingerprint.as_bytes()); + input.extend_from_slice(&temporary_contract_id); + input.extend_from_slice(KEYS_ID_TAG); + sha256::Hash::hash(&input).to_byte_array() + } + + /// The funding secret key for a `keys_id`. + pub fn funding_secret_key_for_keys_id( + &self, + keys_id: [u8; 32], + ) -> Result { + let (level_1, level_2, level_3) = hierarchical_indices(keys_id); + let path = self.hierarchical_derivation_path(level_1, level_2, level_3)?; + let base_key = self + .xprv + .derive_priv(&self.secp, &path) + .map_err(|e| ContractError::Bip32(e.to_string()))? + .private_key; + self.harden(&base_key, level_1, level_2, level_3) + } + + /// The funding secret key for a contract, from its temporary id. + pub fn funding_secret_key( + &self, + temporary_contract_id: [u8; 32], + ) -> Result { + self.funding_secret_key_for_keys_id(self.keys_id(temporary_contract_id)) + } + + /// The funding public key for a contract — publish this in the offer or + /// accept message ([`PartyParams::funding_pubkey`](super::PartyParams::funding_pubkey)). + pub fn funding_pubkey( + &self, + temporary_contract_id: [u8; 32], + ) -> Result { + Ok(self + .funding_secret_key(temporary_contract_id)? + .public_key(&self.secp)) + } + + /// Re-derives the previous contract's funding secret key and wraps it as a + /// [`DlcInputSigningKey`] for the splice input identified by `input_serial_id`. + /// Pass the result to [`sign_accept_spliced`](super::sign_accept_spliced) or + /// [`finalize_sign_spliced`](super::finalize_sign_spliced). + pub fn dlc_input_signing_key( + &self, + prior_temporary_contract_id: [u8; 32], + input_serial_id: u64, + ) -> Result { + Ok(DlcInputSigningKey { + input_serial_id, + prior_funding_secret_key: self.funding_secret_key(prior_temporary_contract_id)?, + }) + } + + fn hierarchical_derivation_path( + &self, + level_1: u32, + level_2: u32, + level_3: u32, + ) -> Result { + let child = |index: u32| { + ChildNumber::from_normal_idx(index) + .map_err(|e| ContractError::Key(format!("invalid derivation index: {e}"))) + }; + Ok(self + .dlc_path + .extend([child(level_1)?, child(level_2)?, child(level_3)?])) + } + + fn harden( + &self, + base_key: &SecretKey, + level_1: u32, + level_2: u32, + level_3: u32, + ) -> Result { + let mut input = Vec::new(); + input.extend_from_slice(self.fingerprint.as_bytes()); + input.extend_from_slice(&base_key.secret_bytes()); + input.extend_from_slice(&level_1.to_be_bytes()); + input.extend_from_slice(&level_2.to_be_bytes()); + input.extend_from_slice(&level_3.to_be_bytes()); + SecretKey::from_slice(sha256::Hash::hash(&input).as_ref()) + .map_err(|e| ContractError::Key(format!("invalid derived key: {e}"))) + } +} + +/// Splits the first 12 bytes of a `keys_id` into three level indices. +fn hierarchical_indices(keys_id: [u8; 32]) -> (u32, u32, u32) { + let level = |offset: usize| { + u32::from_be_bytes([ + keys_id[offset], + keys_id[offset + 1], + keys_id[offset + 2], + keys_id[offset + 3], + ]) % CHILD_NUMBER_RANGE + }; + (level(0), level(4), level(8)) +} + +impl ddk_manager::ContractSignerProvider for ContractKeyProvider { + type Signer = SimpleSigner; + + fn derive_signer_key_id(&self, _is_offer_party: bool, temp_id: [u8; 32]) -> [u8; 32] { + self.keys_id(temp_id) + } + + fn derive_contract_signer( + &self, + key_id: [u8; 32], + ) -> Result { + let secret_key = self + .funding_secret_key_for_keys_id(key_id) + .map_err(|e| ddk_manager::error::Error::InvalidParameters(e.to_string()))?; + Ok(SimpleSigner::new(secret_key)) + } + + fn get_secret_key_for_pubkey( + &self, + _pubkey: &PublicKey, + ) -> Result { + unreachable!("get_secret_key_for_pubkey is only used for channels") + } + + fn get_new_secret_key(&self) -> Result { + unreachable!("get_new_secret_key is only used for channels") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEMP_A: [u8; 32] = [0xA1; 32]; + const TEMP_B: [u8; 32] = [0xB2; 32]; + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn provider() -> ContractKeyProvider { + ContractKeyProvider::from_mnemonic(MNEMONIC, None, Network::Regtest).unwrap() + } + + #[test] + fn derivation_is_deterministic_and_recoverable() { + // A fresh provider over the same key material recomputes the same key. + let a = provider().funding_secret_key(TEMP_A).unwrap(); + let b = provider().funding_secret_key(TEMP_A).unwrap(); + assert_eq!(a, b); + // Different contracts get different keys. + assert_ne!(a, provider().funding_secret_key(TEMP_B).unwrap()); + } + + #[test] + fn funding_pubkey_matches_secret_key() { + let keys = provider(); + let secp = Secp256k1::new(); + let sk = keys.funding_secret_key(TEMP_A).unwrap(); + assert_eq!(keys.funding_pubkey(TEMP_A).unwrap(), sk.public_key(&secp)); + } + + #[test] + fn dlc_input_signing_key_carries_the_recovered_prior_key() { + let keys = provider(); + let signing_key = keys.dlc_input_signing_key(TEMP_A, 900).unwrap(); + assert_eq!(signing_key.input_serial_id, 900); + assert_eq!( + signing_key.prior_funding_secret_key, + keys.funding_secret_key(TEMP_A).unwrap() + ); + } + + #[test] + fn constructors_agree() { + let mnemonic = bip39::Mnemonic::from_str(MNEMONIC).unwrap(); + let seed = mnemonic.to_seed(""); + let from_seed = ContractKeyProvider::from_seed(&seed, Network::Regtest).unwrap(); + let from_xprv = + ContractKeyProvider::from_xprv(Xpriv::new_master(Network::Regtest, &seed).unwrap()); + assert_eq!( + provider().funding_secret_key(TEMP_A).unwrap(), + from_seed.funding_secret_key(TEMP_A).unwrap() + ); + assert_eq!( + from_seed.funding_secret_key(TEMP_A).unwrap(), + from_xprv.funding_secret_key(TEMP_A).unwrap() + ); + } + + #[test] + fn hierarchical_indices_are_deterministic_and_bounded() { + let key_id = [ + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + ]; + assert_eq!(hierarchical_indices(key_id), hierarchical_indices(key_id)); + let (l1, l2, l3) = hierarchical_indices(key_id); + assert_eq!( + l1, + u32::from_be_bytes([0x12, 0x34, 0x56, 0x78]) % CHILD_NUMBER_RANGE + ); + assert_eq!( + l2, + u32::from_be_bytes([0x9A, 0xBC, 0xDE, 0xF0]) % CHILD_NUMBER_RANGE + ); + assert_eq!( + l3, + u32::from_be_bytes([0x11, 0x22, 0x33, 0x44]) % CHILD_NUMBER_RANGE + ); + assert!(l1 < CHILD_NUMBER_RANGE && l2 < CHILD_NUMBER_RANGE && l3 < CHILD_NUMBER_RANGE); + assert_eq!(hierarchical_indices([0u8; 32]), (0, 0, 0)); + } + + #[test] + fn hierarchical_indices_distribute_across_the_range() { + use std::collections::HashSet; + let mut seen = HashSet::new(); + for i in 0..1000u32 { + let mut key_id = [0u8; 32]; + key_id[0..4].copy_from_slice(&i.to_be_bytes()); + key_id[4..8].copy_from_slice(&i.wrapping_mul(7919).to_be_bytes()); + key_id[8..12].copy_from_slice(&i.wrapping_mul(104729).to_be_bytes()); + seen.insert(hierarchical_indices(key_id)); + } + assert!(seen.len() > 900, "distribution too poor: {}", seen.len()); + } + + #[test] + fn derivation_path_is_base_plus_three_levels() { + use bitcoin::bip32::ChildNumber; + let keys = provider(); + let (l1, l2, l3) = hierarchical_indices([1u8; 32]); + let path = keys.hierarchical_derivation_path(l1, l2, l3).unwrap(); + assert_eq!(path.len(), 6); + assert_eq!(path[0], ChildNumber::from_hardened_idx(420).unwrap()); + assert_eq!(path[1], ChildNumber::from_hardened_idx(0).unwrap()); + assert_eq!(path[2], ChildNumber::from_hardened_idx(0).unwrap()); + assert_eq!(path[3], ChildNumber::from_normal_idx(l1).unwrap()); + assert_eq!(path[4], ChildNumber::from_normal_idx(l2).unwrap()); + assert_eq!(path[5], ChildNumber::from_normal_idx(l3).unwrap()); + } + + #[test] + fn hardening_is_deterministic_and_sensitive() { + let keys = provider(); + let base = SecretKey::from_slice(&[0x42; 32]).unwrap(); + assert_eq!( + keys.harden(&base, 100, 200, 300).unwrap(), + keys.harden(&base, 100, 200, 300).unwrap() + ); + assert_ne!(keys.harden(&base, 100, 200, 300).unwrap(), base); + assert_ne!( + keys.harden(&base, 100, 200, 300).unwrap(), + keys.harden(&base, 100, 200, 301).unwrap() + ); + assert_ne!( + keys.harden(&base, 100, 200, 300).unwrap(), + keys.harden(&base, 100, 201, 300).unwrap() + ); + assert_ne!( + keys.harden(&base, 100, 200, 300).unwrap(), + keys.harden(&base, 101, 200, 300).unwrap() + ); + } + + #[test] + fn from_descriptor_requires_a_private_key() { + // Watch-only descriptor (xpub) has no private key to derive from. + let secp = Secp256k1::new(); + let xpub = bitcoin::bip32::Xpub::from_priv( + &secp, + &Xpriv::new_master(Network::Regtest, &[7u8; 32]).unwrap(), + ); + let watch_only = format!("wpkh({xpub}/0/*)"); + assert!(matches!( + ContractKeyProvider::from_descriptor(&watch_only), + Err(ContractError::Descriptor(_)) + )); + } +} diff --git a/ddk/src/contract/mod.rs b/ddk/src/contract/mod.rs new file mode 100644 index 00000000..1442f13c --- /dev/null +++ b/ddk/src/contract/mod.rs @@ -0,0 +1,171 @@ +//! 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), +//! [`AcceptDlc`](ddk_messages::AcceptDlc), and [`SignDlc`](ddk_messages::SignDlc) +//! 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 +//! │ +//! │ ... the oracles attest, or the refund locktime passes ... +//! │ +//! └─ sign_cet / sign_refund ──► Transaction sign_cet / sign_refund ──► Transaction +//! broadcast via chain client broadcast via chain client +//! ``` +//! +//! Either party can settle on its own, and neither needs the other's +//! cooperation to do it: the counterparty's half of the 2-of-2 spend was +//! committed in the messages it already sent. +//! +//! Each party retains only the three wire messages, its DLC funding secret key, +//! and access to the keys of its funding inputs. Everything else — the funding +//! transaction, the CETs, the refund transaction, the contract id, the adaptor +//! information — is rebuilt from those messages whenever it is needed. +//! +//! The `SignDlc` matters to each side differently: the accepting party settles +//! with the signatures it carries, while for the offering party it only +//! confirms that the three messages describe one contract. +//! +//! # 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. +//! +//! # Splicing +//! +//! A new contract can spend a previous contract's 2-of-2 funding output as an +//! input (a *splice*), which is how rollovers and collateral changes are +//! expressed. Only the offering party may contribute a splice input. Build it +//! from the previous contract's messages with +//! [`create_dlc_splice_input`](crate::contract::create_dlc_splice_input) and +//! place it in the offering party's funding inputs. Signing the prior 2-of-2 +//! additionally requires each party's *previous-contract* funding secret key, +//! supplied to [`sign_accept_spliced`](crate::contract::sign_accept_spliced) +//! (offering party) and +//! [`finalize_sign_spliced`](crate::contract::finalize_sign_spliced) (accepting +//! party) as [`DlcInputSigningKey`](crate::contract::DlcInputSigningKey) values. +//! +//! # Settlement +//! +//! A funded contract ends in one of two transactions, both of which spend the +//! 2-of-2 funding output and are built entirely from the wire messages: +//! +//! | Outcome | Function | Counterparty's half comes from | +//! |---------|----------|-------------------------------| +//! | the oracles attest | [`sign_cet`](crate::contract::sign_cet) | its CET adaptor signature, decrypted with the oracle signatures | +//! | nobody attests | [`sign_refund`](crate::contract::sign_refund) | its refund signature, sent with the accept or sign message | +//! +//! Both take the settling party's DLC funding secret key, which supplies this +//! party's half of the 2-of-2 spend and identifies which side is settling — so +//! the same call works for either party. [`sign_cet`](crate::contract::sign_cet) +//! additionally takes the oracle attestations, each paired with the index of +//! its oracle in the contract's announcements; it selects the matching CET, +//! verifies the attestations against the announcements they claim to come from, +//! and returns the signed transaction. +//! +//! Neither function enforces *when* a transaction may be broadcast. CETs carry +//! the offer's `cet_locktime` and the refund its `refund_locktime`; the chain +//! enforces those, and deciding which settlement path to take is the caller's +//! policy. +//! +//! Settling is the most expensive operation in the module: selecting a CET +//! means reconstructing the contract's adaptor information, which for a +//! large numeric contract is the same order of work as accepting it. That is +//! the cost of keeping no state. +//! +//! # 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 keys; +mod psbt; +mod settle; +mod sign; +mod splice; +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, finalize_sign_spliced}; +pub use keys::ContractKeyProvider; +pub use psbt::create_funding_psbt; +pub use settle::{sign_cet, sign_refund}; +pub use sign::{sign_accept, sign_accept_spliced}; +pub use splice::{create_dlc_splice_input, DLC_INPUT_MAX_WITNESS_LEN}; +pub use types::{ + chain_hash_from_network, funding_input, AcceptOfferParams, AcceptResult, CreateOfferParams, + DescriptorInput, DlcInputSigningKey, 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..20cf55d4 --- /dev/null +++ b/ddk/src/contract/psbt.rs @@ -0,0 +1,256 @@ +//! 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) { + // DLC (splice) inputs are 2-of-2 multisig, not wallet-signable. Leave + // them as bare PSBT inputs (no witness UTXO / sighash) so wallet signers + // cannot match them; their witness is completed by the combine step. + if input.dlc_input.is_some() { + continue; + } + 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/settle.rs b/ddk/src/contract/settle.rs new file mode 100644 index 00000000..f9b2065e --- /dev/null +++ b/ddk/src/contract/settle.rs @@ -0,0 +1,247 @@ +//! Contract settlement: turning a funded contract into a spendable transaction. +//! +//! Settlement is the mirror image of funding. Funding combines two parties' +//! wallet signatures into the transaction that *creates* the 2-of-2 output; +//! settlement combines two parties' funding-key signatures into the transaction +//! that *spends* it. Either the oracles attest and a CET is broadcast +//! ([`sign_cet`]), or nobody does and the refund transaction is broadcast after +//! its locktime ([`sign_refund`]). +//! +//! Like the rest of the module, nothing is stored: the CET set, the refund +//! transaction, and the adaptor information are all rebuilt from the offer and +//! accept messages on demand. + +use bitcoin::Transaction; +use ddk_dlc::secp256k1_zkp::{ + ecdsa::Signature, All, EcdsaAdaptorSignature, PublicKey, Secp256k1, SecretKey, +}; +use ddk_messages::oracle_msgs::OracleAttestation; +use ddk_messages::{AcceptDlc, OfferDlc, SignDlc}; + +use super::context::{context_from_messages, ensure_sign_message}; +use super::error::ContractError; +use super::types::Party; + +/// Signs the CET matching a set of oracle attestations. +/// +/// The returned transaction spends the contract's funding output and pays each +/// party the outcome's payout. Broadcast it with the chain client of your +/// choice; this function performs no network access. +/// +/// `funding_secret_key` is the settling party's DLC funding key. It is required +/// because settling means producing *this* party's half of the 2-of-2 funding +/// signature — the counterparty's half comes from decrypting its CET adaptor +/// signature with the oracle signatures. The key also identifies which side is +/// settling, so there is no party argument to get wrong: whichever of +/// `offer.funding_pubkey` and `accept.funding_pubkey` it matches determines +/// whose adaptor signatures are used. +/// +/// `attestations` pairs each attestation with the index of its oracle in the +/// announcements of the contract info it settles. For a contract with several +/// disjoint contract infos, the first one whose outcome the attestations +/// resolve is used, so it is enough to pass the attestations for one event. +/// +/// Returns [`ContractError::NoMatchingOutcome`] when no contract outcome +/// corresponds to the attested outcomes, which is also what an attestation for +/// an event this contract does not use looks like. +pub fn sign_cet( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_secret_key: &SecretKey, + attestations: &[(usize, OracleAttestation)], +) -> Result { + let secp = Secp256k1::new(); + let context = context_from_messages(offer, accept)?; + ensure_sign_message(offer, sign, &context)?; + let party = settling_party(&secp, offer, accept, funding_secret_key)?; + let (counterparty_pubkey, adaptor_signatures) = + counterparty_adaptor_signatures(offer, accept, sign, party); + + let total_collateral = offer.get_total_collateral(); + let funding_script_pubkey = &context.transactions.funding_script_pubkey; + let fund_value = context.transactions.get_fund_output().value; + let outcomes: Vec<(usize, &Vec)> = attestations + .iter() + .map(|(index, attestation)| (*index, &attestation.outcomes)) + .collect(); + + let mut signature_index = 0; + for (info, cet_range) in context.execution_infos.iter().zip(&context.cet_ranges) { + // Verifying the counterparty's adaptor signatures is also how the + // adaptor info and the next signature offset are obtained. + let (adaptor_info, next_index) = info + .verify_and_get_adaptor_info( + &secp, + total_collateral, + &counterparty_pubkey, + funding_script_pubkey, + fund_value, + &context.transactions.cets[cet_range.clone()], + &adaptor_signatures, + signature_index, + ) + .map_err(|e| { + counterparty_error(party)(format!("invalid CET adaptor signatures: {e}")) + })?; + + let Some((signature_infos, range_info)) = + info.get_range_info_for_outcome(&adaptor_info, &outcomes, signature_index) + else { + signature_index = next_index; + continue; + }; + + validate_attestations(&secp, &info.oracle_announcements, attestations)?; + + // `cet_index` is relative to the CETs of this contract info; the + // adaptor index already carries the running offset. + let mut cet = context.transactions.cets[cet_range.start + range_info.cet_index].clone(); + let oracle_signatures: Vec> = attestations + .iter() + .filter_map(|(index, attestation)| { + let signature_info = signature_infos.iter().find(|info| info.0 == *index)?; + Some( + attestation + .signatures + .iter() + .take(signature_info.1) + .cloned() + .collect(), + ) + }) + .collect(); + + ddk_dlc::sign_cet( + &secp, + &mut cet, + &adaptor_signatures[range_info.adaptor_index], + &oracle_signatures, + funding_secret_key, + &counterparty_pubkey, + funding_script_pubkey, + fund_value, + )?; + return Ok(cet); + } + + Err(ContractError::NoMatchingOutcome) +} + +/// Signs the refund transaction. +/// +/// The refund returns each party its own collateral and can only be broadcast +/// once the offer's `refund_locktime` has passed; enforcing that is the chain's +/// job, not this function's. +/// +/// Both parties signed the refund during the offer/accept exchange, so this +/// only adds `funding_secret_key`'s half. As in [`sign_cet`], the key +/// identifies the settling party. The counterparty's stored signature is +/// verified before the two are combined. +pub fn sign_refund( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + funding_secret_key: &SecretKey, +) -> Result { + let secp = Secp256k1::new(); + let context = context_from_messages(offer, accept)?; + ensure_sign_message(offer, sign, &context)?; + let party = settling_party(&secp, offer, accept, funding_secret_key)?; + let (counterparty_pubkey, counterparty_signature): (PublicKey, Signature) = match party { + Party::Offer => (accept.funding_pubkey, accept.refund_signature), + Party::Accept => (offer.funding_pubkey, sign.refund_signature), + }; + + let funding_script_pubkey = &context.transactions.funding_script_pubkey; + let fund_value = context.transactions.get_fund_output().value; + ddk_dlc::verify_tx_input_sig( + &secp, + &counterparty_signature, + &context.transactions.refund, + 0, + funding_script_pubkey, + fund_value, + &counterparty_pubkey, + ) + .map_err(|e| counterparty_error(party)(format!("invalid refund signature: {e}")))?; + + let mut refund = context.transactions.refund.clone(); + ddk_dlc::util::sign_multi_sig_input( + &secp, + &mut refund, + &counterparty_signature, + &counterparty_pubkey, + funding_secret_key, + funding_script_pubkey, + fund_value, + 0, + )?; + Ok(refund) +} + +/// Identifies which side of the contract a funding secret key settles for. +fn settling_party( + secp: &Secp256k1, + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, +) -> Result { + let public_key = PublicKey::from_secret_key(secp, funding_secret_key); + if public_key == offer.funding_pubkey { + Ok(Party::Offer) + } else if public_key == accept.funding_pubkey { + Ok(Party::Accept) + } else { + Err(ContractError::Key( + "funding secret key does not match either party's funding public key".to_string(), + )) + } +} + +/// The counterparty's funding public key and CET adaptor signatures: the accept +/// message carries the accepting party's, the sign message the offering party's. +fn counterparty_adaptor_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + sign: &SignDlc, + party: Party, +) -> (PublicKey, Vec) { + match party { + Party::Offer => ( + accept.funding_pubkey, + (&accept.cet_adaptor_signatures).into(), + ), + Party::Accept => (offer.funding_pubkey, (&sign.cet_adaptor_signatures).into()), + } +} + +/// Attributes a signature failure to the message the counterparty's signatures +/// arrived in. +fn counterparty_error(party: Party) -> fn(String) -> ContractError { + match party { + Party::Offer => ContractError::InvalidAccept, + Party::Accept => ContractError::InvalidSign, + } +} + +/// Checks each attestation against the announcement of the oracle it claims to +/// come from, so a forged or misindexed attestation cannot produce a CET. +fn validate_attestations( + secp: &Secp256k1, + announcements: &[ddk_messages::oracle_msgs::OracleAnnouncement], + attestations: &[(usize, OracleAttestation)], +) -> Result<(), ContractError> { + for (index, attestation) in attestations { + let announcement = announcements.get(*index).ok_or_else(|| { + ContractError::InvalidAttestation(format!( + "attestation refers to oracle {index} but the contract has {} oracles", + announcements.len() + )) + })?; + attestation.validate(secp, announcement).map_err(|e| { + ContractError::InvalidAttestation(format!("attestation from oracle {index}: {e}")) + })?; + } + Ok(()) +} diff --git a/ddk/src/contract/sign.rs b/ddk/src/contract/sign.rs new file mode 100644 index 00000000..3f112728 --- /dev/null +++ b/ddk/src/contract/sign.rs @@ -0,0 +1,198 @@ +//! Sign message creation by the offering party. + +use bitcoin::psbt::Psbt; +use bitcoin::{Transaction, Witness}; +use ddk_dlc::dlc_input::DlcInputInfo; +use ddk_dlc::secp256k1_zkp::{All, PublicKey, 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, funding_input_index, + verify_counterparty_signatures, ContractContext, +}; +use super::error::ContractError; +use super::psbt::{ensure_psbt_matches_funding_transaction, funding_signature_from_witness}; +use super::types::{DlcInputSigningKey, 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 { + sign_accept_spliced(offer, accept, funding_secret_key, signed_funding_psbt, &[]) +} + +/// Verifies the accept message and creates the offering party's sign message, +/// including any splice (DLC) funding inputs. +/// +/// Behaves like [`sign_accept`] for ordinary funding inputs. For each DLC +/// (splice) input in the offer, `dlc_input_keys` must supply the previous +/// contract's funding secret key (matched by serial id); this party produces +/// its half of the prior 2-of-2 signature, which the accepting party verifies +/// and completes in [`finalize_sign_spliced`](super::finalize_sign_spliced). +/// Pass an empty slice when the contract has no splice inputs. +pub fn sign_accept_spliced( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_secret_key: &SecretKey, + signed_funding_psbt: &Psbt, + dlc_input_keys: &[DlcInputSigningKey], +) -> Result { + let context = context_from_messages(offer, accept)?; + ensure_psbt_matches_funding_transaction(signed_funding_psbt, &context.transactions.fund)?; + let secp = Secp256k1::new(); + let funding_signatures = build_offer_funding_signatures( + offer, + accept, + &context.transactions.fund, + signed_funding_psbt, + dlc_input_keys, + &secp, + )?; + sign_with_context( + offer, + accept, + funding_secret_key, + funding_signatures, + context, + ) +} + +/// Assembles the offering party's funding signatures in message order. +/// +/// Ordinary inputs contribute their finalized PSBT witness; each DLC (splice) +/// input contributes a single-element witness holding this party's half of the +/// prior 2-of-2 signature, produced with the supplied prior funding secret key. +fn build_offer_funding_signatures( + offer: &OfferDlc, + accept: &AcceptDlc, + funding_transaction: &Transaction, + signed_funding_psbt: &Psbt, + dlc_input_keys: &[DlcInputSigningKey], + secp: &Secp256k1, +) -> Result { + let mut funding_signatures = Vec::with_capacity(offer.funding_inputs.len()); + for input in &offer.funding_inputs { + let input_index = funding_input_index(offer, accept, input.input_serial_id)?; + if let Some(dlc_input) = &input.dlc_input { + let signing_key = dlc_input_keys + .iter() + .find(|key| key.input_serial_id == input.input_serial_id) + .ok_or_else(|| { + ContractError::InvalidFundingInput(format!( + "missing prior funding secret key for DLC input serial id {}", + input.input_serial_id + )) + })?; + if PublicKey::from_secret_key(secp, &signing_key.prior_funding_secret_key) + != dlc_input.local_fund_pubkey + { + return Err(ContractError::InvalidFundingInput( + "prior funding secret key does not match the DLC input local funding public key" + .to_string(), + )); + } + let dlc_input_info: DlcInputInfo = input.into(); + let signature = ddk_dlc::dlc_input::create_dlc_funding_input_signature( + secp, + funding_transaction, + input_index, + &dlc_input_info, + &signing_key.prior_funding_secret_key, + )?; + funding_signatures.push(funding_signature_from_witness(Witness::from_slice(&[ + signature, + ]))); + } else { + let witness = signed_funding_psbt.inputs[input_index] + .final_script_witness + .clone() + .filter(|witness| !witness.is_empty()) + .ok_or(ContractError::MissingFinalizedInput { input_index })?; + funding_signatures.push(funding_signature_from_witness(witness)); + } + } + Ok(FundingSignatures { funding_signatures }) +} + +/// 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..b19945f7 --- /dev/null +++ b/ddk/src/contract/signing.rs @@ -0,0 +1,290 @@ +//! 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 { + // DLC (splice) inputs are 2-of-2 multisig and are signed through the + // splice path, not the wallet; skip them here. + if input.dlc_input.is_some() { + continue; + } + 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/splice.rs b/ddk/src/contract/splice.rs new file mode 100644 index 00000000..a7393af5 --- /dev/null +++ b/ddk/src/contract/splice.rs @@ -0,0 +1,75 @@ +//! Splice (DLC) funding input construction. +//! +//! A splice reuses the 2-of-2 funding output of a previous, on-chain DLC as an +//! input to a new contract's funding transaction (this is how rollovers and +//! collateral changes are expressed). [`create_dlc_splice_input`] rebuilds that +//! output from the previous contract's offer and accept messages, so callers +//! never have to supply raw transaction data. +//! +//! Only the offering party may contribute a DLC input; the accepting party's +//! funding inputs must be ordinary wallet UTXOs. + +use bitcoin::ScriptBuf; +use ddk_messages::{AcceptDlc, DlcInput, FundingInput, OfferDlc}; + +use super::accept::create_dlc_transactions; +use super::context::contract_id_from_transactions; +use super::error::ContractError; +use super::types::{random_serial_id, Party}; + +/// Maximum witness length reported for a DLC (2-of-2 P2WSH) funding input. +/// +/// Must exceed 108 so that [`ddk_dlc::create_spliced_dlc_transactions`] +/// separates DLC inputs from ordinary P2WPKH wallet inputs. The value matches +/// `ddk-manager`, so a stateful counterparty rebuilds an identical funding +/// transaction. +pub const DLC_INPUT_MAX_WITNESS_LEN: u16 = 220; + +/// Builds a splice (DLC) funding input from a previous contract's messages. +/// +/// The previous contract must be the funded DLC whose 2-of-2 funding output is +/// spent into the new contract. `local_party` identifies which side of the +/// previous contract the *new offering party* was; offer-only splicing uses +/// [`Party::Offer`]. +/// +/// The returned [`FundingInput`] carries the previous funding transaction, its +/// funding output index, and a [`DlcInput`] with both prior funding public keys +/// and the prior contract id. Place it in the offering party's funding inputs +/// when building the new offer. Signing it later additionally requires the +/// prior contract's funding secret key (see +/// [`sign_accept_spliced`](super::sign_accept_spliced) and +/// [`finalize_sign_spliced`](super::finalize_sign_spliced)). +pub fn create_dlc_splice_input( + prev_offer: &OfferDlc, + prev_accept: &AcceptDlc, + local_party: Party, + input_serial_id: Option, + max_witness_len: u16, +) -> Result { + if max_witness_len as usize <= 108 { + return Err(ContractError::InvalidFundingInput( + "DLC input max witness length must be greater than 108".to_string(), + )); + } + let transactions = create_dlc_transactions(prev_offer, prev_accept)?; + let fund_vout = transactions.get_fund_output_index() as u32; + let contract_id = + contract_id_from_transactions(&transactions, &prev_offer.temporary_contract_id); + let (local_fund_pubkey, remote_fund_pubkey) = match local_party { + Party::Offer => (prev_offer.funding_pubkey, prev_accept.funding_pubkey), + Party::Accept => (prev_accept.funding_pubkey, prev_offer.funding_pubkey), + }; + Ok(FundingInput { + input_serial_id: input_serial_id.unwrap_or_else(random_serial_id), + prev_tx: bitcoin::consensus::serialize(&transactions.fund), + prev_tx_vout: fund_vout, + sequence: u32::MAX, + max_witness_len, + redeem_script: ScriptBuf::new(), + dlc_input: Some(DlcInput { + local_fund_pubkey, + remote_fund_pubkey, + contract_id, + }), + }) +} diff --git a/ddk/src/contract/tests.rs b/ddk/src/contract/tests.rs new file mode 100644 index 00000000..eb066312 --- /dev/null +++ b/ddk/src/contract/tests.rs @@ -0,0 +1,292 @@ +//! 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, DlcInput, FundingInput, OfferDlc}; + +use super::context::{ensure_no_dlc_inputs, funding_input_index, validate_offer_funding_inputs}; +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) +} + +/// Builds a DLC (splice) funding input whose previous output is the 2-of-2 of +/// two fixed funding keys, unless `correct_script` is false. +fn dlc_funding_input( + max_witness_len: u16, + redeem_script: ScriptBuf, + correct_script: bool, +) -> FundingInput { + let secp = Secp256k1::new(); + let local_fund_pubkey = SecretKey::from_slice(&[3; 32]).unwrap().public_key(&secp); + let remote_fund_pubkey = SecretKey::from_slice(&[4; 32]).unwrap().public_key(&secp); + let script_pubkey = if correct_script { + ddk_dlc::make_funding_redeemscript(&local_fund_pubkey, &remote_fund_pubkey).to_p2wsh() + } else { + ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([7; 20])) + }; + let previous_transaction = dummy_transaction(Amount::from_sat(100_000), script_pubkey); + FundingInput { + input_serial_id: 9, + prev_tx: bitcoin::consensus::serialize(&previous_transaction), + prev_tx_vout: 0, + sequence: u32::MAX, + max_witness_len, + redeem_script, + dlc_input: Some(DlcInput { + local_fund_pubkey, + remote_fund_pubkey, + contract_id: [5; 32], + }), + } +} + +#[test] +fn validate_offer_funding_inputs_accepts_a_valid_dlc_input() { + let input = dlc_funding_input(220, ScriptBuf::new(), true); + assert!(validate_offer_funding_inputs(&[input]).is_ok()); +} + +#[test] +fn validate_offer_funding_inputs_rejects_malformed_dlc_inputs() { + // Witness length too small to be a 2-of-2. + assert!(matches!( + validate_offer_funding_inputs(&[dlc_funding_input(108, ScriptBuf::new(), true)]), + Err(ContractError::InvalidFundingInput(_)) + )); + // A DLC input must not carry a redeem script. + let redeem = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([1; 20])); + assert!(matches!( + validate_offer_funding_inputs(&[dlc_funding_input(220, redeem, true)]), + Err(ContractError::InvalidFundingInput(_)) + )); + // Previous output is not the 2-of-2 of the named funding keys. + assert!(matches!( + validate_offer_funding_inputs(&[dlc_funding_input(220, ScriptBuf::new(), false)]), + Err(ContractError::InvalidFundingInput(_)) + )); +} + +#[test] +fn ensure_no_dlc_inputs_rejects_a_dlc_input() { + assert!(matches!( + ensure_no_dlc_inputs(&[dlc_funding_input(220, ScriptBuf::new(), true)]), + Err(ContractError::InvalidFundingInput(_)) + )); + // Ordinary inputs are permitted. + assert!(ensure_no_dlc_inputs(&[dummy_funding_input(1)]).is_ok()); +} + +#[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..94187b74 --- /dev/null +++ b/ddk/src/contract/types.rs @@ -0,0 +1,218 @@ +//! 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, SecretKey}; +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, +} + +/// A previous-contract DLC funding secret key used to sign a splice input. +/// +/// Splicing spends the 2-of-2 funding output of a previous contract, which +/// requires that contract's DLC funding secret key — distinct from the new +/// contract's funding key. Each key is matched to its DLC funding input by +/// serial id. +#[derive(Clone, Debug)] +pub struct DlcInputSigningKey { + /// The serial id of the DLC (splice) funding input this key signs. + pub input_serial_id: u64, + /// The funding secret key of the previous contract whose 2-of-2 funding + /// output is being spliced into the new contract. + pub prior_funding_secret_key: SecretKey, +} + +/// 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..7e82bf5b 100644 --- a/ddk/src/lib.rs +++ b/ddk/src/lib.rs @@ -2,45 +2,75 @@ // #![doc = include_str!("../README.md")] #![allow(clippy::result_large_err)] +/// Stateless DLC contract operations. +/// +/// This is the only module available without the `manager` feature; it depends +/// on nothing heavier than `ddk-manager` and is what FFI/mobile consumers bind. +pub mod contract; + /// Build a DDK application. +#[cfg(feature = "manager")] pub mod builder; /// Working with the bitcoin chain. +#[cfg(feature = "manager")] pub mod chain; +#[cfg(feature = "manager")] mod ddk; /// DDK error types +#[cfg(feature = "manager")] pub mod error; /// JSON structs +#[cfg(feature = "manager")] pub mod json; /// Logging infrastructure +#[cfg(feature = "manager")] pub mod logger; /// Nostr related functions. #[cfg(feature = "nostr")] pub mod nostr; /// Oracle clients. +#[cfg(feature = "manager")] pub mod oracle; /// Storage implementations. +#[cfg(feature = "manager")] pub mod storage; /// Transport services. +#[cfg(feature = "manager")] pub mod transport; /// DLC utilities. +#[cfg(feature = "manager")] pub mod util; /// The internal [`bdk_wallet::PersistedWallet`]. +#[cfg(feature = "manager")] pub mod wallet; +pub use ddk_manager; + /// DDK object with all services +#[cfg(feature = "manager")] pub use ddk::DlcDevKit; +#[cfg(feature = "manager")] pub use ddk::DlcManagerMessage; -pub use ddk_manager; +#[cfg(feature = "manager")] use async_trait::async_trait; +#[cfg(feature = "manager")] use bdk_wallet::ChangeSet; +#[cfg(feature = "manager")] use bitcoin::secp256k1::{PublicKey, SecretKey}; +#[cfg(feature = "manager")] use bitcoin::Amount; +#[cfg(feature = "manager")] use ddk::DlcDevKitDlcManager; +#[cfg(feature = "manager")] use ddk_messages::Message; +#[cfg(feature = "manager")] use error::TransportError; +#[cfg(feature = "manager")] use error::WalletError; +#[cfg(feature = "manager")] use std::sync::Arc; +#[cfg(feature = "manager")] use tokio::sync::watch; /// Transport layer for DLC message communication. @@ -66,6 +96,7 @@ use tokio::sync::watch; /// 3. Receive and process incoming messages /// 4. Maintain connection state #[async_trait] +#[cfg(feature = "manager")] pub trait Transport: Send + Sync + 'static { /// Returns a unique identifier for this transport implementation. fn name(&self) -> String; @@ -122,6 +153,7 @@ pub trait Transport: Send + Sync + 'static { /// - Sled storage (persistent, embedded) /// - In-memory storage (temporary, testing) #[async_trait] +#[cfg(feature = "manager")] pub trait Storage: ddk_manager::Storage + Send + Sync + std::fmt::Debug + 'static { /// Initializes the BDK wallet storage and returns initial state. /// @@ -150,6 +182,7 @@ pub trait Storage: ddk_manager::Storage + Send + Sync + std::fmt::Debug + 'stati /// - Key derivation paths /// - Multi-signature support /// - Key rotation policies +#[cfg(feature = "manager")] pub trait KeyStorage { /// Retrieves a secret key by its identifier. fn get_secret_key(&self, key_id: [u8; 32]) -> Result; @@ -170,6 +203,7 @@ pub trait KeyStorage { /// - Nostr-based oracles /// - API-based oracles /// - Local testing oracles +#[cfg(feature = "manager")] pub trait Oracle: ddk_manager::Oracle + Send + Sync + 'static { /// Returns the name of this oracle implementation. fn name(&self) -> String; @@ -187,6 +221,7 @@ pub trait Oracle: ddk_manager::Oracle + Send + Sync + 'static { /// - Contract fund tracking /// - Performance monitoring /// - Risk assessment +#[cfg(feature = "manager")] #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct Balance { /// Total confirmed balance in the wallet diff --git a/ddk/src/wallet/mod.rs b/ddk/src/wallet/mod.rs index a80e4702..ec335ee3 100644 --- a/ddk/src/wallet/mod.rs +++ b/ddk/src/wallet/mod.rs @@ -24,6 +24,7 @@ pub mod address; mod command; +use crate::contract::ContractKeyProvider; use crate::error::{wallet_err_to_manager_err, WalletError}; use crate::logger::Logger; use crate::logger::{log_error, log_info, WriteLog}; @@ -46,9 +47,7 @@ use bdk_wallet::{ AddressInfo, KeychainKind, SignOptions, Wallet, }; use bdk_wallet::{Utxo, WeightedUtxo}; -use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; -use bitcoin::hashes::sha256; -use bitcoin::hashes::Hash; +use bitcoin::bip32::Fingerprint; use bitcoin::key::rand::thread_rng; use bitcoin::Psbt; use bitcoin::{secp256k1::SecretKey, Amount, FeeRate, ScriptBuf, Transaction}; @@ -58,7 +57,6 @@ use std::collections::HashMap; use std::fmt::Debug; use std::future::Future; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::AtomicU32; use std::sync::{atomic::Ordering, Arc}; use tokio::sync::{ @@ -69,13 +67,6 @@ use tokio::sync::{ type FutureResult<'a, T, E> = Pin> + Send + 'a>>; type Result = std::result::Result; -/// We choose this number for the range of child numbers that are used for the DLC key path. -/// This allows for 3400^3 = 39.3 billion possible paths. -/// It is large enough to avoid collisions, but small enough to be practical for a doomsday scenario. -/// -/// Recovery would be ~1 week for each contract key with the Xpriv. -const CHILD_NUMBER_RANGE: u32 = 3_400; - /// The minimum change size for the wallet to create in coin selection. const MIN_CHANGE_SIZE: u64 = 25_000; @@ -196,8 +187,8 @@ pub struct DlcDevKitWallet { secp: Secp256k1, /// Fingerprint of the wallet fingerprint: Fingerprint, - /// Derivation path for DLC keys - dlc_path: DerivationPath, + /// Deterministic derivation of contract funding keys. + contract_keys: ContractKeyProvider, /// Function to generate external addresses address_generator: Option>, /// Logger @@ -270,7 +261,7 @@ impl DlcDevKitWallet { .map_err(|e| WalletError::WalletPersistanceError(e.to_string()))?, }; - let dlc_path = DerivationPath::from_str("m/420'/0'/0'")?; + let contract_keys = ContractKeyProvider::from_xprv(xprv); let (sender, mut receiver) = channel(100); @@ -520,7 +511,7 @@ impl DlcDevKitWallet { xprv, secp, fingerprint, - dlc_path, + contract_keys, address_generator, logger, }) @@ -660,78 +651,6 @@ impl DlcDevKitWallet { *psbt = signed_psbt_received?; Ok(()) } - - /// Converts a 32-byte key ID into hierarchical indices for derivation paths. - /// - /// This function takes a 32-byte key ID and splits it into three 4-byte - /// arrays, which are then used to calculate indices for three levels of - /// derivation paths. The indices are calculated using modulo arithmetic - /// to ensure they fall within the range of 0 to 3399. - fn key_id_to_hierarchical_indices(&self, key_id: [u8; 32]) -> (u32, u32, u32) { - let level_1 = [key_id[0], key_id[1], key_id[2], key_id[3]]; - let level_2 = [key_id[4], key_id[5], key_id[6], key_id[7]]; - let level_3 = [key_id[8], key_id[9], key_id[10], key_id[11]]; - - let level_1_index = u32::from_be_bytes(level_1) % CHILD_NUMBER_RANGE; - let level_2_index = u32::from_be_bytes(level_2) % CHILD_NUMBER_RANGE; - let level_3_index = u32::from_be_bytes(level_3) % CHILD_NUMBER_RANGE; - - // Total combination space: 3400 × 3400 × 3400 = ~39.3 billion possible paths - (level_1_index, level_2_index, level_3_index) - } - - fn get_hierarchical_derivation_path(&self, key_id: [u8; 32]) -> Result { - let (level_1_index, level_2_index, level_3_index) = - self.key_id_to_hierarchical_indices(key_id); - let child_one = ChildNumber::from_normal_idx(level_1_index) - .map_err(|_| WalletError::InvalidDerivationIndex)?; - let child_two = ChildNumber::from_normal_idx(level_2_index) - .map_err(|_| WalletError::InvalidDerivationIndex)?; - let child_three = ChildNumber::from_normal_idx(level_3_index) - .map_err(|_| WalletError::InvalidDerivationIndex)?; - - let path = self.dlc_path.clone(); - let full_path = path.extend([child_one, child_two, child_three]); - - Ok(full_path) - } - - fn apply_hardening_to_base_key( - &self, - base_key: &SecretKey, - level_1: u32, - level_2: u32, - level_3: u32, - ) -> Result { - let mut hardening_input = Vec::new(); - hardening_input.extend_from_slice(self.fingerprint.as_bytes()); - hardening_input.extend_from_slice(&base_key.secret_bytes()); - hardening_input.extend_from_slice(&level_1.to_be_bytes()); - hardening_input.extend_from_slice(&level_2.to_be_bytes()); - hardening_input.extend_from_slice(&level_3.to_be_bytes()); - - let hardened_hash = sha256::Hash::hash(&hardening_input); - - SecretKey::from_slice(hardened_hash.as_ref()).map_err(|_| WalletError::InvalidSecretKey) - } - - #[tracing::instrument(skip(self, key_id))] - fn derive_secret_key_from_key_id(&self, key_id: [u8; 32]) -> Result { - let derivation_path = self.get_hierarchical_derivation_path(key_id)?; - - let base_secret_key = self.xprv.derive_priv(&self.secp, &derivation_path)?; - - let (level_1, level_2, level_3) = self.key_id_to_hierarchical_indices(key_id); - - let hardened_key = self.apply_hardening_to_base_key( - &base_secret_key.private_key, - level_1, - level_2, - level_3, - )?; - - Ok(hardened_key) - } } /// Implementation of Lightning's FeeEstimator trait for the wallet. @@ -766,37 +685,19 @@ impl ddk_manager::ContractSignerProvider for DlcDevKitWallet { /// # Returns /// A 32-byte key ID for the contract #[tracing::instrument(skip(self))] - fn derive_signer_key_id(&self, _is_offer_party: bool, temp_id: [u8; 32]) -> [u8; 32] { - let mut key_id_input = Vec::new(); - - key_id_input.extend_from_slice(self.fingerprint.as_bytes()); - key_id_input.extend_from_slice(&temp_id); - key_id_input.extend_from_slice(b"CONTRACT_SIGNER_KEY_ID_V0"); - - let key_id_hash = sha256::Hash::hash(&key_id_input); - key_id_hash.to_byte_array() + fn derive_signer_key_id(&self, is_offer_party: bool, temp_id: [u8; 32]) -> [u8; 32] { + self.contract_keys + .derive_signer_key_id(is_offer_party, temp_id) } - /// Creates a contract signer from a key ID. - /// - /// Takes the key ID generated by `derive_signer_key_id` and creates a - /// SimpleSigner that can sign transactions for the specific contract. - /// - /// # Arguments - /// * `key_id` - The key ID to derive the signer from - /// - /// # Returns - /// A SimpleSigner configured for the contract + /// Creates a contract signer from a key ID by delegating to the + /// [`ContractKeyProvider`]. #[tracing::instrument(skip(self, key_id))] fn derive_contract_signer( &self, key_id: [u8; 32], ) -> std::result::Result { - let secret_key = self - .derive_secret_key_from_key_id(key_id) - .map_err(|e| ManagerError::WalletError(Box::new(e)))?; - - Ok(SimpleSigner::new(secret_key)) + self.contract_keys.derive_contract_signer(key_id) } /// Gets a secret key for a given public key. @@ -1012,11 +913,7 @@ mod tests { use crate::logger::{LogLevel, Logger}; use crate::storage::memory::MemoryStorage; use bitcoin::{ - address::NetworkChecked, - bip32::ChildNumber, - key::rand::Fill, - secp256k1::{PublicKey, SecretKey}, - Address, AddressType, Amount, FeeRate, Network, + address::NetworkChecked, key::rand::Fill, Address, AddressType, Amount, FeeRate, Network, }; use ddk_manager::{ContractSigner, ContractSignerProvider}; @@ -1103,217 +1000,6 @@ mod tests { assert!(balance.confirmed == Amount::ZERO) } - #[tokio::test] - async fn derive_secret_key_from_key_id() { - let wallet = create_wallet().await; - let mut temp_key_id = [0u8; 32]; - temp_key_id - .try_fill(&mut bitcoin::key::rand::thread_rng()) - .unwrap(); - - let key_id = wallet.derive_signer_key_id(true, temp_key_id); - let secret_key = wallet.derive_secret_key_from_key_id(key_id); - assert!(secret_key.is_ok()); - } - - #[tokio::test] - async fn key_id_to_hierarchical_indices_deterministic() { - let wallet = create_wallet().await; - - // Test with a known key_id - let key_id = [ - 0x12, 0x34, 0x56, 0x78, // level_1: should give same result each time - 0x9A, 0xBC, 0xDE, 0xF0, // level_2 - 0x11, 0x22, 0x33, 0x44, // level_3 - 0x55, 0x66, 0x77, 0x88, // unused bytes - 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, - 0x07, 0x08, - ]; - - let (level1_1, level2_1, level3_1) = wallet.key_id_to_hierarchical_indices(key_id); - let (level1_2, level2_2, level3_2) = wallet.key_id_to_hierarchical_indices(key_id); - - // Should be deterministic - same input produces same output - assert_eq!(level1_1, level1_2); - assert_eq!(level2_1, level2_2); - assert_eq!(level3_1, level3_2); - - // Verify indices are within expected range - assert!(level1_1 < 3400); - assert!(level2_1 < 3400); - assert!(level3_1 < 3400); - - // Calculate expected values manually to verify correctness - let expected_level1 = u32::from_be_bytes([0x12, 0x34, 0x56, 0x78]) % 3400; - let expected_level2 = u32::from_be_bytes([0x9A, 0xBC, 0xDE, 0xF0]) % 3400; - let expected_level3 = u32::from_be_bytes([0x11, 0x22, 0x33, 0x44]) % 3400; - - assert_eq!(level1_1, expected_level1); - assert_eq!(level2_1, expected_level2); - assert_eq!(level3_1, expected_level3); - } - - #[tokio::test] - async fn key_id_to_hierarchical_indices_distribution() { - let wallet = create_wallet().await; - let mut level1_values = HashSet::new(); - let mut level2_values = HashSet::new(); - let mut level3_values = HashSet::new(); - - // Test with 1000 different key_ids to check distribution - for i in 0..1000u32 { - let mut key_id = [0u8; 32]; - // Create variation in the first 12 bytes - key_id[0..4].copy_from_slice(&i.to_be_bytes()); - key_id[4..8].copy_from_slice(&(i.wrapping_mul(7919)).to_be_bytes()); - key_id[8..12].copy_from_slice(&(i.wrapping_mul(104729)).to_be_bytes()); - - let (level1, level2, level3) = wallet.key_id_to_hierarchical_indices(key_id); - level1_values.insert(level1); - level2_values.insert(level2); - level3_values.insert(level3); - } - - // Should have good distribution - expect most values to be unique for small sample - assert!( - level1_values.len() > 900, - "Level 1 distribution too poor: {} unique values", - level1_values.len() - ); - assert!( - level2_values.len() > 900, - "Level 2 distribution too poor: {} unique values", - level2_values.len() - ); - assert!( - level3_values.len() > 900, - "Level 3 distribution too poor: {} unique values", - level3_values.len() - ); - } - - #[tokio::test] - async fn get_hierarchical_derivation_path() { - let wallet = create_wallet().await; - - let key_id = [1u8; 32]; // Simple test key_id - let path = wallet - .get_hierarchical_derivation_path(key_id) - .expect("Should create valid derivation path"); - - // Verify the path has the correct structure - // Should be: m/9999'/0'/0'/level1/level2/level3 (6 components total) - assert_eq!(path.len(), 6); - - // Verify base path components (hardened derivation) - assert_eq!(path[0], ChildNumber::from_hardened_idx(420).unwrap()); - assert_eq!(path[1], ChildNumber::from_hardened_idx(0).unwrap()); - assert_eq!(path[2], ChildNumber::from_hardened_idx(0).unwrap()); - - // The last three should be normal (non-hardened) derivation - assert!(!path[3].is_hardened()); - assert!(!path[4].is_hardened()); - assert!(!path[5].is_hardened()); - - // Verify indices match what we expect from key_id_to_hierarchical_indices - let (expected_level1, expected_level2, expected_level3) = - wallet.key_id_to_hierarchical_indices(key_id); - assert_eq!( - path[3], - ChildNumber::from_normal_idx(expected_level1).unwrap() - ); - assert_eq!( - path[4], - ChildNumber::from_normal_idx(expected_level2).unwrap() - ); - assert_eq!( - path[5], - ChildNumber::from_normal_idx(expected_level3).unwrap() - ); - } - - #[tokio::test] - async fn apply_hardening_to_base_key_deterministic() { - let wallet = create_wallet().await; - - // Create a test base key - let base_key = SecretKey::from_slice(&[0x42; 32]).expect("Valid secret key"); - let level1 = 123; - let level2 = 456; - let level3 = 789; - - // Apply hardening multiple times - let hardened1 = wallet - .apply_hardening_to_base_key(&base_key, level1, level2, level3) - .expect("Hardening should succeed"); - let hardened2 = wallet - .apply_hardening_to_base_key(&base_key, level1, level2, level3) - .expect("Hardening should succeed"); - - // Should be deterministic - assert_eq!(hardened1.secret_bytes(), hardened2.secret_bytes()); - - // Should be different from the base key - assert_ne!(hardened1.secret_bytes(), base_key.secret_bytes()); - } - - #[tokio::test] - async fn apply_hardening_different_inputs_produce_different_outputs() { - let wallet = create_wallet().await; - let base_key = SecretKey::from_slice(&[0x42; 32]).expect("Valid secret key"); - - // Test different level combinations produce different results - let hardened1 = wallet - .apply_hardening_to_base_key(&base_key, 100, 200, 300) - .unwrap(); - let hardened2 = wallet - .apply_hardening_to_base_key(&base_key, 100, 200, 301) - .unwrap(); // level3 different - let hardened3 = wallet - .apply_hardening_to_base_key(&base_key, 100, 201, 300) - .unwrap(); // level2 different - let hardened4 = wallet - .apply_hardening_to_base_key(&base_key, 101, 200, 300) - .unwrap(); // level1 different - - // All should be different - assert_ne!(hardened1.secret_bytes(), hardened2.secret_bytes()); - assert_ne!(hardened1.secret_bytes(), hardened3.secret_bytes()); - assert_ne!(hardened1.secret_bytes(), hardened4.secret_bytes()); - assert_ne!(hardened2.secret_bytes(), hardened3.secret_bytes()); - assert_ne!(hardened2.secret_bytes(), hardened4.secret_bytes()); - assert_ne!(hardened3.secret_bytes(), hardened4.secret_bytes()); - } - - #[tokio::test] - async fn derive_secret_key_from_key_id_complete_flow() { - let wallet = create_wallet().await; - - let key_id = [0x33; 32]; // Test key_id - let secret_key1 = wallet - .derive_secret_key_from_key_id(key_id) - .expect("Should derive secret key successfully"); - let secret_key2 = wallet - .derive_secret_key_from_key_id(key_id) - .expect("Should derive secret key successfully"); - - // Should be deterministic - assert_eq!(secret_key1.secret_bytes(), secret_key2.secret_bytes()); - - // Verify the secret key is valid for secp256k1 - let public_key = PublicKey::from_secret_key(&wallet.secp, &secret_key1); - assert!(public_key - .verify( - &wallet.secp, - &bitcoin::secp256k1::Message::from_digest([0u8; 32]), - &wallet.secp.sign_ecdsa( - &bitcoin::secp256k1::Message::from_digest([0u8; 32]), - &secret_key1 - ) - ) - .is_ok()); - } - #[tokio::test] async fn derive_signer_key_id_deterministic() { let wallet = create_wallet().await; @@ -1428,31 +1114,6 @@ mod tests { ); } - #[tokio::test] - async fn hierarchical_indices_bounds() { - let wallet = create_wallet().await; - - // Test edge cases with extreme values - let max_key_id = [0xFF; 32]; - let min_key_id = [0x00; 32]; - - let (max_l1, max_l2, max_l3) = wallet.key_id_to_hierarchical_indices(max_key_id); - let (min_l1, min_l2, min_l3) = wallet.key_id_to_hierarchical_indices(min_key_id); - - // All indices should be within bounds - assert!(max_l1 < 3400); - assert!(max_l2 < 3400); - assert!(max_l3 < 3400); - assert!(min_l1 < 3400); - assert!(min_l2 < 3400); - assert!(min_l3 < 3400); - - // Min key_id should produce all zeros - assert_eq!(min_l1, 0); - assert_eq!(min_l2, 0); - assert_eq!(min_l3, 0); - } - #[tokio::test] async fn collision_resistance_sample() { let wallet = create_wallet().await; diff --git a/ddk/tests/stateless.rs b/ddk/tests/stateless.rs new file mode 100644 index 00000000..479de668 --- /dev/null +++ b/ddk/tests/stateless.rs @@ -0,0 +1,1653 @@ +//! 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_splice_input, create_dlc_transactions, + create_funding_psbt, create_offer, finalize_sign, finalize_sign_spliced, funding_input, + sign_accept, sign_accept_spliced, sign_cet, sign_refund, signing, AcceptOfferParams, + ContractError, CreateOfferParams, DescriptorInput, DlcInputSigningKey, InputDerivation, Party, + PartyParams, DLC_INPUT_MAX_WITNESS_LEN, +}; +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, tagged_attestation_msg, DigitDecompositionEventDescriptor, + EnumEventDescriptor, EventDescriptor, OracleAnnouncement, OracleAttestation, OracleEvent, + OracleInfo, SingleOracleInfo, +}; +use ddk_messages::{AcceptDlc, FundingInput, OfferDlc, SignDlc, WitnessElement}; +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 { + fund_with_xpriv(secp, offerer, accepter, offer, accept).1 +} + +/// Like [`complete_with_xpriv`], but also returns the sign message, which the +/// settlement functions need. +fn fund_with_xpriv( + _secp: &Secp256k1, + offerer: &PartySetup, + accepter: &PartySetup, + offer: &OfferDlc, + accept: &AcceptDlc, +) -> (SignDlc, 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); + (sign_result.sign, 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() + ); +} + +/// Attests `outcomes` with the same oracle and nonce keys +/// [`oracle_announcement`] publishes, producing an attestation the contract +/// accepts as genuine. +fn oracle_attestation(outcomes: Vec) -> OracleAttestation { + let secp = Secp256k1::new(); + let oracle_key = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[88; 32]).unwrap()); + let signatures = outcomes + .iter() + .enumerate() + .map(|(index, outcome)| { + let nonce = SecretKey::from_slice(&[90 + index as u8; 32]).unwrap(); + ddk_dlc::secp_utils::schnorrsig_sign_with_nonce( + &secp, + &tagged_attestation_msg(outcome), + &oracle_key, + &nonce.secret_bytes(), + ) + }) + .collect(); + OracleAttestation { + event_id: "stateless-test".to_string(), + oracle_public_key: XOnlyPublicKey::from_keypair(&oracle_key).0, + signatures, + outcomes, + } +} + +/// Decomposes `value` into the fixed-width binary digit strings a digit +/// decomposition oracle attests. +fn digit_outcomes(value: u64, nb_digits: usize) -> Vec { + (0..nb_digits) + .rev() + .map(|position| ((value >> position) & 1).to_string()) + .collect() +} + +/// Checks that a settlement transaction spends the funding output with a +/// complete 2-of-2 witness. +fn assert_spends_funding_output( + settlement: &Transaction, + offer: &OfferDlc, + accept: &AcceptDlc, + funding_transaction: &Transaction, +) { + let transactions = create_dlc_transactions(offer, accept).unwrap(); + assert_eq!(settlement.input.len(), 1); + assert_eq!( + settlement.input[0].previous_output, + OutPoint { + txid: funding_transaction.compute_txid(), + vout: transactions.get_fund_output_index() as u32, + } + ); + let witness = &settlement.input[0].witness; + assert_eq!(witness.len(), 4, "expected a 2-of-2 witness"); + assert!(witness[0].is_empty(), "multisig witness must start empty"); + assert_eq!( + witness[3], + transactions.funding_script_pubkey.to_bytes(), + "witness script is not the funding script" + ); +} + +#[test] +fn either_party_can_settle_with_a_cet() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, funding_transaction) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + let attestations = vec![(0, oracle_attestation(vec!["up".to_string()]))]; + + // "up" pays the whole contract to the offering party. + let cet = sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &attestations, + ) + .unwrap(); + assert_spends_funding_output(&cet, &offer, &accept, &funding_transaction); + assert_eq!(cet.output.len(), 1); + assert_eq!(cet.output[0].script_pubkey, offer.payout_spk); + + // The accepting party settles the same outcome independently, and lands on + // the same transaction: the witness differs only by which half each party + // produced, and the txid does not commit to it. + let counterpart = sign_cet( + &offer, + &accept, + &sign, + &accepter.funding_secret_key, + &attestations, + ) + .unwrap(); + assert_spends_funding_output(&counterpart, &offer, &accept, &funding_transaction); + assert_eq!(cet.compute_txid(), counterpart.compute_txid()); +} + +#[test] +fn the_attested_outcome_selects_the_cet() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + // "down" pays the whole contract to the accepting party instead. + let cet = sign_cet( + &offer, + &accept, + &sign, + &accepter.funding_secret_key, + &[(0, oracle_attestation(vec!["down".to_string()]))], + ) + .unwrap(); + assert_eq!(cet.output.len(), 1); + assert_eq!(cet.output[0].script_pubkey, accept.payout_spk); +} + +#[test] +fn numerical_contracts_settle_with_a_cet() { + let secp = Secp256k1::new(); + let offerer = PartySetup::new(&secp, 61, NETWORK, Amount::from_sat(150_000), 1); + let accepter = PartySetup::new(&secp, 62, 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 = 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() + .accept; + let (sign, funding_transaction) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + // A digit decomposition attestation may be consumed as a prefix, so the + // number of oracle signatures used is decided by the CET that matched. + let cet = sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &[(0, oracle_attestation(digit_outcomes(500, 10)))], + ) + .unwrap(); + assert_spends_funding_output(&cet, &offer, &accept, &funding_transaction); +} + +#[test] +fn either_party_can_settle_with_the_refund() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, funding_transaction) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + let transactions = create_dlc_transactions(&offer, &accept).unwrap(); + + for funding_secret_key in [offerer.funding_secret_key, accepter.funding_secret_key] { + let refund = sign_refund(&offer, &accept, &sign, &funding_secret_key).unwrap(); + assert_spends_funding_output(&refund, &offer, &accept, &funding_transaction); + assert_eq!( + refund.compute_txid(), + transactions.refund.compute_txid(), + "the refund must be the one rebuilt from the messages" + ); + // Each party gets its own collateral back. + assert_eq!(refund.output.len(), 2); + assert!(refund + .output + .iter() + .any(|output| output.script_pubkey == offer.payout_spk)); + assert!(refund + .output + .iter() + .any(|output| output.script_pubkey == accept.payout_spk)); + } +} + +#[test] +fn settling_with_a_foreign_key_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + let stranger = SecretKey::from_slice(&[77; 32]).unwrap(); + + assert!(matches!( + sign_cet( + &offer, + &accept, + &sign, + &stranger, + &[(0, oracle_attestation(vec!["up".to_string()]))], + ), + Err(ContractError::Key(_)) + )); + assert!(matches!( + sign_refund(&offer, &accept, &sign, &stranger), + Err(ContractError::Key(_)) + )); +} + +#[test] +fn an_unknown_outcome_has_no_cet() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + assert!(matches!( + sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &[(0, oracle_attestation(vec!["sideways".to_string()]))], + ), + Err(ContractError::NoMatchingOutcome) + )); +} + +#[test] +fn a_forged_attestation_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + // A well-formed attestation for a real outcome, signed by an oracle the + // contract does not use. + let impostor = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[13; 32]).unwrap()); + let nonce = SecretKey::from_slice(&[14; 32]).unwrap(); + let forged = OracleAttestation { + event_id: "stateless-test".to_string(), + oracle_public_key: XOnlyPublicKey::from_keypair(&impostor).0, + signatures: vec![ddk_dlc::secp_utils::schnorrsig_sign_with_nonce( + &secp, + &tagged_attestation_msg("up"), + &impostor, + &nonce.secret_bytes(), + )], + outcomes: vec!["up".to_string()], + }; + + assert!(matches!( + sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &[(0, forged)], + ), + Err(ContractError::InvalidAttestation(_)) + )); +} + +#[test] +fn an_out_of_range_oracle_index_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + + // The contract uses a single oracle, so index 4 does not exist. The enum + // outcome lookup does not range check the index it is handed, so this is + // caught when the attestation is checked against the announcement it claims + // to come from — without which the CET would be signed with the wrong + // adaptor signature. + let error = sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &[(4, oracle_attestation(vec!["up".to_string()]))], + ) + .unwrap_err(); + assert!( + matches!(error, ContractError::InvalidAttestation(_)), + "unexpected error: {error}" + ); +} + +#[test] +fn settling_with_a_mismatched_sign_message_is_rejected() { + let secp = Secp256k1::new(); + let (offerer, accepter, offer, accept) = enum_contract(&secp, NETWORK); + let (mut sign, _) = fund_with_xpriv(&secp, &offerer, &accepter, &offer, &accept); + sign.contract_id[0] ^= 0xff; + + assert!(matches!( + sign_cet( + &offer, + &accept, + &sign, + &offerer.funding_secret_key, + &[(0, oracle_attestation(vec!["up".to_string()]))], + ), + Err(ContractError::InvalidSign(_)) + )); + assert!(matches!( + sign_refund(&offer, &accept, &sign, &offerer.funding_secret_key), + Err(ContractError::InvalidSign(_)) + )); +} + +/// 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(()) + } +} + +/// The offer-side signing state for a splice, produced by [`prepare_splice`] +/// and finalized either by [`complete_splice`] or directly in a negative test. +struct PreparedSplice { + offer_b: OfferDlc, + accept_b: AcceptDlc, + sign: SignDlc, + accept_psbt: Psbt, + unsigned_fund_b: Transaction, + splice_serial: u64, + splice_input: FundingInput, + prior_accept_key: SecretKey, + fund_outpoint_a: OutPoint, + fund_value_a: Amount, +} + +/// Builds and fully signs contract A, then builds a single-funded contract B +/// whose offer spends A's funding output as a splice input, and produces the +/// offering party's sign message (with its half of the splice signature). +fn prepare_splice(splice_in: bool) -> PreparedSplice { + let secp = Secp256k1::new(); + + // Contract A: an ordinary dual-funded enum contract, fully signed. + let (offerer_a, accepter_a, offer_a, accept_a) = enum_contract(&secp, NETWORK); + let funding_tx_a = complete_with_xpriv(&secp, &offerer_a, &accepter_a, &offer_a, &accept_a); + let transactions_a = create_dlc_transactions(&offer_a, &accept_a).unwrap(); + let fund_value_a = transactions_a.get_fund_output().value; + let fund_outpoint_a = OutPoint { + txid: funding_tx_a.compute_txid(), + vout: transactions_a.get_fund_output_index() as u32, + }; + + // The splice input spends A's 2-of-2 funding output. + let splice_serial = 900; + let splice_input = create_dlc_splice_input( + &offer_a, + &accept_a, + Party::Offer, + Some(splice_serial), + DLC_INPUT_MAX_WITNESS_LEN, + ) + .unwrap(); + + // Contract B is single-funded by the offering party with fresh funding keys. + let offerer_b = PartySetup::new(&secp, 5, NETWORK, Amount::from_sat(200_000), 10); + let accepter_b = PartySetup::new(&secp, 6, NETWORK, Amount::from_sat(200_000), 11); + let splice_amount = Amount::from_sat(40_000); + let (offer_collateral_b, offer_funding_inputs) = if splice_in { + ( + fund_value_a + splice_amount, + vec![splice_input.clone(), offerer_b.funding_input.clone()], + ) + } else { + (fund_value_a - splice_amount, vec![splice_input.clone()]) + }; + + let offer_b = create_offer(offer_params( + &secp, + &offerer_b, + enum_contract_info(offer_collateral_b), + offer_collateral_b, + NETWORK, + offer_funding_inputs, + )) + .unwrap(); + let accept_b = accept_offer( + &offer_b, + AcceptOfferParams { + party: accepter_b.party_params(&secp, vec![]), + min_timeout_interval: MIN_TIMEOUT, + max_timeout_interval: MAX_TIMEOUT, + }, + &accepter_b.funding_secret_key, + ) + .unwrap() + .accept; + + // The offering party signs its wallet input (splice-in only) and its half of + // the prior 2-of-2 with the previous contract's funding key. + let mut offer_psbt = create_funding_psbt(&offer_b, &accept_b).unwrap(); + if splice_in { + signing::sign_funding_psbt_with_xpriv( + &offer_b, + &accept_b, + &mut offer_psbt, + &offerer_b.xpriv, + &offerer_b.derivations(), + ) + .unwrap(); + } + let offer_splice_key = DlcInputSigningKey { + input_serial_id: splice_serial, + prior_funding_secret_key: offerer_a.funding_secret_key, + }; + let sign = sign_accept_spliced( + &offer_b, + &accept_b, + &offerer_b.funding_secret_key, + &offer_psbt, + std::slice::from_ref(&offer_splice_key), + ) + .unwrap() + .sign; + + let accept_psbt = create_funding_psbt(&offer_b, &accept_b).unwrap(); + let unsigned_fund_b = create_dlc_transactions(&offer_b, &accept_b).unwrap().fund; + + PreparedSplice { + offer_b, + accept_b, + sign, + accept_psbt, + unsigned_fund_b, + splice_serial, + splice_input, + prior_accept_key: accepter_a.funding_secret_key, + fund_outpoint_a, + fund_value_a, + } +} + +/// Runs [`prepare_splice`] and completes the funding transaction on the +/// accepting side, contributing its half of the splice signature. +fn complete_splice(splice_in: bool) -> (Transaction, PreparedSplice) { + let prepared = prepare_splice(splice_in); + let accept_splice_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: prepared.prior_accept_key, + }; + let funding_tx_b = finalize_sign_spliced( + &prepared.offer_b, + &prepared.accept_b, + &prepared.sign, + &prepared.accept_psbt, + std::slice::from_ref(&accept_splice_key), + ) + .unwrap(); + (funding_tx_b, prepared) +} + +/// Asserts the completed funding transaction spends contract A's funding output +/// with a valid, combined 2-of-2 witness. +fn assert_splice_input_signed(funding_tx_b: &Transaction, prepared: &PreparedSplice) { + let input_index = funding_tx_b + .input + .iter() + .position(|tx_in| tx_in.previous_output == prepared.fund_outpoint_a) + .expect("splice funding transaction must spend contract A's funding output"); + assert_eq!( + funding_tx_b.compute_txid(), + prepared.unsigned_fund_b.compute_txid() + ); + + let witness: Vec> = funding_tx_b.input[input_index] + .witness + .iter() + .map(|element| element.to_vec()) + .collect(); + assert_eq!(witness.len(), 4, "expected a 2-of-2 witness"); + assert!(witness[0].is_empty(), "multisig witness must start empty"); + + let dlc_input_info: ddk_dlc::dlc_input::DlcInputInfo = (&prepared.splice_input).into(); + let expected_script = ddk_dlc::make_funding_redeemscript( + &dlc_input_info.local_fund_pubkey, + &dlc_input_info.remote_fund_pubkey, + ); + assert_eq!(witness[3], expected_script.to_bytes()); + + // Each half signature must verify against one of the prior 2-of-2 keys. + let secp = Secp256k1::new(); + for pubkey in [ + dlc_input_info.local_fund_pubkey, + dlc_input_info.remote_fund_pubkey, + ] { + assert!( + [&witness[1], &witness[2]].into_iter().any(|signature| { + ddk_dlc::dlc_input::verify_dlc_funding_input_signature( + &secp, + &prepared.unsigned_fund_b, + input_index, + &dlc_input_info, + signature.clone(), + &pubkey, + ) + .is_ok() + }), + "no signature verifies against a prior funding key" + ); + } +} + +#[test] +fn splice_in_completes_the_lifecycle() { + let (funding_tx_b, prepared) = complete_splice(true); + assert_splice_input_signed(&funding_tx_b, &prepared); + let fund_value_b = create_dlc_transactions(&prepared.offer_b, &prepared.accept_b) + .unwrap() + .get_fund_output() + .value; + assert!( + fund_value_b > prepared.fund_value_a, + "splice-in must increase the funded amount" + ); +} + +#[test] +fn splice_out_completes_the_lifecycle() { + let (funding_tx_b, prepared) = complete_splice(false); + assert_splice_input_signed(&funding_tx_b, &prepared); + let fund_value_b = create_dlc_transactions(&prepared.offer_b, &prepared.accept_b) + .unwrap() + .get_fund_output() + .value; + assert!( + fund_value_b < prepared.fund_value_a, + "splice-out must decrease the funded amount" + ); +} + +#[test] +fn finalize_sign_spliced_rejects_a_wrong_prior_key() { + let prepared = prepare_splice(true); + // A key that does not control the prior 2-of-2 output. + let wrong_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: SecretKey::from_slice(&[9; 32]).unwrap(), + }; + assert!(matches!( + finalize_sign_spliced( + &prepared.offer_b, + &prepared.accept_b, + &prepared.sign, + &prepared.accept_psbt, + std::slice::from_ref(&wrong_key), + ), + Err(ContractError::InvalidFundingInput(_)) + )); +} + +#[test] +fn finalize_sign_spliced_rejects_a_tampered_offer_half() { + let prepared = prepare_splice(true); + let secp = Secp256k1::new(); + let dlc_input_info: ddk_dlc::dlc_input::DlcInputInfo = (&prepared.splice_input).into(); + let input_index = prepared + .unsigned_fund_b + .input + .iter() + .position(|tx_in| tx_in.previous_output == prepared.fund_outpoint_a) + .unwrap(); + // A signature by the remote (accept) key verifies against remote_fund_pubkey, + // not local_fund_pubkey, so the offer-half verification must reject it. + let wrong_half = ddk_dlc::dlc_input::create_dlc_funding_input_signature( + &secp, + &prepared.unsigned_fund_b, + input_index, + &dlc_input_info, + &prepared.prior_accept_key, + ) + .unwrap(); + let mut tampered = prepared.sign.clone(); + let position = prepared + .offer_b + .funding_inputs + .iter() + .position(|input| input.dlc_input.is_some()) + .unwrap(); + tampered.funding_signatures.funding_signatures[position].witness_elements = + vec![WitnessElement { + witness: wrong_half, + }]; + let accept_splice_key = DlcInputSigningKey { + input_serial_id: prepared.splice_serial, + prior_funding_secret_key: prepared.prior_accept_key, + }; + assert!(matches!( + finalize_sign_spliced( + &prepared.offer_b, + &prepared.accept_b, + &tampered, + &prepared.accept_psbt, + std::slice::from_ref(&accept_splice_key), + ), + Err(ContractError::InvalidSign(_)) + )); +} diff --git a/ddk/tests/stateless_execution.rs b/ddk/tests/stateless_execution.rs new file mode 100644 index 00000000..e0f1f06f --- /dev/null +++ b/ddk/tests/stateless_execution.rs @@ -0,0 +1,795 @@ +//! End-to-end execution tests for the stateless contract API. +//! +//! These are the stateless counterpart to +//! `ddk-manager/tests/manager_execution_tests.rs`. Each test funds real UTXOs +//! on regtest, drives a contract through `create_offer` → `accept_offer` → +//! `sign_accept` → `finalize_sign` using only wire messages, broadcasts the +//! funding transaction to a real node, and then settles the contract with a CET +//! built from real oracle attestations or with the refund transaction. No +//! contract manager, storage backend, or persisted contract state is involved. +//! +//! The matrix covers contract shapes (enum, numeric, numeric with oracle +//! difference tolerance, disjoint, single-funded, spliced), oracle counts and +//! thresholds, both settling parties, and every funding-input signer and DLC +//! funding-key source the API supports. +//! +//! They are `#[ignore]`d because they are slow, not because they need setup: +//! each one boots its own bitcoind and electrs through [`ddk_testenv`] and +//! mines real blocks, so nothing has to be running beforehand. +//! +//! ```sh +//! cargo test --test stateless_execution -- --ignored +//! ``` + +mod stateless_utils; + +use bitcoin::Amount; +use ddk::contract::Party; +use stateless_utils::*; + +/// Runs an enum contract to a confirmed funding transaction and settles it with +/// a CET, using whichever oracle set, signers, and key sources are given. +async fn enum_close( + label: &str, + nb_oracles: usize, + threshold: u16, + closer: Party, + offer_spec: PartySpec, + accept_spec: PartySpec, +) { + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::enums(nb_oracles, threshold, label).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new(&ctx, offer_spec, temporary_contract_id).await, + TestParty::new(&ctx, accept_spec, temporary_contract_id).await, + ), + ) + .await; + + let attestations = oracles.attest_enum("a").await; + close_with_cet(&ctx, &contract, closer, &attestations).await; +} + +/// Runs a numeric contract and settles it with a CET. +async fn numeric_close( + label: &str, + nb_oracles: usize, + threshold: u16, + with_difference: bool, + closer: Party, +) { + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::numerics(nb_oracles, threshold, label).await; + let difference = with_difference.then(difference_params); + + let contract = fund_contract( + &ctx, + ContractSetup::new( + numeric_contract_info(&oracles, OFFER_COLLATERAL, ACCEPT_COLLATERAL, difference), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 11, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 12, 2), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + // Well inside the payout curve so the tolerated oracle spread stays in range. + let attestations = oracles.attest_numeric(500, with_difference).await; + close_with_cet(&ctx, &contract, closer, &attestations).await; +} + +#[tokio::test] +#[ignore] +async fn enum_single_oracle_close() { + enum_close( + "enum_single_oracle_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 1, 1), + PartySpec::new(Party::Accept, 2, 2), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn enum_single_oracle_close_by_accept_party() { + enum_close( + "enum_single_oracle_close_by_accept_party", + 1, + 1, + Party::Accept, + PartySpec::new(Party::Offer, 3, 1), + PartySpec::new(Party::Accept, 4, 2), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn enum_three_of_three_oracles_close() { + enum_close( + "enum_three_of_three_oracles_close", + 3, + 3, + Party::Offer, + PartySpec::new(Party::Offer, 5, 1), + PartySpec::new(Party::Accept, 6, 2), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn enum_three_of_five_oracles_close() { + enum_close( + "enum_three_of_five_oracles_close", + 5, + 3, + Party::Accept, + PartySpec::new(Party::Offer, 7, 1), + PartySpec::new(Party::Accept, 8, 2), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn numeric_single_oracle_close() { + numeric_close("numeric_single_oracle_close", 1, 1, false, Party::Offer).await; +} + +#[tokio::test] +#[ignore] +async fn numeric_three_of_three_oracles_close() { + numeric_close( + "numeric_three_of_three_oracles_close", + 3, + 3, + false, + Party::Accept, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn numeric_two_of_five_oracles_close() { + numeric_close( + "numeric_two_of_five_oracles_close", + 5, + 2, + false, + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn numeric_with_difference_three_of_three_oracles_close() { + numeric_close( + "numeric_with_difference_three_of_three_oracles_close", + 3, + 3, + true, + Party::Offer, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn numeric_with_difference_three_of_five_oracles_close() { + numeric_close( + "numeric_with_difference_three_of_five_oracles_close", + 5, + 3, + true, + Party::Accept, + ) + .await; +} + +/// A disjoint contract settles from whichever of its two events attests first. +#[tokio::test] +#[ignore] +async fn disjoint_contract_closes_on_the_enum_event() { + let label = "disjoint_contract_closes_on_the_enum_event"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let enum_oracles = TestOracles::enums(1, 1, &format!("{label}-enum")).await; + let numeric_oracles = TestOracles::numerics(1, 1, &format!("{label}-numeric")).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + disjoint_contract_info( + &enum_oracles, + &numeric_oracles, + OFFER_COLLATERAL, + ACCEPT_COLLATERAL, + ), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 21, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 22, 2), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + let attestations = enum_oracles.attest_enum("b").await; + close_with_cet(&ctx, &contract, Party::Offer, &attestations).await; +} + +#[tokio::test] +#[ignore] +async fn disjoint_contract_closes_on_the_numeric_event() { + let label = "disjoint_contract_closes_on_the_numeric_event"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let enum_oracles = TestOracles::enums(1, 1, &format!("{label}-enum")).await; + let numeric_oracles = TestOracles::numerics(1, 1, &format!("{label}-numeric")).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + disjoint_contract_info( + &enum_oracles, + &numeric_oracles, + OFFER_COLLATERAL, + ACCEPT_COLLATERAL, + ), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 23, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 24, 2), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + let attestations = numeric_oracles.attest_numeric(700, false).await; + close_with_cet(&ctx, &contract, Party::Accept, &attestations).await; +} + +/// The offering party funds the whole contract; the accepting party contributes +/// no inputs and no collateral. +#[tokio::test] +#[ignore] +async fn single_funded_contract_closes() { + let label = "single_funded_contract_closes"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::enums(1, 1, label).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&oracles, TOTAL_COLLATERAL), + TOTAL_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 31, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::unfunded(Party::Accept, 32), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + assert_eq!(contract.accept.accept_collateral, Amount::ZERO); + assert!(contract.accept.funding_inputs.is_empty()); + assert_eq!(contract.funding_transaction.input.len(), 1); + + let attestations = oracles.attest_enum("c").await; + close_with_cet(&ctx, &contract, Party::Offer, &attestations).await; +} + +/// Both parties agree to walk away: neither event is attested and the refund +/// transaction, signed during the offer/accept exchange, is broadcast. +#[tokio::test] +#[ignore] +async fn refund_closes_the_contract() { + let label = "refund_closes_the_contract"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::enums(1, 1, label).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 33, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 34, 2), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + let refund = close_with_refund(&ctx, &contract, Party::Offer).await; + assert_eq!(refund.output.len(), 2); +} + +#[tokio::test] +#[ignore] +async fn refund_closes_the_contract_from_the_accept_party() { + let label = "refund_closes_the_contract_from_the_accept_party"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::enums(1, 1, label).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 35, 1), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 36, 2), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + close_with_refund(&ctx, &contract, Party::Accept).await; +} + +/// Several inputs per party, with serial ids that interleave across parties so +/// the witness-to-input mapping cannot rely on ordering. +#[tokio::test] +#[ignore] +async fn multiple_inputs_with_interleaved_serial_ids_close() { + let label = "multiple_inputs_with_interleaved_serial_ids_close"; + let ctx = ChainContext::new(label).await; + let temporary_contract_id = temporary_contract_id(label); + let oracles = TestOracles::enums(1, 1, label).await; + + let contract = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + temporary_contract_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 37, 0) + .with_utxos(vec![(UTXO_VALUE, 900), (UTXO_VALUE, 5)]), + temporary_contract_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 38, 0) + .with_utxos(vec![(UTXO_VALUE, 37), (UTXO_VALUE, 1_200)]), + temporary_contract_id, + ) + .await, + ), + ) + .await; + + assert_eq!(contract.funding_transaction.input.len(), 4); + let attestations = oracles.attest_enum("d").await; + close_with_cet(&ctx, &contract, Party::Offer, &attestations).await; +} + +// --- Splicing ------------------------------------------------------------- +// +// A splice spends the previous contract's 2-of-2 funding output as an input to +// the new contract. Both parties' previous funding keys are recomputed from the +// previous contract's temporary id, never stored. + +/// Funds a contract, splices its funding output into a second single-funded +/// contract with `collateral_delta` added to (or removed from) the funded +/// amount, and settles the second contract. +async fn splice_and_close(label: &str, splice_in: bool) { + let ctx = ChainContext::new(label).await; + + // The contract being spliced. + let previous_id = temporary_contract_id(&format!("{label}-previous")); + let previous_oracles = TestOracles::enums(1, 1, &format!("{label}-previous")).await; + let previous = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&previous_oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + previous_id, + TestParty::new(&ctx, PartySpec::new(Party::Offer, 41, 1), previous_id).await, + TestParty::new(&ctx, PartySpec::new(Party::Accept, 42, 2), previous_id).await, + ), + ) + .await; + + let splice_serial_id = 900; + let splice = previous.splice_setup(splice_serial_id); + let delta = Amount::from_sat(100_000); + let collateral = if splice_in { + previous.fund_value() + delta + } else { + previous.fund_value() - delta + }; + + // The spliced contract is single-funded by the offering party: the splice + // input carries the previous contract's whole funded amount. + let spliced_id = temporary_contract_id(&format!("{label}-spliced")); + let spliced_oracles = TestOracles::enums(1, 1, &format!("{label}-spliced")).await; + let offer_spec = if splice_in { + PartySpec::new(Party::Offer, 43, 10) + } else { + PartySpec::unfunded(Party::Offer, 43) + }; + let spliced = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&spliced_oracles, collateral), + collateral, + spliced_id, + TestParty::new(&ctx, offer_spec, spliced_id).await, + TestParty::new(&ctx, PartySpec::unfunded(Party::Accept, 44), spliced_id).await, + ) + .with_splice(splice), + ) + .await; + + // The spliced funding transaction must spend the previous funding output + // with a complete 2-of-2 witness, which the node has now validated. + ctx.assert_spent_by(previous.fund_outpoint(), &spliced.funding_transaction) + .await; + let splice_input = spliced + .funding_transaction + .input + .iter() + .find(|input| input.previous_output == previous.fund_outpoint()) + .expect("the spliced funding transaction must spend the previous funding output"); + assert_eq!(splice_input.witness.len(), 4, "expected a 2-of-2 witness"); + assert!(splice_input.witness[0].is_empty()); + + if splice_in { + assert!( + spliced.fund_value() > previous.fund_value(), + "a splice-in must increase the funded amount" + ); + } else { + assert!( + spliced.fund_value() < previous.fund_value(), + "a splice-out must decrease the funded amount" + ); + } + + let attestations = spliced_oracles.attest_enum("a").await; + close_with_cet(&ctx, &spliced, Party::Offer, &attestations).await; +} + +#[tokio::test] +#[ignore] +async fn splice_in_funds_and_closes() { + splice_and_close("splice_in_funds_and_closes", true).await; +} + +#[tokio::test] +#[ignore] +async fn splice_out_funds_and_closes() { + splice_and_close("splice_out_funds_and_closes", false).await; +} + +// --- Funding input signers ------------------------------------------------ +// +// The same lifecycle, with each party's wallet UTXOs signed by a different +// source. The contract logic is identical in every case; only who produces the +// funding witnesses changes. + +#[tokio::test] +#[ignore] +async fn xpriv_signer_funds_and_closes() { + enum_close( + "xpriv_signer_funds_and_closes", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 51, 1).with_input_source(InputSource::Xpriv), + PartySpec::new(Party::Accept, 52, 2).with_input_source(InputSource::Xpriv), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn descriptor_signer_funds_and_closes() { + enum_close( + "descriptor_signer_funds_and_closes", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 53, 1).with_input_source(InputSource::Descriptor), + PartySpec::new(Party::Accept, 54, 2).with_input_source(InputSource::Descriptor), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn ddk_wallet_signer_funds_and_closes() { + enum_close( + "ddk_wallet_signer_funds_and_closes", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 55, 1).with_input_source(InputSource::DdkWallet), + PartySpec::new(Party::Accept, 56, 2).with_input_source(InputSource::DdkWallet), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn external_signer_funds_and_closes() { + enum_close( + "external_signer_funds_and_closes", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 57, 1).with_input_source(InputSource::ExternalSigner), + PartySpec::new(Party::Accept, 58, 2).with_input_source(InputSource::ExternalSigner), + ) + .await; +} + +/// The two parties do not have to use the same signer, and a hardware-style +/// external signer interoperates with a DDK wallet. +#[tokio::test] +#[ignore] +async fn mixed_signers_fund_and_close() { + enum_close( + "mixed_signers_fund_and_close", + 1, + 1, + Party::Accept, + PartySpec::new(Party::Offer, 59, 1).with_input_source(InputSource::ExternalSigner), + PartySpec::new(Party::Accept, 60, 2).with_input_source(InputSource::DdkWallet), + ) + .await; +} + +// --- DLC funding key sources ---------------------------------------------- +// +// The key controlling the 2-of-2 output, the CET adaptor signatures, and the +// refund signature. Every provider variant derives it from the contract's +// temporary id, so it is recomputable rather than stored. + +#[tokio::test] +#[ignore] +async fn raw_funding_keys_fund_and_close() { + enum_close( + "raw_funding_keys_fund_and_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 61, 1).with_funding_key_source(FundingKeySource::RawSecretKey), + PartySpec::new(Party::Accept, 62, 2) + .with_funding_key_source(FundingKeySource::RawSecretKey), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn xprv_derived_funding_keys_fund_and_close() { + enum_close( + "xprv_derived_funding_keys_fund_and_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 63, 1).with_funding_key_source(FundingKeySource::Xprv), + PartySpec::new(Party::Accept, 64, 2).with_funding_key_source(FundingKeySource::Xprv), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn seed_derived_funding_keys_fund_and_close() { + enum_close( + "seed_derived_funding_keys_fund_and_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 65, 1).with_funding_key_source(FundingKeySource::Seed), + PartySpec::new(Party::Accept, 66, 2).with_funding_key_source(FundingKeySource::Seed), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn mnemonic_derived_funding_keys_fund_and_close() { + enum_close( + "mnemonic_derived_funding_keys_fund_and_close", + 1, + 1, + Party::Accept, + PartySpec::new(Party::Offer, 67, 1).with_funding_key_source(FundingKeySource::Mnemonic), + PartySpec::new(Party::Accept, 68, 2).with_funding_key_source(FundingKeySource::Mnemonic), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn descriptor_derived_funding_keys_fund_and_close() { + enum_close( + "descriptor_derived_funding_keys_fund_and_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 69, 1).with_funding_key_source(FundingKeySource::Descriptor), + PartySpec::new(Party::Accept, 70, 2).with_funding_key_source(FundingKeySource::Descriptor), + ) + .await; +} + +/// Each party can pick its own key source independently. +#[tokio::test] +#[ignore] +async fn mixed_funding_key_sources_fund_and_close() { + enum_close( + "mixed_funding_key_sources_fund_and_close", + 1, + 1, + Party::Offer, + PartySpec::new(Party::Offer, 71, 1) + .with_funding_key_source(FundingKeySource::Mnemonic) + .with_input_source(InputSource::Descriptor), + PartySpec::new(Party::Accept, 72, 2) + .with_funding_key_source(FundingKeySource::RawSecretKey) + .with_input_source(InputSource::DdkWallet), + ) + .await; +} + +/// Splicing needs the *previous* contract's funding key, which a +/// [`ddk::contract::ContractKeyProvider`] recomputes from the previous +/// temporary contract id. This runs the splice with mnemonic-backed providers +/// on both sides to exercise that recovery path. +#[tokio::test] +#[ignore] +async fn splice_recovers_prior_keys_from_a_mnemonic() { + let label = "splice_recovers_prior_keys_from_a_mnemonic"; + let ctx = ChainContext::new(label).await; + + let previous_id = temporary_contract_id(&format!("{label}-previous")); + let previous_oracles = TestOracles::enums(1, 1, &format!("{label}-previous")).await; + let previous = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&previous_oracles, TOTAL_COLLATERAL), + OFFER_COLLATERAL, + previous_id, + TestParty::new( + &ctx, + PartySpec::new(Party::Offer, 81, 1) + .with_funding_key_source(FundingKeySource::Mnemonic), + previous_id, + ) + .await, + TestParty::new( + &ctx, + PartySpec::new(Party::Accept, 82, 2) + .with_funding_key_source(FundingKeySource::Mnemonic), + previous_id, + ) + .await, + ), + ) + .await; + + // Both parties are rebuilt from scratch for the new contract: their new + // funding keys come from the new temporary id, and the keys for the + // previous 2-of-2 are recomputed from the previous one. + let collateral = previous.fund_value() - Amount::from_sat(100_000); + let spliced_id = temporary_contract_id(&format!("{label}-spliced")); + let spliced_oracles = TestOracles::enums(1, 1, &format!("{label}-spliced")).await; + let offerer = TestParty::new( + &ctx, + PartySpec::unfunded(Party::Offer, 81).with_funding_key_source(FundingKeySource::Mnemonic), + spliced_id, + ) + .await; + let accepter = TestParty::new( + &ctx, + PartySpec::unfunded(Party::Accept, 82).with_funding_key_source(FundingKeySource::Mnemonic), + spliced_id, + ) + .await; + assert_ne!( + offerer.funding_pubkey(), + previous.offerer.funding_pubkey(), + "the spliced contract must use a fresh funding key" + ); + let splice = splice_from(&previous, &offerer, &accepter, 900); + + let spliced = fund_contract( + &ctx, + ContractSetup::new( + enum_contract_info(&spliced_oracles, collateral), + collateral, + spliced_id, + offerer, + accepter, + ) + .with_splice(splice), + ) + .await; + + ctx.assert_spent_by(previous.fund_outpoint(), &spliced.funding_transaction) + .await; + + let attestations = spliced_oracles.attest_enum("a").await; + close_with_cet(&ctx, &spliced, Party::Accept, &attestations).await; +} diff --git a/ddk/tests/stateless_utils.rs b/ddk/tests/stateless_utils.rs new file mode 100644 index 00000000..4366fb9a --- /dev/null +++ b/ddk/tests/stateless_utils.rs @@ -0,0 +1,1223 @@ +//! Scaffolding for the stateless contract execution tests. +//! +//! This mirrors `ddk-manager/tests/test_utils.rs`: a live regtest chain +//! (a bitcoind and an electrs that [`ChainContext`] starts through +//! [`ddk_testenv`]), real funded UTXOs, and real Kormir oracles. What it +//! does *not* mirror is state — no [`ddk_manager::manager::Manager`], no +//! [`ddk_manager::Storage`], and no persisted contract exists anywhere in these +//! tests. Every step goes through [`ddk::contract`] and the wire messages it +//! produces, and the resulting transactions are broadcast to a real node. +//! +//! The pieces a scenario composes are: +//! +//! | Concern | Type | +//! |---------|------| +//! | chain access | [`ChainContext`] | +//! | oracles and attestations | [`TestOracles`] | +//! | a party's keys, UTXOs, and signer | [`TestParty`] | +//! | offer → accept → sign → broadcast | [`fund_contract`] | +//! | CET / refund settlement | [`close_with_cet`], [`close_with_refund`] | + +#![allow(dead_code)] + +use std::sync::Arc; +use std::time::Duration; + +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::psbt::Psbt; +use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, Transaction, Txid, Witness}; +use bitcoincore_rpc::{Client, RpcApi}; +use ddk::chain::EsploraClient; +use ddk::contract::{ + accept_offer, chain_hash_from_network, create_dlc_splice_input, create_dlc_transactions, + create_funding_psbt, create_offer, finalize_sign_spliced, funding_input, sign_accept_spliced, + sign_cet, sign_refund, signing, AcceptOfferParams, ContractError, ContractKeyProvider, + CreateOfferParams, DescriptorInput, DlcInputSigningKey, InputDerivation, Party, PartyParams, + DLC_INPUT_MAX_WITNESS_LEN, +}; +use ddk::logger::Logger; +use ddk::oracle::memory::MemoryOracle; +use ddk::storage::memory::MemoryStorage; +use ddk::wallet::DlcDevKitWallet; +use ddk_dlc::secp256k1_zkp::{All, PublicKey, Secp256k1, SecretKey}; +use ddk_dlc::DlcTransactions; +use ddk_manager::contract::numerical_descriptor::{DifferenceParams, NumericalDescriptor}; +use ddk_manager::payout_curve::{RoundingInterval, RoundingIntervals}; +use ddk_manager::{Blockchain, Oracle}; +use ddk_messages::contract_msgs::{ + ContractDescriptor, ContractInfo, ContractInfoInner, ContractOutcome, DisjointContractInfo, + EnumeratedContractDescriptor, NumericOutcomeContractDescriptor, SingleContractInfo, +}; +use ddk_messages::oracle_msgs::{ + MultiOracleInfo, OracleAnnouncement, OracleAttestation, OracleInfo, OracleParams, + SingleOracleInfo, +}; +use ddk_messages::{AcceptDlc, FundingInput, OfferDlc, SignDlc}; +use ddk_testenv::TestEnv; +use ddk_trie::OracleNumericInfo; +use std::str::FromStr; + +/// The chain every test runs against. +pub const NETWORK: Network = Network::Regtest; + +/// Oracle event maturity, deliberately in the past. +/// +/// CET and refund locktimes are derived from it, and regtest block timestamps +/// track the real wall clock, so both are already spendable the moment the +/// funding transaction confirms. This is the same trick the manager execution +/// tests use. +pub const EVENT_MATURITY: u32 = 1_623_133_104; + +/// Distance between the oracle maturity and the refund locktime. +pub const REFUND_DELAY: u32 = 604_800; + +/// The accepting party's timeout policy, satisfied exactly by [`REFUND_DELAY`]. +pub const MIN_TIMEOUT_INTERVAL: u32 = REFUND_DELAY; +pub const MAX_TIMEOUT_INTERVAL: u32 = REFUND_DELAY; + +pub const BASE: u32 = 2; +pub const NB_DIGITS: u16 = 10; +pub const MIN_SUPPORT_EXP: usize = 1; +pub const MAX_ERROR_EXP: usize = 2; + +pub const OFFER_COLLATERAL: Amount = Amount::from_sat(1_000_000); +pub const ACCEPT_COLLATERAL: Amount = Amount::from_sat(1_000_000); +pub const TOTAL_COLLATERAL: Amount = Amount::from_sat(2_000_000); + +/// Value of each UTXO funded into a party's wallet. +pub const UTXO_VALUE: Amount = Amount::from_sat(5_000_000); + +pub const FEE_RATE_PER_VB: u64 = 2; + +/// Ordinary P2WPKH funding inputs carry a two-element witness. +pub const P2WPKH_MAX_WITNESS_LEN: u16 = 108; + +const BIP84_ACCOUNT: &str = "84h/1h/0h"; +const PAYOUT_INDEX: u32 = 100; +const CHANGE_INDEX: u32 = 101; + +/// Two distinct BIP39 test vectors so mnemonic-derived parties get distinct keys. +const OFFER_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const ACCEPT_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + +/// A deterministic temporary contract id derived from a test label. +/// +/// Both parties derive their DLC funding keys from this id, so it has to be +/// fixed before either party is built. +pub fn temporary_contract_id(label: &str) -> [u8; 32] { + sha256::Hash::hash(label.as_bytes()).to_byte_array() +} + +/// Where a party's *wallet input* signatures come from. +/// +/// Each variant drives a different [`ddk::contract::signing`] entry point over +/// the same funding PSBT; the contract logic never learns which was used. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InputSource { + /// [`signing::sign_funding_psbt_with_xpriv`] with explicit BIP32 paths. + Xpriv, + /// [`signing::sign_funding_psbt_with_descriptor`] with a private `wpkh()` descriptor. + Descriptor, + /// [`signing::sign_funding_psbt_with_wallet`] backed by a real [`DlcDevKitWallet`]. + DdkWallet, + /// No DDK code at all: the PSBT is serialized, signed and finalized with + /// plain rust-bitcoin, then handed back. + ExternalSigner, +} + +/// Where a party's *DLC funding key* comes from. +/// +/// Every variant other than [`FundingKeySource::RawSecretKey`] goes through +/// [`ContractKeyProvider`], so the key is a pure function of the contract's +/// temporary id and can be recomputed later (which is what splicing needs). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FundingKeySource { + /// A key the application holds directly. + RawSecretKey, + /// [`ContractKeyProvider::from_xprv`]. + Xprv, + /// [`ContractKeyProvider::from_seed`]. + Seed, + /// [`ContractKeyProvider::from_mnemonic`]. + Mnemonic, + /// [`ContractKeyProvider::from_descriptor`]. + Descriptor, +} + +/// Access to the regtest chain: bitcoind for funding and mining, Esplora for +/// broadcasting and confirmation checks. +pub struct ChainContext { + pub esplora: Arc, + pub sink: Client, + pub logger: Arc, + pub network: Network, + /// Held for its [`Drop`]: the bitcoind and electrs children die with it. + /// Declared last so the clients above are torn down before the nodes are. + _env: TestEnv, +} + +impl ChainContext { + /// Boots a bitcoind and an electrs used by this context alone. + /// + /// Private rather than shared, matching + /// `ddk-manager/tests/test_utils.rs::test_env`: these tests mine to reach + /// confirmation depths and settle against locktimes, so blocks a sibling + /// test mined on a shared chain would move the tip out from under them. + /// [`TestEnv`] leaves the chain past coinbase maturity, so it can fund a + /// party immediately. + pub async fn new(name: &str) -> Self { + let env = TestEnv::new(); + let logger = Arc::new(Logger::disabled(name.to_string())); + let esplora = Arc::new( + EsploraClient::new(env.esplora_host(), NETWORK, logger.clone()) + .expect("could not build the Esplora client"), + ); + Self { + esplora, + sink: env.rpc(), + logger, + network: NETWORK, + _env: env, + } + } + + /// Mines blocks and waits for Esplora to catch up to the new tip. + pub async fn generate_blocks(&self, nb_blocks: u32) { + let previous_height = self.esplora.async_client.get_height().await.unwrap(); + let sink_address = self + .sink + .get_new_address(None, None) + .expect("RPC error") + .assume_checked(); + self.sink + .generate_to_address(nb_blocks as u64, &sink_address) + .expect("RPC error"); + + let target_height = previous_height + nb_blocks; + let mut attempts = 0; + loop { + if self.esplora.async_client.get_height().await.unwrap() >= target_height { + return; + } + attempts += 1; + assert!( + attempts < 150, + "Esplora did not reach height {target_height}" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + + /// Pays `amount` to `script_pubkey`, confirms it, and returns the funding + /// transaction with the index of the matching output. + pub async fn fund_script( + &self, + script_pubkey: &ScriptBuf, + amount: Amount, + ) -> (Transaction, u32) { + let address = Address::from_script(script_pubkey, self.network) + .expect("script pubkey is not a valid address"); + let txid = self + .sink + .send_to_address(&address, amount, None, None, None, None, None, None) + .expect("RPC error"); + self.generate_blocks(3).await; + let transaction = self + .sink + .get_raw_transaction(&txid, None) + .expect("RPC error"); + let vout = transaction + .output + .iter() + .position(|output| output.script_pubkey == *script_pubkey) + .expect("funding transaction does not pay the requested script") + as u32; + self.wait_for_confirmation(&txid).await; + (transaction, vout) + } + + /// Broadcasts a transaction, failing the test with the node's reason if it + /// is rejected. + pub async fn broadcast(&self, transaction: &Transaction) { + self.esplora + .send_transaction(transaction) + .await + .unwrap_or_else(|e| { + panic!( + "node rejected transaction {}: {e}", + transaction.compute_txid() + ) + }); + } + + /// Broadcasts a transaction and mines until it is `nb_blocks` deep. + /// + /// The depth is read back from Esplora rather than assumed, and topped up + /// until the target is reached: a transaction that does not make it into + /// the next block is one confirmation short of where the block count says + /// it should be. + pub async fn broadcast_and_confirm(&self, transaction: &Transaction, nb_blocks: u32) { + self.broadcast(transaction).await; + let txid = transaction.compute_txid(); + self.generate_blocks(nb_blocks).await; + self.wait_for_confirmation(&txid).await; + for _ in 0..30 { + let confirmations = self + .esplora + .get_transaction_confirmations(&txid) + .await + .unwrap_or(0); + if confirmations >= nb_blocks { + return; + } + self.generate_blocks(nb_blocks - confirmations).await; + tokio::time::sleep(Duration::from_millis(200)).await; + } + panic!("transaction {txid} never reached {nb_blocks} confirmations"); + } + + async fn wait_for_confirmation(&self, txid: &Txid) { + for _ in 0..150 { + if self + .esplora + .get_transaction_confirmations(txid) + .await + .unwrap_or(0) + > 0 + { + return; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + panic!("transaction {txid} never confirmed"); + } + + /// Asserts the chain records `outpoint` as spent by `spender`. + pub async fn assert_spent_by(&self, outpoint: OutPoint, spender: &Transaction) { + let status = self + .esplora + .async_client + .get_output_status(&outpoint.txid, outpoint.vout as u64) + .await + .expect("Esplora error") + .expect("unknown output"); + assert_eq!( + status.txid, + Some(spender.compute_txid()), + "{outpoint} was not spent by the expected transaction" + ); + } +} + +/// A set of oracles that have all announced the same event. +pub struct TestOracles { + pub oracles: Vec, + pub announcements: Vec, + pub threshold: u16, + pub event_id: String, +} + +impl TestOracles { + /// Creates `nb_oracles` oracles announcing the same enum event. + pub async fn enums(nb_oracles: usize, threshold: u16, event_id: &str) -> Self { + let mut oracles = Vec::with_capacity(nb_oracles); + let mut announcements = Vec::with_capacity(nb_oracles); + for _ in 0..nb_oracles { + let oracle = MemoryOracle::default(); + let announcement = oracle + .oracle + .create_enum_event(event_id.to_string(), enum_outcomes(), EVENT_MATURITY) + .await + .unwrap(); + announcements.push(announcement); + oracles.push(oracle); + } + Self { + oracles, + announcements, + threshold, + event_id: event_id.to_string(), + } + } + + /// Creates `nb_oracles` oracles announcing the same digit decomposition event. + pub async fn numerics(nb_oracles: usize, threshold: u16, event_id: &str) -> Self { + let mut oracles = Vec::with_capacity(nb_oracles); + let mut announcements = Vec::with_capacity(nb_oracles); + for _ in 0..nb_oracles { + let oracle = MemoryOracle::default(); + let announcement = oracle + .oracle + .create_numeric_event( + event_id.to_string(), + NB_DIGITS, + false, + 0, + "sats".to_string(), + EVENT_MATURITY, + ) + .await + .unwrap(); + announcements.push(announcement); + oracles.push(oracle); + } + Self { + oracles, + announcements, + threshold, + event_id: event_id.to_string(), + } + } + + /// Attests `outcome` with the first `threshold` oracles and returns the + /// attestations paired with their index in [`Self::announcements`]. + pub async fn attest_enum(&self, outcome: &str) -> Vec<(usize, OracleAttestation)> { + let mut attestations = Vec::new(); + for index in 0..self.threshold as usize { + self.oracles[index] + .oracle + .sign_enum_event(self.event_id.clone(), outcome.to_string()) + .await + .unwrap(); + attestations.push(( + index, + self.oracles[index] + .get_attestation(&self.event_id) + .await + .unwrap(), + )); + } + attestations + } + + /// Attests a numeric outcome with the first `threshold` oracles. + /// + /// When `spread` is set the oracles after the first alternate one unit + /// either side of `outcome`, which is the disagreement a contract with + /// difference params is built to tolerate. + pub async fn attest_numeric( + &self, + outcome: i64, + spread: bool, + ) -> Vec<(usize, OracleAttestation)> { + let mut attestations = Vec::new(); + for index in 0..self.threshold as usize { + let signed_outcome = if spread && index > 0 { + if index % 2 == 0 { + outcome + 1 + } else { + outcome - 1 + } + } else { + outcome + }; + self.oracles[index] + .oracle + .sign_numeric_event(self.event_id.clone(), signed_outcome) + .await + .unwrap(); + attestations.push(( + index, + self.oracles[index] + .get_attestation(&self.event_id) + .await + .unwrap(), + )); + } + attestations + } + + fn oracle_info(&self, oracle_params: Option) -> OracleInfo { + if self.announcements.len() == 1 && oracle_params.is_none() { + OracleInfo::Single(SingleOracleInfo { + oracle_announcement: self.announcements[0].clone(), + }) + } else { + OracleInfo::Multi(MultiOracleInfo { + threshold: self.threshold, + oracle_announcements: self.announcements.clone(), + oracle_params, + }) + } + } +} + +pub fn enum_outcomes() -> Vec { + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ] +} + +pub fn max_numeric_value() -> u64 { + (BASE as u64).pow(NB_DIGITS as u32) - 1 +} + +/// A two-outcome-per-side enum descriptor over [`enum_outcomes`]. +fn enum_descriptor(total_collateral: Amount) -> ContractDescriptor { + ContractDescriptor::EnumeratedContractDescriptor(EnumeratedContractDescriptor { + payouts: enum_outcomes() + .into_iter() + .enumerate() + .map(|(index, outcome)| ContractOutcome { + outcome, + offer_payout: if index % 2 == 0 { + total_collateral + } else { + Amount::ZERO + }, + }) + .collect(), + }) +} + +fn numeric_descriptor( + nb_oracles: usize, + offer_collateral: Amount, + accept_collateral: Amount, + difference_params: Option, +) -> ContractDescriptor { + let payout_function = ddk_payouts::generate_payout_curve( + 0, + 900, + offer_collateral, + accept_collateral, + 5, + max_numeric_value(), + ) + .unwrap(); + let numerical = NumericalDescriptor { + payout_function, + rounding_intervals: RoundingIntervals { + intervals: vec![RoundingInterval { + begin_interval: 0, + rounding_mod: 1, + }], + }, + difference_params, + oracle_numeric_infos: OracleNumericInfo { + base: BASE as usize, + nb_digits: vec![NB_DIGITS as usize; nb_oracles], + }, + }; + ContractDescriptor::NumericOutcomeContractDescriptor(NumericOutcomeContractDescriptor::from( + &numerical, + )) +} + +/// A single-event enum contract. +pub fn enum_contract_info(oracles: &TestOracles, total_collateral: Amount) -> ContractInfo { + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral, + contract_info: ContractInfoInner { + contract_descriptor: enum_descriptor(total_collateral), + oracle_info: oracles.oracle_info(None), + }, + }) +} + +/// A single-event numeric contract, optionally tolerating oracle disagreement. +pub fn numeric_contract_info( + oracles: &TestOracles, + offer_collateral: Amount, + accept_collateral: Amount, + difference_params: Option, +) -> ContractInfo { + let oracle_params = difference_params.as_ref().map(|params| OracleParams { + max_error_exp: params.max_error_exp as u16, + min_fail_exp: params.min_support_exp as u16, + maximize_coverage: params.maximize_coverage, + }); + ContractInfo::SingleContractInfo(SingleContractInfo { + total_collateral: offer_collateral + accept_collateral, + contract_info: ContractInfoInner { + contract_descriptor: numeric_descriptor( + oracles.announcements.len(), + offer_collateral, + accept_collateral, + difference_params, + ), + oracle_info: oracles.oracle_info(oracle_params), + }, + }) +} + +/// A disjoint contract: either the enum event or the numeric event can settle it. +pub fn disjoint_contract_info( + enum_oracles: &TestOracles, + numeric_oracles: &TestOracles, + offer_collateral: Amount, + accept_collateral: Amount, +) -> ContractInfo { + let total_collateral = offer_collateral + accept_collateral; + ContractInfo::DisjointContractInfo(DisjointContractInfo { + total_collateral, + contract_infos: vec![ + ContractInfoInner { + contract_descriptor: enum_descriptor(total_collateral), + oracle_info: enum_oracles.oracle_info(None), + }, + ContractInfoInner { + contract_descriptor: numeric_descriptor( + numeric_oracles.announcements.len(), + offer_collateral, + accept_collateral, + None, + ), + oracle_info: numeric_oracles.oracle_info(None), + }, + ], + }) +} + +pub fn difference_params() -> DifferenceParams { + DifferenceParams { + max_error_exp: MAX_ERROR_EXP, + min_support_exp: MIN_SUPPORT_EXP, + maximize_coverage: false, + } +} + +/// A funding input together with the derivation index of the key controlling it. +pub struct PartyInput { + pub funding_input: FundingInput, + pub derivation_index: u32, +} + +/// One side of a contract: its DLC funding key, its on-chain UTXOs, and the +/// signer that will sign them. +pub struct TestParty { + pub role: Party, + pub input_source: InputSource, + pub funding_key_source: FundingKeySource, + pub xpriv: Xpriv, + pub contract_keys: Option, + pub funding_secret_key: SecretKey, + pub inputs: Vec, + pub payout_spk: ScriptBuf, + pub change_spk: ScriptBuf, + pub wallet: Option>, +} + +/// How to build a party. +pub struct PartySpec { + pub role: Party, + /// Distinguishes this party's key material from the other's. + pub seed_byte: u8, + pub input_source: InputSource, + pub funding_key_source: FundingKeySource, + /// One on-chain UTXO is funded per entry, with the matching serial id. + pub utxos: Vec<(Amount, u64)>, +} + +impl PartySpec { + /// A dual-funded party with a single UTXO. + pub fn new(role: Party, seed_byte: u8, serial_id: u64) -> Self { + Self { + role, + seed_byte, + input_source: InputSource::Xpriv, + funding_key_source: FundingKeySource::Xprv, + utxos: vec![(UTXO_VALUE, serial_id)], + } + } + + /// A party that contributes no funding inputs (the accepting side of a + /// single-funded contract, or the accepting side of a splice). + pub fn unfunded(role: Party, seed_byte: u8) -> Self { + Self { + role, + seed_byte, + input_source: InputSource::Xpriv, + funding_key_source: FundingKeySource::Xprv, + utxos: vec![], + } + } + + pub fn with_input_source(mut self, input_source: InputSource) -> Self { + self.input_source = input_source; + self + } + + pub fn with_funding_key_source(mut self, funding_key_source: FundingKeySource) -> Self { + self.funding_key_source = funding_key_source; + self + } + + pub fn with_utxos(mut self, utxos: Vec<(Amount, u64)>) -> Self { + self.utxos = utxos; + self + } +} + +impl TestParty { + /// Builds a party: derives its keys, funds its UTXOs on-chain, and (for + /// [`InputSource::DdkWallet`]) stands up a real wallet that owns them. + pub async fn new(ctx: &ChainContext, spec: PartySpec, temporary_contract_id: [u8; 32]) -> Self { + let secp = Secp256k1::new(); + let xpriv = Xpriv::new_master(NETWORK, &[spec.seed_byte; 64]).unwrap(); + let (contract_keys, funding_secret_key) = + funding_key(&spec, &xpriv, temporary_contract_id, &secp); + + let wallet = if spec.input_source == InputSource::DdkWallet { + Some(Arc::new( + DlcDevKitWallet::new( + &[spec.seed_byte.wrapping_add(50); 64], + ctx.esplora.clone(), + NETWORK, + Arc::new(MemoryStorage::new()), + None, + ctx.logger.clone(), + ) + .await + .expect("could not create the wallet"), + )) + } else { + None + }; + + let mut inputs = Vec::with_capacity(spec.utxos.len()); + for (derivation_index, (value, serial_id)) in spec.utxos.iter().enumerate() { + let derivation_index = derivation_index as u32; + let script_pubkey = match &wallet { + // The wallet must own the UTXO it is later asked to sign. + Some(wallet) => wallet + .new_external_address() + .await + .unwrap() + .address + .script_pubkey(), + None => p2wpkh_script(&secp, &xpriv, &input_path(derivation_index)), + }; + let (previous_transaction, vout) = ctx.fund_script(&script_pubkey, *value).await; + inputs.push(PartyInput { + funding_input: funding_input( + &previous_transaction, + vout, + Some(*serial_id), + u32::MAX, + P2WPKH_MAX_WITNESS_LEN, + ScriptBuf::new(), + ) + .unwrap(), + derivation_index, + }); + } + if let Some(wallet) = &wallet { + wallet.sync().await.expect("could not sync the wallet"); + } + + Self { + role: spec.role, + input_source: spec.input_source, + funding_key_source: spec.funding_key_source, + xpriv, + contract_keys, + funding_secret_key, + inputs, + payout_spk: p2wpkh_script(&secp, &xpriv, &input_path(PAYOUT_INDEX)), + change_spk: p2wpkh_script(&secp, &xpriv, &input_path(CHANGE_INDEX)), + wallet, + } + } + + pub fn funding_pubkey(&self) -> PublicKey { + self.funding_secret_key.public_key(&Secp256k1::new()) + } + + pub fn funding_inputs(&self) -> Vec { + self.inputs + .iter() + .map(|input| input.funding_input.clone()) + .collect() + } + + pub fn party_params(&self, extra_inputs: Vec) -> PartyParams { + let mut funding_inputs = extra_inputs; + funding_inputs.extend(self.funding_inputs()); + PartyParams { + funding_pubkey: self.funding_pubkey(), + funding_inputs, + payout_spk: self.payout_spk.clone(), + payout_serial_id: None, + change_spk: self.change_spk.clone(), + change_serial_id: None, + } + } + + /// Recovers this party's funding key for a *previous* contract, so it can + /// sign that contract's 2-of-2 output when splicing. + /// + /// Providers recompute the key from the previous temporary contract id; + /// a raw key is simply the one the party already holds. + pub fn dlc_input_signing_key( + &self, + prior_temporary_contract_id: [u8; 32], + input_serial_id: u64, + ) -> DlcInputSigningKey { + match &self.contract_keys { + Some(keys) => keys + .dlc_input_signing_key(prior_temporary_contract_id, input_serial_id) + .unwrap(), + None => DlcInputSigningKey { + input_serial_id, + prior_funding_secret_key: self.funding_secret_key, + }, + } + } + + /// Signs and finalizes this party's funding inputs in the PSBT, through + /// whichever signer the party was built with. + pub async fn sign_funding_psbt( + &self, + offer: &OfferDlc, + accept: &AcceptDlc, + psbt: &mut Psbt, + ) -> Result<(), ContractError> { + if self.inputs.is_empty() { + return Ok(()); + } + match self.input_source { + InputSource::Xpriv => { + let derivations: Vec = self + .inputs + .iter() + .map(|input| InputDerivation { + input_serial_id: input.funding_input.input_serial_id, + derivation_path: input_path(input.derivation_index), + }) + .collect(); + signing::sign_funding_psbt_with_xpriv( + offer, + accept, + psbt, + &self.xpriv, + &derivations, + ) + } + InputSource::Descriptor => { + let descriptor = format!("wpkh({}/{BIP84_ACCOUNT}/0/*)", self.xpriv); + let inputs: Vec = self + .inputs + .iter() + .map(|input| DescriptorInput { + input_serial_id: input.funding_input.input_serial_id, + derivation_index: input.derivation_index, + }) + .collect(); + signing::sign_funding_psbt_with_descriptor( + offer, + accept, + psbt, + &descriptor, + &inputs, + ) + } + InputSource::DdkWallet => { + let wallet = self.wallet.as_ref().expect("wallet party has a wallet"); + signing::sign_funding_psbt_with_wallet( + offer, + accept, + psbt, + wallet.as_ref(), + self.role, + ) + .await + } + InputSource::ExternalSigner => { + let paths: Vec = self + .inputs + .iter() + .map(|input| input_path(input.derivation_index)) + .collect(); + *psbt = external_signer(psbt, &self.xpriv, &paths); + Ok(()) + } + } + } +} + +fn funding_key( + spec: &PartySpec, + xpriv: &Xpriv, + temporary_contract_id: [u8; 32], + secp: &Secp256k1, +) -> (Option, SecretKey) { + let provider = match spec.funding_key_source { + FundingKeySource::RawSecretKey => { + let secret_key = SecretKey::from_slice(&[spec.seed_byte; 32]).unwrap(); + let _ = secp; + return (None, secret_key); + } + FundingKeySource::Xprv => ContractKeyProvider::from_xprv(*xpriv), + FundingKeySource::Seed => { + ContractKeyProvider::from_seed(&[spec.seed_byte.wrapping_add(7); 64], NETWORK).unwrap() + } + FundingKeySource::Mnemonic => { + let mnemonic = match spec.role { + Party::Offer => OFFER_MNEMONIC, + Party::Accept => ACCEPT_MNEMONIC, + }; + ContractKeyProvider::from_mnemonic(mnemonic, None, NETWORK).unwrap() + } + FundingKeySource::Descriptor => { + ContractKeyProvider::from_descriptor(&format!("wpkh({xpriv}/{BIP84_ACCOUNT}/0/*)")) + .unwrap() + } + }; + let funding_secret_key = provider.funding_secret_key(temporary_contract_id).unwrap(); + (Some(provider), funding_secret_key) +} + +fn input_path(index: u32) -> DerivationPath { + DerivationPath::from_str(&format!("{BIP84_ACCOUNT}/0/{index}")).unwrap() +} + +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()) +} + +/// Simulates a wallet outside DDK: the PSBT is serialized, signed and finalized +/// with nothing but rust-bitcoin, and returned. +/// +/// Only inputs whose script is controlled by one of `paths` are touched; the +/// counterparty's inputs are left untouched. +fn external_signer(psbt: &Psbt, xpriv: &Xpriv, paths: &[DerivationPath]) -> Psbt { + let secp = Secp256k1::new(); + let mut external = Psbt::deserialize(&psbt.serialize()).unwrap(); + let fingerprint = xpriv.fingerprint(&secp); + for path in paths { + 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()); + for index in 0..external.inputs.len() { + let owns_input = external.inputs[index] + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey == owned_script) + .unwrap_or(false); + if owns_input { + external.inputs[index] + .bip32_derivation + .insert(public_key.inner, (fingerprint, path.clone())); + } + } + } + external.sign(xpriv, &secp).unwrap(); + for index in 0..external.inputs.len() { + let Some((public_key, signature)) = external.inputs[index] + .partial_sigs + .iter() + .map(|(pk, sig)| (*pk, *sig)) + .next() + else { + continue; + }; + external.inputs[index].final_script_witness = Some(Witness::from_slice(&[ + signature.to_vec(), + public_key.to_bytes(), + ])); + external.inputs[index].partial_sigs.clear(); + } + Psbt::deserialize(&external.serialize()).unwrap() +} + +/// The splice half of a [`ContractSetup`]: a previous contract's funding output +/// plus each party's key for it. +pub struct SpliceSetup { + pub funding_input: FundingInput, + pub offer_key: DlcInputSigningKey, + pub accept_key: DlcInputSigningKey, +} + +/// Everything needed to drive one contract from offer to a confirmed funding +/// transaction. +pub struct ContractSetup { + pub contract_info: ContractInfo, + pub offer_collateral: Amount, + pub temporary_contract_id: [u8; 32], + pub offerer: TestParty, + pub accepter: TestParty, + pub splice: Option, +} + +impl ContractSetup { + pub fn new( + contract_info: ContractInfo, + offer_collateral: Amount, + temporary_contract_id: [u8; 32], + offerer: TestParty, + accepter: TestParty, + ) -> Self { + Self { + contract_info, + offer_collateral, + temporary_contract_id, + offerer, + accepter, + splice: None, + } + } + + pub fn with_splice(mut self, splice: SpliceSetup) -> Self { + self.splice = Some(splice); + self + } +} + +/// A contract whose funding transaction is confirmed on chain. +/// +/// The three wire messages are the only contract state that exists; everything +/// else here is derived from them. +pub struct FundedContract { + pub offer: OfferDlc, + pub accept: AcceptDlc, + pub sign: SignDlc, + pub funding_transaction: Transaction, + pub transactions: DlcTransactions, + pub offerer: TestParty, + pub accepter: TestParty, + pub temporary_contract_id: [u8; 32], +} + +impl FundedContract { + pub fn fund_outpoint(&self) -> OutPoint { + OutPoint { + txid: self.funding_transaction.compute_txid(), + vout: self.transactions.get_fund_output_index() as u32, + } + } + + pub fn fund_value(&self) -> Amount { + self.transactions.get_fund_output().value + } + + pub fn party(&self, party: Party) -> &TestParty { + match party { + Party::Offer => &self.offerer, + Party::Accept => &self.accepter, + } + } + + /// Builds the splice input that spends this contract's funding output, + /// with each party recovering its own key for it. + pub fn splice_setup(&self, input_serial_id: u64) -> SpliceSetup { + splice_from(self, &self.offerer, &self.accepter, input_serial_id) + } +} + +/// Builds a splice input over `previous`'s funding output, with `offerer` and +/// `accepter` each recovering their previous-contract funding key from their +/// own key source. +/// +/// The parties passed here are the ones signing the *new* contract; they may be +/// freshly constructed, which is the point — nothing about the previous +/// contract's keys was carried over, only its temporary id and wire messages. +pub fn splice_from( + previous: &FundedContract, + offerer: &TestParty, + accepter: &TestParty, + input_serial_id: u64, +) -> SpliceSetup { + SpliceSetup { + funding_input: create_dlc_splice_input( + &previous.offer, + &previous.accept, + Party::Offer, + Some(input_serial_id), + DLC_INPUT_MAX_WITNESS_LEN, + ) + .unwrap(), + offer_key: offerer.dlc_input_signing_key(previous.temporary_contract_id, input_serial_id), + accept_key: accepter.dlc_input_signing_key(previous.temporary_contract_id, input_serial_id), + } +} + +/// Runs a contract from `create_offer` through to a confirmed funding +/// transaction, using only the stateless API and the wire messages. +pub async fn fund_contract(ctx: &ChainContext, setup: ContractSetup) -> FundedContract { + let ContractSetup { + contract_info, + offer_collateral, + temporary_contract_id, + offerer, + accepter, + splice, + } = setup; + + let splice_inputs = splice + .as_ref() + .map(|splice| vec![splice.funding_input.clone()]) + .unwrap_or_default(); + let offer_dlc_keys: Vec = splice + .as_ref() + .map(|splice| vec![splice.offer_key.clone()]) + .unwrap_or_default(); + let accept_dlc_keys: Vec = splice + .as_ref() + .map(|splice| vec![splice.accept_key.clone()]) + .unwrap_or_default(); + + let offer = create_offer(CreateOfferParams { + chain_hash: chain_hash_from_network(NETWORK), + temporary_contract_id: Some(temporary_contract_id), + contract_info, + offer_collateral, + party: offerer.party_params(splice_inputs), + fund_output_serial_id: None, + fee_rate_per_vb: FEE_RATE_PER_VB, + cet_locktime: EVENT_MATURITY, + refund_locktime: EVENT_MATURITY + REFUND_DELAY, + contract_flags: 0, + }) + .expect("could not create the offer"); + + let accept_result = accept_offer( + &offer, + AcceptOfferParams { + party: accepter.party_params(vec![]), + min_timeout_interval: MIN_TIMEOUT_INTERVAL, + max_timeout_interval: MAX_TIMEOUT_INTERVAL, + }, + &accepter.funding_secret_key, + ) + .expect("could not accept the offer"); + let accept = accept_result.accept; + + // The offering party signs its own inputs, then the accept message. + let mut offer_psbt = create_funding_psbt(&offer, &accept).unwrap(); + offerer + .sign_funding_psbt(&offer, &accept, &mut offer_psbt) + .await + .expect("the offering party could not sign the funding PSBT"); + let sign = sign_accept_spliced( + &offer, + &accept, + &offerer.funding_secret_key, + &offer_psbt, + &offer_dlc_keys, + ) + .expect("could not create the sign message") + .sign; + + // The accepting party signs its own inputs and completes the transaction. + let mut accept_psbt = create_funding_psbt(&offer, &accept).unwrap(); + accepter + .sign_funding_psbt(&offer, &accept, &mut accept_psbt) + .await + .expect("the accepting party could not sign the funding PSBT"); + let funding_transaction = + finalize_sign_spliced(&offer, &accept, &sign, &accept_psbt, &accept_dlc_keys) + .expect("could not finalize the funding transaction"); + + let transactions = create_dlc_transactions(&offer, &accept).unwrap(); + assert_eq!( + funding_transaction.compute_txid(), + transactions.fund.compute_txid(), + "the completed funding transaction is not the one rebuilt from the messages" + ); + assert_eq!( + funding_transaction.input.len(), + offer.funding_inputs.len() + accept.funding_inputs.len() + ); + assert!( + funding_transaction + .input + .iter() + .all(|input| !input.witness.is_empty()), + "every funding input must carry a witness" + ); + + ctx.broadcast_and_confirm(&funding_transaction, 6).await; + + FundedContract { + offer, + accept, + sign, + funding_transaction, + transactions, + offerer, + accepter, + temporary_contract_id, + } +} + +/// Settles the contract by broadcasting the CET for `attestations`. +pub async fn close_with_cet( + ctx: &ChainContext, + contract: &FundedContract, + closer: Party, + attestations: &[(usize, OracleAttestation)], +) -> Transaction { + let cet = sign_cet( + &contract.offer, + &contract.accept, + &contract.sign, + &contract.party(closer).funding_secret_key, + attestations, + ) + .expect("could not sign the CET"); + assert!( + contract + .transactions + .cets + .iter() + .any(|candidate| candidate.compute_txid() == cet.compute_txid()), + "the signed CET is not one of the CETs rebuilt from the messages" + ); + assert_eq!(cet.input.len(), 1); + assert_eq!(cet.input[0].previous_output, contract.fund_outpoint()); + for output in &cet.output { + assert!( + output.script_pubkey == contract.offer.payout_spk + || output.script_pubkey == contract.accept.payout_spk, + "a CET output does not pay either party" + ); + } + assert!( + cet.output.iter().map(|output| output.value).sum::() < contract.fund_value(), + "the CET must pay a fee out of the funding output" + ); + + ctx.broadcast_and_confirm(&cet, 1).await; + ctx.assert_spent_by(contract.fund_outpoint(), &cet).await; + cet +} + +/// Settles the contract by broadcasting the refund transaction. +/// +/// Both parties signed the refund during the offer/accept exchange; `closer` +/// contributes the second half of the 2-of-2 here. +pub async fn close_with_refund( + ctx: &ChainContext, + contract: &FundedContract, + closer: Party, +) -> Transaction { + let refund = sign_refund( + &contract.offer, + &contract.accept, + &contract.sign, + &contract.party(closer).funding_secret_key, + ) + .expect("could not sign the refund transaction"); + + assert_eq!( + refund.compute_txid(), + contract.transactions.refund.compute_txid(), + "the refund is not the one rebuilt from the messages" + ); + assert_eq!(refund.input[0].previous_output, contract.fund_outpoint()); + ctx.broadcast_and_confirm(&refund, 1).await; + ctx.assert_spent_by(contract.fund_outpoint(), &refund).await; + refund +}