Skip to content

Commit e5fefa1

Browse files
committed
test(electrum): detect_receive_tx_cancel
1 parent 7c9dd8d commit e5fefa1

1 file changed

Lines changed: 130 additions & 3 deletions

File tree

crates/electrum/tests/test_electrum.rs

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
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,
34
local_chain::LocalChain,
5+
miniscript::Descriptor,
46
spk_client::{FullScanRequest, SyncRequest, SyncResponse},
57
spk_txout::SpkTxOutIndex,
68
Balance, ConfirmationBlockTime, IndexedTxGraph, Indexer, Merge, TxGraph,
79
};
810
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+
};
1016
use core::time::Duration;
11-
use std::collections::{BTreeSet, HashSet};
17+
use std::collections::{BTreeSet, HashMap, HashSet};
1218
use std::str::FromStr;
1319

1420
// Batch size for `sync_with_electrum`.
@@ -54,6 +60,127 @@ where
5460
Ok(update)
5561
}
5662

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+
57184
#[test]
58185
pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {
59186
let env = TestEnv::new()?;

0 commit comments

Comments
 (0)