|
| 1 | +use bdk_chain::keychain_txout::KeychainTxOutIndex; |
| 2 | +use bdk_chain::local_chain::LocalChain; |
1 | 3 | use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
|
2 |
| -use bdk_chain::{ConfirmationBlockTime, TxGraph}; |
| 4 | +use bdk_chain::{ConfirmationBlockTime, IndexedTxGraph, TxGraph}; |
| 5 | +use bdk_core::bitcoin::key::Secp256k1; |
3 | 6 | use bdk_esplora::EsploraAsyncExt;
|
| 7 | +use bdk_testenv::bitcoincore_rpc::json::CreateRawTransactionInput; |
| 8 | +use bdk_testenv::bitcoincore_rpc::RawTx; |
4 | 9 | use esplora_client::{self, Builder};
|
5 |
| -use std::collections::{BTreeSet, HashSet}; |
| 10 | +use miniscript::Descriptor; |
| 11 | +use std::collections::{BTreeSet, HashMap, HashSet}; |
6 | 12 | use std::str::FromStr;
|
7 | 13 | use std::thread::sleep;
|
8 | 14 | use std::time::Duration;
|
9 | 15 |
|
10 | 16 | use bdk_chain::bitcoin::{Address, Amount};
|
11 | 17 | use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
|
12 | 18 |
|
| 19 | +// Ensure that a wallet can detect a malicious replacement of an incoming transaction. |
| 20 | +// |
| 21 | +// This checks that both the Electrum chain source and the receiving structures properly track the |
| 22 | +// replaced transaction as missing. |
| 23 | +#[tokio::test] |
| 24 | +pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> { |
| 25 | + const SEND_TX_FEE: Amount = Amount::from_sat(1000); |
| 26 | + const UNDO_SEND_TX_FEE: Amount = Amount::from_sat(2000); |
| 27 | + |
| 28 | + use bdk_chain::keychain_txout::SyncRequestBuilderExt; |
| 29 | + let env = TestEnv::new()?; |
| 30 | + let rpc_client = env.rpc_client(); |
| 31 | + let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap()); |
| 32 | + let client = Builder::new(base_url.as_str()).build_async()?; |
| 33 | + |
| 34 | + let (receiver_desc, _) = Descriptor::parse_descriptor(&Secp256k1::signing_only(), "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)") |
| 35 | + .expect("must be valid"); |
| 36 | + let mut graph = IndexedTxGraph::<ConfirmationBlockTime, _>::new(KeychainTxOutIndex::new(0)); |
| 37 | + let _ = graph.index.insert_descriptor((), receiver_desc.clone())?; |
| 38 | + let (chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); |
| 39 | + |
| 40 | + // Derive the receiving address from the descriptor. |
| 41 | + let ((_, receiver_spk), _) = graph.index.reveal_next_spk(()).unwrap(); |
| 42 | + let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?; |
| 43 | + |
| 44 | + env.mine_blocks(101, None)?; |
| 45 | + |
| 46 | + // Select a UTXO to use as an input for constructing our test transactions. |
| 47 | + let selected_utxo = rpc_client |
| 48 | + .list_unspent(None, None, None, Some(false), None)? |
| 49 | + .into_iter() |
| 50 | + // Find a block reward tx. |
| 51 | + .find(|utxo| utxo.amount == Amount::from_int_btc(50)) |
| 52 | + .expect("Must find a block reward UTXO"); |
| 53 | + |
| 54 | + // Derive the sender's address from the selected UTXO. |
| 55 | + let sender_spk = selected_utxo.script_pub_key.clone(); |
| 56 | + let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest) |
| 57 | + .expect("Failed to derive address from UTXO"); |
| 58 | + |
| 59 | + // Setup the common inputs used by both `send_tx` and `undo_send_tx`. |
| 60 | + let inputs = [CreateRawTransactionInput { |
| 61 | + txid: selected_utxo.txid, |
| 62 | + vout: selected_utxo.vout, |
| 63 | + sequence: None, |
| 64 | + }]; |
| 65 | + |
| 66 | + // Create and sign the `send_tx` that sends funds to the receiver address. |
| 67 | + let send_tx_outputs = HashMap::from([( |
| 68 | + receiver_addr.to_string(), |
| 69 | + selected_utxo.amount - SEND_TX_FEE, |
| 70 | + )]); |
| 71 | + let send_tx = rpc_client.create_raw_transaction(&inputs, &send_tx_outputs, None, Some(true))?; |
| 72 | + let send_tx = rpc_client |
| 73 | + .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)? |
| 74 | + .transaction()?; |
| 75 | + |
| 76 | + // Create and sign the `undo_send_tx` transaction. This redirects funds back to the sender |
| 77 | + // address. |
| 78 | + let undo_send_outputs = HashMap::from([( |
| 79 | + sender_addr.to_string(), |
| 80 | + selected_utxo.amount - UNDO_SEND_TX_FEE, |
| 81 | + )]); |
| 82 | + let undo_send_tx = |
| 83 | + rpc_client.create_raw_transaction(&inputs, &undo_send_outputs, None, Some(true))?; |
| 84 | + let undo_send_tx = rpc_client |
| 85 | + .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)? |
| 86 | + .transaction()?; |
| 87 | + |
| 88 | + // Sync after broadcasting the `send_tx`. Ensure that we detect and receive the `send_tx`. |
| 89 | + let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?; |
| 90 | + env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?; |
| 91 | + let sync_request = SyncRequest::builder() |
| 92 | + .chain_tip(chain.tip()) |
| 93 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 94 | + .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); |
| 95 | + let sync_response = client.sync(sync_request, 1).await?; |
| 96 | + assert!( |
| 97 | + sync_response |
| 98 | + .tx_update |
| 99 | + .txs |
| 100 | + .iter() |
| 101 | + .any(|tx| tx.compute_txid() == send_txid), |
| 102 | + "sync response must include the send_tx" |
| 103 | + ); |
| 104 | + let changeset = graph.apply_update(sync_response.tx_update.clone()); |
| 105 | + assert!( |
| 106 | + changeset.tx_graph.txs.contains(&send_tx), |
| 107 | + "tx graph must deem send_tx relevant and include it" |
| 108 | + ); |
| 109 | + |
| 110 | + // Sync after broadcasting the `undo_send_tx`. Verify that `send_tx` is now missing from the |
| 111 | + // mempool. |
| 112 | + let undo_send_txid = env |
| 113 | + .rpc_client() |
| 114 | + .send_raw_transaction(undo_send_tx.raw_hex())?; |
| 115 | + env.wait_until_electrum_sees_txid(undo_send_txid, Duration::from_secs(6))?; |
| 116 | + let sync_request = SyncRequest::builder() |
| 117 | + .chain_tip(chain.tip()) |
| 118 | + .revealed_spks_from_indexer(&graph.index, ..) |
| 119 | + .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..)); |
| 120 | + let sync_response = client.sync(sync_request, 1).await?; |
| 121 | + assert!( |
| 122 | + sync_response |
| 123 | + .tx_update |
| 124 | + .evicted_ats |
| 125 | + .iter() |
| 126 | + .any(|(txid, _)| *txid == send_txid), |
| 127 | + "sync response must track send_tx as missing from mempool" |
| 128 | + ); |
| 129 | + let changeset = graph.apply_update(sync_response.tx_update.clone()); |
| 130 | + assert!( |
| 131 | + changeset.tx_graph.last_evicted.contains_key(&send_txid), |
| 132 | + "tx graph must track send_tx as missing" |
| 133 | + ); |
| 134 | + |
| 135 | + Ok(()) |
| 136 | +} |
13 | 137 | #[tokio::test]
|
14 | 138 | pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {
|
15 | 139 | let env = TestEnv::new()?;
|
|
0 commit comments