diff --git a/Cargo.toml b/Cargo.toml index 0e1efc902f..d9c9f2e97d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/electrum", "crates/esplora", "crates/bitcoind_rpc", + "crates/testenv", "example-crates/example_cli", "example-crates/example_electrum", "example-crates/example_esplora", diff --git a/crates/bitcoind_rpc/Cargo.toml b/crates/bitcoind_rpc/Cargo.toml index bc629854e4..dc8b64d66b 100644 --- a/crates/bitcoind_rpc/Cargo.toml +++ b/crates/bitcoind_rpc/Cargo.toml @@ -19,7 +19,7 @@ bitcoincore-rpc = { version = "0.17" } bdk_chain = { path = "../chain", version = "0.6", default-features = false } [dev-dependencies] -bitcoind = { version = "0.33", features = ["25_0"] } +testenv = { path = "../testenv", default_features = false } anyhow = { version = "1" } [features] diff --git a/crates/bitcoind_rpc/tests/test_emitter.rs b/crates/bitcoind_rpc/tests/test_emitter.rs index 521124e5d8..cff932abe9 100644 --- a/crates/bitcoind_rpc/tests/test_emitter.rs +++ b/crates/bitcoind_rpc/tests/test_emitter.rs @@ -7,155 +7,9 @@ use bdk_chain::{ local_chain::{self, CheckPoint, LocalChain}, Append, BlockId, IndexedTxGraph, SpkTxOutIndex, }; -use bitcoin::{ - address::NetworkChecked, block::Header, hash_types::TxMerkleNode, hashes::Hash, - secp256k1::rand::random, Block, CompactTarget, OutPoint, ScriptBuf, ScriptHash, Transaction, - TxIn, TxOut, WScriptHash, -}; -use bitcoincore_rpc::{ - bitcoincore_rpc_json::{GetBlockTemplateModes, GetBlockTemplateRules}, - RpcApi, -}; - -struct TestEnv { - #[allow(dead_code)] - daemon: bitcoind::BitcoinD, - client: bitcoincore_rpc::Client, -} - -impl TestEnv { - fn new() -> anyhow::Result { - let daemon = match std::env::var_os("TEST_BITCOIND") { - Some(bitcoind_path) => bitcoind::BitcoinD::new(bitcoind_path), - None => bitcoind::BitcoinD::from_downloaded(), - }?; - let client = bitcoincore_rpc::Client::new( - &daemon.rpc_url(), - bitcoincore_rpc::Auth::CookieFile(daemon.params.cookie_file.clone()), - )?; - Ok(Self { daemon, client }) - } - - fn mine_blocks( - &self, - count: usize, - address: Option
, - ) -> anyhow::Result> { - let coinbase_address = match address { - Some(address) => address, - None => self.client.get_new_address(None, None)?.assume_checked(), - }; - let block_hashes = self - .client - .generate_to_address(count as _, &coinbase_address)?; - Ok(block_hashes) - } - - fn mine_empty_block(&self) -> anyhow::Result<(usize, BlockHash)> { - let bt = self.client.get_block_template( - GetBlockTemplateModes::Template, - &[GetBlockTemplateRules::SegWit], - &[], - )?; - - let txdata = vec![Transaction { - version: 1, - lock_time: bitcoin::absolute::LockTime::from_height(0)?, - input: vec![TxIn { - previous_output: bitcoin::OutPoint::default(), - script_sig: ScriptBuf::builder() - .push_int(bt.height as _) - // randomn number so that re-mining creates unique block - .push_int(random()) - .into_script(), - sequence: bitcoin::Sequence::default(), - witness: bitcoin::Witness::new(), - }], - output: vec![TxOut { - value: 0, - script_pubkey: ScriptBuf::new_p2sh(&ScriptHash::all_zeros()), - }], - }]; - - let bits: [u8; 4] = bt - .bits - .clone() - .try_into() - .expect("rpc provided us with invalid bits"); - - let mut block = Block { - header: Header { - version: bitcoin::block::Version::default(), - prev_blockhash: bt.previous_block_hash, - merkle_root: TxMerkleNode::all_zeros(), - time: Ord::max(bt.min_time, std::time::UNIX_EPOCH.elapsed()?.as_secs()) as u32, - bits: CompactTarget::from_consensus(u32::from_be_bytes(bits)), - nonce: 0, - }, - txdata, - }; - - block.header.merkle_root = block.compute_merkle_root().expect("must compute"); - - for nonce in 0..=u32::MAX { - block.header.nonce = nonce; - if block.header.target().is_met_by(block.block_hash()) { - break; - } - } - - self.client.submit_block(&block)?; - Ok((bt.height as usize, block.block_hash())) - } - - fn invalidate_blocks(&self, count: usize) -> anyhow::Result<()> { - let mut hash = self.client.get_best_block_hash()?; - for _ in 0..count { - let prev_hash = self.client.get_block_info(&hash)?.previousblockhash; - self.client.invalidate_block(&hash)?; - match prev_hash { - Some(prev_hash) => hash = prev_hash, - None => break, - } - } - Ok(()) - } - - fn reorg(&self, count: usize) -> anyhow::Result> { - let start_height = self.client.get_block_count()?; - self.invalidate_blocks(count)?; - - let res = self.mine_blocks(count, None); - assert_eq!( - self.client.get_block_count()?, - start_height, - "reorg should not result in height change" - ); - res - } - - fn reorg_empty_blocks(&self, count: usize) -> anyhow::Result> { - let start_height = self.client.get_block_count()?; - self.invalidate_blocks(count)?; - - let res = (0..count) - .map(|_| self.mine_empty_block()) - .collect::, _>>()?; - assert_eq!( - self.client.get_block_count()?, - start_height, - "reorg should not result in height change" - ); - Ok(res) - } - - fn send(&self, address: &Address, amount: Amount) -> anyhow::Result { - let txid = self - .client - .send_to_address(address, amount, None, None, None, None, None, None)?; - Ok(txid) - } -} +use bitcoin::{hashes::Hash, Block, OutPoint, ScriptBuf, WScriptHash}; +use bitcoincore_rpc::RpcApi; +use testenv::TestEnv; fn block_to_chain_update(block: &bitcoin::Block, height: u32) -> local_chain::Update { let this_id = BlockId { @@ -188,17 +42,22 @@ fn block_to_chain_update(block: &bitcoin::Block, height: u32) -> local_chain::Up #[test] pub fn test_sync_local_chain() -> anyhow::Result<()> { let env = TestEnv::new()?; - let (mut local_chain, _) = LocalChain::from_genesis_hash(env.client.get_block_hash(0)?); - let mut emitter = Emitter::new(&env.client, local_chain.tip(), 0); + let tip = env.rpc_client().get_block_count()?; + let (mut local_chain, _) = LocalChain::from_genesis_hash(env.rpc_client().get_block_hash(0)?); + let mut emitter = Emitter::new(env.rpc_client(), local_chain.tip(), 0); - // mine some blocks and returned the actual block hashes + // Mine some blocks and return the actual block hashes. + // We must first add the hashes of the genesis block and the block automatically generated from + // initializing `ElectrsD`. let exp_hashes = { - let mut hashes = vec![env.client.get_block_hash(0)?]; // include genesis block - hashes.extend(env.mine_blocks(101, None)?); + let mut hashes = (0..=tip) + .map(|height| env.rpc_client().get_block_hash(height)) + .collect::, _>>()?; + hashes.extend(env.mine_blocks(101 - tip as usize, None)?); hashes }; - // see if the emitter outputs the right blocks + // See if the emitter outputs the right blocks. println!("first sync:"); while let Some((height, block)) = emitter.next_block()? { assert_eq!( @@ -225,7 +84,7 @@ pub fn test_sync_local_chain() -> anyhow::Result<()> { "final local_chain state is unexpected", ); - // perform reorg + // Perform reorg. let reorged_blocks = env.reorg(6)?; let exp_hashes = exp_hashes .iter() @@ -234,7 +93,7 @@ pub fn test_sync_local_chain() -> anyhow::Result<()> { .cloned() .collect::>(); - // see if the emitter outputs the right blocks + // See if the emitter outputs the right blocks. println!("after reorg:"); let mut exp_height = exp_hashes.len() - reorged_blocks.len(); while let Some((height, block)) = emitter.next_block()? { @@ -287,16 +146,25 @@ fn test_into_tx_graph() -> anyhow::Result<()> { let env = TestEnv::new()?; println!("getting new addresses!"); - let addr_0 = env.client.get_new_address(None, None)?.assume_checked(); - let addr_1 = env.client.get_new_address(None, None)?.assume_checked(); - let addr_2 = env.client.get_new_address(None, None)?.assume_checked(); + let addr_0 = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); + let addr_1 = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); + let addr_2 = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); println!("got new addresses!"); println!("mining block!"); env.mine_blocks(101, None)?; println!("mined blocks!"); - let (mut chain, _) = LocalChain::from_genesis_hash(env.client.get_block_hash(0)?); + let (mut chain, _) = LocalChain::from_genesis_hash(env.rpc_client().get_block_hash(0)?); let mut indexed_tx_graph = IndexedTxGraph::::new({ let mut index = SpkTxOutIndex::::default(); index.insert_spk(0, addr_0.script_pubkey()); @@ -305,7 +173,7 @@ fn test_into_tx_graph() -> anyhow::Result<()> { index }); - let emitter = &mut Emitter::new(&env.client, chain.tip(), 0); + let emitter = &mut Emitter::new(env.rpc_client(), chain.tip(), 0); while let Some((height, block)) = emitter.next_block()? { let _ = chain.apply_update(block_to_chain_update(&block, height))?; @@ -317,7 +185,7 @@ fn test_into_tx_graph() -> anyhow::Result<()> { let exp_txids = { let mut txids = BTreeSet::new(); for _ in 0..3 { - txids.insert(env.client.send_to_address( + txids.insert(env.rpc_client().send_to_address( &addr_0, Amount::from_sat(10_000), None, @@ -353,7 +221,7 @@ fn test_into_tx_graph() -> anyhow::Result<()> { // mine a block that confirms the 3 txs let exp_block_hash = env.mine_blocks(1, None)?[0]; - let exp_block_height = env.client.get_block_info(&exp_block_hash)?.height as u32; + let exp_block_height = env.rpc_client().get_block_info(&exp_block_hash)?.height as u32; let exp_anchors = exp_txids .iter() .map({ @@ -394,10 +262,10 @@ fn ensure_block_emitted_after_reorg_is_at_reorg_height() -> anyhow::Result<()> { let env = TestEnv::new()?; let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), EMITTER_START_HEIGHT as _, ); @@ -467,21 +335,24 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { let env = TestEnv::new()?; let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), 0, ); // setup addresses - let addr_to_mine = env.client.get_new_address(None, None)?.assume_checked(); + let addr_to_mine = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); let spk_to_track = ScriptBuf::new_v0_p2wsh(&WScriptHash::all_zeros()); let addr_to_track = Address::from_script(&spk_to_track, bitcoin::Network::Regtest)?; // setup receiver - let (mut recv_chain, _) = LocalChain::from_genesis_hash(env.client.get_block_hash(0)?); + let (mut recv_chain, _) = LocalChain::from_genesis_hash(env.rpc_client().get_block_hash(0)?); let mut recv_graph = IndexedTxGraph::::new({ let mut recv_index = SpkTxOutIndex::default(); recv_index.insert_spk((), spk_to_track.clone()); @@ -497,7 +368,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { // lock outputs that send to `addr_to_track` let outpoints_to_lock = env - .client + .rpc_client() .get_transaction(&txid, None)? .transaction()? .output @@ -506,7 +377,7 @@ fn tx_can_become_unconfirmed_after_reorg() -> anyhow::Result<()> { .filter(|(_, txo)| txo.script_pubkey == spk_to_track) .map(|(vout, _)| OutPoint::new(txid, vout as _)) .collect::>(); - env.client.lock_unspent(&outpoints_to_lock)?; + env.rpc_client().lock_unspent(&outpoints_to_lock)?; let _ = env.mine_blocks(1, None)?; } @@ -555,16 +426,19 @@ fn mempool_avoids_re_emission() -> anyhow::Result<()> { let env = TestEnv::new()?; let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), 0, ); // mine blocks and sync up emitter - let addr = env.client.get_new_address(None, None)?.assume_checked(); + let addr = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); env.mine_blocks(BLOCKS_TO_MINE, Some(addr.clone()))?; while emitter.next_header()?.is_some() {} @@ -617,16 +491,19 @@ fn mempool_re_emits_if_tx_introduction_height_not_reached() -> anyhow::Result<() let env = TestEnv::new()?; let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), 0, ); // mine blocks to get initial balance, sync emitter up to tip - let addr = env.client.get_new_address(None, None)?.assume_checked(); + let addr = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); env.mine_blocks(PREMINE_COUNT, Some(addr.clone()))?; while emitter.next_header()?.is_some() {} @@ -701,16 +578,19 @@ fn mempool_during_reorg() -> anyhow::Result<()> { let env = TestEnv::new()?; let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), 0, ); // mine blocks to get initial balance - let addr = env.client.get_new_address(None, None)?.assume_checked(); + let addr = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); env.mine_blocks(PREMINE_COUNT, Some(addr.clone()))?; // introduce mempool tx at each block extension @@ -728,7 +608,7 @@ fn mempool_during_reorg() -> anyhow::Result<()> { .into_iter() .map(|(tx, _)| tx.txid()) .collect::>(), - env.client + env.rpc_client() .get_raw_mempool()? .into_iter() .collect::>(), @@ -747,7 +627,7 @@ fn mempool_during_reorg() -> anyhow::Result<()> { // emission. // TODO: How can have have reorg logic in `TestEnv` NOT blacklast old blocks first? let tx_introductions = dbg!(env - .client + .rpc_client() .get_raw_mempool_verbose()? .into_iter() .map(|(txid, entry)| (txid, entry.height as usize)) @@ -823,10 +703,10 @@ fn no_agreement_point() -> anyhow::Result<()> { // start height is 99 let mut emitter = Emitter::new( - &env.client, + env.rpc_client(), CheckPoint::new(BlockId { height: 0, - hash: env.client.get_block_hash(0)?, + hash: env.rpc_client().get_block_hash(0)?, }), (PREMINE_COUNT - 2) as u32, ); @@ -844,12 +724,12 @@ fn no_agreement_point() -> anyhow::Result<()> { let block_hash_100a = block_header_100a.block_hash(); // get hash for block 101a - let block_hash_101a = env.client.get_block_hash(101)?; + let block_hash_101a = env.rpc_client().get_block_hash(101)?; // invalidate blocks 99a, 100a, 101a - env.client.invalidate_block(&block_hash_99a)?; - env.client.invalidate_block(&block_hash_100a)?; - env.client.invalidate_block(&block_hash_101a)?; + env.rpc_client().invalidate_block(&block_hash_99a)?; + env.rpc_client().invalidate_block(&block_hash_100a)?; + env.rpc_client().invalidate_block(&block_hash_101a)?; // mine new blocks 99b, 100b, 101b env.mine_blocks(3, None)?; diff --git a/crates/electrum/Cargo.toml b/crates/electrum/Cargo.toml index 107a24f48e..71dd0ea9af 100644 --- a/crates/electrum/Cargo.toml +++ b/crates/electrum/Cargo.toml @@ -15,3 +15,8 @@ readme = "README.md" bdk_chain = { path = "../chain", version = "0.6.0", default-features = false } electrum-client = { version = "0.18" } #rustls = { version = "=0.21.1", optional = true, features = ["dangerous_configuration"] } + +[dev-dependencies] +testenv = { path = "../testenv", default-features = false } +electrsd = { version= "0.25.0", features = ["bitcoind_25_0", "esplora_a33e97e1", "legacy"] } +anyhow = "1" \ No newline at end of file diff --git a/crates/electrum/tests/test_electrum.rs b/crates/electrum/tests/test_electrum.rs new file mode 100644 index 0000000000..7d18526e92 --- /dev/null +++ b/crates/electrum/tests/test_electrum.rs @@ -0,0 +1,238 @@ +use anyhow::Result; +use bdk_chain::{ + bitcoin::{hashes::Hash, Address, Amount, ScriptBuf, WScriptHash}, + keychain::Balance, + local_chain::LocalChain, + ConfirmationTimeHeightAnchor, IndexedTxGraph, SpkTxOutIndex, +}; +use bdk_electrum::{ElectrumExt, ElectrumUpdate}; +use electrsd::bitcoind::bitcoincore_rpc::RpcApi; +use electrum_client::ElectrumApi; +use std::time::Duration; +use testenv::TestEnv; + +fn wait_for_block(env: &TestEnv, client: &electrum_client::Client) -> Result<()> { + client.block_headers_subscribe()?; + let mut delay = Duration::from_millis(64); + + loop { + env.electrsd.trigger()?; + client.ping()?; + if client.block_headers_pop()?.is_some() { + return Ok(()); + } + + if delay.as_millis() < 512 { + delay = delay.mul_f32(2.0); + } + std::thread::sleep(delay); + } +} + +fn get_balance( + recv_chain: &LocalChain, + recv_graph: &IndexedTxGraph>, +) -> Result { + let chain_tip = recv_chain.tip().block_id(); + let outpoints = recv_graph.index.outpoints().clone(); + let balance = recv_graph + .graph() + .balance(recv_chain, chain_tip, outpoints, |_, _| true); + Ok(balance) +} + +/// Ensure that [`ElectrumExt`] can sync properly. +/// +/// 1. Mine 101 blocks. +/// 2. Send a tx. +/// 3. Mine extra block to confirm sent tx. +/// 4. Check [`Balance`] to ensure tx is confirmed. +#[test] +fn scan_detects_confirmed_tx() -> Result<()> { + const SEND_AMOUNT: Amount = Amount::from_sat(10_000); + + let env = TestEnv::new()?; + let client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + + // Setup addresses. + let addr_to_mine = env + .bitcoind + .client + .get_new_address(None, None)? + .assume_checked(); + let spk_to_track = ScriptBuf::new_v0_p2wsh(&WScriptHash::all_zeros()); + let addr_to_track = Address::from_script(&spk_to_track, bdk_chain::bitcoin::Network::Regtest)?; + + // Setup receiver. + let (mut recv_chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); + let mut recv_graph = IndexedTxGraph::::new({ + let mut recv_index = SpkTxOutIndex::default(); + recv_index.insert_spk((), spk_to_track.clone()); + recv_index + }); + + // Mine some blocks. + env.mine_blocks(101, Some(addr_to_mine))?; + + // Create transaction that is tracked by our receiver. + env.send(&addr_to_track, SEND_AMOUNT)?; + + // Mine a block to confirm sent tx. + env.mine_blocks(1, None)?; + + // Sync up to tip. + wait_for_block(&env, &client)?; + let ElectrumUpdate { + chain_update, + relevant_txids, + } = client.scan_without_keychain(recv_chain.tip(), [spk_to_track], None, None, 5)?; + + let missing = relevant_txids.missing_full_txs(recv_graph.graph()); + let graph_update = relevant_txids.into_confirmation_time_tx_graph(&client, None, missing)?; + let _ = recv_chain.apply_update(chain_update); + let _ = recv_graph.apply_update(graph_update); + + // Check to see if tx is confirmed. + assert_eq!( + get_balance(&recv_chain, &recv_graph)?, + Balance { + confirmed: SEND_AMOUNT.to_sat(), + ..Balance::default() + }, + ); + + Ok(()) +} + +#[test] +fn test_reorg_is_detected_in_electrsd() -> Result<()> { + let env = TestEnv::new()?; + let client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + + // Mine some blocks. + env.mine_blocks(101, None)?; + wait_for_block(&env, &client)?; + let height = env.bitcoind.client.get_block_count()?; + let blocks = (0..=height) + .map(|i| env.bitcoind.client.get_block_hash(i)) + .collect::, _>>()?; + + // Perform reorg on six blocks. + env.reorg(6)?; + wait_for_block(&env, &client)?; + let reorged_height = env.bitcoind.client.get_block_count()?; + let reorged_blocks = (0..=height) + .map(|i| env.bitcoind.client.get_block_hash(i)) + .collect::, _>>()?; + + assert_eq!(height, reorged_height); + + // Block hashes should not be equal on the six reorged blocks. + for (i, (block, reorged_block)) in blocks.iter().zip(reorged_blocks.iter()).enumerate() { + match i <= height as usize - 6 { + true => assert_eq!(block, reorged_block), + false => assert_ne!(block, reorged_block), + } + } + + Ok(()) +} + +/// Ensure that confirmed txs that are reorged become unconfirmed. +/// +/// 1. Mine 101 blocks. +/// 2. Mine 11 blocks with a confirmed tx in each. +/// 3. Perform 11 separate reorgs on each block with a confirmed tx. +/// 4. Check [`Balance`] after each reorg to ensure unconfirmed amount is correct. +#[test] +fn tx_can_become_unconfirmed_after_reorg() -> Result<()> { + const REORG_COUNT: usize = 11; + const SEND_AMOUNT: Amount = Amount::from_sat(10_000); + + let env = TestEnv::new()?; + let client = electrum_client::Client::new(env.electrsd.electrum_url.as_str())?; + + // Setup addresses. + let addr_to_mine = env + .bitcoind + .client + .get_new_address(None, None)? + .assume_checked(); + let spk_to_track = ScriptBuf::new_v0_p2wsh(&WScriptHash::all_zeros()); + let addr_to_track = Address::from_script(&spk_to_track, bdk_chain::bitcoin::Network::Regtest)?; + + // Setup receiver. + let (mut recv_chain, _) = LocalChain::from_genesis_hash(env.bitcoind.client.get_block_hash(0)?); + let mut recv_graph = IndexedTxGraph::::new({ + let mut recv_index = SpkTxOutIndex::default(); + recv_index.insert_spk((), spk_to_track.clone()); + recv_index + }); + + // Mine some blocks. + env.mine_blocks(101, Some(addr_to_mine))?; + + // Create transactions that are tracked by our receiver. + for _ in 0..REORG_COUNT { + env.send(&addr_to_track, SEND_AMOUNT)?; + env.mine_blocks(1, None)?; + } + + // Sync up to tip. + wait_for_block(&env, &client)?; + let ElectrumUpdate { + chain_update, + relevant_txids, + } = client.scan_without_keychain(recv_chain.tip(), [spk_to_track.clone()], None, None, 5)?; + + let missing = relevant_txids.missing_full_txs(recv_graph.graph()); + let graph_update = relevant_txids.into_confirmation_time_tx_graph(&client, None, missing)?; + let _ = recv_chain.apply_update(chain_update); + let _ = recv_graph.apply_update(graph_update); + + // Check if initial balance is correct. + assert_eq!( + get_balance(&recv_chain, &recv_graph)?, + Balance { + confirmed: SEND_AMOUNT.to_sat() * REORG_COUNT as u64, + ..Balance::default() + }, + "initial balance must be correct", + ); + + // Perform reorgs with different depths. + for depth in 1..=REORG_COUNT { + env.reorg_empty_blocks(depth)?; + + wait_for_block(&env, &client)?; + let ElectrumUpdate { + chain_update, + relevant_txids, + } = client.scan_without_keychain( + recv_chain.tip(), + [spk_to_track.clone()], + None, + None, + 5, + )?; + + let missing = relevant_txids.missing_full_txs(recv_graph.graph()); + let graph_update = + relevant_txids.into_confirmation_time_tx_graph(&client, None, missing)?; + let _ = recv_chain.apply_update(chain_update); + let _ = recv_graph.apply_update(graph_update); + + assert_eq!( + get_balance(&recv_chain, &recv_graph)?, + Balance { + confirmed: SEND_AMOUNT.to_sat() * (REORG_COUNT - depth) as u64, + trusted_pending: SEND_AMOUNT.to_sat() * depth as u64, + ..Balance::default() + }, + "reorg_count: {}", + depth, + ); + } + + Ok(()) +} diff --git a/crates/esplora/Cargo.toml b/crates/esplora/Cargo.toml index 386941dbd7..1ea87453aa 100644 --- a/crates/esplora/Cargo.toml +++ b/crates/esplora/Cargo.toml @@ -21,6 +21,9 @@ futures = { version = "0.3.26", optional = true } bitcoin = { version = "0.30.0", optional = true, default-features = false } miniscript = { version = "10.0.0", optional = true, default-features = false } +[dev-dependencies] +testenv = { path = "../testenv", default_features = false } + [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] electrsd = { version= "0.25.0", features = ["bitcoind_25_0", "esplora_a33e97e1", "legacy"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/crates/esplora/tests/async_ext.rs b/crates/esplora/tests/async_ext.rs index 38833f588e..b7927f5c69 100644 --- a/crates/esplora/tests/async_ext.rs +++ b/crates/esplora/tests/async_ext.rs @@ -1,68 +1,21 @@ use bdk_esplora::EsploraAsyncExt; +use electrsd::bitcoind::anyhow; use electrsd::bitcoind::bitcoincore_rpc::RpcApi; -use electrsd::bitcoind::{self, anyhow, BitcoinD}; -use electrsd::{Conf, ElectrsD}; -use esplora_client::{self, AsyncClient, Builder}; +use esplora_client::{self, Builder}; use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; use std::thread::sleep; use std::time::Duration; -use bdk_chain::bitcoin::{Address, Amount, BlockHash, Txid}; - -struct TestEnv { - bitcoind: BitcoinD, - #[allow(dead_code)] - electrsd: ElectrsD, - client: AsyncClient, -} - -impl TestEnv { - fn new() -> Result { - let bitcoind_exe = - bitcoind::downloaded_exe_path().expect("bitcoind version feature must be enabled"); - let bitcoind = BitcoinD::new(bitcoind_exe).unwrap(); - - let mut electrs_conf = Conf::default(); - electrs_conf.http_enabled = true; - let electrs_exe = - electrsd::downloaded_exe_path().expect("electrs version feature must be enabled"); - let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrs_conf)?; - - let base_url = format!("http://{}", &electrsd.esplora_url.clone().unwrap()); - let client = Builder::new(base_url.as_str()).build_async()?; - - Ok(Self { - bitcoind, - electrsd, - client, - }) - } - - fn mine_blocks( - &self, - count: usize, - address: Option
, - ) -> anyhow::Result> { - let coinbase_address = match address { - Some(address) => address, - None => self - .bitcoind - .client - .get_new_address(None, None)? - .assume_checked(), - }; - let block_hashes = self - .bitcoind - .client - .generate_to_address(count as _, &coinbase_address)?; - Ok(block_hashes) - } -} +use bdk_chain::bitcoin::{Address, Amount, Txid}; +use testenv::TestEnv; #[tokio::test] pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { let env = TestEnv::new()?; + let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap()); + let client = Builder::new(base_url.as_str()).build_async()?; + let receive_address0 = Address::from_str("bcrt1qc6fweuf4xjvz4x3gx3t9e0fh4hvqyu2qw4wvxm")?.assume_checked(); let receive_address1 = @@ -95,12 +48,11 @@ pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().await.unwrap() < 102 { + while client.get_height().await.unwrap() < 102 { sleep(Duration::from_millis(10)) } - let graph_update = env - .client + let graph_update = client .scan_txs( misc_spks.into_iter(), vec![].into_iter(), @@ -121,6 +73,8 @@ pub async fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { #[tokio::test] pub async fn test_async_update_tx_graph_gap_limit() -> anyhow::Result<()> { let env = TestEnv::new()?; + let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap()); + let client = Builder::new(base_url.as_str()).build_async()?; let _block_hashes = env.mine_blocks(101, None)?; // Now let's test the gap limit. First of all get a chain of 10 addresses. @@ -160,14 +114,13 @@ pub async fn test_async_update_tx_graph_gap_limit() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().await.unwrap() < 103 { + while client.get_height().await.unwrap() < 103 { sleep(Duration::from_millis(10)) } // A scan with a gap limit of 2 won't find the transaction, but a scan with a gap limit of 3 // will. - let (graph_update, active_indices) = env - .client + let (graph_update, active_indices) = client .scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), @@ -178,8 +131,7 @@ pub async fn test_async_update_tx_graph_gap_limit() -> anyhow::Result<()> { .await?; assert!(graph_update.full_txs().next().is_none()); assert!(active_indices.is_empty()); - let (graph_update, active_indices) = env - .client + let (graph_update, active_indices) = client .scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), @@ -203,14 +155,13 @@ pub async fn test_async_update_tx_graph_gap_limit() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().await.unwrap() < 104 { + while client.get_height().await.unwrap() < 104 { sleep(Duration::from_millis(10)) } // A scan with gap limit 4 won't find the second transaction, but a scan with gap limit 5 will. // The last active indice won't be updated in the first case but will in the second one. - let (graph_update, active_indices) = env - .client + let (graph_update, active_indices) = client .scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), @@ -223,8 +174,7 @@ pub async fn test_async_update_tx_graph_gap_limit() -> anyhow::Result<()> { assert_eq!(txs.len(), 1); assert!(txs.contains(&txid_4th_addr)); assert_eq!(active_indices[&0], 3); - let (graph_update, active_indices) = env - .client + let (graph_update, active_indices) = client .scan_txs_with_keychains(keychains, vec![].into_iter(), vec![].into_iter(), 5, 1) .await?; let txs: HashSet<_> = graph_update.full_txs().map(|tx| tx.txid).collect(); diff --git a/crates/esplora/tests/blocking_ext.rs b/crates/esplora/tests/blocking_ext.rs index 5a76172325..6a9adc38f2 100644 --- a/crates/esplora/tests/blocking_ext.rs +++ b/crates/esplora/tests/blocking_ext.rs @@ -1,68 +1,21 @@ use bdk_esplora::EsploraExt; +use electrsd::bitcoind::anyhow; use electrsd::bitcoind::bitcoincore_rpc::RpcApi; -use electrsd::bitcoind::{self, anyhow, BitcoinD}; -use electrsd::{Conf, ElectrsD}; -use esplora_client::{self, BlockingClient, Builder}; +use esplora_client::{self, Builder}; use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; use std::thread::sleep; use std::time::Duration; -use bdk_chain::bitcoin::{Address, Amount, BlockHash, Txid}; - -struct TestEnv { - bitcoind: BitcoinD, - #[allow(dead_code)] - electrsd: ElectrsD, - client: BlockingClient, -} - -impl TestEnv { - fn new() -> Result { - let bitcoind_exe = - bitcoind::downloaded_exe_path().expect("bitcoind version feature must be enabled"); - let bitcoind = BitcoinD::new(bitcoind_exe).unwrap(); - - let mut electrs_conf = Conf::default(); - electrs_conf.http_enabled = true; - let electrs_exe = - electrsd::downloaded_exe_path().expect("electrs version feature must be enabled"); - let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrs_conf)?; - - let base_url = format!("http://{}", &electrsd.esplora_url.clone().unwrap()); - let client = Builder::new(base_url.as_str()).build_blocking()?; - - Ok(Self { - bitcoind, - electrsd, - client, - }) - } - - fn mine_blocks( - &self, - count: usize, - address: Option
, - ) -> anyhow::Result> { - let coinbase_address = match address { - Some(address) => address, - None => self - .bitcoind - .client - .get_new_address(None, None)? - .assume_checked(), - }; - let block_hashes = self - .bitcoind - .client - .generate_to_address(count as _, &coinbase_address)?; - Ok(block_hashes) - } -} +use bdk_chain::bitcoin::{Address, Amount, Txid}; +use testenv::TestEnv; #[test] pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { let env = TestEnv::new()?; + let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap()); + let client = Builder::new(base_url.as_str()).build_blocking()?; + let receive_address0 = Address::from_str("bcrt1qc6fweuf4xjvz4x3gx3t9e0fh4hvqyu2qw4wvxm")?.assume_checked(); let receive_address1 = @@ -95,11 +48,11 @@ pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().unwrap() < 102 { + while client.get_height().unwrap() < 102 { sleep(Duration::from_millis(10)) } - let graph_update = env.client.scan_txs( + let graph_update = client.scan_txs( misc_spks.into_iter(), vec![].into_iter(), vec![].into_iter(), @@ -119,6 +72,8 @@ pub fn test_update_tx_graph_without_keychain() -> anyhow::Result<()> { #[test] pub fn test_update_tx_graph_gap_limit() -> anyhow::Result<()> { let env = TestEnv::new()?; + let base_url = format!("http://{}", &env.electrsd.esplora_url.clone().unwrap()); + let client = Builder::new(base_url.as_str()).build_blocking()?; let _block_hashes = env.mine_blocks(101, None)?; // Now let's test the gap limit. First of all get a chain of 10 addresses. @@ -158,13 +113,13 @@ pub fn test_update_tx_graph_gap_limit() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().unwrap() < 103 { + while client.get_height().unwrap() < 103 { sleep(Duration::from_millis(10)) } // A scan with a gap limit of 2 won't find the transaction, but a scan with a gap limit of 3 // will. - let (graph_update, active_indices) = env.client.scan_txs_with_keychains( + let (graph_update, active_indices) = client.scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), vec![].into_iter(), @@ -173,7 +128,7 @@ pub fn test_update_tx_graph_gap_limit() -> anyhow::Result<()> { )?; assert!(graph_update.full_txs().next().is_none()); assert!(active_indices.is_empty()); - let (graph_update, active_indices) = env.client.scan_txs_with_keychains( + let (graph_update, active_indices) = client.scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), vec![].into_iter(), @@ -195,13 +150,13 @@ pub fn test_update_tx_graph_gap_limit() -> anyhow::Result<()> { None, )?; let _block_hashes = env.mine_blocks(1, None)?; - while env.client.get_height().unwrap() < 104 { + while client.get_height().unwrap() < 104 { sleep(Duration::from_millis(10)) } // A scan with gap limit 4 won't find the second transaction, but a scan with gap limit 5 will. // The last active indice won't be updated in the first case but will in the second one. - let (graph_update, active_indices) = env.client.scan_txs_with_keychains( + let (graph_update, active_indices) = client.scan_txs_with_keychains( keychains.clone(), vec![].into_iter(), vec![].into_iter(), @@ -212,13 +167,8 @@ pub fn test_update_tx_graph_gap_limit() -> anyhow::Result<()> { assert_eq!(txs.len(), 1); assert!(txs.contains(&txid_4th_addr)); assert_eq!(active_indices[&0], 3); - let (graph_update, active_indices) = env.client.scan_txs_with_keychains( - keychains, - vec![].into_iter(), - vec![].into_iter(), - 5, - 1, - )?; + let (graph_update, active_indices) = + client.scan_txs_with_keychains(keychains, vec![].into_iter(), vec![].into_iter(), 5, 1)?; let txs: HashSet<_> = graph_update.full_txs().map(|tx| tx.txid).collect(); assert_eq!(txs.len(), 2); assert!(txs.contains(&txid_4th_addr) && txs.contains(&txid_last_addr)); diff --git a/crates/testenv/Cargo.toml b/crates/testenv/Cargo.toml new file mode 100644 index 0000000000..667cd8c20f --- /dev/null +++ b/crates/testenv/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "testenv" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# For no-std, remember to enable the bitcoin/no-std feature +# bitcoin = { version = "0.30", default-features = false } +bitcoincore-rpc = { version = "0.17" } +bdk_chain = { path = "../chain", version = "0.6", default-features = false } +electrsd = { version= "0.25.0", features = ["bitcoind_25_0", "esplora_a33e97e1", "legacy"] } +anyhow = { version = "1" } + +[features] +default = ["std"] +std = ["bdk_chain/std"] +serde = ["bdk_chain/serde"] \ No newline at end of file diff --git a/crates/testenv/src/lib.rs b/crates/testenv/src/lib.rs new file mode 100644 index 0000000000..c4fbc93f84 --- /dev/null +++ b/crates/testenv/src/lib.rs @@ -0,0 +1,196 @@ +use bdk_chain::bitcoin::{ + address::NetworkChecked, block::Header, hash_types::TxMerkleNode, hashes::Hash, + secp256k1::rand::random, Address, Amount, Block, BlockHash, CompactTarget, ScriptBuf, + ScriptHash, Transaction, TxIn, TxOut, Txid, +}; +use bitcoincore_rpc::{ + bitcoincore_rpc_json::{GetBlockTemplateModes, GetBlockTemplateRules}, + RpcApi, +}; +use electrsd::electrum_client::ElectrumApi; + +/// Struct for running a regtest environment with a single `bitcoind` node with an `electrs` +/// instance connected to it. +pub struct TestEnv { + pub bitcoind: electrsd::bitcoind::BitcoinD, + pub electrsd: electrsd::ElectrsD, +} + +impl TestEnv { + /// Construct a new [`TestEnv`] instance with default configurations. + pub fn new() -> anyhow::Result { + let bitcoind = match std::env::var_os("BITCOIND_EXE") { + Some(bitcoind_path) => electrsd::bitcoind::BitcoinD::new(bitcoind_path), + None => { + let bitcoind_exe = electrsd::bitcoind::downloaded_exe_path() + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + electrsd::bitcoind::BitcoinD::with_conf( + bitcoind_exe, + &electrsd::bitcoind::Conf::default(), + ) + } + }?; + + let electrsd = match std::env::var_os("ELECTRS_EXE") { + Some(env_electrs_exe) => electrsd::ElectrsD::new(env_electrs_exe, &bitcoind), + None => { + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + let electrs_exe = electrsd::downloaded_exe_path() + .expect("electrs version feature must be enabled"); + electrsd::ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf) + } + }?; + + Ok(Self { bitcoind, electrsd }) + } + + /// Exposes the [`ElectrumApi`] calls from the Electrum client. + pub fn electrum_client(&self) -> &impl ElectrumApi { + &self.electrsd.client + } + + /// Exposes the [`RpcApi`] calls from [`bitcoincore_rpc`]. + pub fn rpc_client(&self) -> &impl RpcApi { + &self.bitcoind.client + } + + /// Mine a number of blocks of a given size `count`, which may be specified to a given coinbase + /// `address`. + pub fn mine_blocks( + &self, + count: usize, + address: Option
, + ) -> anyhow::Result> { + let coinbase_address = match address { + Some(address) => address, + None => self + .bitcoind + .client + .get_new_address(None, None)? + .assume_checked(), + }; + let block_hashes = self + .bitcoind + .client + .generate_to_address(count as _, &coinbase_address)?; + Ok(block_hashes) + } + + /// Mine a block that is guaranteed to be empty even with transactions in the mempool. + pub fn mine_empty_block(&self) -> anyhow::Result<(usize, BlockHash)> { + let bt = self.bitcoind.client.get_block_template( + GetBlockTemplateModes::Template, + &[GetBlockTemplateRules::SegWit], + &[], + )?; + + let txdata = vec![Transaction { + version: 1, + lock_time: bdk_chain::bitcoin::absolute::LockTime::from_height(0)?, + input: vec![TxIn { + previous_output: bdk_chain::bitcoin::OutPoint::default(), + script_sig: ScriptBuf::builder() + .push_int(bt.height as _) + // randomn number so that re-mining creates unique block + .push_int(random()) + .into_script(), + sequence: bdk_chain::bitcoin::Sequence::default(), + witness: bdk_chain::bitcoin::Witness::new(), + }], + output: vec![TxOut { + value: 0, + script_pubkey: ScriptBuf::new_p2sh(&ScriptHash::all_zeros()), + }], + }]; + + let bits: [u8; 4] = bt + .bits + .clone() + .try_into() + .expect("rpc provided us with invalid bits"); + + let mut block = Block { + header: Header { + version: bdk_chain::bitcoin::block::Version::default(), + prev_blockhash: bt.previous_block_hash, + merkle_root: TxMerkleNode::all_zeros(), + time: Ord::max(bt.min_time, std::time::UNIX_EPOCH.elapsed()?.as_secs()) as u32, + bits: CompactTarget::from_consensus(u32::from_be_bytes(bits)), + nonce: 0, + }, + txdata, + }; + + block.header.merkle_root = block.compute_merkle_root().expect("must compute"); + + for nonce in 0..=u32::MAX { + block.header.nonce = nonce; + if block.header.target().is_met_by(block.block_hash()) { + break; + } + } + + self.bitcoind.client.submit_block(&block)?; + Ok((bt.height as usize, block.block_hash())) + } + + /// Invalidate a number of blocks of a given size `count`. + pub fn invalidate_blocks(&self, count: usize) -> anyhow::Result<()> { + let mut hash = self.bitcoind.client.get_best_block_hash()?; + for _ in 0..count { + let prev_hash = self + .bitcoind + .client + .get_block_info(&hash)? + .previousblockhash; + self.bitcoind.client.invalidate_block(&hash)?; + match prev_hash { + Some(prev_hash) => hash = prev_hash, + None => break, + } + } + Ok(()) + } + + /// Reorg a number of blocks of a given size `count`. + pub fn reorg(&self, count: usize) -> anyhow::Result> { + let start_height = self.bitcoind.client.get_block_count()?; + self.invalidate_blocks(count)?; + + let res = self.mine_blocks(count, None); + assert_eq!( + self.bitcoind.client.get_block_count()?, + start_height, + "reorg should not result in height change" + ); + res + } + + /// Reorg with a number of empty blocks of a given size `count`. + pub fn reorg_empty_blocks(&self, count: usize) -> anyhow::Result> { + let start_height = self.bitcoind.client.get_block_count()?; + self.invalidate_blocks(count)?; + + let res = (0..count) + .map(|_| self.mine_empty_block()) + .collect::, _>>()?; + assert_eq!( + self.bitcoind.client.get_block_count()?, + start_height, + "reorg should not result in height change" + ); + Ok(res) + } + + /// Send a tx of a given `amount` to a given `address`. + pub fn send(&self, address: &Address, amount: Amount) -> anyhow::Result { + let txid = self + .bitcoind + .client + .send_to_address(address, amount, None, None, None, None, None, None)?; + Ok(txid) + } +}