Also add `detect_receive_tx_cancel` test.
Also rm `miniscript` dependency.
Update ci to rm `miniscript/no-std` for `bdk_esplora`.
Co-authored-by: Wei Chen <wzc110@gmail.com>
- name: Check esplora
working-directory: ./crates/esplora
# TODO "--target thumbv6m-none-eabi" should work but currently does not
- run: cargo check --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
+ run: cargo check --no-default-features --features bdk_chain/hashbrown
check-wasm:
needs: prepare
run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown
- name: Check esplora
working-directory: ./crates/esplora
- run: cargo check --target wasm32-unknown-unknown --no-default-features --features miniscript/no-std,bdk_chain/hashbrown,async
+ run: cargo check --target wasm32-unknown-unknown --no-default-features --features bdk_core/hashbrown,async
fmt:
needs: prepare
[dependencies]
bdk_core = { path = "../core", version = "0.4.1", default-features = false }
-esplora-client = { version = "0.11.0", default-features = false }
+esplora-client = { version = "0.11.0", default-features = false }
async-trait = { version = "0.1.66", optional = true }
futures = { version = "0.3.26", optional = true }
-miniscript = { version = "12.0.0", optional = true, default-features = false }
[dev-dependencies]
-esplora-client = { version = "0.11.0" }
+esplora-client = { version = "0.11.0" }
bdk_chain = { path = "../chain" }
bdk_testenv = { path = "../testenv" }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[features]
default = ["std", "async-https", "blocking-https"]
-std = ["bdk_chain/std", "miniscript?/std"]
+std = ["bdk_core/std"]
tokio = ["esplora-client/tokio"]
async = ["async-trait", "futures", "esplora-client/async"]
async-https = ["async", "esplora-client/async-https"]
use async_trait::async_trait;
use bdk_core::collections::{BTreeMap, BTreeSet, HashSet};
-use bdk_core::spk_client::{FullScanRequest, FullScanResponse, SyncRequest, SyncResponse};
+use bdk_core::spk_client::{
+ FullScanRequest, FullScanResponse, SpkWithExpectedTxids, SyncRequest, SyncResponse,
+};
use bdk_core::{
- bitcoin::{BlockHash, OutPoint, ScriptBuf, Txid},
+ bitcoin::{BlockHash, OutPoint, Txid},
BlockId, CheckPoint, ConfirmationBlockTime, Indexed, TxUpdate,
};
use esplora_client::Sleeper;
stop_gap: usize,
parallel_requests: usize,
) -> Result<FullScanResponse<K>, Error> {
- let mut request = request.into();
+ let mut request: FullScanRequest<K> = request.into();
let start_time = request.start_time();
let keychains = request.keychains();
let mut inserted_txs = HashSet::<Txid>::new();
let mut last_active_indices = BTreeMap::<K, u32>::new();
for keychain in keychains {
- let keychain_spks = request.iter_spks(keychain.clone());
+ let keychain_spks = request
+ .iter_spks(keychain.clone())
+ .map(|(spk_i, spk)| (spk_i, spk.into()));
let (update, last_active_index) = fetch_txs_with_keychain_spks(
self,
start_time,
request: R,
parallel_requests: usize,
) -> Result<SyncResponse, Error> {
- let mut request = request.into();
+ let mut request: SyncRequest<I> = request.into();
let start_time = request.start_time();
let chain_tip = request.chain_tip();
self,
start_time,
&mut inserted_txs,
- request.iter_spks(),
+ request.iter_spks_with_expected_txids(),
parallel_requests,
)
.await?,
parallel_requests: usize,
) -> Result<(TxUpdate<ConfirmationBlockTime>, Option<u32>), Error>
where
- I: Iterator<Item = Indexed<ScriptBuf>> + Send,
+ I: Iterator<Item = Indexed<SpkWithExpectedTxids>> + Send,
S: Sleeper + Clone + Send + Sync,
{
- type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
+ type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>, HashSet<Txid>);
let mut update = TxUpdate::<ConfirmationBlockTime>::default();
let mut last_index = Option::<u32>::None;
.take(parallel_requests)
.map(|(spk_index, spk)| {
let client = client.clone();
+ let expected_txids = spk.expected_txids;
+ let spk = spk.spk;
async move {
let mut last_seen = None;
let mut spk_txs = Vec::new();
last_seen = txs.last().map(|tx| tx.txid);
spk_txs.extend(txs);
if tx_count < 25 {
- break Result::<_, Error>::Ok((spk_index, spk_txs));
+ break;
}
}
+ let got_txids = spk_txs.iter().map(|tx| tx.txid).collect::<HashSet<_>>();
+ let evicted_txids = expected_txids
+ .difference(&got_txids)
+ .copied()
+ .collect::<HashSet<_>>();
+ Result::<TxsOfSpkIndex, Error>::Ok((spk_index, spk_txs, evicted_txids))
}
})
.collect::<FuturesOrdered<_>>();
break;
}
- for (index, txs) in handles.try_collect::<Vec<TxsOfSpkIndex>>().await? {
+ for (index, txs, evicted) in handles.try_collect::<Vec<TxsOfSpkIndex>>().await? {
last_index = Some(index);
if !txs.is_empty() {
last_active_index = Some(index);
insert_anchor_or_seen_at_from_status(&mut update, start_time, tx.txid, tx.status);
insert_prevouts(&mut update, tx.vin);
}
+ update
+ .evicted_ats
+ .extend(evicted.into_iter().map(|txid| (txid, start_time)));
}
let last_index = last_index.expect("Must be set since handles wasn't empty.");
parallel_requests: usize,
) -> Result<TxUpdate<ConfirmationBlockTime>, Error>
where
- I: IntoIterator<Item = ScriptBuf> + Send,
+ I: IntoIterator<Item = SpkWithExpectedTxids> + Send,
I::IntoIter: Send,
S: Sleeper + Clone + Send + Sync,
{
use bdk_core::collections::{BTreeMap, BTreeSet, HashSet};
-use bdk_core::spk_client::{FullScanRequest, FullScanResponse, SyncRequest, SyncResponse};
+use bdk_core::spk_client::{
+ FullScanRequest, FullScanResponse, SpkWithExpectedTxids, SyncRequest, SyncResponse,
+};
use bdk_core::{
- bitcoin::{BlockHash, OutPoint, ScriptBuf, Txid},
+ bitcoin::{BlockHash, OutPoint, Txid},
BlockId, CheckPoint, ConfirmationBlockTime, Indexed, TxUpdate,
};
use esplora_client::{OutputStatus, Tx};
stop_gap: usize,
parallel_requests: usize,
) -> Result<FullScanResponse<K>, Error> {
- let mut request = request.into();
+ let mut request: FullScanRequest<K> = request.into();
let start_time = request.start_time();
let chain_tip = request.chain_tip();
let mut inserted_txs = HashSet::<Txid>::new();
let mut last_active_indices = BTreeMap::<K, u32>::new();
for keychain in request.keychains() {
- let keychain_spks = request.iter_spks(keychain.clone());
+ let keychain_spks = request
+ .iter_spks(keychain.clone())
+ .map(|(spk_i, spk)| (spk_i, spk.into()));
let (update, last_active_index) = fetch_txs_with_keychain_spks(
self,
start_time,
self,
start_time,
&mut inserted_txs,
- request.iter_spks(),
+ request.iter_spks_with_expected_txids(),
parallel_requests,
)?);
tx_update.extend(fetch_txs_with_txids(
Ok(tip)
}
-fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<ScriptBuf>>>(
+fn fetch_txs_with_keychain_spks<I: Iterator<Item = Indexed<SpkWithExpectedTxids>>>(
client: &esplora_client::BlockingClient,
start_time: u64,
inserted_txs: &mut HashSet<Txid>,
stop_gap: usize,
parallel_requests: usize,
) -> Result<(TxUpdate<ConfirmationBlockTime>, Option<u32>), Error> {
- type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>);
+ type TxsOfSpkIndex = (u32, Vec<esplora_client::Tx>, HashSet<Txid>);
let mut update = TxUpdate::<ConfirmationBlockTime>::default();
let mut last_index = Option::<u32>::None;
.by_ref()
.take(parallel_requests)
.map(|(spk_index, spk)| {
- std::thread::spawn({
- let client = client.clone();
- move || -> Result<TxsOfSpkIndex, Error> {
- let mut last_seen = None;
- let mut spk_txs = Vec::new();
- loop {
- let txs = client.scripthash_txs(&spk, last_seen)?;
- let tx_count = txs.len();
- last_seen = txs.last().map(|tx| tx.txid);
- spk_txs.extend(txs);
- if tx_count < 25 {
- break Ok((spk_index, spk_txs));
- }
+ let client = client.clone();
+ let expected_txids = spk.expected_txids;
+ let spk = spk.spk;
+ std::thread::spawn(move || -> Result<TxsOfSpkIndex, Error> {
+ let mut last_txid = None;
+ let mut spk_txs = Vec::new();
+ loop {
+ let txs = client.scripthash_txs(&spk, last_txid)?;
+ let tx_count = txs.len();
+ last_txid = txs.last().map(|tx| tx.txid);
+ spk_txs.extend(txs);
+ if tx_count < 25 {
+ break;
}
}
+ let got_txids = spk_txs.iter().map(|tx| tx.txid).collect::<HashSet<_>>();
+ let evicted_txids = expected_txids
+ .difference(&got_txids)
+ .copied()
+ .collect::<HashSet<_>>();
+ Ok((spk_index, spk_txs, evicted_txids))
})
})
.collect::<Vec<JoinHandle<Result<TxsOfSpkIndex, Error>>>>();
}
for handle in handles {
- let (index, txs) = handle.join().expect("thread must not panic")?;
+ let (index, txs, evicted) = handle.join().expect("thread must not panic")?;
last_index = Some(index);
if !txs.is_empty() {
last_active_index = Some(index);
insert_anchor_or_seen_at_from_status(&mut update, start_time, tx.txid, tx.status);
insert_prevouts(&mut update, tx.vin);
}
+ update
+ .evicted_ats
+ .extend(evicted.into_iter().map(|txid| (txid, start_time)));
}
let last_index = last_index.expect("Must be set since handles wasn't empty.");
/// requests to make in parallel.
///
/// Refer to [crate-level docs](crate) for more.
-fn fetch_txs_with_spks<I: IntoIterator<Item = ScriptBuf>>(
+fn fetch_txs_with_spks<I: IntoIterator<Item = SpkWithExpectedTxids>>(
client: &esplora_client::BlockingClient,
start_time: u64,
inserted_txs: &mut HashSet<Txid>,
+use bdk_chain::bitcoin::{Address, Amount};
+use bdk_chain::local_chain::LocalChain;
use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
-use bdk_chain::{ConfirmationBlockTime, TxGraph};
+use bdk_chain::spk_txout::SpkTxOutIndex;
+use bdk_chain::{ConfirmationBlockTime, IndexedTxGraph, TxGraph};
use bdk_esplora::EsploraAsyncExt;
+use bdk_testenv::bitcoincore_rpc::json::CreateRawTransactionInput;
+use bdk_testenv::bitcoincore_rpc::RawTx;
+use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
use esplora_client::{self, Builder};
-use std::collections::{BTreeSet, HashSet};
+use std::collections::{BTreeSet, HashMap, HashSet};
use std::str::FromStr;
use std::thread::sleep;
use std::time::Duration;
-use bdk_chain::bitcoin::{Address, Amount};
-use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
+mod common;
+
+// Ensure that a wallet can detect a malicious replacement of an incoming transaction.
+//
+// This checks that both the Esplora chain source and the receiving structures properly track the
+// replaced transaction as missing.
+#[tokio::test]
+pub async fn detect_receive_tx_cancel() -> anyhow::Result<()> {
+ const SEND_TX_FEE: Amount = Amount::from_sat(1000);
+ const UNDO_SEND_TX_FEE: Amount = Amount::from_sat(2000);
+
+ let env = TestEnv::new()?;
+ let rpc_client = env.rpc_client();
+ let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
+ let client = Builder::new(base_url.as_str()).build_async()?;
+
+ let mut graph = IndexedTxGraph::<ConfirmationBlockTime, _>::new(SpkTxOutIndex::<()>::default());
+ let (chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?);
+
+ // Get receiving address.
+ let receiver_spk = common::get_test_spk();
+ let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?;
+ graph.index.insert_spk((), receiver_spk);
+
+ env.mine_blocks(101, None)?;
+
+ // Select a UTXO to use as an input for constructing our test transactions.
+ let selected_utxo = rpc_client
+ .list_unspent(None, None, None, Some(false), None)?
+ .into_iter()
+ // Find a block reward tx.
+ .find(|utxo| utxo.amount == Amount::from_int_btc(50))
+ .expect("Must find a block reward UTXO");
+
+ // Derive the sender's address from the selected UTXO.
+ let sender_spk = selected_utxo.script_pub_key.clone();
+ let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest)
+ .expect("Failed to derive address from UTXO");
+
+ // Setup the common inputs used by both `send_tx` and `undo_send_tx`.
+ let inputs = [CreateRawTransactionInput {
+ txid: selected_utxo.txid,
+ vout: selected_utxo.vout,
+ sequence: None,
+ }];
+
+ // Create and sign the `send_tx` that sends funds to the receiver address.
+ let send_tx_outputs = HashMap::from([(
+ receiver_addr.to_string(),
+ selected_utxo.amount - SEND_TX_FEE,
+ )]);
+ let send_tx = rpc_client.create_raw_transaction(&inputs, &send_tx_outputs, None, Some(true))?;
+ let send_tx = rpc_client
+ .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)?
+ .transaction()?;
+ // Create and sign the `undo_send_tx` transaction. This redirects funds back to the sender
+ // address.
+ let undo_send_outputs = HashMap::from([(
+ sender_addr.to_string(),
+ selected_utxo.amount - UNDO_SEND_TX_FEE,
+ )]);
+ let undo_send_tx =
+ rpc_client.create_raw_transaction(&inputs, &undo_send_outputs, None, Some(true))?;
+ let undo_send_tx = rpc_client
+ .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)?
+ .transaction()?;
+
+ // Sync after broadcasting the `send_tx`. Ensure that we detect and receive the `send_tx`.
+ let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?;
+ env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?;
+ let sync_request = SyncRequest::builder()
+ .chain_tip(chain.tip())
+ .spks_with_indexes(graph.index.all_spks().clone())
+ .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..));
+ let sync_response = client.sync(sync_request, 1).await?;
+ assert!(
+ sync_response
+ .tx_update
+ .txs
+ .iter()
+ .any(|tx| tx.compute_txid() == send_txid),
+ "sync response must include the send_tx"
+ );
+ let changeset = graph.apply_update(sync_response.tx_update.clone());
+ assert!(
+ changeset.tx_graph.txs.contains(&send_tx),
+ "tx graph must deem send_tx relevant and include it"
+ );
+
+ // Sync after broadcasting the `undo_send_tx`. Verify that `send_tx` is now missing from the
+ // mempool.
+ let undo_send_txid = env
+ .rpc_client()
+ .send_raw_transaction(undo_send_tx.raw_hex())?;
+ env.wait_until_electrum_sees_txid(undo_send_txid, Duration::from_secs(6))?;
+ let sync_request = SyncRequest::builder()
+ .chain_tip(chain.tip())
+ .spks_with_indexes(graph.index.all_spks().clone())
+ .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..));
+ let sync_response = client.sync(sync_request, 1).await?;
+ assert!(
+ sync_response
+ .tx_update
+ .evicted_ats
+ .iter()
+ .any(|(txid, _)| *txid == send_txid),
+ "sync response must track send_tx as missing from mempool"
+ );
+ let changeset = graph.apply_update(sync_response.tx_update.clone());
+ assert!(
+ changeset.tx_graph.last_evicted.contains_key(&send_txid),
+ "tx graph must track send_tx as missing"
+ );
+
+ Ok(())
+}
#[tokio::test]
pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {
let env = TestEnv::new()?;
+use bdk_chain::bitcoin::{Address, Amount};
+use bdk_chain::local_chain::LocalChain;
use bdk_chain::spk_client::{FullScanRequest, SyncRequest};
-use bdk_chain::{ConfirmationBlockTime, TxGraph};
+use bdk_chain::spk_txout::SpkTxOutIndex;
+use bdk_chain::{ConfirmationBlockTime, IndexedTxGraph, TxGraph};
use bdk_esplora::EsploraExt;
+use bdk_testenv::bitcoincore_rpc::json::CreateRawTransactionInput;
+use bdk_testenv::bitcoincore_rpc::RawTx;
+use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
use esplora_client::{self, Builder};
-use std::collections::{BTreeSet, HashSet};
+use std::collections::{BTreeSet, HashMap, HashSet};
use std::str::FromStr;
use std::thread::sleep;
use std::time::Duration;
-use bdk_chain::bitcoin::{Address, Amount};
-use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv};
+mod common;
+
+// Ensure that a wallet can detect a malicious replacement of an incoming transaction.
+//
+// This checks that both the Esplora chain source and the receiving structures properly track the
+// replaced transaction as missing.
+#[test]
+pub fn detect_receive_tx_cancel() -> anyhow::Result<()> {
+ const SEND_TX_FEE: Amount = Amount::from_sat(1000);
+ const UNDO_SEND_TX_FEE: Amount = Amount::from_sat(2000);
+
+ let env = TestEnv::new()?;
+ let rpc_client = env.rpc_client();
+ let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap());
+ let client = Builder::new(base_url.as_str()).build_blocking();
+
+ let mut graph = IndexedTxGraph::<ConfirmationBlockTime, _>::new(SpkTxOutIndex::<()>::default());
+ let (chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?);
+
+ // Get receiving address.
+ let receiver_spk = common::get_test_spk();
+ let receiver_addr = Address::from_script(&receiver_spk, bdk_chain::bitcoin::Network::Regtest)?;
+ graph.index.insert_spk((), receiver_spk);
+
+ env.mine_blocks(101, None)?;
+
+ // Select a UTXO to use as an input for constructing our test transactions.
+ let selected_utxo = rpc_client
+ .list_unspent(None, None, None, Some(false), None)?
+ .into_iter()
+ // Find a block reward tx.
+ .find(|utxo| utxo.amount == Amount::from_int_btc(50))
+ .expect("Must find a block reward UTXO");
+
+ // Derive the sender's address from the selected UTXO.
+ let sender_spk = selected_utxo.script_pub_key.clone();
+ let sender_addr = Address::from_script(&sender_spk, bdk_chain::bitcoin::Network::Regtest)
+ .expect("Failed to derive address from UTXO");
+
+ // Setup the common inputs used by both `send_tx` and `undo_send_tx`.
+ let inputs = [CreateRawTransactionInput {
+ txid: selected_utxo.txid,
+ vout: selected_utxo.vout,
+ sequence: None,
+ }];
+
+ // Create and sign the `send_tx` that sends funds to the receiver address.
+ let send_tx_outputs = HashMap::from([(
+ receiver_addr.to_string(),
+ selected_utxo.amount - SEND_TX_FEE,
+ )]);
+ let send_tx = rpc_client.create_raw_transaction(&inputs, &send_tx_outputs, None, Some(true))?;
+ let send_tx = rpc_client
+ .sign_raw_transaction_with_wallet(send_tx.raw_hex(), None, None)?
+ .transaction()?;
+
+ // Create and sign the `undo_send_tx` transaction. This redirects funds back to the sender
+ // address.
+ let undo_send_outputs = HashMap::from([(
+ sender_addr.to_string(),
+ selected_utxo.amount - UNDO_SEND_TX_FEE,
+ )]);
+ let undo_send_tx =
+ rpc_client.create_raw_transaction(&inputs, &undo_send_outputs, None, Some(true))?;
+ let undo_send_tx = rpc_client
+ .sign_raw_transaction_with_wallet(undo_send_tx.raw_hex(), None, None)?
+ .transaction()?;
+
+ // Sync after broadcasting the `send_tx`. Ensure that we detect and receive the `send_tx`.
+ let send_txid = env.rpc_client().send_raw_transaction(send_tx.raw_hex())?;
+ env.wait_until_electrum_sees_txid(send_txid, Duration::from_secs(6))?;
+ let sync_request = SyncRequest::builder()
+ .chain_tip(chain.tip())
+ .spks_with_indexes(graph.index.all_spks().clone())
+ .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..));
+ let sync_response = client.sync(sync_request, 1)?;
+ assert!(
+ sync_response
+ .tx_update
+ .txs
+ .iter()
+ .any(|tx| tx.compute_txid() == send_txid),
+ "sync response must include the send_tx"
+ );
+ let changeset = graph.apply_update(sync_response.tx_update.clone());
+ assert!(
+ changeset.tx_graph.txs.contains(&send_tx),
+ "tx graph must deem send_tx relevant and include it"
+ );
+
+ // Sync after broadcasting the `undo_send_tx`. Verify that `send_tx` is now missing from the
+ // mempool.
+ let undo_send_txid = env
+ .rpc_client()
+ .send_raw_transaction(undo_send_tx.raw_hex())?;
+ env.wait_until_electrum_sees_txid(undo_send_txid, Duration::from_secs(6))?;
+ let sync_request = SyncRequest::builder()
+ .chain_tip(chain.tip())
+ .spks_with_indexes(graph.index.all_spks().clone())
+ .expected_spk_txids(graph.list_expected_spk_txids(&chain, chain.tip().block_id(), ..));
+ let sync_response = client.sync(sync_request, 1)?;
+ assert!(
+ sync_response
+ .tx_update
+ .evicted_ats
+ .iter()
+ .any(|(txid, _)| *txid == send_txid),
+ "sync response must track send_tx as missing from mempool"
+ );
+ let changeset = graph.apply_update(sync_response.tx_update.clone());
+ assert!(
+ changeset.tx_graph.last_evicted.contains_key(&send_txid),
+ "tx graph must track send_tx as missing"
+ );
+
+ Ok(())
+}
#[test]
pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> {
--- /dev/null
+use bdk_core::bitcoin::key::{Secp256k1, UntweakedPublicKey};
+use bdk_core::bitcoin::ScriptBuf;
+
+const PK_BYTES: &[u8] = &[
+ 12, 244, 72, 4, 163, 4, 211, 81, 159, 82, 153, 123, 125, 74, 142, 40, 55, 237, 191, 231, 31,
+ 114, 89, 165, 83, 141, 8, 203, 93, 240, 53, 101,
+];
+
+#[allow(dead_code)]
+pub fn get_test_spk() -> ScriptBuf {
+ let secp = Secp256k1::new();
+ let pk = UntweakedPublicKey::from_slice(PK_BYTES).expect("Must be valid PK");
+ ScriptBuf::new_p2tr(&secp, pk, None)
+}