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