From c43ff88e2637690a672265ac568fd9e53d0ad088 Mon Sep 17 00:00:00 2001 From: Nishant Bansal Date: Mon, 18 May 2026 00:06:58 +0530 Subject: [PATCH] smite-ir: add CreateFundingTransaction IR operation Signed-off-by: Nishant Bansal --- smite-ir/src/builder.rs | 3 + smite-ir/src/mutators/operation_param.rs | 1 + smite-ir/src/operation.rs | 22 ++- smite-ir/src/tests.rs | 202 ++++++++++++++++++++++ smite-ir/src/variable.rs | 6 +- smite-scenarios/src/executor.rs | 208 ++++++++++++++++++++++- 6 files changed, 437 insertions(+), 5 deletions(-) diff --git a/smite-ir/src/builder.rs b/smite-ir/src/builder.rs index 7d14918d..9697893a 100644 --- a/smite-ir/src/builder.rs +++ b/smite-ir/src/builder.rs @@ -168,6 +168,9 @@ impl ProgramBuilder { VariableType::AcceptChannel => { panic!("cannot generate fresh AcceptChannel: requires protocol interaction") } + VariableType::FundingTransaction => { + panic!("cannot generate fresh FundingTransaction: requires composed inputs") + } } } diff --git a/smite-ir/src/mutators/operation_param.rs b/smite-ir/src/mutators/operation_param.rs index 6ce1306f..5255171d 100644 --- a/smite-ir/src/mutators/operation_param.rs +++ b/smite-ir/src/mutators/operation_param.rs @@ -82,6 +82,7 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool { // Non-mutable variants. Reaching here means `is_param_mutable` and this // match have drifted out of sync. Operation::DerivePoint + | Operation::CreateFundingTransaction | Operation::LoadTargetPubkeyFromContext | Operation::LoadChainHashFromContext | Operation::BuildOpenChannel diff --git a/smite-ir/src/operation.rs b/smite-ir/src/operation.rs index 7e56ed9b..c5641b68 100644 --- a/smite-ir/src/operation.rs +++ b/smite-ir/src/operation.rs @@ -66,6 +66,14 @@ pub enum Operation { /// Extract a field from a parsed `accept_channel` response. /// Input: `AcceptChannel`. ExtractAcceptChannel(AcceptChannelField), + /// Create a BOLT 3 funding transaction for the channel funding flow. + /// + /// Inputs (4): + /// 0: `opener_funding_pubkey` (`Point`) + /// 1: `acceptor_funding_pubkey` (`Point`) + /// 2: `funding_satoshis` (`Amount`) + /// 3: `feerate_per_kw` (`FeeratePerKw`) + CreateFundingTransaction, // -- Build: construct a BOLT message from inputs -- /// Build an `open_channel` message (BOLT 2, type 32). @@ -532,6 +540,7 @@ impl fmt::Display for Operation { // Operations with inputs: parens added by Program::Display. Self::DerivePoint => write!(f, "DerivePoint"), Self::ExtractAcceptChannel(field) => write!(f, "Extract{field}"), + Self::CreateFundingTransaction => write!(f, "CreateFundingTransaction"), Self::BuildOpenChannel => write!(f, "BuildOpenChannel"), Self::SendMessage => write!(f, "SendMessage"), } @@ -557,6 +566,7 @@ impl Operation { Self::LoadTargetPubkeyFromContext | Self::DerivePoint => Some(VariableType::Point), Self::LoadChainHashFromContext => Some(VariableType::ChainHash), Self::ExtractAcceptChannel(field) => Some(field.output_type()), + Self::CreateFundingTransaction => Some(VariableType::FundingTransaction), Self::BuildOpenChannel => Some(VariableType::Message), Self::SendMessage | Self::MineBlocks(_) => None, Self::RecvAcceptChannel => Some(VariableType::AcceptChannel), @@ -587,6 +597,12 @@ impl Operation { Self::DerivePoint => vec![VariableType::PrivateKey], Self::ExtractAcceptChannel(_) => vec![VariableType::AcceptChannel], Self::SendMessage => vec![VariableType::Message], + Self::CreateFundingTransaction => vec![ + VariableType::Point, // opener_funding_pubkey + VariableType::Point, // acceptor_funding_pubkey + VariableType::Amount, // funding_satoshis + VariableType::FeeratePerKw, // feerate_per_kw + ], Self::BuildOpenChannel => vec![ VariableType::ChainHash, // chain_hash @@ -639,7 +655,8 @@ impl Operation { | Self::ExtractAcceptChannel(_) | Self::BuildOpenChannel | Self::SendMessage - | Self::MineBlocks(_) => vec![], + | Self::MineBlocks(_) + | Self::CreateFundingTransaction => vec![], Self::RecvAcceptChannel => AcceptChannelField::ALL .iter() @@ -673,7 +690,8 @@ impl Operation { | Self::DerivePoint | Self::BuildOpenChannel | Self::SendMessage - | Self::RecvAcceptChannel => false, + | Self::RecvAcceptChannel + | Self::CreateFundingTransaction => false, } } } diff --git a/smite-ir/src/tests.rs b/smite-ir/src/tests.rs index 9e907b33..509d7323 100644 --- a/smite-ir/src/tests.rs +++ b/smite-ir/src/tests.rs @@ -234,6 +234,22 @@ fn postcard_roundtrip() { operation: Operation::MineBlocks(6), inputs: vec![], }, + Instruction { + operation: Operation::LoadPrivateKey(key(2)), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![7], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(15_000), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![1, 8, 4, 9], + }, ], }; @@ -298,6 +314,184 @@ fn validate_rejects_mine_blocks_with_wrong_input() { ); } +#[test] +fn create_funding_transaction_operation() { + let op = Operation::CreateFundingTransaction; + assert_eq!( + op.input_types(), + vec![ + VariableType::Point, + VariableType::Point, + VariableType::Amount, + VariableType::FeeratePerKw, + ], + ); + assert_eq!(op.output_type(), Some(VariableType::FundingTransaction)); + assert!(!op.is_param_mutable()); +} + +fn create_funding_transaction_instructions() -> Vec { + vec![ + Instruction { + operation: Operation::LoadPrivateKey(key(1)), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![0], + }, + Instruction { + operation: Operation::LoadPrivateKey(key(2)), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![2], + }, + Instruction { + operation: Operation::LoadAmount(10_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(15_000), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![1, 3, 4, 5], + }, + ] +} + +#[test] +fn displays_create_funding_transaction_program() { + let program = Program { + instructions: create_funding_transaction_instructions(), + }; + let text = program.to_string(); + let lines: Vec<&str> = text.lines().collect(); + + let z31 = "00".repeat(31); + + let expected = vec![ + format!("v0 = LoadPrivateKey(0x{z31}01)"), + "v1 = DerivePoint(v0)".into(), + format!("v2 = LoadPrivateKey(0x{z31}02)"), + "v3 = DerivePoint(v2)".into(), + "v4 = LoadAmount(10000000)".into(), + "v5 = LoadFeeratePerKw(15000)".into(), + "v6 = CreateFundingTransaction(v1, v3, v4, v5)".into(), + ]; + assert_eq!(lines, expected); +} + +#[test] +fn validate_accepts_create_funding_transaction() { + let program = Program { + instructions: create_funding_transaction_instructions(), + }; + program + .validate() + .expect("CreateFundingTransaction should validate"); +} + +#[test] +fn validate_rejects_create_funding_transaction_with_wrong_input_count() { + let program = Program { + instructions: vec![Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![], + }], + }; + assert_eq!( + program.validate(), + Err(ValidateError::WrongInputCount { + instr: 0, + expected: 4, + got: 0, + }), + ); +} + +#[test] +fn validate_rejects_create_funding_transaction_with_wrong_input_type() { + fn program_with_wrong_input(pos: usize, wrong: Operation) -> Program { + let mut inputs = vec![2, 2, 3, 4]; + inputs[pos] = 0; + + Program { + instructions: vec![ + Instruction { + operation: wrong, // wrong-typed value + inputs: vec![], + }, + Instruction { + operation: Operation::LoadPrivateKey(key(1)), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![1], + }, + Instruction { + operation: Operation::LoadAmount(100_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(1_500), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs, + }, + ], + } + } + + // input pos, wrong, expected, got + let cases = [ + ( + 0, + Operation::LoadAmount(100_000), + VariableType::Point, + VariableType::Amount, + ), + ( + 1, + Operation::LoadFeeratePerKw(1_500), + VariableType::Point, + VariableType::FeeratePerKw, + ), + ( + 2, + Operation::LoadPrivateKey(key(1)), + VariableType::Amount, + VariableType::PrivateKey, + ), + ( + 3, + Operation::LoadAmount(100_000), + VariableType::FeeratePerKw, + VariableType::Amount, + ), + ]; + + for (input, wrong, expected, got) in cases { + let result = program_with_wrong_input(input, wrong).validate(); + + assert_eq!( + result, + Err(ValidateError::TypeMismatch { + instr: 5, + input, + expected, + got, + }), + ); + } +} + // Ensure AcceptChannelField and AcceptChannelField::ALL stay in sync. The // exhaustive match in this test will fail to compile if a variant is added // without updating it, and the assertion will fail if the match is updated @@ -685,6 +879,14 @@ fn generate_fresh_accept_channel_panics() { builder.generate_fresh(VariableType::AcceptChannel, &mut rng); } +#[test] +#[should_panic(expected = "cannot generate fresh FundingTransaction")] +fn generate_fresh_funding_transaction_panics() { + let mut rng = SmallRng::seed_from_u64(0); + let mut builder = ProgramBuilder::new(); + builder.generate_fresh(VariableType::FundingTransaction, &mut rng); +} + #[test] #[should_panic(expected = "expected 1 inputs, got 0")] fn append_wrong_input_count_panics() { diff --git a/smite-ir/src/variable.rs b/smite-ir/src/variable.rs index 88d71108..f3944ca0 100644 --- a/smite-ir/src/variable.rs +++ b/smite-ir/src/variable.rs @@ -4,7 +4,7 @@ //! The serialized program stores data only in [`Operation`] literals. use bitcoin::secp256k1::PublicKey; -use smite::bolt::{AcceptChannel, ChannelId}; +use smite::bolt::{AcceptChannel, ChannelId, FundingTransaction}; const CHAIN_HASH_SIZE: usize = 32; const PRIVATE_KEY_SIZE: usize = 32; @@ -42,6 +42,8 @@ pub enum Variable { Message(Vec), /// Parsed `accept_channel` response. AcceptChannel(AcceptChannel), + /// Constructed funding transaction with funding output index. + FundingTransaction(FundingTransaction), } impl Variable { @@ -63,6 +65,7 @@ impl Variable { Self::Features(_) => VariableType::Features, Self::Message(_) => VariableType::Message, Self::AcceptChannel(_) => VariableType::AcceptChannel, + Self::FundingTransaction(_) => VariableType::FundingTransaction, } } } @@ -85,4 +88,5 @@ pub enum VariableType { Features, Message, AcceptChannel, + FundingTransaction, } diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index 83f98a60..63707ef4 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -3,10 +3,12 @@ //! Executes an IR program against a target node over an established connection, //! producing side effects (sending/receiving messages). +use bitcoin::ScriptBuf; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use smite::bitcoin::BitcoinCli; +use smite::bitcoin::{BitcoinCli, Utxo}; use smite::bolt::{ - AcceptChannel, ChannelId, Message, OpenChannel, OpenChannelTlvs, Pong, msg_type, + AcceptChannel, ChannelId, FundingTransaction, Message, OpenChannel, OpenChannelTlvs, Pong, + build_funding_transaction, msg_type, }; use smite::noise::{ConnectionError, NoiseConnection}; use smite_ir::operation::AcceptChannelField; @@ -16,12 +18,26 @@ use smite_ir::{Operation, Program, Variable, VariableType}; pub trait BitcoinRpc { /// Mines the given number of blocks. fn mine_blocks(&mut self, num_blocks: u8); + + /// Returns the wallet's spendable UTXOs. + fn get_utxos(&mut self) -> Vec; + + /// Returns the scriptPubKey for a newly generated wallet address. + fn get_new_address_script_pubkey(&mut self) -> ScriptBuf; } impl BitcoinRpc for BitcoinCli { fn mine_blocks(&mut self, num_blocks: u8) { BitcoinCli::mine_blocks(self, num_blocks); } + + fn get_utxos(&mut self) -> Vec { + BitcoinCli::get_utxos(self) + } + + fn get_new_address_script_pubkey(&mut self) -> ScriptBuf { + BitcoinCli::get_new_address_script_pubkey(self) + } } /// State captured during snapshot setup, available to IR programs at execution @@ -103,6 +119,10 @@ pub enum ExecuteError { /// Received a different message type than expected. #[error("unexpected message: expected type {expected}, got {got}")] UnexpectedMessage { expected: u16, got: u16 }, + + /// Wallet UTXOs could not cover the funding amount and fees. + #[error("funding: {0}")] + InsufficientFunds(#[from] smite::bolt::InsufficientFunds), } /// Executes an IR program against a target over the given connection. @@ -162,6 +182,11 @@ pub fn execute( Some(extract_field(ac, *field)) } + Operation::CreateFundingTransaction => { + let ft = create_funding_transaction(&variables, &instr.inputs, bitcoin_cli)?; + Some(Variable::FundingTransaction(ft)) + } + // -- Build operations -- Operation::BuildOpenChannel => { let oc = build_open_channel(&variables, &instr.inputs)?; @@ -334,6 +359,35 @@ fn resolve_accept_channel( // -- Operation handlers -- +/// Create a funding transaction by querying the bitcoind for UTXOs and a +/// change address, then calling [`build_funding_transaction`]. +fn create_funding_transaction( + variables: &[Option], + inputs: &[usize], + cli: &mut impl BitcoinRpc, +) -> Result { + let opener_pubkey = resolve_pubkey(variables, inputs[0])?; + let acceptor_pubkey = resolve_pubkey(variables, inputs[1])?; + let funding_satoshis = resolve_amount(variables, inputs[2])?; + let feerate_per_kw = resolve_feerate(variables, inputs[3])?; + + // Query wallet state from bitcoind for coin selection and change. + let utxos = cli.get_utxos(); + let change_spk = cli.get_new_address_script_pubkey(); + + // Create the funding transaction. + let funding = build_funding_transaction( + &opener_pubkey, + &acceptor_pubkey, + funding_satoshis, + feerate_per_kw, + utxos, + change_spk, + )?; + + Ok(funding) +} + /// Builds an `OpenChannel` from 20 input variables (wire order). fn build_open_channel( variables: &[Option], @@ -447,6 +501,7 @@ mod tests { use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use bitcoin::{Amount, OutPoint}; use smite::bolt::{AcceptChannelTlvs, Init, Ping}; use smite_ir::Instruction; @@ -488,12 +543,22 @@ mod tests { #[derive(Default)] struct MockBitcoinCli { mine_blocks_calls: Vec, + utxos: Vec, + change_spk: ScriptBuf, } impl BitcoinRpc for MockBitcoinCli { fn mine_blocks(&mut self, num_blocks: u8) { self.mine_blocks_calls.push(num_blocks); } + + fn get_utxos(&mut self) -> Vec { + self.utxos.clone() + } + + fn get_new_address_script_pubkey(&mut self) -> ScriptBuf { + self.change_spk.clone() + } } // -- Helpers -- @@ -515,6 +580,29 @@ mod tests { } } + fn sample_utxo() -> Utxo { + Utxo { + amount: Amount::from_sat(5_000_000_000), + outpoint: OutPoint { + txid: "fd2105607605d2302994ffea703b09f66b6351816ee737a93e42a841ea20bbad" + .parse() + .expect("valid txid"), + vout: 0, + }, + script_pubkey: ScriptBuf::from( + hex::decode("76a9143ca33c2e4446f4a305f23c80df8ad1afdcf652f988ac") + .expect("valid P2PKH scriptpubkey hex"), + ), + } + } + + fn sample_change_spk() -> ScriptBuf { + ScriptBuf::from( + hex::decode("00143ca33c2e4446f4a305f23c80df8ad1afdcf652f9") + .expect("valid P2WPKH scriptpubkey hex"), + ) + } + fn sample_accept_channel() -> AcceptChannel { AcceptChannel { temporary_channel_id: ChannelId::new([0xaa; 32]), @@ -1103,6 +1191,122 @@ mod tests { )); } + #[test] + fn execute_create_funding_transaction() { + let instrs = vec![ + Instruction { + operation: Operation::LoadPrivateKey([0x11; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![0], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x22; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![2], + }, + Instruction { + operation: Operation::LoadAmount(10_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(15_000), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![1, 3, 4, 5], + }, + ]; + + let mut conn = MockConnection::new(); + let mut mock_cli = MockBitcoinCli { + utxos: vec![sample_utxo()], + change_spk: sample_change_spk(), + ..Default::default() + }; + execute( + &Program { + instructions: instrs, + }, + &sample_context(), + &mut conn, + &mut mock_cli, + std::time::Instant::now(), + ) + .expect("funding tx construction should succeed"); + + // TODO: Once `resolve_funding_transaction` is added, fetch the funding + // transaction here and assert that it matches the expected transaction + // constructed from the sample UTXO and change script pubkey. + } + + #[test] + fn execute_create_funding_transaction_insufficient_funds() { + let instrs = vec![ + Instruction { + operation: Operation::LoadPrivateKey([0x11; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![0], + }, + Instruction { + operation: Operation::LoadPrivateKey([0x22; 32]), + inputs: vec![], + }, + Instruction { + operation: Operation::DerivePoint, + inputs: vec![2], + }, + Instruction { + operation: Operation::LoadAmount(10_000_000), + inputs: vec![], + }, + Instruction { + operation: Operation::LoadFeeratePerKw(15_000), + inputs: vec![], + }, + Instruction { + operation: Operation::CreateFundingTransaction, + inputs: vec![1, 3, 4, 5], + }, + ]; + + // UTXO too small to cover the funding amount and fees. + let small_utxo = Utxo { + amount: Amount::from_sat(1_000), + ..sample_utxo() + }; + let mut conn = MockConnection::new(); + let mut mock_cli = MockBitcoinCli { + utxos: vec![small_utxo], + change_spk: sample_change_spk(), + ..Default::default() + }; + let err = execute( + &Program { + instructions: instrs, + }, + &sample_context(), + &mut conn, + &mut mock_cli, + std::time::Instant::now(), + ) + .unwrap_err(); + let ExecuteError::InsufficientFunds(funds_err) = err else { + panic!("expected InsufficientFunds, got {err:?}"); + }; + assert_eq!(funds_err.available, Amount::from_sat(1_000)); + assert_eq!(funds_err.required, Amount::from_sat(10_012_060)); + } + #[test] fn execute_invalid_private_key() { let instrs = vec![