From 6653a0b021ffbeb0155cf01901b6142e4d312e28 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Fri, 15 May 2026 23:37:32 +0530 Subject: [PATCH] smite: add funding transaction construction Signed-off-by: Nishant Bansal --- Cargo.lock | 2 + Cargo.toml | 1 + smite-scenarios/Cargo.toml | 2 +- smite/Cargo.toml | 2 + smite/src/bitcoin.rs | 178 ++++++++++ smite/src/bolt.rs | 2 + smite/src/bolt/commitment.rs | 54 +-- smite/src/bolt/funding.rs | 626 +++++++++++++++++++++++++++++++++++ 8 files changed, 820 insertions(+), 47 deletions(-) create mode 100644 smite/src/bolt/funding.rs diff --git a/Cargo.lock b/Cargo.lock index 96587c6..a085d9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,8 @@ dependencies = [ "hex", "log", "nix", + "serde", + "serde_json", "simple_logger", "smite-nyx-sys", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 3c01e0b..e3a107e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,4 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] } bitcoin = "0.32" serde = { version = "1", features = ["derive"] } thiserror = "2" +serde_json = "1" diff --git a/smite-scenarios/Cargo.toml b/smite-scenarios/Cargo.toml index b4d1eed..15dcd6c 100644 --- a/smite-scenarios/Cargo.toml +++ b/smite-scenarios/Cargo.toml @@ -24,5 +24,5 @@ bitcoin.workspace = true hex = "0.4" libc = "0.2" serde.workspace = true -serde_json = "1" +serde_json.workspace = true tempfile = "3" diff --git a/smite/Cargo.toml b/smite/Cargo.toml index 357f5f9..66a524f 100644 --- a/smite/Cargo.toml +++ b/smite/Cargo.toml @@ -15,6 +15,8 @@ log.workspace = true simple_logger.workspace = true bitcoin.workspace = true thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true # Noise protocol crypto dependencies (BOLT 8) chacha20poly1305 = { version = "0.10", default-features = false, features = [ diff --git a/smite/src/bitcoin.rs b/smite/src/bitcoin.rs index c5fa1aa..83a7848 100644 --- a/smite/src/bitcoin.rs +++ b/smite/src/bitcoin.rs @@ -1,8 +1,43 @@ //! This module implements utilities for interacting with regtest //! `bitcoind` instances via `bitcoin-cli`. +use std::cmp::Ordering; use std::path::PathBuf; use std::process::Command; +use std::str::FromStr; + +use bitcoin::consensus::encode::serialize_hex; +use bitcoin::{Address, Amount, Network, OutPoint, ScriptBuf, Transaction, Txid}; +use serde::Deserialize; + +/// A spendable UTXO used as a transaction input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Utxo { + /// The value of the UTXO. + pub amount: Amount, + /// The transaction outpoint identifying the UTXO. + pub outpoint: OutPoint, + /// The script pubkey of the UTXO being spent. + pub script_pubkey: ScriptBuf, +} + +impl Ord for Utxo { + fn cmp(&self, other: &Self) -> Ordering { + // Sort in decreasing order of amount to support largest-first coin + // selection (as used in `bdk_wallet`) and ensure deterministic ordering. + other + .amount + .cmp(&self.amount) + .then_with(|| other.script_pubkey.cmp(&self.script_pubkey)) + .then_with(|| other.outpoint.cmp(&self.outpoint)) + } +} + +impl PartialOrd for Utxo { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} /// Connection info for invoking `bitcoin-cli` against the regtest `bitcoind` /// started by a target. @@ -48,4 +83,147 @@ impl BitcoinCli { String::from_utf8_lossy(&mine_out.stderr) ); } + + /// Returns the wallet's spendable UTXOs, sorted deterministically. + /// + /// # Panics + /// + /// - If `bitcoin-cli listunspent` fails to execute or exits non-zero. + /// - If the output is not valid JSON, or any entry has an invalid amount, + /// txid, or hex scriptPubKey. + #[must_use] + pub fn get_utxos(&self) -> Vec { + #[derive(Deserialize)] + struct UnspentOutput { + txid: String, + vout: u32, + amount: f64, + #[serde(rename = "scriptPubKey")] + script_pubkey: String, + spendable: bool, + } + + let utxo_out = self + .run() + .arg("listunspent") + .output() + .expect("bitcoin-cli listunspent should not fail"); + assert!( + utxo_out.status.success(), + "bitcoin-cli listunspent failed: {}", + String::from_utf8_lossy(&utxo_out.stderr) + ); + + let utxos: Vec = + serde_json::from_slice(&utxo_out.stdout).expect("listunspent should return valid JSON"); + + let mut spendable: Vec = utxos + .into_iter() + .filter(|u| u.spendable) + .map(|u| Utxo { + amount: Amount::from_btc(u.amount).expect("listunspent amount should be valid BTC"), + outpoint: OutPoint::new( + Txid::from_str(&u.txid).expect("listunspent should return valid txid"), + u.vout, + ), + script_pubkey: ScriptBuf::from( + hex::decode(&u.script_pubkey) + .expect("listunspent should return valid hex scriptPubKey"), + ), + }) + .collect(); + // Sorted for determinism and to support largest-first coin selection + // during transaction construction. + spendable.sort(); + + spendable + } + + /// Returns the scriptPubKey for a newly generated wallet address. + /// + /// # Panics + /// + /// - If `bitcoin-cli getnewaddress` fails to execute or exits non-zero. + /// - If the output is not valid UTF-8 or not a valid regtest address. + #[must_use] + pub fn get_new_address_script_pubkey(&self) -> ScriptBuf { + let addr_out = self + .run() + .arg("getnewaddress") + .output() + .expect("bitcoin-cli getnewaddress should not fail"); + assert!( + addr_out.status.success(), + "bitcoin-cli getnewaddress failed: {}", + String::from_utf8_lossy(&addr_out.stderr) + ); + + let addr_str = String::from_utf8(addr_out.stdout).expect("bitcoin address is valid UTF-8"); + Address::from_str(addr_str.trim()) + .and_then(|a| a.require_network(Network::Regtest)) + .expect("getnewaddress should return a valid address") + .script_pubkey() + } + + /// Signs and broadcasts a transaction. + /// + /// # Panics + /// + /// - If `bitcoin-cli signrawtransactionwithwallet` or `sendrawtransaction` + /// fails to execute or exits non-zero. + /// - If the sign output is not valid JSON. + /// - If signing returns `complete=false`. + /// - If `sendrawtransaction` does not return a valid UTF-8 txid. + /// - If the broadcasted txid does not match the given transaction's txid. + pub fn sign_and_broadcast_tx(&self, tx: &Transaction) { + #[derive(Deserialize)] + struct SignRawTransactionResponse { + hex: String, + complete: bool, + } + + let tx_hex = serialize_hex(tx); + + let signed_out = self + .run() + .arg("signrawtransactionwithwallet") + .arg(&tx_hex) + .output() + .expect("bitcoin-cli signrawtransactionwithwallet should not fail"); + assert!( + signed_out.status.success(), + "bitcoin-cli signrawtransactionwithwallet failed: {}", + String::from_utf8_lossy(&signed_out.stderr) + ); + + let signed_tx: SignRawTransactionResponse = serde_json::from_slice(&signed_out.stdout) + .expect("signrawtransactionwithwallet should return valid JSON"); + assert!( + signed_tx.complete, + "signrawtransactionwithwallet returned complete=false" + ); + + let broadcast_out = self + .run() + .arg("sendrawtransaction") + .arg(&signed_tx.hex) + .output() + .expect("bitcoin-cli sendrawtransaction should not fail"); + assert!( + broadcast_out.status.success(), + "bitcoin-cli sendrawtransaction failed: {}", + String::from_utf8_lossy(&broadcast_out.stderr) + ); + + // Safe because bitcoind descriptor wallets currently default to native + // SegWit, so signing does not alter the txid computed from the unsigned + // Transaction. + let broadcast_txid = String::from_utf8(broadcast_out.stdout) + .expect("sendrawtransaction should return a valid UTF-8 txid"); + assert_eq!( + broadcast_txid.trim(), + tx.compute_txid().to_string(), + "sendrawtransaction returned unexpected txid" + ); + } } diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index 1f7d131..5e75286 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -9,6 +9,7 @@ mod attribution_data; mod channel_ready; mod commitment; mod error; +mod funding; mod funding_created; mod funding_signed; mod gossip_timestamp_filter; @@ -42,6 +43,7 @@ pub use commitment::{ HolderIdentity, Side, }; pub use error::Error; +pub use funding::{FundingTransaction, InsufficientFunds, build_funding_transaction}; pub use funding_created::FundingCreated; pub use funding_signed::FundingSigned; pub use gossip_timestamp_filter::GossipTimestampFilter; diff --git a/smite/src/bolt/commitment.rs b/smite/src/bolt/commitment.rs index d0ed621..99ac2a5 100644 --- a/smite/src/bolt/commitment.rs +++ b/smite/src/bolt/commitment.rs @@ -1,5 +1,7 @@ //! BOLT 3 commitment transaction construction and signing. +use super::funding::build_funding_witness_script; + use bitcoin::absolute::LockTime; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; @@ -271,15 +273,17 @@ impl ChannelConfig { output: outputs, }; - // Funding output redeem script. - let funding_redeemscript = - build_funding_redeemscript(&self.opener.funding_pubkey, &self.acceptor.funding_pubkey); + // Funding output witness script. + let funding_witness_script = build_funding_witness_script( + &self.opener.funding_pubkey, + &self.acceptor.funding_pubkey, + ); // Compute the BIP143 sighash let sighash = SighashCache::new(&tx) .p2wsh_signature_hash( 0, - &funding_redeemscript, + &funding_witness_script, Amount::from_sat(self.funding_satoshis), EcdsaSighashType::All, ) @@ -539,24 +543,6 @@ fn build_anchor_scriptpubkey(funding_pubkey: &PublicKey) -> ScriptBuf { .to_p2wsh() } -/// Builds the funding output redeem script per BOLT 3. -fn build_funding_redeemscript(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf { - let key1_bytes = pubkey1.serialize(); - let key2_bytes = pubkey2.serialize(); - let (lesser, greater) = if key1_bytes < key2_bytes { - (&key1_bytes, &key2_bytes) - } else { - (&key2_bytes, &key1_bytes) - }; - Builder::new() - .push_opcode(opcodes::OP_PUSHNUM_2) - .push_slice(lesser) - .push_slice(greater) - .push_opcode(opcodes::OP_PUSHNUM_2) - .push_opcode(opcodes::OP_CHECKMULTISIG) - .into_script() -} - /// Signs a commitment sighash with the given funding private key. fn sign(sighash: &[u8; 32], funding_privkey: &SecretKey) -> Signature { let secp = Secp256k1::new(); @@ -615,30 +601,6 @@ mod tests { assert!(!supports_option_anchors(&[0x00, 0x10])); } - // BOLT 3 Appendix B: Funding Transaction Test Vectors - // https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-b-funding-transaction-test-vectors - - #[test] - fn funding_redeemscript_is_key_order_independent() { - let local_funding_pubkey = - pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"); - let remote_funding_pubkey = - pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"); - - let funding_redeemscript_1 = - build_funding_redeemscript(&local_funding_pubkey, &remote_funding_pubkey); - let funding_redeemscript_2 = - build_funding_redeemscript(&remote_funding_pubkey, &local_funding_pubkey); - - // Argument order must not matter as keys are sorted lexicographically. - assert_eq!(funding_redeemscript_1, funding_redeemscript_2); - - assert_eq!( - hex::encode(funding_redeemscript_1.as_bytes()), - "5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae", - ); - } - fn bolt3_commitment_params( feerate_per_kw: u32, to_opener_msat: u64, diff --git a/smite/src/bolt/funding.rs b/smite/src/bolt/funding.rs new file mode 100644 index 0000000..779a15a --- /dev/null +++ b/smite/src/bolt/funding.rs @@ -0,0 +1,626 @@ +//! BOLT 3 funding transaction construction. + +use bitcoin::absolute::LockTime; +use bitcoin::opcodes::all as opcodes; +use bitcoin::script::Builder; +use bitcoin::secp256k1::PublicKey; +use bitcoin::transaction::{InputWeightPrediction, Version, predict_weight}; +use bitcoin::{Amount, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + +use crate::bitcoin::Utxo; + +/// Error returned when available UTXOs cannot cover the funding amount plus +/// estimated miner fee. +#[derive(Debug, thiserror::Error)] +#[error( + "insufficient funds to cover funding amount and fee: required {required}, available {available}" +)] +pub struct InsufficientFunds { + /// Total amount required, including fees. + pub required: Amount, + /// Total spendable amount available from the selected UTXOs. + pub available: Amount, +} + +/// A constructed funding transaction along with the index of the 2-of-2 +/// funding output within it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingTransaction { + /// The Bitcoin transaction containing the funding output. + pub tx: Transaction, + /// Index of the funding output. + pub vout: u32, +} + +/// Builds a funding transaction with a 2-of-2 P2WSH output between the opener +/// and acceptor. +/// +/// Coins are selected for spending in the order the `utxos` are provided. +/// +/// # Errors +/// +/// Returns [`InsufficientFunds`] if the provided inputs do not contain enough +/// value to cover `funding_satoshis` and the required transaction fees. +/// +/// # Panics +/// +/// Panics if an input has an unsupported script pubkey type, since fee +/// estimation currently only supports P2PKH and P2WPKH inputs. +pub fn build_funding_transaction( + opener_funding_pubkey: &PublicKey, + acceptor_funding_pubkey: &PublicKey, + funding_satoshis: u64, + feerate_per_kw: u32, + utxos: Vec, + change_spk: ScriptBuf, +) -> Result { + let funding_amt = Amount::from_sat(funding_satoshis); + let funding_spk = + build_funding_witness_script(opener_funding_pubkey, acceptor_funding_pubkey).to_p2wsh(); + + let mut inputs = Vec::new(); + let mut input_weights = Vec::new(); + let mut outputs = vec![TxOut { + value: funding_amt, + script_pubkey: funding_spk.clone(), + }]; + let mut total = Amount::ZERO; + let mut expected_fee_no_change = + predict_tx_fee(feerate_per_kw, &input_weights, &[funding_spk.len()]); + + for utxo in utxos { + total += utxo.amount; + + inputs.push(TxIn { + previous_output: utxo.outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }); + + // By default, the address bitcoind generates coins to is always a P2WPKH + // address, but to support the BOLT 3 test vectors, we also include P2PKH. + let input_weight = if utxo.script_pubkey.is_p2pkh() { + InputWeightPrediction::P2PKH_COMPRESSED_MAX + } else { + // Assert this so fee estimation breaks loudly if the default ever changes. + assert!( + utxo.script_pubkey.is_p2wpkh(), + "unsupported input script pubkey; fee estimation only handles P2PKH and P2WPKH" + ); + InputWeightPrediction::P2WPKH_MAX + }; + input_weights.push(input_weight); + + // Check whether the selected inputs can cover the funding amount and fees. + expected_fee_no_change = + predict_tx_fee(feerate_per_kw, &input_weights, &[funding_spk.len()]); + if total >= funding_amt + expected_fee_no_change { + break; + } + } + + // Verify that the selected inputs can cover the funding amount and fees. + if total < funding_amt + expected_fee_no_change { + return Err(InsufficientFunds { + required: funding_amt + expected_fee_no_change, + available: total, + }); + } + + // Add remaining funds after accounting for fees as a change output, + // unless the resulting change would be dust. + let expected_fee_with_change = predict_tx_fee( + feerate_per_kw, + &input_weights, + &[funding_spk.len(), change_spk.len()], + ); + let dust = change_spk.minimal_non_dust(); + if let Some(change) = total + .checked_sub(funding_amt + expected_fee_with_change) + .filter(|c| *c >= dust) + { + outputs.push(TxOut { + value: change, + script_pubkey: change_spk, + }); + } + + Ok(FundingTransaction { + tx: Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: inputs, + output: outputs, + }, + vout: 0, + }) +} + +/// Returns the predicted fee cost of a transaction. +fn predict_tx_fee( + feerate_per_kw: u32, + input_weights: &[InputWeightPrediction], + output_scriptlens: &[usize], +) -> Amount { + let weight = predict_weight( + input_weights.iter().copied(), + output_scriptlens.iter().copied(), + ); + Amount::from_sat((u64::from(feerate_per_kw) * weight.to_wu()) / 1000) +} + +/// Builds the funding output witness script per BOLT 3. +pub fn build_funding_witness_script(pubkey1: &PublicKey, pubkey2: &PublicKey) -> ScriptBuf { + let key1_bytes = pubkey1.serialize(); + let key2_bytes = pubkey2.serialize(); + let (lesser, greater) = if key1_bytes < key2_bytes { + (&key1_bytes, &key2_bytes) + } else { + (&key2_bytes, &key1_bytes) + }; + Builder::new() + .push_opcode(opcodes::OP_PUSHNUM_2) + .push_slice(lesser) + .push_slice(greater) + .push_opcode(opcodes::OP_PUSHNUM_2) + .push_opcode(opcodes::OP_CHECKMULTISIG) + .into_script() +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::OutPoint; + use bitcoin::consensus::encode::serialize_hex; + use bitcoin::ecdsa::Signature; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use bitcoin::sighash::{EcdsaSighashType, SighashCache}; + + fn pubkey(hex_str: &str) -> PublicKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + PublicKey::from_slice(&bytes).expect("valid pubkey") + } + + fn secret(hex_str: &str) -> SecretKey { + let bytes = hex::decode(hex_str).expect("valid hex"); + SecretKey::from_slice(&bytes).expect("valid secret key") + } + + // Signs a P2PKH input (input 0) of the funding transaction, producing the + // script_sig with signature and public key. + fn sign_p2pkh_input(funding: &mut FundingTransaction, utxo: &Utxo, input_privkey: &SecretKey) { + let input_pubkey = PublicKey::from_secret_key(&Secp256k1::signing_only(), input_privkey); + let sighash = SighashCache::new(&funding.tx) + .legacy_signature_hash(0, &utxo.script_pubkey, EcdsaSighashType::All.to_u32()) + .expect("valid sighash"); + let sig = Signature::sighash_all( + Secp256k1::signing_only().sign_ecdsa(&sighash.into(), input_privkey), + ); + + funding.tx.input[0].script_sig = Builder::new() + .push_slice(sig.serialize()) + .push_slice(input_pubkey.serialize()) + .into_script(); + } + + // Signs P2WPKH inputs of the funding transaction, producing the witness + // with signature and public key. + fn sign_p2wpkh_input( + funding: &mut FundingTransaction, + utxos: &[Utxo], + input_privkeys: &[SecretKey], + ) { + assert_eq!(utxos.len(), input_privkeys.len()); + + let secp = Secp256k1::signing_only(); + + for (i, (utxo, input_privkey)) in utxos.iter().zip(input_privkeys).enumerate() { + let amount = utxo.amount; + let input_spk = &utxo.script_pubkey; + + let input_pubkey = PublicKey::from_secret_key(&secp, input_privkey); + let sighash = SighashCache::new(&funding.tx) + .p2wpkh_signature_hash(i, input_spk, amount, EcdsaSighashType::All) + .expect("valid sighash"); + let sig = Signature::sighash_all(secp.sign_ecdsa_low_r(&sighash.into(), input_privkey)); + + funding.tx.input[i].witness = + Witness::from_slice(&[sig.serialize().as_ref(), &input_pubkey.serialize()[..]]); + } + } + + // BOLT 3 Appendix B: Funding Transaction Test Vectors + // https://github.com/lightning/bolts/blob/master/03-transactions.md#appendix-b-funding-transaction-test-vectors + + #[test] + fn valid_funding_tx_with_p2pkh_input_and_p2wpkh_change() { + let utxos = vec![Utxo { + amount: Amount::from_sat(5_000_000_000), + outpoint: OutPoint { + txid: "fd2105607605d2302994ffea703b09f66b6351816ee737a93e42a841ea20bbad" + .parse() + .expect("valid txid"), + vout: 0, + }, + // P2PKH scriptpubkey of the block 1 coinbase output being spent. + script_pubkey: ScriptBuf::from( + hex::decode("76a9143ca33c2e4446f4a305f23c80df8ad1afdcf652f988ac") + .expect("valid P2PKH scriptpubkey hex"), + ), + }]; + + // P2WPKH scriptpubkey for the change destination. + let change_spk = ScriptBuf::from( + hex::decode("00143ca33c2e4446f4a305f23c80df8ad1afdcf652f9") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let mut funding = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + utxos.clone(), + change_spk, + ) + .expect("inputs should cover funding amount and fees"); + + assert_eq!(funding.vout, 0); + assert_eq!(funding.tx.output.len(), 2); + assert_eq!( + funding.tx.output[funding.vout as usize].value, + Amount::from_sat(10_000_000) + ); + + // The BOLT 3 test vectors assume that the sequence used in the inputs + // disables absolute locktime and replace-by-fee. However, our funding + // transaction construction enables replace-by-fee, so we override the + // sequence here to validate against the BOLT 3 test vectors. + funding.tx.input[0].sequence = Sequence::MAX; + + // Sign the P2PKH input with the block 1 coinbase privkey to verify the + // BOLT 3 txid. + sign_p2pkh_input( + &mut funding, + &utxos[0], + &secret("6bd078650fcee8444e4e09825227b801a1ca928debb750eb36e6d56124bb20e8"), + ); + + assert_eq!( + serialize_hex(&funding.tx), + "0200000001adbb20ea41a8423ea937e76e8151636bf6093b70eaff942930d20576600521fd000000006b48304502210090587b6201e166ad6af0227d3036a9454223d49a1f11839c1a362184340ef0240220577f7cd5cca78719405cbf1de7414ac027f0239ef6e214c90fcaab0454d84b3b012103535b32d5eb0a6ed0982a0479bbadc9868d9836f6ba94dd5a63be16d875069184ffffffff028096980000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd20256d29010000001600143ca33c2e4446f4a305f23c80df8ad1afdcf652f900000000" + ); + assert_eq!( + funding.tx.compute_txid().to_string(), + "8984484a580b825b9972d7adb15050b3ab624ccd731946b3eeddb92f4e7ef6be" + ); + } + + /// Not from BOLT 3 test vectors; expected serialized tx and txid were + /// generated by `bdk_wallet`. + /// Tests the case where the spendable UTXOs are only sufficient to cover + /// the funding amount and fees, but not the change output. + #[test] + fn valid_funding_tx_with_p2wpkh_input_and_no_change() { + let utxos = vec![Utxo { + amount: Amount::from_sat(10_008_942), + outpoint: OutPoint { + txid: "a1f7b953dc8c3db0222d931d3e2613f9971af75a09a005b31af057f8414cc5d7" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014a10d9257489e685dda030662390dc177852faf13") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }]; + + // P2WPKH scriptPubKey for the change output (will be dropped). + let change_spk = ScriptBuf::from( + hex::decode("00142e532c12351a5c81e23c8a76d19345ca7b6de57a") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let mut funding = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + utxos.clone(), + change_spk, + ) + .expect("inputs should cover funding amount and fees"); + + assert_eq!(funding.vout, 0); + assert_eq!(funding.tx.output.len(), 1); + assert_eq!( + funding.tx.output[funding.vout as usize].value, + Amount::from_sat(10_000_000) + ); + + sign_p2wpkh_input( + &mut funding, + &utxos, + &[secret( + "6c856f454ca42dc1df9cb154270ba11f7a9cc17392097101d92685ea81345b88", + )], + ); + + assert_eq!( + serialize_hex(&funding.tx), + "02000000000101d7c54c41f857f01ab305a0095af71a97f913263e1d932d22b03d8cdc53b9f7a10000000000fdffffff018096980000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd02473044022037f8b8e50c6e12a270a8856ceaecbdfe40132d744985c3fd690b0820b97e368c0220294d38ed56053f25be89ed48d06d5fabe8ab900ce6597d70e4e957e0d324fa24012102ceb69e22333f83556c5d1efba75a03346bf3d52cfbd39fc5d24ded034ef7d9f400000000" + ); + assert_eq!( + funding.tx.compute_txid().to_string(), + "09b0549b35f14ee862f63bd75811c6c27963c4dea6766ec6836952ec78df1e7e" + ); + } + + /// Not from BOLT 3 test vectors; expected serialized tx and txid were + /// generated by `bdk_wallet`. + /// Tests the case where the spendable UTXOs are sufficient to cover the + /// funding amount and fees, and the resulting change output equals the + /// dust limit. + #[test] + fn valid_funding_tx_with_p2wpkh_input_and_change_at_dust_limit() { + let utxos = vec![Utxo { + amount: Amount::from_sat(10_009_444), + outpoint: OutPoint { + txid: "7e7cd7f911a0e095105cbcd72290482c34369beceb6b14f0965dba35fce2c474" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014a10d9257489e685dda030662390dc177852faf13") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }]; + + // P2WPKH scriptPubKey for the change output. + let change_spk = ScriptBuf::from( + hex::decode("0014dbe223abef0f0dc3d41a01d0a8e3e0f7eea7f61f") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let mut funding = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + utxos.clone(), + change_spk, + ) + .expect("inputs should cover funding amount and fees"); + + assert_eq!(funding.vout, 0); + assert_eq!(funding.tx.output.len(), 2); + assert_eq!(funding.tx.output[0].value, Amount::from_sat(10_000_000)); + + // Bitcoin Core considers P2WPKH outputs worth less than 294 satoshis + // to be dust at the default dust relay fee of 3000 sat/kB. + assert_eq!(funding.tx.output[1].value, Amount::from_sat(294)); + + sign_p2wpkh_input( + &mut funding, + &utxos, + &[secret( + "6c856f454ca42dc1df9cb154270ba11f7a9cc17392097101d92685ea81345b88", + )], + ); + + assert_eq!( + serialize_hex(&funding.tx), + "0200000000010174c4e2fc35ba5d96f0146bebec9b36342c489022d7bc5c1095e0a011f9d77c7e0000000000fdffffff028096980000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd2601000000000000160014dbe223abef0f0dc3d41a01d0a8e3e0f7eea7f61f0247304402207ab0a59f970733752f9e1e91fab3681003e9d5733bfe6a53bf484b415b8b611602206737ffd43bd3450ee53e67b7c489f52e16a8777ab9f7a7f59d7aed5e7d7a3bdb012102ceb69e22333f83556c5d1efba75a03346bf3d52cfbd39fc5d24ded034ef7d9f400000000" + ); + assert_eq!( + funding.tx.compute_txid().to_string(), + "7066ff548b215f084e1ed166fec587b83f1e211dc8eb7b13b2b8e0440f81cd59" + ); + } + + /// Not from BOLT 3 test vectors; expected serialized tx and txid were + /// generated by `bdk_wallet`. + /// Tests the case where multiple UTXO inputs are available, but only a + /// subset of them are used. + #[test] + fn valid_funding_tx_with_multiple_inputs() { + let utxos = vec![ + Utxo { + amount: Amount::from_sat(10_011_000), + outpoint: OutPoint { + txid: "fe4e8d394f82812a85cbcea9dbee1a1cdfa56a7416ee764b15a100303b6e6d6a" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014255bdf13fe8864b038f90ed40251d8ba4efc005e") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }, + Utxo { + amount: Amount::from_sat(10_010_000), + outpoint: OutPoint { + txid: "0eabe9a1a0e3332abf1137a688ef11afa7e626abb96c1e76c7e892b3065291bc" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014f31409f93323c31054ed14d6efe5ac32e05a5abc") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }, + Utxo { + amount: Amount::from_sat(10_000_000), + outpoint: OutPoint { + txid: "8bc86cceeac83860cae4fb2ff389304fdecac68b49574afe802a5a606012f295" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("0014a10d9257489e685dda030662390dc177852faf13") + .expect("valid P2WPKH scriptpubkey hex"), + ), + }, + ]; + + // P2WPKH scriptPubKey for the change output. + let change_spk = ScriptBuf::from( + hex::decode("0014dbe223abef0f0dc3d41a01d0a8e3e0f7eea7f61f") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let mut funding = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 15_000_000, + 15_000, + utxos.clone(), + change_spk, + ) + .expect("inputs should cover funding amount and fees"); + + assert_eq!(funding.vout, 0); + assert_eq!(funding.tx.input.len(), 2); + assert_eq!(funding.tx.output.len(), 2); + assert_eq!( + funding.tx.output[funding.vout as usize].value, + Amount::from_sat(15_000_000) + ); + + sign_p2wpkh_input( + &mut funding, + &utxos[..2], + &[ + secret("213375f0a88d2519baf76c80f51f546fac9974e60ce8d416dbadeff41b88837c"), + secret("ad97abe5507fff5748c5d59885548b4f9cc8dfe06b795c5b5dd9e2fbf28f6674"), + ], + ); + + assert_eq!( + serialize_hex(&funding.tx), + "020000000001026a6d6e3b3000a1154b76ee16746aa5df1c1aeedba9cecb852a81824f398d4efe0000000000fdffffffbc915206b392e8c7761e6cb9ab26e6a7af11ef88a63711bf2a33e3a0a1e9ab0e0000000000fdffffff02c0e1e40000000000220020c015c4a6be010e21657068fc2e6a9d02b27ebe4d490a25846f7237f104d1a3cd9a694c0000000000160014dbe223abef0f0dc3d41a01d0a8e3e0f7eea7f61f024730440220241b86d533920bf831b0f293f33a235f88058192ec43dae4d487198652f634b302202a40f65d398eb7c01d580d23d6da4b9a911e1f4fc202b24c981452b3595bfb600121027c0cd74ffa26b13782539ce945f945d386606fb08490a2778089dad0ad29a2b402473044022054db9ac7982c3d78643883f69638b01c0d01d0363336ef001232a8348065e7d6022069846cbdda1202633548535529dbcb71fb3051091ad6c5e3e13a03fa755268d8012102d466308945a80e73cb65d35e30adcfaacfd8e4fb657edbe15537d770cf9021a900000000" + ); + assert_eq!( + funding.tx.compute_txid().to_string(), + "e737c301bcdcd305c52995f56c4cf6c9234bd4e99b16a38ff9a7cc897f5cf28d" + ); + } + + /// Not from BOLT 3 test vectors. + /// Tests the case where no spendable UTXOs are provided to cover the + /// funding amount and fees. + #[test] + fn funding_tx_with_empty_utxos() { + let change_spk = ScriptBuf::from( + hex::decode("00143ca33c2e4446f4a305f23c80df8ad1afdcf652f9") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let err = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + vec![], + change_spk, + ) + .unwrap_err(); + assert!(matches!(err, InsufficientFunds { .. })); + assert_eq!(err.required, Amount::from_sat(10_003_180)); + assert_eq!(err.available, Amount::from_sat(0)); + } + + /// Not from BOLT 3 test vectors. + /// Tests the case where the spendable UTXOs are insufficient to cover the + /// funding amount and fees. + #[test] + fn funding_tx_with_insufficient_funds() { + let utxos = vec![Utxo { + amount: Amount::from_sat(1_000), + outpoint: OutPoint { + txid: "fd2105607605d2302994ffea703b09f66b6351816ee737a93e42a841ea20bbad" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("76a9143ca33c2e4446f4a305f23c80df8ad1afdcf652f988ac") + .expect("valid P2PKH scriptpubkey hex"), + ), + }]; + let change_spk = ScriptBuf::from( + hex::decode("00143ca33c2e4446f4a305f23c80df8ad1afdcf652f9") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let err = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + utxos, + change_spk, + ) + .unwrap_err(); + assert!(matches!(err, InsufficientFunds { .. })); + assert_eq!(err.required, Amount::from_sat(10_012_060)); + assert_eq!(err.available, Amount::from_sat(1_000)); + } + + /// Not from BOLT 3 test vectors. + /// Tests that fee estimation panics when an input has an unsupported script + /// pubkey type (here P2TR), guarding against bitcoind's default address + /// type silently shifting away from P2WPKH. + #[test] + #[should_panic(expected = "unsupported input script pubkey")] + fn funding_tx_panics_on_unsupported_input_script() { + let utxos = vec![Utxo { + amount: Amount::from_sat(10_008_942), + outpoint: OutPoint { + txid: "a1f7b953dc8c3db0222d931d3e2613f9971af75a09a005b31af057f8414cc5d7" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("51201baeaaf9047cc42055a37a3ac981bdf7f5ab96fad0d2d07c54608e8a181b9477") + .expect("valid P2TR scriptpubkey hex"), + ), + }]; + + let change_spk = ScriptBuf::from( + hex::decode("00142e532c12351a5c81e23c8a76d19345ca7b6de57a") + .expect("valid P2WPKH scriptpubkey hex"), + ); + let _ = build_funding_transaction( + &pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"), + &pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"), + 10_000_000, + 15_000, + utxos, + change_spk, + ); + } + + /// Not from BOLT 3 test vectors. + #[test] + fn funding_witness_script_is_key_order_independent() { + let local_funding_pubkey = + pubkey("023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb"); + let remote_funding_pubkey = + pubkey("030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c1"); + + let funding_witness_script_1 = + build_funding_witness_script(&local_funding_pubkey, &remote_funding_pubkey); + let funding_witness_script_2 = + build_funding_witness_script(&remote_funding_pubkey, &local_funding_pubkey); + + // Argument order must not matter as keys are sorted lexicographically. + assert_eq!(funding_witness_script_1, funding_witness_script_2); + + assert_eq!( + hex::encode(funding_witness_script_1.as_bytes()), + "5221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae" + ); + } +}