diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index 5f8613d26..5b36eb6b5 100755 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -27,3 +27,4 @@ cargo update -p ring --precise "0.17.12" cargo update -p once_cell --precise "1.20.3" cargo update -p base64ct --precise "1.6.0" cargo update -p minreq --precise "2.13.2" +cargo update -p tracing-core --precise "0.1.33" diff --git a/crates/esplora/src/async_ext.rs b/crates/esplora/src/async_ext.rs index 94e31170d..dc82063bd 100644 --- a/crates/esplora/src/async_ext.rs +++ b/crates/esplora/src/async_ext.rs @@ -205,12 +205,16 @@ async fn fetch_block( // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain // tip is used to signal for the last-synced-up-to-height. - let &tip_height = latest_blocks - .keys() - .last() - .expect("must have atleast one entry"); - if height > tip_height { - return Ok(None); + match latest_blocks.keys().last().copied() { + None => { + debug_assert!(false, "`latest_blocks` should not be empty"); + return Ok(None); + } + Some(tip_height) => { + if height > tip_height { + return Ok(None); + } + } } Ok(Some(client.get_block_hash(height).await?)) @@ -227,27 +231,36 @@ async fn chain_update( anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>, ) -> Result { let mut point_of_agreement = None; + let mut local_cp_hash = local_tip.hash(); let mut conflicts = vec![]; + for local_cp in local_tip.iter() { let remote_hash = match fetch_block(client, latest_blocks, local_cp.height()).await? { Some(hash) => hash, None => continue, }; if remote_hash == local_cp.hash() { - point_of_agreement = Some(local_cp.clone()); + point_of_agreement = Some(local_cp); break; - } else { - // it is not strictly necessary to include all the conflicted heights (we do need the - // first one) but it seems prudent to make sure the updated chain's heights are a - // superset of the existing chain after update. - conflicts.push(BlockId { - height: local_cp.height(), - hash: remote_hash, - }); } + local_cp_hash = local_cp.hash(); + // It is not strictly necessary to include all the conflicted heights (we do need the + // first one) but it seems prudent to make sure the updated chain's heights are a + // superset of the existing chain after update. + conflicts.push(BlockId { + height: local_cp.height(), + hash: remote_hash, + }); } - let mut tip = point_of_agreement.expect("remote esplora should have same genesis block"); + let mut tip = match point_of_agreement { + Some(tip) => tip, + None => { + return Err(Box::new(esplora_client::Error::HeaderHashNotFound( + local_cp_hash, + ))); + } + }; tip = tip .extend(conflicts.into_iter().rev()) @@ -545,7 +558,7 @@ mod test { local_chain::LocalChain, BlockId, }; - use bdk_core::ConfirmationBlockTime; + use bdk_core::{bitcoin, ConfirmationBlockTime}; use bdk_testenv::{anyhow, bitcoincore_rpc::RpcApi, TestEnv}; use esplora_client::Builder; @@ -557,6 +570,41 @@ mod test { }}; } + // Test that `chain_update` fails due to wrong network. + #[tokio::test] + async fn test_chain_update_wrong_network_error() -> 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 initial_height = env.rpc_client().get_block_count()? as u32; + + let mine_to = 16; + let _ = env.mine_blocks((mine_to - initial_height) as usize, None)?; + while client.get_height().await? < mine_to { + std::thread::sleep(Duration::from_millis(64)); + } + let latest_blocks = fetch_latest_blocks(&client).await?; + assert!(!latest_blocks.is_empty()); + assert_eq!(latest_blocks.keys().last(), Some(&mine_to)); + + let genesis_hash = + bitcoin::constants::genesis_block(bitcoin::Network::Testnet4).block_hash(); + let cp = bdk_chain::CheckPoint::new(BlockId { + height: 0, + hash: genesis_hash, + }); + + let anchors = BTreeSet::new(); + let res = chain_update(&client, &latest_blocks, &cp, &anchors).await; + use esplora_client::Error; + assert!( + matches!(*res.unwrap_err(), Error::HeaderHashNotFound(hash) if hash == genesis_hash), + "`chain_update` should error if it can't connect to the local CP", + ); + + Ok(()) + } + /// Ensure that update does not remove heights (from original), and all anchor heights are /// included. #[tokio::test] diff --git a/crates/esplora/src/blocking_ext.rs b/crates/esplora/src/blocking_ext.rs index d6f665a6f..6c4966bfb 100644 --- a/crates/esplora/src/blocking_ext.rs +++ b/crates/esplora/src/blocking_ext.rs @@ -190,12 +190,16 @@ fn fetch_block( // We avoid fetching blocks higher than previously fetched `latest_blocks` as the local chain // tip is used to signal for the last-synced-up-to-height. - let &tip_height = latest_blocks - .keys() - .last() - .expect("must have atleast one entry"); - if height > tip_height { - return Ok(None); + match latest_blocks.keys().last().copied() { + None => { + debug_assert!(false, "`latest_blocks` should not be empty"); + return Ok(None); + } + Some(tip_height) => { + if height > tip_height { + return Ok(None); + } + } } Ok(Some(client.get_block_hash(height)?)) @@ -212,27 +216,36 @@ fn chain_update( anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>, ) -> Result { let mut point_of_agreement = None; + let mut local_cp_hash = local_tip.hash(); let mut conflicts = vec![]; + for local_cp in local_tip.iter() { let remote_hash = match fetch_block(client, latest_blocks, local_cp.height())? { Some(hash) => hash, None => continue, }; if remote_hash == local_cp.hash() { - point_of_agreement = Some(local_cp.clone()); + point_of_agreement = Some(local_cp); break; - } else { - // it is not strictly necessary to include all the conflicted heights (we do need the - // first one) but it seems prudent to make sure the updated chain's heights are a - // superset of the existing chain after update. - conflicts.push(BlockId { - height: local_cp.height(), - hash: remote_hash, - }); } + local_cp_hash = local_cp.hash(); + // It is not strictly necessary to include all the conflicted heights (we do need the + // first one) but it seems prudent to make sure the updated chain's heights are a + // superset of the existing chain after update. + conflicts.push(BlockId { + height: local_cp.height(), + hash: remote_hash, + }); } - let mut tip = point_of_agreement.expect("remote esplora should have same genesis block"); + let mut tip = match point_of_agreement { + Some(tip) => tip, + None => { + return Err(Box::new(esplora_client::Error::HeaderHashNotFound( + local_cp_hash, + ))); + } + }; tip = tip .extend(conflicts.into_iter().rev()) @@ -498,6 +511,7 @@ fn fetch_txs_with_outpoints>( #[cfg(test)] mod test { use crate::blocking_ext::{chain_update, fetch_latest_blocks}; + use bdk_chain::bitcoin; use bdk_chain::bitcoin::hashes::Hash; use bdk_chain::bitcoin::Txid; use bdk_chain::local_chain::LocalChain; @@ -522,6 +536,41 @@ mod test { }}; } + // Test that `chain_update` fails due to wrong network. + #[test] + fn test_chain_update_wrong_network_error() -> 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 initial_height = env.rpc_client().get_block_count()? as u32; + + let mine_to = 16; + let _ = env.mine_blocks((mine_to - initial_height) as usize, None)?; + while client.get_height()? < mine_to { + std::thread::sleep(Duration::from_millis(64)); + } + let latest_blocks = fetch_latest_blocks(&client)?; + assert!(!latest_blocks.is_empty()); + assert_eq!(latest_blocks.keys().last(), Some(&mine_to)); + + let genesis_hash = + bitcoin::constants::genesis_block(bitcoin::Network::Testnet4).block_hash(); + let cp = bdk_chain::CheckPoint::new(BlockId { + height: 0, + hash: genesis_hash, + }); + + let anchors = BTreeSet::new(); + let res = chain_update(&client, &latest_blocks, &cp, &anchors); + use esplora_client::Error; + assert!( + matches!(*res.unwrap_err(), Error::HeaderHashNotFound(hash) if hash == genesis_hash), + "`chain_update` should error if it can't connect to the local CP", + ); + + Ok(()) + } + /// Ensure that update does not remove heights (from original), and all anchor heights are /// included. #[test]