Skip to content

Commit bbd5a88

Browse files
committed
test(electrum): detect receive tx cancel
1 parent 58a6704 commit bbd5a88

File tree

1 file changed

+143
-3
lines changed

1 file changed

+143
-3
lines changed

crates/electrum/tests/test_electrum.rs

Lines changed: 143 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,140 @@ where
5460
Ok(update)
5561
}
5662

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

0 commit comments

Comments
 (0)