|
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,127 @@ where |
54 | 60 | Ok(update) |
55 | 61 | } |
56 | 62 |
|
| 63 | +// Ensure that a wallet can detect a malicious replacement of an incoming transaction. |
| 64 | +// |
| 65 | +// This checks that both the Electrum chain source and the receiving structures properly track the |
| 66 | +// replaced transaction as missing. |
| 67 | +#[test] |
| 68 | +pub fn detect_receive_tx_cancel() -> anyhow::Result<()> { |
| 69 | + const SEND_TX_FEE: Amount = Amount::from_sat(1000); |
| 70 | + const UNDO_SEND_TX_FEE: Amount = Amount::from_sat(2000); |
| 71 | + |
| 72 | + use bdk_chain::keychain_txout::SyncRequestBuilderExt; |
| 73 | + let env = TestEnv::new()?; |
| 74 | + let rpc_client = env.rpc_client(); |
| 75 | + let electrum_client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; |
| 76 | + let client = BdkElectrumClient::new(electrum_client); |
| 77 | + |
| 78 | + let (receiver_desc, _) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)") |
| 79 | + .expect("must be valid"); |
| 80 | + let mut graph = IndexedTxGraph::<ConfirmationBlockTime, _>::new(KeychainTxOutIndex::new(0)); |
| 81 | + let _ = graph.index.insert_descriptor((), receiver_desc.clone())?; |
| 82 | + let (chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); |
| 83 | + |
| 84 | + // Derive the receiving address from the descriptor. |
| 85 | + let ((_, receiver_spk), _) = graph.index.reveal_next_spk(()).unwrap(); |
| 86 | + let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?; |
| 87 | + |
| 88 | + env.mine_blocks(101, None)?; |
| 89 | + |
| 90 | + // Select a UTXO to use as an input for constructing our test transactions. |
| 91 | + let selected_utxo = rpc_client |
| 92 | + .list_unspent(None, None, None, Some(false), None)? |
| 93 | + .into_iter() |
| 94 | + // Find a block reward tx. |
| 95 | + .find(|utxo| utxo.amount == Amount::from_int_btc(50)) |
| 96 | + .expect("Must find a block reward UTXO"); |
| 97 | + |
| 98 | + // Derive the sender's address from the selected UTXO. |
| 99 | + let sender_spk = selected_utxo.script_pub_key.clone(); |
| 100 | + let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest) |
| 101 | + .expect("Failed to derive address from UTXO"); |
| 102 | + |
| 103 | + // Setup the common inputs used by both `send_tx` and `undo_send_tx`. |
| 104 | + let inputs = [CreateRawTransactionInput { |
| 105 | + txid: selected_utxo.txid, |
| 106 | + vout: selected_utxo.vout, |
| 107 | + sequence: None, |
| 108 | + }]; |
| 109 | + |
| 110 | + // Create and sign the `send_tx` that sends funds to the receiver address. |
| 111 | + let send_tx_outputs = HashMap::from([( |
| 112 | + receiver_addr.to_string(), |
| 113 | + selected_utxo.amount - SEND_TX_FEE, |
| 114 | + )]); |
| 115 | + let send_tx = rpc_client.create_raw_transaction(&inputs, &send_tx_outputs, None, Some(true))?; |
| 116 | + let send_tx = rpc_client |
| 117 | + .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)? |
| 118 | + .transaction()?; |
| 119 | + |
| 120 | + // Create and sign the `undo_send_tx` transaction. This redirects funds back to the sender |
| 121 | + // address. |
| 122 | + let undo_send_outputs = HashMap::from([( |
| 123 | + sender_addr.to_string(), |
| 124 | + selected_utxo.amount - UNDO_SEND_TX_FEE, |
| 125 | + )]); |
| 126 | + let undo_send_tx = |
| 127 | + rpc_client.create_raw_transaction(&inputs, &undo_send_outputs, None, Some(true))?; |
| 128 | + let undo_send_tx = rpc_client |
| 129 | + .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)? |
| 130 | + .transaction()?; |
| 131 | + |
| 132 | + // Sync after broadcasting the `send_tx`. Ensure that we detect and receive the `send_tx`. |
| 133 | + let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?; |
| 134 | + env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?; |
| 135 | + let sync_request = SyncRequest::builder() |
| 136 | + .chain_tip(chain.tip()) |
| 137 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 138 | + .check_unconfirmed_statuses( |
| 139 | + &graph.index, |
| 140 | + graph.graph().canonical_iter(&chain, chain.tip().block_id()), |
| 141 | + ); |
| 142 | + let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; |
| 143 | + assert!( |
| 144 | + sync_response |
| 145 | + .tx_update |
| 146 | + .txs |
| 147 | + .iter() |
| 148 | + .any(|tx| tx.compute_txid() == send_txid), |
| 149 | + "sync response must include the send_tx" |
| 150 | + ); |
| 151 | + let changeset = graph.apply_update(sync_response.tx_update.clone()); |
| 152 | + assert!( |
| 153 | + changeset.tx_graph.txs.contains(&send_tx), |
| 154 | + "tx graph must deem send_tx relevant and include it" |
| 155 | + ); |
| 156 | + |
| 157 | + // Sync after broadcasting the `undo_send_tx`. Verify that `send_tx` is now missing from the |
| 158 | + // mempool. |
| 159 | + let undo_send_txid = env |
| 160 | + .rpc_client() |
| 161 | + .send_raw_transaction(undo_send_tx.raw_hex())?; |
| 162 | + env.wait_until_electrum_sees_txid(undo_send_txid, Duration::from_secs(6))?; |
| 163 | + let sync_request = SyncRequest::builder() |
| 164 | + .chain_tip(chain.tip()) |
| 165 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 166 | + .check_unconfirmed_statuses( |
| 167 | + &graph.index, |
| 168 | + graph.graph().canonical_iter(&chain, chain.tip().block_id()), |
| 169 | + ); |
| 170 | + let sync_response = client.sync(sync_request, BATCH_SIZE, true)?; |
| 171 | + assert!( |
| 172 | + sync_response.tx_update.missing.contains(&send_txid), |
| 173 | + "sync response must track send_tx as missing from mempool" |
| 174 | + ); |
| 175 | + let changeset = graph.apply_update(sync_response.tx_update.clone()); |
| 176 | + assert!( |
| 177 | + changeset.tx_graph.last_missing.contains_key(&send_txid), |
| 178 | + "tx graph must track send_tx as missing" |
| 179 | + ); |
| 180 | + |
| 181 | + Ok(()) |
| 182 | +} |
| 183 | + |
57 | 184 | #[test] |
58 | 185 | pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { |
59 | 186 | let env = TestEnv::new()?; |
|
0 commit comments