]> Untitled Git - bdk/commitdiff
fix(esplora): `chain_update` errors if no point of connection
authorvalued mammal <valuedmammal@protonmail.com>
Thu, 5 Jun 2025 17:17:21 +0000 (13:17 -0400)
committervalued mammal <valuedmammal@protonmail.com>
Tue, 1 Jul 2025 15:24:25 +0000 (11:24 -0400)
Before, the `chain_update` function would hit a panic if the
local checkpoint was not on the same network as the remote
server. Now if we have iterated all of the blocks of the
`local_cp` and do not find a "point of agreement", then we
return early with a `esplora_client::Error::HeaderHashNotFound`.

crates/esplora/src/async_ext.rs
crates/esplora/src/blocking_ext.rs

index 94e31170d85a6bf5634099e1e97cb76f3460cac7..4ea697c932b84b41780f6b89eaaa07d71885c7d5 100644 (file)
@@ -205,12 +205,10 @@ async fn fetch_block<S: Sleeper>(
 
     // 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);
+    if let Some(tip_height) = latest_blocks.keys().last().copied() {
+        if height > tip_height {
+            return Ok(None);
+        }
     }
 
     Ok(Some(client.get_block_hash(height).await?))
@@ -227,27 +225,36 @@ async fn chain_update<S: Sleeper>(
     anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>,
 ) -> Result<CheckPoint, Error> {
     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 +552,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 +564,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]
index d6f665a6fd7d522b3103ea37e7e960f1db8b4084..6977c8f0bcf2fd4051db7ce982c87ee7cf04f560 100644 (file)
@@ -190,12 +190,10 @@ 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);
+    if let Some(tip_height) = latest_blocks.keys().last().copied() {
+        if height > tip_height {
+            return Ok(None);
+        }
     }
 
     Ok(Some(client.get_block_hash(height)?))
@@ -212,27 +210,36 @@ fn chain_update(
     anchors: &BTreeSet<(ConfirmationBlockTime, Txid)>,
 ) -> Result<CheckPoint, Error> {
     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 +505,7 @@ fn fetch_txs_with_outpoints<I: IntoIterator<Item = OutPoint>>(
 #[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 +530,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]