Skip to content

Commit 2e31bc7

Browse files
committed
test(esplora): detect_receive_tx_cancel
1 parent e5fefa1 commit 2e31bc7

File tree

2 files changed

+270
-10
lines changed

2 files changed

+270
-10
lines changed

crates/esplora/tests/async_ext.rs

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,144 @@
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+
};
39
use bdk_esplora::EsploraAsyncExt;
410
use esplora_client::{self, Builder};
5-
use std::collections::{BTreeSet, HashSet};
11+
use std::collections::{BTreeSet, HashMap, HashSet};
612
use std::str::FromStr;
713
use std::thread::sleep;
814
use std::time::Duration;
915

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+
}
12142

13143
#[tokio::test]
14144
pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {

crates/esplora/tests/blocking_ext.rs

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,144 @@
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+
};
39
use bdk_esplora::EsploraExt;
410
use esplora_client::{self, Builder};
5-
use std::collections::{BTreeSet, HashSet};
11+
use std::collections::{BTreeSet, HashMap, HashSet};
612
use std::str::FromStr;
713
use std::thread::sleep;
814
use std::time::Duration;
915

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+
#[test]
27+
pub 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_blocking();
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)?;
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)?;
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+
}
12142

13143
#[test]
14144
pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {

0 commit comments

Comments
 (0)