Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 139 additions & 4 deletions zebra-crosslink/zebra-crosslink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2005,11 +2005,22 @@ async fn tfl_block_sequence(
.await;

if let Ok(StateResponse::BlockHashes(chunk_hashes)) = res {
if c == 0 && include_start_hash && !chunk_hashes.is_empty() {
assert_eq!(
chunk_hashes[0], start_hash,
"first hash is not the one requested"
if c == 0 && include_start_hash && !chunk_hashes.is_empty() && chunk_hashes[0] != start_hash {
// The best chain switched between resolving start_hash and this
// walk: FindBlockHashes follows the CURRENT best chain, so a
// same-height sibling switch (e.g. a stale internal-miner submit
// displacing a just-committed block, see #39) makes the first
// returned hash differ from the one requested. That is a normal
// reorg race, not an internal error. Return what we have; the
// callers' paranoid guards already retry against the new chain.
// This was a fatal assert that took whole nodes down whenever
// the race hit (multiple operator reports 2026-07-20/21,
// "panicked at zebra-crosslink/src/lib.rs:1939/:1959").
println!(
"PARANOID != WRONG: first hash {} is not the requested {} (best chain switched mid-walk)",
chunk_hashes[0], start_hash
);
break;
}

chunk = chunk_hashes;
Expand Down Expand Up @@ -2186,3 +2197,127 @@ async fn _tfl_dump_block_sequence(
.await;
tfl_dump_blocks(&blocks[..], &infos[..]);
}

#[cfg(test)]
mod tfl_block_sequence_reorg_tests {
use super::*;
use crate::service::TFLServiceCalls;
use std::sync::atomic::{AtomicUsize, Ordering};
use zebra_chain::serialization::ZcashDeserializeInto;

/// Builds `TFLServiceCalls` whose state procedure answers with the given
/// sync closure. The other procedures are unused by `tfl_block_sequence`
/// and fail the call if ever hit.
fn state_only_calls(
state: impl Fn(StateRequest) -> StateResponse + Send + Sync + 'static,
) -> TFLServiceCalls {
let state = Arc::new(state);
TFLServiceCalls {
state: Arc::new(move |req| {
let state = state.clone();
Box::pin(async move { Ok(state(req)) })
}),
read_state: Arc::new(|_| Box::pin(async { Err("read_state unused in test".into()) })),
mempool: Arc::new(|_| Box::pin(async { Err("mempool unused in test".into()) })),
force_feed_pos: Arc::new(|_, _| {
Box::pin(async { Err("force_feed_pos unused in test".to_string()) })
}),
}
}

fn test_blocks() -> (Arc<Block>, Arc<Block>) {
let block_1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES
.zcash_deserialize_into::<Arc<Block>>()
.expect("block 1 should deserialize");
let block_2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES
.zcash_deserialize_into::<Arc<Block>>()
.expect("block 2 should deserialize");
(block_1, block_2)
}

/// A best-chain switch between resolving the start hash and walking
/// `FindBlockHashes` makes element 0 a same-height sibling of the block we
/// started from. This used to be a fatal assert that aborted the node; it
/// must return empty vectors instead, so the viz2 caller's paranoid guards
/// reject the empty sequence and re-read the tip on the next loop tick.
#[tokio::test]
async fn sibling_first_hash_returns_empty_instead_of_panicking() {
let (block_1, block_2) = test_blocks();
let start_hash = block_1.hash();
// stands in for the sibling that displaced block_1 at the same height
let sibling_hash = block_2.hash();
let header = block_1.header.clone();

let calls = state_only_calls(move |req| match req {
StateRequest::BlockHeader(_) => StateResponse::BlockHeader {
header: header.clone(),
hash: start_hash,
height: ZebBlockHeight(1),
next_block_hash: None,
},
StateRequest::FindBlockHashes { .. } => {
StateResponse::BlockHashes(vec![sibling_hash])
}
other => panic!("unexpected state request in test: {other:?}"),
});

let (hashes, blocks) = tfl_block_sequence(
&calls,
start_hash,
Some((ZebBlockHeight(3), sibling_hash)),
true,
true,
)
.await;

assert!(hashes.is_empty(), "no sibling sequence may be consumed");
assert!(
blocks.is_empty(),
"no blocks may be fetched or published for a sibling walk"
);
}

/// The guard must not fire on a consistent walk: element 0 equal to the
/// requested start hash returns the sequence as before.
#[tokio::test]
async fn consistent_walk_still_returns_the_sequence() {
let (block_1, block_2) = test_blocks();
let start_hash = block_1.hash();
let next_hash = block_2.hash();
let header = block_1.header.clone();

let find_calls = AtomicUsize::new(0);
let calls = state_only_calls(move |req| match req {
StateRequest::BlockHeader(_) => StateResponse::BlockHeader {
header: header.clone(),
hash: start_hash,
height: ZebBlockHeight(1),
next_block_hash: None,
},
StateRequest::FindBlockHashes { .. } => {
if find_calls.fetch_add(1, Ordering::SeqCst) == 0 {
StateResponse::BlockHashes(vec![start_hash, next_hash])
} else {
// end of chain
StateResponse::BlockHashes(Vec::new())
}
}
other => panic!("unexpected state request in test: {other:?}"),
});

let (hashes, blocks) = tfl_block_sequence(
&calls,
start_hash,
Some((ZebBlockHeight(2), next_hash)),
true,
false,
)
.await;

assert_eq!(
hashes.iter().map(|h| h.1).collect::<Vec<_>>(),
vec![start_hash, next_hash],
);
assert!(blocks.is_empty(), "read_extra_info=false returns no blocks");
}
}