|
1 | 1 | use bdk_chain::{ |
2 | | - bitcoin::{hashes::Hash, Address, Amount, ScriptBuf, WScriptHash}, |
| 2 | + bitcoin::{hashes::Hash, secp256k1::Secp256k1, Address, Amount, ScriptBuf, WScriptHash}, |
| 3 | + indexer::keychain_txout::KeychainTxOutIndex, |
3 | 4 | local_chain::LocalChain, |
| 5 | + miniscript::Descriptor, |
4 | 6 | spk_client::{FullScanRequest, SyncRequest, SyncResponse}, |
5 | 7 | spk_txout::SpkTxOutIndex, |
6 | 8 | Balance, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph, |
7 | 9 | }; |
8 | 10 | use bdk_electrum::BdkElectrumClient; |
9 | | -use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv}; |
| 11 | +use bdk_testenv::{ |
| 12 | + anyhow, |
| 13 | + bitcoincore_rpc::{json::CreateRawTransactionInput, RawTx, RpcApi}, |
| 14 | + TestEnv, |
| 15 | +}; |
10 | 16 | use core::time::Duration; |
11 | | -use std::collections::{BTreeSet, HashSet}; |
| 17 | +use std::collections::{BTreeSet, HashMap, HashSet}; |
12 | 18 | use std::str::FromStr; |
13 | 19 |
|
14 | 20 | // Batch size for `sync_with_electrum`. |
@@ -54,6 +60,140 @@ where |
54 | 60 | Ok(update) |
55 | 61 | } |
56 | 62 |
|
| 63 | +// This test simulates a transaction cancellation scenario using replace-by-fee (RBF) by |
| 64 | +// broadcasting a conflicting transaction with a higher fee, verifying that double spending is |
| 65 | +// correctly handled by the transaction graph. |
| 66 | +#[test] |
| 67 | +pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { |
| 68 | + use bdk_chain::keychain_txout::SyncRequestBuilderExt; |
| 69 | + let env = TestEnv::new()?; |
| 70 | + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; |
| 71 | + let client = BdkElectrumClient::new(electrum_client); |
| 72 | + |
| 73 | + let (descriptor, _keymap) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)") |
| 74 | + .expect("must be valid"); |
| 75 | + |
| 76 | + let mut graph = IndexedTxGraph::<ConfirmationBlockTime, KeychainTxOutIndex<()>>::new( |
| 77 | + KeychainTxOutIndex::new(10), |
| 78 | + ); |
| 79 | + let _ = graph |
| 80 | + .index |
| 81 | + .insert_descriptor((), descriptor.clone()) |
| 82 | + .unwrap(); |
| 83 | + let (chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); |
| 84 | + |
| 85 | + // Derive the receiving address from the descriptor. |
| 86 | + let ((_, receiver_spk), _) = graph.index.reveal_next_spk(()).unwrap(); |
| 87 | + let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?; |
| 88 | + |
| 89 | + env.mine_blocks(101, None)?; |
| 90 | + |
| 91 | + // Select a UTXO to use as an input for constructing our test transactions. |
| 92 | + let utxos = env |
| 93 | + .rpc_client() |
| 94 | + .list_unspent(None, None, None, Some(false), None)?; |
| 95 | + let selected_utxo = utxos |
| 96 | + .into_iter() |
| 97 | + .find(|utxo| utxo.amount >= Amount::from_sat(40_000)) |
| 98 | + .expect("Must have a UTXO with sufficient funds"); |
| 99 | + |
| 100 | + // Derive the sender's address from the selected UTXO. |
| 101 | + let sender_spk = selected_utxo.script_pub_key.clone(); |
| 102 | + let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest) |
| 103 | + .expect("Failed to derive address from UTXO"); |
| 104 | + |
| 105 | + // Setup the common input used by both `send_tx` and `undo_send_tx`. |
| 106 | + let input = [CreateRawTransactionInput { |
| 107 | + txid: selected_utxo.txid, |
| 108 | + vout: selected_utxo.vout, |
| 109 | + sequence: None, |
| 110 | + }]; |
| 111 | + |
| 112 | + let utxo_amount = selected_utxo.amount.to_sat(); |
| 113 | + |
| 114 | + // Create output for `send_tx`, directing funds to the receiver address. |
| 115 | + let output = HashMap::from([( |
| 116 | + receiver_addr.to_string(), |
| 117 | + Amount::from_sat(utxo_amount - 100_000), |
| 118 | + )]); |
| 119 | + |
| 120 | + // Create and sign the `send_tx` transaction. |
| 121 | + let send_tx = env |
| 122 | + .rpc_client() |
| 123 | + .create_raw_transaction(&input, &output, None, Some(true))?; |
| 124 | + let send_tx = env |
| 125 | + .rpc_client() |
| 126 | + .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)? |
| 127 | + .transaction()?; |
| 128 | + |
| 129 | + // Create the output for `undo_send_tx`, redirecting the funds back to the sender address. The |
| 130 | + // amount is reduced to increase the transaction fee, ensuring that `undo_send_tx` can replace |
| 131 | + // `send_tx` via RBF. |
| 132 | + let output = HashMap::from([( |
| 133 | + sender_addr.to_string(), |
| 134 | + Amount::from_sat(utxo_amount - 150_000), |
| 135 | + )]); |
| 136 | + |
| 137 | + // Create and sign the `undo_send_tx` transaction. |
| 138 | + let undo_send_tx = |
| 139 | + env.rpc_client() |
| 140 | + .create_raw_transaction(&input, &output, None, Some(true))?; |
| 141 | + let undo_send_tx = env |
| 142 | + .rpc_client() |
| 143 | + .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)? |
| 144 | + .transaction()?; |
| 145 | + |
| 146 | + // Broadcast the send transaction. |
| 147 | + let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?; |
| 148 | + |
| 149 | + env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?; |
| 150 | + |
| 151 | + // Sync and check that our sync result and graph update contain the `send_tx`. |
| 152 | + let request = SyncRequest::builder() |
| 153 | + .chain_tip(chain.tip()) |
| 154 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 155 | + .unconfirmed_outpoints( |
| 156 | + graph.graph().canonical_iter(&chain, chain.tip().block_id()), |
| 157 | + &graph.index, |
| 158 | + ); |
| 159 | + let sync_result = client.sync(request, BATCH_SIZE, true)?; |
| 160 | + assert!(sync_result |
| 161 | + .tx_update |
| 162 | + .txs |
| 163 | + .iter() |
| 164 | + .any(|tx| tx.compute_txid() == send_txid)); |
| 165 | + |
| 166 | + let changeset = graph.apply_update(sync_result.tx_update.clone()); |
| 167 | + assert!(changeset.tx_graph.txs.contains(&send_tx)); |
| 168 | + |
| 169 | + // Broadcast the `undo_send_txid` transaction to replace `send_tx`. |
| 170 | + let undo_send_txid = env |
| 171 | + .rpc_client() |
| 172 | + .send_raw_transaction(undo_send_tx.raw_hex())?; |
| 173 | + |
| 174 | + env.wait_until_electrum_sees_txid(undo_send_txid, Duration::from_secs(6))?; |
| 175 | + |
| 176 | + // Sync and check that our sync result and graph update now contain `undo_send_tx`. |
| 177 | + let request = SyncRequest::builder() |
| 178 | + .chain_tip(chain.tip()) |
| 179 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 180 | + .unconfirmed_outpoints( |
| 181 | + graph.graph().canonical_iter(&chain, chain.tip().block_id()), |
| 182 | + &graph.index, |
| 183 | + ); |
| 184 | + let sync_result = client.sync(request, BATCH_SIZE, true)?; |
| 185 | + assert!(sync_result |
| 186 | + .tx_update |
| 187 | + .txs |
| 188 | + .iter() |
| 189 | + .any(|tx| tx.compute_txid() == undo_send_txid)); |
| 190 | + |
| 191 | + let changeset = graph.apply_update(sync_result.tx_update.clone()); |
| 192 | + assert!(changeset.tx_graph.txs.contains(&undo_send_tx)); |
| 193 | + |
| 194 | + Ok(()) |
| 195 | +} |
| 196 | + |
57 | 197 | #[test] |
58 | 198 | pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { |
59 | 199 | let env = TestEnv::new()?; |
|
0 commit comments