From 6d7114d6b226414daa8003323f93ae6b8597a6cb Mon Sep 17 00:00:00 2001 From: Dens Sumesh Date: Wed, 25 Jun 2025 10:28:23 -0700 Subject: [PATCH 1/4] feature: added ability to create transaction from a block --- Cargo.toml | 2 +- crates/abci/src/tests/executor.rs | 2 +- crates/node/Cargo.toml | 1 + crates/node/src/handlers/deposit/handler.rs | 3 +- .../src/handlers/signing/create_signature.rs | 1 + crates/node/src/handlers/signing/utils.rs | 1 + .../handlers/withdrawl/create_withdrawl.rs | 9 +- crates/node/src/wallet/mod.rs | 7 + crates/node/src/wallet/taproot.rs | 145 ++++++++++++++++++ crates/protocol/Cargo.toml | 1 + crates/protocol/src/transaction.rs | 84 ++++++++-- crates/types/proto/p2p.proto | 5 +- crates/types/src/intents.rs | 6 + tests/src/consensus/block_consensus.rs | 1 + tests/src/protocol/deposits.rs | 9 ++ tests/src/withdrawl/mod.rs | 1 + 16 files changed, 261 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ee33adbf..b6662b33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ esplora-client = { version = "0.12.0", default-features = false, features = [ "async-https", "tokio", ] } -bincode = { version = "2.0.1", features = ["serde"] } +bincode = { version = "2.0.1", features = ["serde", "derive"] } sha2 = "0.10.9" rocksdb = "0.23.0" async-trait = "0.1.88" diff --git a/crates/abci/src/tests/executor.rs b/crates/abci/src/tests/executor.rs index e1e35b68..5e513433 100644 --- a/crates/abci/src/tests/executor.rs +++ b/crates/abci/src/tests/executor.rs @@ -413,7 +413,7 @@ async fn test_execute_transaction_push_operations() { let mut executor = create_test_executor(); let initial_state = ChainState::new(); - let transaction = Transaction::new( + let transaction = Transaction::new8( TransactionType::Deposit, vec![ Operation::OpPush { diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 4bca3a9e..9e4bd170 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -58,6 +58,7 @@ num-traits.workspace = true metrics.workspace = true metrics-exporter-prometheus.workspace = true actix-web.workspace = true +itertools = "0.14" protocol = { path = "../protocol" } types = { path = "../types" } diff --git a/crates/node/src/handlers/deposit/handler.rs b/crates/node/src/handlers/deposit/handler.rs index 7013250d..9c3be3ec 100644 --- a/crates/node/src/handlers/deposit/handler.rs +++ b/crates/node/src/handlers/deposit/handler.rs @@ -125,8 +125,7 @@ impl Handler for DepositIntentState { info!("✅ Added broadcasted transaction to pending pool"); } - let address = transaction.get_deposit_transaction_address()?; - let tx = node.oracle.get_transaction_by_address(&address).await?; + let tx = transaction.get_deposit_transaction_address()?; node.wallet.ingest_external_tx(&tx)?; } Err(e) => { diff --git a/crates/node/src/handlers/signing/create_signature.rs b/crates/node/src/handlers/signing/create_signature.rs index c6f35a10..d1367758 100644 --- a/crates/node/src/handlers/signing/create_signature.rs +++ b/crates/node/src/handlers/signing/create_signature.rs @@ -402,6 +402,7 @@ impl SigningState { &tx, pending.fee, pending.user_pubkey, + pending.address_to, ) .await?; debug!("📤 Broadcasted transaction"); diff --git a/crates/node/src/handlers/signing/utils.rs b/crates/node/src/handlers/signing/utils.rs index ef834a20..441fd04d 100644 --- a/crates/node/src/handlers/signing/utils.rs +++ b/crates/node/src/handlers/signing/utils.rs @@ -75,6 +75,7 @@ impl SigningState { PendingSpend { tx, user_pubkey, + address_to: address.to_string(), recipient_script, fee: estimated_fee_sat, }, diff --git a/crates/node/src/handlers/withdrawl/create_withdrawl.rs b/crates/node/src/handlers/withdrawl/create_withdrawl.rs index 3215f997..e485f244 100644 --- a/crates/node/src/handlers/withdrawl/create_withdrawl.rs +++ b/crates/node/src/handlers/withdrawl/create_withdrawl.rs @@ -132,11 +132,13 @@ impl SpendIntentState { tx: &BitcoinTransaction, fee: u64, user_pubkey: String, + address_to: String, ) -> Result<(), NodeError> { node.oracle.broadcast_transaction(tx).await?; let transaction = Transaction::create_withdrawal_transaction( &user_pubkey, + &address_to, tx.output[0].value.to_sat() + fee, )?; @@ -155,6 +157,7 @@ impl SpendIntentState { let spend_intent = PendingSpend { tx: tx.clone(), user_pubkey: user_pubkey.clone(), + address_to, recipient_script, fee, }; @@ -184,7 +187,11 @@ impl SpendIntentState { let debit = pay_out.value.to_sat() + pending.fee; - let transaction = Transaction::create_withdrawal_transaction(&pending.user_pubkey, debit)?; + let transaction = Transaction::create_withdrawal_transaction( + &pending.user_pubkey, + &pending.address_to, + debit, + )?; let ChainResponse::AddTransactionToBlock { error: None } = node .chain_interface_tx diff --git a/crates/node/src/wallet/mod.rs b/crates/node/src/wallet/mod.rs index 1b251a2e..6ae31cb5 100644 --- a/crates/node/src/wallet/mod.rs +++ b/crates/node/src/wallet/mod.rs @@ -1,5 +1,6 @@ // PendingSpend struct shared across node handlers use bitcoin::{Address, PublicKey, Transaction, secp256k1::Scalar}; +use protocol::block::Block; use types::errors::NodeError; pub mod taproot; @@ -18,6 +19,12 @@ pub trait Wallet: Send + Sync { dry_run: bool, ) -> Result<(Transaction, [u8; 32]), NodeError>; + fn get_transaction_for_block( + &self, + block: Block, + estimated_fee_sat: u64, + ) -> Result; + fn sign( &mut self, tx: &Transaction, diff --git a/crates/node/src/wallet/taproot.rs b/crates/node/src/wallet/taproot.rs index 946da517..7a697f45 100644 --- a/crates/node/src/wallet/taproot.rs +++ b/crates/node/src/wallet/taproot.rs @@ -10,13 +10,22 @@ use bitcoin::{ Amount, Network, ScriptBuf, Sequence, Transaction, TxIn, TxOut, absolute::LockTime, transaction::Version, witness::Witness, }; +use itertools::Itertools; use oracle::oracle::Oracle; +use protocol::block::Block; +use protocol::transaction::TransactionType; +use std::str::FromStr; use std::sync::Arc; use types::errors::NodeError; use types::utxo::Utxo; use super::Wallet; +const IN_SZ_VBYTES: f64 = 68.0; // assume P2WPKH/P2TR key-spend +const OUT_SZ_VBYTES: f64 = 31.0; // P2WPKH/P2TR output +const TX_OVH_VBYTES: f64 = 10.5; // version + locktime + marker/flag +const DUST: u64 = 546; + #[derive(Debug, Clone)] pub struct TrackedUtxo { pub utxo: Utxo, @@ -325,4 +334,140 @@ impl Wallet for TaprootWallet { fn get_utxos(&self) -> Vec { self.utxos.clone() } + + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + fn get_transaction_for_block( + &self, + block: Block, + feerate_sat_per_vb: u64, + ) -> Result { + let mut payouts: Vec<(bitcoin::Address, u64)> = Vec::new(); + + for tx in &block.body.transactions { + if tx.r#type != TransactionType::Withdrawal { + continue; + } + + let meta = tx + .metadata + .as_ref() + .ok_or_else(|| NodeError::Error("Withdrawal tx missing metadata".into()))?; + + let addr_str = meta + .get("address_to") + .and_then(|v| v.as_str()) + .ok_or_else(|| NodeError::Error("Withdrawal tx missing address_to".into()))?; + + let amt_sat = meta + .get("amount_sat") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| NodeError::Error("Withdrawal tx missing amount_sat".into()))?; + + let addr = bitcoin::Address::from_str(addr_str) + .map_err(|e| NodeError::Error(e.to_string())) + .map(bitcoin::Address::assume_checked)?; + payouts.push((addr, amt_sat)); + } + + if payouts.is_empty() { + return Err(NodeError::Error( + "Block contains no withdrawals to process".into(), + )); + } + + let fee_rate = feerate_sat_per_vb as f64; + let total_payout: u64 = payouts.iter().map(|(_, v)| *v).sum(); + let utxos = &self.utxos; // shorthand + + if utxos.is_empty() { + return Err(NodeError::Error("Wallet has no spendable UTXOs".into())); + } + + let mut candidates = utxos.clone(); + candidates.sort_by(|a, b| b.utxo.value.cmp(&a.utxo.value)); + + let mut chosen: Option> = None; + let mut chosen_change = 0_u64; + + 'outer: for k in 1..=candidates.len() { + for idx_set in (0..candidates.len()).combinations(k) { + let inputs: Vec<&TrackedUtxo> = idx_set.iter().map(|&i| &candidates[i]).collect(); + let in_sum: u64 = inputs.iter().map(|t| t.utxo.value.to_sat()).sum(); + + let mut n_out = payouts.len() + 1; + let mut weight = (n_out as f64).mul_add( + OUT_SZ_VBYTES, + (k as f64).mul_add(IN_SZ_VBYTES, TX_OVH_VBYTES), + ); + let mut fee = (weight * fee_rate).ceil() as u64; + + if in_sum < total_payout + fee { + continue; // under-funded + } + + let mut change = in_sum - total_payout - fee; + if change > 0 && change < DUST { + n_out -= 1; + weight = (n_out as f64).mul_add( + OUT_SZ_VBYTES, + (k as f64).mul_add(IN_SZ_VBYTES, TX_OVH_VBYTES), + ); + fee = (weight * fee_rate).ceil() as u64; + change = in_sum - total_payout - fee; + } + + if change == 0 || change >= DUST { + chosen = Some(inputs.into_iter().cloned().collect()); + chosen_change = change; + break 'outer; + } + } + } + + let selected = chosen.ok_or_else(|| { + NodeError::Error("Insufficient funds to satisfy withdrawals + fee".into()) + })?; + + let tx_inputs: Vec = selected + .iter() + .map(|t| TxIn { + previous_output: t.utxo.outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }) + .collect(); + + let mut tx_outputs: Vec = payouts + .iter() + .map(|(addr, amt)| TxOut { + value: Amount::from_sat(*amt), + script_pubkey: addr.script_pubkey(), + }) + .collect(); + + if chosen_change > 0 { + let change_addr = self + .addresses + .first() + .ok_or_else(|| NodeError::Error("Wallet has no change address".into()))?; + tx_outputs.push(TxOut { + value: Amount::from_sat(chosen_change), + script_pubkey: change_addr.script_pubkey(), + }); + } + + let tx = bitcoin::Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: tx_inputs, + output: tx_outputs, + }; + + Ok(tx) + } } diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index fa69d759..616b7764 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -18,6 +18,7 @@ tokio.workspace = true async-trait.workspace = true esplora-client.workspace = true hex.workspace = true +serde_json.workspace = true types = { path = "../types" } oracle = { path = "../oracle" } diff --git a/crates/protocol/src/transaction.rs b/crates/protocol/src/transaction.rs index 0bf0e019..fd1a9dfb 100644 --- a/crates/protocol/src/transaction.rs +++ b/crates/protocol/src/transaction.rs @@ -1,4 +1,5 @@ -use bincode::{Decode, Encode}; +use bincode::de::{BorrowDecode, BorrowDecoder}; +use bincode::{Decode, Encode, de::Decoder, enc::Encoder}; use bitcoin::hashes::Hash; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -6,11 +7,47 @@ use types::errors::NodeError; pub type TransactionId = [u8; 32]; -#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Transaction { pub version: u32, pub r#type: TransactionType, pub operations: Vec, + pub metadata: Option, +} + +impl Encode for Transaction { + fn encode(&self, e: &mut E) -> Result<(), bincode::error::EncodeError> { + self.version.encode(e)?; + self.r#type.encode(e)?; + self.operations.encode(e)?; + let metadata_string = serde_json::to_string(&self.metadata).unwrap(); + metadata_string.encode(e)?; + Ok(()) + } +} + +impl Decode for Transaction { + fn decode(d: &mut D) -> Result { + let version = u32::decode(d)?; + let r#type = TransactionType::decode(d)?; + let operations = Vec::::decode(d)?; + let metadata = Option::::decode(d)?; + let metadata = metadata.map(|s| serde_json::from_str(&s).unwrap()); + Ok(Self { + version, + r#type, + operations, + metadata, + }) + } +} + +impl<'de, C> BorrowDecode<'de, C> for Transaction { + fn borrow_decode>( + d: &mut D, + ) -> Result { + Self::decode(d) + } } #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)] @@ -57,11 +94,16 @@ pub enum Operation { impl Transaction { #[must_use] - pub const fn new(r#type: TransactionType, operations: Vec) -> Self { + pub const fn new( + r#type: TransactionType, + operations: Vec, + metadata: Option, + ) -> Self { Self { version: 1, r#type, operations, + metadata, } } @@ -79,6 +121,11 @@ impl Transaction { hasher.update(&op_bytes); } + if let Some(metadata) = &self.metadata { + let metadata_string = serde_json::to_string(metadata).unwrap(); + hasher.update(metadata_string.as_bytes()); + } + let result = hasher.finalize(); let mut id = [0u8; 32]; id.copy_from_slice(&result); @@ -111,20 +158,32 @@ impl Transaction { }, Operation::OpIncrementBalance, ], + Some(serde_json::json!({ + "tx": tx, + "user_pubkey": user_pubkey, + "amount_sat": amount_sat, + })), )) } - pub fn get_deposit_transaction_address(&self) -> Result { - let Operation::OpPush { value } = &self.operations[2] else { - return Err(NodeError::Error("Not a deposit transaction".to_string())); - }; - let txid = bitcoin::Txid::from_slice(value) - .map_err(|_| NodeError::Error("Invalid transaction ID length".to_string()))?; - Ok(txid.to_string()) + pub fn get_deposit_transaction_address(&self) -> Result { + let tx = self + .metadata + .as_ref() + .ok_or_else(|| NodeError::Error("No metadata".to_string()))?; + let tx = serde_json::from_value( + tx.get("tx") + .ok_or_else(|| NodeError::Error("No tx".to_string()))? + .clone(), + ) + .map_err(|_| NodeError::Error("Invalid tx".to_string()))?; + + Ok(tx) } pub fn create_withdrawal_transaction( user_pubkey: &str, + address_to: &str, amount_sat: u64, ) -> Result { Ok(Self::new( @@ -138,6 +197,11 @@ impl Transaction { }, Operation::OpDecrementBalance, ], + Some(serde_json::json!({ + "user_pubkey": user_pubkey, + "amount_sat": amount_sat, + "address_to": address_to, + })), )) } } diff --git a/crates/types/proto/p2p.proto b/crates/types/proto/p2p.proto index ebcb55a9..1adb12ae 100644 --- a/crates/types/proto/p2p.proto +++ b/crates/types/proto/p2p.proto @@ -108,8 +108,9 @@ message DepositIntent { message PendingSpend { bytes transaction = 1; string user_pubkey = 2; - bytes recipient_script = 3; - uint64 fee = 4; + string address_to = 3; + bytes recipient_script = 4; + uint64 fee = 5; } // ========== Bitcoin Transaction Wrapper ========== diff --git a/crates/types/src/intents.rs b/crates/types/src/intents.rs index a7e0ece1..bdf55ce4 100644 --- a/crates/types/src/intents.rs +++ b/crates/types/src/intents.rs @@ -58,6 +58,7 @@ pub struct WithdrawlIntent { pub struct PendingSpend { pub tx: Transaction, pub user_pubkey: String, + pub address_to: String, pub recipient_script: ScriptBuf, pub fee: u64, } @@ -70,6 +71,7 @@ impl Encode for PendingSpend { let raw_tx = bitcoin::consensus::encode::serialize(&self.tx); bincode::Encode::encode(&raw_tx, encoder)?; bincode::Encode::encode(&self.user_pubkey, encoder)?; + bincode::Encode::encode(&self.address_to, encoder)?; bincode::Encode::encode(&self.recipient_script.as_bytes(), encoder)?; bincode::Encode::encode(&self.fee, encoder)?; Ok(()) @@ -84,11 +86,13 @@ impl Decode for PendingSpend { let raw_tx: Transaction = bitcoin::consensus::encode::deserialize(&raw_tx_bytes) .map_err(|_| bincode::error::DecodeError::Other("Failed to deserialize transaction"))?; let user_pubkey = bincode::Decode::decode(decoder)?; + let address_to = bincode::Decode::decode(decoder)?; let recipient_script = ScriptBuf::from_bytes(bincode::Decode::decode(decoder)?); let fee = bincode::Decode::decode(decoder)?; Ok(Self { tx: raw_tx, user_pubkey, + address_to, recipient_script, fee, }) @@ -103,6 +107,7 @@ impl ProtoEncode for PendingSpend { let proto_intent = p2p_proto::PendingSpend { transaction: transaction_bytes, user_pubkey: self.user_pubkey.clone(), + address_to: self.address_to.clone(), recipient_script: script_bytes, fee: self.fee, }; @@ -127,6 +132,7 @@ impl ProtoDecode for PendingSpend { Ok(Self { tx, user_pubkey: proto_intent.user_pubkey, + address_to: proto_intent.address_to, recipient_script, fee: proto_intent.fee, }) diff --git a/tests/src/consensus/block_consensus.rs b/tests/src/consensus/block_consensus.rs index 309c7190..1bfd5530 100644 --- a/tests/src/consensus/block_consensus.rs +++ b/tests/src/consensus/block_consensus.rs @@ -203,6 +203,7 @@ mod block_consensus_tests { }, Operation::OpIncrementBalance, ], + None, ) } diff --git a/tests/src/protocol/deposits.rs b/tests/src/protocol/deposits.rs index 2e136fe9..d37217b1 100644 --- a/tests/src/protocol/deposits.rs +++ b/tests/src/protocol/deposits.rs @@ -94,6 +94,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -197,6 +198,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -237,6 +239,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -274,6 +277,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -312,6 +316,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -342,6 +347,7 @@ mod deposit_test { }, Operation::OpCheckOracle, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -368,6 +374,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -415,6 +422,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); @@ -469,6 +477,7 @@ mod deposit_test { }, Operation::OpIncrementBalance, ], + None, ); let mut executor = TransactionExecutorImpl::new(Box::new(mock_oracle)); diff --git a/tests/src/withdrawl/mod.rs b/tests/src/withdrawl/mod.rs index 52a8798a..7b3895b6 100644 --- a/tests/src/withdrawl/mod.rs +++ b/tests/src/withdrawl/mod.rs @@ -28,6 +28,7 @@ mod withdrawl_tests { }, protocol::transaction::Operation::OpIncrementBalance, ], + None, ); node.chain_interface_tx From 77513ef171cd021675c643ce9cf13154cbaa61f6 Mon Sep 17 00:00:00 2001 From: Dens Sumesh Date: Wed, 25 Jun 2025 10:38:05 -0700 Subject: [PATCH 2/4] bugfix: fix the tests --- crates/abci/src/tests/executor.rs | 6 +++++- crates/abci/src/tests/lib_tests.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/abci/src/tests/executor.rs b/crates/abci/src/tests/executor.rs index 5e513433..f1613e1a 100644 --- a/crates/abci/src/tests/executor.rs +++ b/crates/abci/src/tests/executor.rs @@ -413,7 +413,7 @@ async fn test_execute_transaction_push_operations() { let mut executor = create_test_executor(); let initial_state = ChainState::new(); - let transaction = Transaction::new8( + let transaction = Transaction::new( TransactionType::Deposit, vec![ Operation::OpPush { @@ -423,6 +423,7 @@ async fn test_execute_transaction_push_operations() { value: vec![4, 5, 6], }, ], + None, ); let result = executor @@ -467,6 +468,7 @@ async fn test_execute_transaction_deposit_flow() { }, Operation::OpIncrementBalance, ], + None, ); let result = executor @@ -503,6 +505,7 @@ async fn test_execute_transaction_withdrawal_flow() { }, Operation::OpDecrementBalance, ], + None, ); let result = executor @@ -532,6 +535,7 @@ async fn test_execute_transaction_error_propagation() { }, Operation::OpIncrementBalance, ], + None, ); let result = executor diff --git a/crates/abci/src/tests/lib_tests.rs b/crates/abci/src/tests/lib_tests.rs index 2923ede9..46f17755 100644 --- a/crates/abci/src/tests/lib_tests.rs +++ b/crates/abci/src/tests/lib_tests.rs @@ -198,6 +198,7 @@ async fn test_execute_deposit_transaction() { }, Operation::OpIncrementBalance, ], + None, ); // Execute transaction @@ -252,6 +253,7 @@ async fn test_execute_withdrawal_transaction() { }, Operation::OpIncrementBalance, ], + None, ); chain_interface @@ -280,6 +282,7 @@ async fn test_execute_withdrawal_transaction() { }, Operation::OpDecrementBalance, ], + None, ); // Execute withdrawal @@ -321,6 +324,7 @@ async fn test_execute_transaction_insufficient_balance() { }, Operation::OpDecrementBalance, ], + None, ); // Add transaction to pending @@ -373,6 +377,7 @@ async fn test_execute_transaction_state_persistence() { }, Operation::OpIncrementBalance, ], + None, ); chain_interface @@ -427,6 +432,7 @@ async fn test_execute_multiple_transactions() { }, Operation::OpIncrementBalance, ], + None, ); chain_interface @@ -465,6 +471,7 @@ async fn test_transaction_error_propagation() { }, Operation::OpIncrementBalance, // This should fail due to no allowance ], + None, ); let result = chain_interface @@ -530,6 +537,7 @@ async fn test_concurrent_operations() { }, Operation::OpIncrementBalance, ], + None, ); chain_interface From f12892ac70bdbaededf10c824b215ce0b57201ba Mon Sep 17 00:00:00 2001 From: Dens Sumesh Date: Wed, 25 Jun 2025 10:50:32 -0700 Subject: [PATCH 3/4] feature: add tests for the taproot wallet --- tests/src/lib.rs | 1 + tests/src/wallet/taproot.rs | 274 +++++++++++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 2 deletions(-) diff --git a/tests/src/lib.rs b/tests/src/lib.rs index a05f9e4c..9e59f4a6 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -7,4 +7,5 @@ pub mod mocks; pub mod protocol; pub mod signing; pub mod util; +pub mod wallet; pub mod withdrawl; diff --git a/tests/src/wallet/taproot.rs b/tests/src/wallet/taproot.rs index d6a81406..ef5f720f 100644 --- a/tests/src/wallet/taproot.rs +++ b/tests/src/wallet/taproot.rs @@ -1,13 +1,46 @@ #[cfg(test)] mod taproot_wallet_tests { use crate::mocks::pubkey::random_public_key; - use bitcoin::Network; use bitcoin::secp256k1::Scalar; + use bitcoin::{Amount, Network}; use node::wallet::TaprootWallet; use node::wallet::Wallet; use oracle::mock::MockOracle; + use protocol::block::{Block, BlockBody, BlockHeader}; + use protocol::transaction::{Transaction, TransactionType}; + use serde_json::json; use tokio::sync::broadcast; - use types::network_event::NetworkEvent; + use types::network::network_event::NetworkEvent; + + fn create_test_wallet() -> TaprootWallet { + let (tx_channel, _) = broadcast::channel::(100); + let oracle = MockOracle::new(tx_channel, None); + TaprootWallet::new(Box::new(oracle), Vec::new(), Network::Testnet) + } + + fn create_withdrawal_transaction(address: &str, amount_sat: u64) -> Transaction { + Transaction::new( + TransactionType::Withdrawal, + vec![], + Some(json!({ + "address_to": address, + "amount_sat": amount_sat + })), + ) + } + + fn create_test_block(transactions: Vec) -> Block { + Block { + header: BlockHeader { + version: 1, + previous_block_hash: [0u8; 32], + state_root: [0u8; 32], + height: 1, + proposer: vec![], + }, + body: BlockBody { transactions }, + } + } #[tokio::test] async fn test_taproot_wallet_create_and_refresh() { @@ -34,4 +67,241 @@ mod taproot_wallet_tests { assert_eq!(wallet.utxos.len(), 9); } + + #[tokio::test] + async fn test_get_transaction_for_block_single_withdrawal() { + let mut wallet = create_test_wallet(); + let pubkey = random_public_key(); + let tweak = Scalar::from_be_bytes([1u8; 32]).unwrap(); + + // Setup wallet with address and UTXOs + wallet.generate_new_address(pubkey, tweak); + wallet + .refresh_utxos(Some(true)) + .await + .expect("refresh_utxos failed"); + + // Create a block with a single withdrawal + let withdrawal_tx = + create_withdrawal_transaction("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", 50000); + let block = create_test_block(vec![withdrawal_tx]); + + // Test transaction creation + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_ok()); + + let bitcoin_tx = result.unwrap(); + assert!(!bitcoin_tx.input.is_empty()); + assert_eq!(bitcoin_tx.output.len(), 2); // 1 payout + 1 change + assert_eq!(bitcoin_tx.output[0].value, Amount::from_sat(50000)); + } + + #[tokio::test] + async fn test_get_transaction_for_block_multiple_withdrawals() { + let mut wallet = create_test_wallet(); + let pubkey = random_public_key(); + let tweak = Scalar::from_be_bytes([1u8; 32]).unwrap(); + + // Setup wallet with address and UTXOs + wallet.generate_new_address(pubkey, tweak); + wallet + .refresh_utxos(Some(true)) + .await + .expect("refresh_utxos failed"); + + // Create a block with multiple withdrawals + let withdrawal_tx1 = + create_withdrawal_transaction("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", 25000); + let withdrawal_tx2 = create_withdrawal_transaction( + "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", + 30000, + ); + let block = create_test_block(vec![withdrawal_tx1, withdrawal_tx2]); + + // Test transaction creation + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_ok()); + + let bitcoin_tx = result.unwrap(); + assert!(!bitcoin_tx.input.is_empty()); + assert_eq!(bitcoin_tx.output.len(), 3); // 2 payouts + 1 change + assert_eq!(bitcoin_tx.output[0].value, Amount::from_sat(25000)); + assert_eq!(bitcoin_tx.output[1].value, Amount::from_sat(30000)); + } + + #[tokio::test] + async fn test_get_transaction_for_block_no_withdrawals() { + let wallet = create_test_wallet(); + + // Create a block with no withdrawal transactions + let deposit_tx = Transaction::new(TransactionType::Deposit, vec![], None); + let block = create_test_block(vec![deposit_tx]); + + // Test should return error for no withdrawals + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no withdrawals")); + } + + #[tokio::test] + async fn test_get_transaction_for_block_missing_metadata() { + let wallet = create_test_wallet(); + + // Create a withdrawal transaction with missing metadata + let withdrawal_tx = Transaction::new(TransactionType::Withdrawal, vec![], None); + let block = create_test_block(vec![withdrawal_tx]); + + // Test should return error for missing metadata + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("missing metadata")); + } + + #[tokio::test] + async fn test_get_transaction_for_block_missing_address() { + let wallet = create_test_wallet(); + + // Create a withdrawal transaction with missing address_to + let withdrawal_tx = Transaction::new( + TransactionType::Withdrawal, + vec![], + Some(json!({ "amount_sat": 50000 })), + ); + let block = create_test_block(vec![withdrawal_tx]); + + // Test should return error for missing address + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing address_to") + ); + } + + #[tokio::test] + async fn test_get_transaction_for_block_missing_amount() { + let wallet = create_test_wallet(); + + // Create a withdrawal transaction with missing amount_sat + let withdrawal_tx = Transaction::new( + TransactionType::Withdrawal, + vec![], + Some(json!({ "address_to": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" })), + ); + let block = create_test_block(vec![withdrawal_tx]); + + // Test should return error for missing amount + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing amount_sat") + ); + } + + #[tokio::test] + async fn test_get_transaction_for_block_insufficient_funds() { + let wallet = create_test_wallet(); + + // Create a withdrawal for a large amount without UTXOs + let withdrawal_tx = create_withdrawal_transaction( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + 1_000_000_000, + ); + let block = create_test_block(vec![withdrawal_tx]); + + // Test should return error for insufficient funds + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no spendable UTXOs") + ); + } + + #[tokio::test] + async fn test_get_transaction_for_block_invalid_address() { + let wallet = create_test_wallet(); + + // Create a withdrawal transaction with invalid address + let withdrawal_tx = Transaction::new( + TransactionType::Withdrawal, + vec![], + Some(json!({ + "address_to": "invalid_address", + "amount_sat": 50000 + })), + ); + let block = create_test_block(vec![withdrawal_tx]); + + // Test should return error for invalid address + let result = wallet.get_transaction_for_block(block, 10); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_transaction_for_block_dust_change() { + let mut wallet = create_test_wallet(); + let pubkey = random_public_key(); + let tweak = Scalar::from_be_bytes([1u8; 32]).unwrap(); + + // Setup wallet with address and UTXOs + wallet.generate_new_address(pubkey, tweak); + wallet + .refresh_utxos(Some(true)) + .await + .expect("refresh_utxos failed"); + + let withdrawal_tx = + create_withdrawal_transaction("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", 500); + let block = create_test_block(vec![withdrawal_tx]); + + // Test transaction creation with very low fee rate to maximize change + let result = wallet.get_transaction_for_block(block, 1); // Low fee rate + assert!(result.is_ok()); + + let bitcoin_tx = result.unwrap(); + assert_eq!(bitcoin_tx.output[0].value, Amount::from_sat(500)); + + assert!(!bitcoin_tx.output.is_empty()); + assert_eq!(bitcoin_tx.output.len(), 2); + } + + #[tokio::test] + async fn test_get_transaction_for_block_high_fee_rate() { + let mut wallet = create_test_wallet(); + let pubkey = random_public_key(); + let tweak = Scalar::from_be_bytes([1u8; 32]).unwrap(); + + // Setup wallet with address and UTXOs + wallet.generate_new_address(pubkey, tweak); + wallet + .refresh_utxos(Some(true)) + .await + .expect("refresh_utxos failed"); + + // Create a withdrawal with high fee rate + let withdrawal_tx = + create_withdrawal_transaction("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", 50000); + let block = create_test_block(vec![withdrawal_tx]); + + // Test transaction creation with high fee rate + let result = wallet.get_transaction_for_block(block, 100); // High fee rate + assert!(result.is_ok()); + + let bitcoin_tx = result.unwrap(); + assert!(!bitcoin_tx.input.is_empty()); + assert_eq!(bitcoin_tx.output[0].value, Amount::from_sat(50000)); + + // With high fee rate, change should be significantly less + if bitcoin_tx.output.len() == 2 { + assert!(bitcoin_tx.output[1].value.to_sat() < 40000); // Less than low fee case + } + } } From bc6a1f1c5dcce4b92ca3e4253d3da332229f9ad4 Mon Sep 17 00:00:00 2001 From: Dens Sumesh Date: Wed, 25 Jun 2025 13:33:44 -0700 Subject: [PATCH 4/4] bugfix: fix integ tests --- crates/node/src/start_node.rs | 11 ++++++----- crates/oracle/src/mock.rs | 2 +- crates/protocol/src/transaction.rs | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/node/src/start_node.rs b/crates/node/src/start_node.rs index 2019ac2c..5bd88884 100644 --- a/crates/node/src/start_node.rs +++ b/crates/node/src/start_node.rs @@ -142,7 +142,7 @@ pub async fn start_node( .parse() .unwrap(); - let mut oracle: Box = if use_mock_oracle.unwrap_or(false) { + let oracle: Box = if use_mock_oracle.unwrap_or(false) { Box::new(MockOracle::new( swarm.network_events.clone(), Some(deposit_intent_tx.clone()), @@ -208,6 +208,11 @@ pub async fn start_node( consensus_interface.start().await; }); + let mut oracle_clone = oracle.clone(); + let deposit_monitor_handle = tokio::spawn(async move { + oracle_clone.poll_new_transactions(vec![]).await; + }); + let mut node_state = NodeState::new_from_config( &network_handle, config, @@ -261,10 +266,6 @@ pub async fn start_node( let main_loop_handle = tokio::spawn(async move { node_state.start().await }); - let deposit_monitor_handle = tokio::spawn(async move { - oracle.poll_new_transactions(vec![]).await; - }); - // Create shutdown signal handler for Docker compatibility let shutdown_signal = async { #[cfg(unix)] diff --git a/crates/oracle/src/mock.rs b/crates/oracle/src/mock.rs index bb5ef7a6..41a2d475 100644 --- a/crates/oracle/src/mock.rs +++ b/crates/oracle/src/mock.rs @@ -142,7 +142,7 @@ impl Oracle for MockOracle { async fn poll_new_transactions(&mut self, _addresses: Vec
) { info!("Polling new transactions"); - let Some(dep_tx_sender) = self.deposit_intent_rx.take() else { + let Some(ref dep_tx_sender) = self.deposit_intent_rx else { return; }; diff --git a/crates/protocol/src/transaction.rs b/crates/protocol/src/transaction.rs index fd1a9dfb..bc5c21d8 100644 --- a/crates/protocol/src/transaction.rs +++ b/crates/protocol/src/transaction.rs @@ -31,8 +31,8 @@ impl Decode for Transaction { let version = u32::decode(d)?; let r#type = TransactionType::decode(d)?; let operations = Vec::::decode(d)?; - let metadata = Option::::decode(d)?; - let metadata = metadata.map(|s| serde_json::from_str(&s).unwrap()); + let metadata = String::decode(d)?; + let metadata = serde_json::from_str(&metadata).unwrap(); Ok(Self { version, r#type,