From 007359fa551cd8e87ce889d2c7555699a26801e8 Mon Sep 17 00:00:00 2001 From: Wei Chen Date: Fri, 17 Jan 2025 18:29:55 +0800 Subject: [PATCH] test(electrum): detect receive tx cancel WIP --- crates/electrum/tests/test_electrum.rs | 374 ++++++++++++++++++++++++- 1 file changed, 362 insertions(+), 12 deletions(-) diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs index a5abbd2b5..1d0858727 100644 --- a/crates/electrum/tests/test_electrum.rs +++ b/crates/electrum/tests/test_electrum.rs @@ -1,14 +1,29 @@ use bdk_chain::{ - bitcoin::{hashes::Hash, Address, Amount, ScriptBuf, WScriptHash}, + bitcoin::{ + consensus::encode, hashes::Hash, secp256k1::Secp256k1, transaction::Version, Address, + Amount, OutPoint, PrivateKey, Psbt, PublicKey, ScriptBuf, Transaction, TxIn, TxOut, + WScriptHash, + }, + indexer::keychain_txout::KeychainTxOutIndex, + keychain_txout::SyncRequestBuilderExt, local_chain::LocalChain, + miniscript::{ + descriptor::{DescriptorSecretKey, SinglePubKey}, + Descriptor, DescriptorPublicKey, + }, spk_client::{FullScanRequest, SyncRequest, SyncResponse}, spk_txout::SpkTxOutIndex, Balance, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, }; use bdk_electrum::BdkElectrumClient; -use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv}; -use core::time::Duration; -use std::collections::{BTreeSet, HashSet}; +use bdk_testenv::{ + anyhow, + bitcoincore_rpc::{json::CreateRawTransactionInput, RawTx, RpcApi}, + utils::new_tx, + TestEnv, +}; +use core::{sync, time::Duration}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::str::FromStr; // Batch size for `sync_with_electrum`. @@ -26,7 +41,7 @@ fn get_balance( Ok(balance) } -fn sync_with_electrum( +fn sync_with_spks( client: &BdkElectrumClient, spks: Spks, chain: &mut LocalChain, @@ -54,6 +69,341 @@ where Ok(update) } +fn sync_with_keychain( + client: &BdkElectrumClient, + chain: &mut LocalChain, + graph: &mut IndexedTxGraph>, +) -> anyhow::Result { + use bdk_chain::keychain_txout::SyncRequestBuilderExt; + + let request = SyncRequest::builder() + .chain_tip(chain.tip()) + .revealed_spks_from_indexer(&graph.index, ..) + .unconfirmed_outpoints( + graph.graph().canonical_iter(chain, chain.tip().block_id()), + &graph.index, + ); + + //println!("REQUEST: {:?}", request); + + let update = client.sync(request, BATCH_SIZE, true)?; + + if let Some(chain_update) = update.chain_update.clone() { + let _ = chain + .apply_update(chain_update) + .map_err(|err| anyhow::anyhow!("LocalChain update error: {:?}", err))?; + } + let _ = graph.apply_update(update.tx_update.clone()); + + Ok(update) +} + +#[test] +pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { + let env = TestEnv::new()?; + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + let client = BdkElectrumClient::new(electrum_client); + + let secp = Secp256k1::new(); + let (descriptor, keymap) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)") + .expect("must be valid"); + let receiver_spk = descriptor.at_derivation_index(9).unwrap().script_pubkey(); + + let key_info = match keymap.iter().next().expect("keymap not empty") { + (DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => { + let pk = match single_pub.key { + SinglePubKey::FullKey(pk) => pk, + SinglePubKey::XOnly(_) => unimplemented!("single xonly pubkey"), + }; + // Return both the public and private keys. + Ok((pk, prv.key)) + } + (_, DescriptorSecretKey::XPrv(k)) => { + // Return the extended private key. + Err(k.xkey) + } + _ => panic!("Unsupported key configuration"), + }; + + let mut recv_graph = IndexedTxGraph::>::new( + KeychainTxOutIndex::new(10), + ); + let _ = recv_graph + .index + .insert_descriptor((), descriptor.clone()) + .unwrap(); + let (mut recv_chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); + + env.mine_blocks(101, None)?; + + let utxos = env + .rpc_client() + .list_unspent(None, None, None, None, None)?; + let selected_utxo = utxos + .into_iter() + .find(|utxo| utxo.amount >= Amount::from_sat(40_000)) + .expect("Must have a UTXO with sufficient funds"); + + let block_hash = env.rpc_client().get_block_hash( + (env.rpc_client().get_block_count()? + 1) - selected_utxo.confirmations as u64, + )?; + let utxo_tx = env + .rpc_client() + .get_block(&block_hash)? + .txdata + .iter() + .find(|tx| tx.compute_txid() == selected_utxo.txid) + .cloned() + .expect("Tx must exist"); + + let funding_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(selected_utxo.txid, selected_utxo.vout), + ..Default::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(40_000), + script_pubkey: Address::from_str("bcrt1qfjg5lv3dvc9az8patec8fjddrs4aqtauadnagr")? + .assume_checked() + .script_pubkey(), + }, + TxOut { + value: Amount::from_sat(selected_utxo.amount.to_sat() - 41_500), + script_pubkey: selected_utxo.script_pub_key, + }, + ], + version: Version::TWO, + ..new_tx(0) + }; + + let mut funding_psbt = Psbt::from_unsigned_tx(funding_tx.clone())?; + //funding_psbt.inputs[0].witness_utxo = Some(utxo_tx.output[selected_utxo.vout as usize].clone()); + funding_psbt.inputs[0].non_witness_utxo = Some(utxo_tx.clone()); + + match key_info { + Ok((pk, prv_key)) => { + let keys: HashMap = [(pk, prv_key)].into(); + funding_psbt.sign(&keys, &secp).unwrap(); + } + Err(xprv) => { + funding_psbt.sign(&xprv, &secp).unwrap(); + } + } + + // Broadcast the funding transaction + let funding_txid = env + .rpc_client() + .send_raw_transaction(&encode::serialize(&funding_psbt.extract_tx()?))?; + println!("THERE"); + + env.mine_blocks(1, None)?; + env.wait_until_electrum_sees_block(Duration::from_secs(6))?; + + let funding_tx = env + .rpc_client() + .get_transaction(&funding_txid, None)? + .transaction()?; + + let funding_amt = funding_tx + .output + .iter() + .map(|o| o.value.to_sat()) + .sum::(); + + let mut send_psbt = Psbt::from_unsigned_tx(Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(funding_txid, 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(10_000), + script_pubkey: receiver_spk.clone(), + }, + TxOut { + value: Amount::from_sat(funding_amt - 10_500), + script_pubkey: funding_tx.output[0].script_pubkey.clone(), + }, + ], + version: Version::TWO, + ..new_tx(1) + })?; + send_psbt.inputs[0].non_witness_utxo = Some(funding_tx.clone()); + + let mut undo_send_psbt = Psbt::from_unsigned_tx(Transaction { + input: vec![TxIn { + previous_output: OutPoint::new(funding_txid, 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: Amount::from_sat(5_000), + script_pubkey: receiver_spk, + }, + TxOut { + value: Amount::from_sat(funding_amt - 5_500), + script_pubkey: funding_tx.output[0].script_pubkey.clone(), + }, + ], + version: Version::TWO, + ..new_tx(2) + })?; + undo_send_psbt.inputs[0].non_witness_utxo = Some(funding_tx.clone()); + + match key_info { + Ok((pk, prv_key)) => { + let keys: HashMap = [(pk, prv_key)].into(); + send_psbt.sign(&keys, &secp).unwrap(); + undo_send_psbt.sign(&keys, &secp).unwrap(); + } + Err(xprv) => { + send_psbt.sign(&xprv, &secp).unwrap(); + undo_send_psbt.sign(&xprv, &secp).unwrap(); + } + } + + // Broadcast the send transaction. + let send_txid = env + .rpc_client() + .send_raw_transaction(&encode::serialize(&send_psbt.extract_tx()?))?; + + // Broadcast the cancel transaction to create a conflict. + let undo_send_txid = env + .rpc_client() + .send_raw_transaction(&encode::serialize(&undo_send_psbt.extract_tx()?))?; + + // Sync and check for conflicts. + let sync_result = sync_with_keychain(&client, &mut recv_chain, &mut recv_graph)?; + assert!(sync_result + .tx_update + .txs + .iter() + .any(|tx| tx.compute_txid() == send_txid)); + assert!(sync_result + .tx_update + .txs + .iter() + .any(|tx| tx.compute_txid() == undo_send_txid)); + + println!("Detected transactions: {:?}", sync_result.tx_update.txs); + + Ok(()) +} + +#[test] +pub fn detect_receive_2_fast_2_cancel() -> anyhow::Result<()> { + let env = TestEnv::new()?; + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + let client = BdkElectrumClient::new(electrum_client); + env.rpc_client() + .create_wallet("", None, None, None, None) + .expect("Failed to create wallet"); + + let (descriptor, _keymap) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)") + .expect("must be valid"); + let sender_spk = descriptor.at_derivation_index(0)?.script_pubkey(); + let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest)?; + let receiver_spk = descriptor.at_derivation_index(9)?.script_pubkey(); + let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?; + + let mut graph = IndexedTxGraph::>::new( + KeychainTxOutIndex::new(10), + ); + let _ = graph + .index + .insert_descriptor((), descriptor.clone()) + .unwrap(); + let (mut chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); + + env.mine_blocks(101, None)?; + + let utxos = env + .rpc_client() + .list_unspent(None, None, None, Some(false), None)?; + let selected_utxo = utxos + .into_iter() + .find(|utxo| utxo.amount >= Amount::from_sat(40_000)) + .expect("Must have a UTXO with sufficient funds"); + // let sender_spk = selected_utxo.script_pub_key.clone(); + // let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest) + // .expect("Failed to derive address from UTXO"); + + let inputs = [CreateRawTransactionInput { + txid: selected_utxo.txid, + vout: selected_utxo.vout, + sequence: None, + }]; + + let utxo_amount = selected_utxo.amount.to_sat(); + + let mut outputs = HashMap::new(); + outputs.insert(receiver_addr.to_string(), Amount::from_sat(100_000)); + outputs.insert( + sender_addr.to_string(), + Amount::from_sat(utxo_amount - 102_000), + ); + + let send_tx = env + .rpc_client() + .create_raw_transaction(&inputs, &outputs, None, Some(true))?; + let send_tx = env + .rpc_client() + .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)? + .transaction()?; + + let address = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); + let mut outputs = HashMap::new(); + outputs.insert(address.to_string(), Amount::from_sat(50_000)); + outputs.insert( + sender_addr.to_string(), + Amount::from_sat(utxo_amount - 102_000), + ); + + let undo_send_tx = + env.rpc_client() + .create_raw_transaction(&inputs, &outputs, None, Some(true))?; + let undo_send_tx = env + .rpc_client() + .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)? + .transaction()?; + + // Broadcast the send transaction. + let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?; + + env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?; + + // Sync and check for conflicts. + let sync_result = sync_with_keychain(&client, &mut chain, &mut graph)?; + println!("SYNC RESULT: {:?}", sync_result); + assert!(sync_result + .tx_update + .txs + .iter() + .any(|tx| tx.compute_txid() == send_txid)); + + // Broadcast the cancel transaction to create a conflict. + let undo_send_txid = env + .rpc_client() + .send_raw_transaction(undo_send_tx.raw_hex())?; + + env.mine_blocks(1, None)?; + env.wait_until_electrum_sees_block(Duration::from_secs(6))?; + + // assert!(sync_result + // .tx_update + // .txs + // .iter() + // .any(|tx| tx.compute_txid() == undo_send_txid)); + + println!("Detected transactions: {:?}", sync_result.tx_update.txs); + + Ok(()) +} + #[test] pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { let env = TestEnv::new()?; @@ -322,7 +672,7 @@ fn test_sync() -> anyhow::Result<()> { let txid = env.send(&addr_to_track, SEND_AMOUNT)?; env.wait_until_electrum_sees_txid(txid, Duration::from_secs(6))?; - let _ = sync_with_electrum( + let _ = sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain, @@ -343,7 +693,7 @@ fn test_sync() -> anyhow::Result<()> { env.mine_blocks(1, None)?; env.wait_until_electrum_sees_block(Duration::from_secs(6))?; - let _ = sync_with_electrum( + let _ = sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain, @@ -364,7 +714,7 @@ fn test_sync() -> anyhow::Result<()> { env.reorg_empty_blocks(1)?; env.wait_until_electrum_sees_block(Duration::from_secs(6))?; - let _ = sync_with_electrum( + let _ = sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain, @@ -384,7 +734,7 @@ fn test_sync() -> anyhow::Result<()> { env.mine_blocks(1, None)?; env.wait_until_electrum_sees_block(Duration::from_secs(6))?; - let _ = sync_with_electrum(&client, [spk_to_track], &mut recv_chain, &mut recv_graph)?; + let _ = sync_with_spks(&client, [spk_to_track], &mut recv_chain, &mut recv_graph)?; // Check if balance is correct once transaction is confirmed again. assert_eq!( @@ -470,7 +820,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { // Sync up to tip. env.wait_until_electrum_sees_block(Duration::from_secs(6))?; - let update = sync_with_electrum( + let update = sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain, @@ -501,7 +851,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { env.reorg_empty_blocks(depth)?; env.wait_until_electrum_sees_block(Duration::from_secs(6))?; - let update = sync_with_electrum( + let update = sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain, @@ -549,7 +899,7 @@ fn test_sync_with_coinbase() -> anyhow::Result<()> { env.wait_until_electrum_sees_block(Duration::from_secs(6))?; // Check to see if electrum syncs properly. - assert!(sync_with_electrum( + assert!(sync_with_spks( &client, [spk_to_track.clone()], &mut recv_chain,