fix(indexer): deliver only from peer blocks inside the verified run

This commit is contained in:
moudyellaz 2026-08-06 10:17:12 +02:00
parent 5c28c4cc0c
commit c7739733db
2 changed files with 346 additions and 160 deletions

View File

@ -36,9 +36,9 @@ const PEER_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
/// sleep can overshoot, understates it. /// sleep can overshoot, understates it.
const PEER_BLOCK_POLL_INTERVAL: Duration = Duration::from_secs(1); const PEER_BLOCK_POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Consecutive passes a peer reader re-reads the same undecodable slot before /// Consecutive passes a peer reader spends stuck on one slot before it says so
/// giving up and reading past it. /// as something more than the per-pass failure. It never reads past the slot.
const DECODE_RETRY_LIMIT: u32 = 3; const STUCK_SLOT_ALERT_PASSES: u32 = 3;
/// Why a cross-zone dispatch could not be verified. /// Why a cross-zone dispatch could not be verified.
/// ///
@ -111,6 +111,7 @@ impl PeerChain {
/// What one consistent look at the peer cache says about a referenced block. /// What one consistent look at the peer cache says about a referenced block.
enum PeerLookup { enum PeerLookup {
/// Held, and inside the run verified from the peer's genesis.
Cached(Box<Block>), Cached(Box<Block>),
/// Inside the verified run but not held, so it is not on the peer chain. /// Inside the verified run but not held, so it is not on the peer chain.
InsideRun, InsideRun,
@ -126,33 +127,70 @@ struct PeerBlocks {
} }
impl PeerBlocks { impl PeerBlocks {
/// Caches `block` unless a different block is already held at its id, and /// Caches `block` if it is the next one this reader needs, and says whether
/// says whether it was newly cached. /// it was newly cached.
/// ///
/// First write wins. An identical re-read is a no-op, which the reader does /// Sequential, exactly like the watcher on the sequencer side. Caching ahead
/// on every slot retry. A differing block at a held id is equivocation, and /// of the run looks harmless, since an id that does not continue the run
/// replacing the entry is what makes it a remote halt: the prefix already /// cannot advance it, but it is not: when the predecessor later arrives the
/// certified the old value, so the next dispatch naming that id re-derives /// prefix walks straight through the block held ahead, while the watcher,
/// against the new one and reads as forged. /// which reads strictly in order and never reconsiders what it passed over,
/// has already discarded it. The two then hold different blocks at one id,
/// and the next dispatch naming it re-derives against the wrong one and
/// halts ingestion. Refusing to look ahead is what keeps the two in step.
/// ///
/// A peer resetting its chain is indistinguishable from this and is refused /// First write wins at every id but one. An identical re-read is a no-op,
/// the same way. The cache is process-local, so a restart adopts it. /// which the reader does on every slot retry. A differing block inside the
/// run is equivocation, and replacing it is the remote halt from #648: the
/// prefix certified the old value, so the next dispatch naming that id
/// re-derives against the new one and reads as forged.
///
/// The exception is the one id that would extend the run. Holding the first
/// arrival there is its own trap: a peer inscribing a block that claims that
/// id and does not continue the chain locks it out for good, since the
/// honest block is refused when it lands and the run can never walk past it,
/// and the channel is append-only so a restart replays the same order and
/// holds the same block. There, and only there, a block that continues the
/// run displaces one that does not.
async fn insert(&self, zone: ZoneId, block: Block) -> bool { async fn insert(&self, zone: ZoneId, block: Block) -> bool {
let mut chains = self.chains.write().await; let mut chains = self.chains.write().await;
let chain = chains.entry(zone).or_default(); let chain = chains.entry(zone).or_default();
let next = chain.next_expected();
// Below the peer's genesis as well as ahead of the run: an id under
// GENESIS_BLOCK_ID is on no chain the run can ever walk, and cached it
// would resolve as the peer's own block for ever after.
if block.header.block_id > next || block.header.block_id < GENESIS_BLOCK_ID {
debug!(
"Peer reader for {} not caching block {}: only block {next} continues the run verified from that peer's genesis.",
hex::encode(zone),
block.header.block_id
);
return false;
}
if let Some(held) = chain.blocks.get(&block.header.block_id) { if let Some(held) = chain.blocks.get(&block.header.block_id) {
if held.header.hash == block.header.hash { if held.header.hash == block.header.hash {
return false; return false;
} }
error!( if block.header.block_id != next || !Self::extends_the_run(chain, &block) {
"Peer zone {} equivocated at block {}: holding {}, refusing {}. Restart the indexer if this peer legitimately reset its chain.", error!(
"Peer zone {} equivocated at block {}: holding {}, refusing {}. Nothing at or above block {} can be delivered from until that peer inscribes a block continuing the run verified from its genesis.",
hex::encode(zone),
block.header.block_id,
held.header.hash,
block.header.hash,
block.header.block_id
);
return false;
}
info!(
"Peer zone {} block {}: replacing held block {} with {}, which continues the verified run where the held one never could.",
hex::encode(zone), hex::encode(zone),
block.header.block_id, block.header.block_id,
held.header.hash, held.header.hash,
block.header.hash block.header.hash
); );
return false;
} }
chain.blocks.insert(block.header.block_id, block); chain.blocks.insert(block.header.block_id, block);
@ -160,29 +198,47 @@ impl PeerBlocks {
true true
} }
/// Whether `block` links to the block at the head of the verified run.
///
/// False before the peer's genesis has been read, so the first block at that
/// id wins and is never displaced, which is how the watcher anchors too.
fn extends_the_run(chain: &PeerChain, block: &Block) -> bool {
chain
.verified_prefix
.and_then(|prefix| chain.blocks.get(&prefix))
.is_some_and(|tip| block.header.prev_block_hash == tip.header.hash)
}
/// Resolves `block_id` under a single read lock. /// Resolves `block_id` under a single read lock.
/// ///
/// Answering "is it cached?" and "is it inside the verified run?" under two /// Cached is not the same as verified, and only the second may be delivered
/// separate locks races with the peer reader: an insert landing between them /// from. A peer writes its own block ids, and a block enters the cache on
/// its own hash and signature alone, so one claiming an id its chain never
/// reached is cached like any other. The run walked from the peer's genesis
/// is what says the peer built it. A block outside that run therefore reads
/// as one the reader has not got to, which stalls the dispatch naming it
/// rather than certifying it, and a peer inscribing a block ahead of its
/// chain cannot get a message delivered under an id it has not reached.
///
/// One lock, because answering "is it cached?" and "is it inside the run?"
/// separately races with the peer reader: an insert landing between them
/// reads as absent-and-inside-the-run, which is the forgery signal, for a /// reads as absent-and-inside-the-run, which is the forgery signal, for a
/// block that is in fact cached. That is the normal steady state, a waiting /// block that is in fact cached. That is the normal steady state, a waiting
/// verifier and the block it waits for arriving, so it must be one look. /// verifier and the block it waits for arriving.
async fn resolve(&self, zone: ZoneId, block_id: u64) -> PeerLookup { async fn resolve(&self, zone: ZoneId, block_id: u64) -> PeerLookup {
let chains = self.chains.read().await; let chains = self.chains.read().await;
let Some(chain) = chains.get(&zone) else { let Some(chain) = chains.get(&zone) else {
return PeerLookup::Behind; return PeerLookup::Behind;
}; };
if let Some(block) = chain.blocks.get(&block_id) { if chain.verified_prefix.is_none_or(|prefix| prefix < block_id) {
return PeerLookup::Cached(Box::new(block.clone())); return PeerLookup::Behind;
}
if chain
.verified_prefix
.is_some_and(|prefix| prefix >= block_id)
{
PeerLookup::InsideRun
} else {
PeerLookup::Behind
} }
chain
.blocks
.get(&block_id)
.map_or(PeerLookup::InsideRun, |block| {
PeerLookup::Cached(Box::new(block.clone()))
})
} }
#[cfg(test)] #[cfg(test)]
@ -528,10 +584,9 @@ async fn read_peer(
let mut cursor = None; let mut cursor = None;
// The slot the reader is stuck on and how many passes it has spent there. // The slot the reader is stuck on and how many passes it has spent there.
// Keyed by slot: the retry budget is per slot, so a failure at a new slot // Keyed by slot so a failure at a new slot does not inherit an older slot's
// must not inherit an older slot's count and be skipped on its first try. // count, and used only to say so once rather than every pass.
let mut stalled: Option<(Slot, u32)> = None; let mut stalled: Option<(Slot, u32)> = None;
let mut skip_slot = None;
loop { loop {
match zone_indexer.next_messages(cursor).await { match zone_indexer.next_messages(cursor).await {
Ok(stream) => { Ok(stream) => {
@ -541,7 +596,6 @@ async fn read_peer(
expected_pubkey.as_ref(), expected_pubkey.as_ref(),
&peers, &peers,
cursor, cursor,
skip_slot,
) )
.await; .await;
cursor = pass.cursor; cursor = pass.cursor;
@ -550,22 +604,18 @@ async fn read_peer(
Some((prev, attempts)) if prev == slot => attempts.saturating_add(1), Some((prev, attempts)) if prev == slot => attempts.saturating_add(1),
_ => 1, _ => 1,
}; };
if attempts >= DECODE_RETRY_LIMIT { stalled = Some((slot, attempts));
// Reading on leaves a hole: dispatches referencing the // Every threshold rather than on the crossing alone: a stall
// skipped block can no longer be verified, but every // that never clears would otherwise be reported once and
// later block stays readable. // then look resolved for as long as it lasts.
if attempts > 0 && attempts.is_multiple_of(STUCK_SLOT_ALERT_PASSES) {
error!( error!(
"Peer reader for {} could not decode slot {slot:?} after {attempts} attempts; reading past it.", "Peer reader for {} has been stuck at slot {slot:?} for {attempts} passes. The run verified from that peer's genesis stops below it, so every dispatch naming a later block stalls until this slot can be read.",
hex::encode(peer_zone) hex::encode(peer_zone)
); );
skip_slot = Some(slot);
stalled = None;
} else {
stalled = Some((slot, attempts));
} }
} else { } else {
stalled = None; stalled = None;
skip_slot = None;
} }
} }
Err(err) => error!( Err(err) => error!(
@ -582,10 +632,11 @@ async fn read_peer(
/// ///
/// A block that fails to deserialize ends the pass and holds the cursor at the /// A block that fails to deserialize ends the pass and holds the cursor at the
/// last fully-consumed slot, so the next poll re-reads it and a transient /// last fully-consumed slot, so the next poll re-reads it and a transient
/// failure heals itself. `skip_slot` names a slot the caller gave up on after /// failure heals itself. It is never read past, however long it stays stuck:
/// [`DECODE_RETRY_LIMIT`] attempts, which is read past instead so a permanently /// a hole stops [`PeerChain::verified_prefix`] below it, and since only blocks
/// undecodable inscription cannot wedge the reader. Skipping only leaves a hole, /// inside that run may be delivered from, reading on would cache blocks that can
/// which cannot advance [`PeerChain::verified_prefix`] past itself. /// never be used while the reader claimed to have caught up. The watcher on the
/// sequencer side stops at the same hole for the same reason.
/// ///
/// The cursor advances only on a slot boundary, since one slot can carry several /// The cursor advances only on a slot boundary, since one slot can carry several
/// messages and resuming mid-slot would skip the ones after the failure. This /// messages and resuming mid-slot would skip the ones after the failure. This
@ -598,7 +649,6 @@ async fn consume_peer_stream<S>(
expected_pubkey: Option<&PublicKey>, expected_pubkey: Option<&PublicKey>,
peers: &PeerBlocks, peers: &PeerBlocks,
resume_from: Option<Slot>, resume_from: Option<Slot>,
skip_slot: Option<Slot>,
) -> PeerPass ) -> PeerPass
where where
S: Stream<Item = (ZoneMessage, Slot)>, S: Stream<Item = (ZoneMessage, Slot)>,
@ -625,12 +675,6 @@ where
peers.insert(peer_zone, block).await; peers.insert(peer_zone, block).await;
} }
} }
Err(err) if skip_slot == Some(slot) => {
debug!(
"Peer reader skipping undecodable block from {} at slot {slot:?}: {err}",
hex::encode(peer_zone)
);
}
Err(err) => { Err(err) => {
error!( error!(
"Peer reader failed to deserialize block from {} at slot {slot:?}: {err}. Holding the cursor and retrying.", "Peer reader failed to deserialize block from {} at slot {slot:?}: {err}. Holding the cursor and retrying.",
@ -652,7 +696,7 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use common::test_utils::produce_dummy_block; use common::{HashType, test_utils::produce_dummy_block};
use futures::stream; use futures::stream;
use lee::{ use lee::{
PrivateKey, PublicKey, PublicTransaction, PrivateKey, PublicKey, PublicTransaction,
@ -667,6 +711,9 @@ mod tests {
const SELF_ZONE: ZoneId = [1; 32]; const SELF_ZONE: ZoneId = [1; 32];
const PEER_ZONE: ZoneId = [2; 32]; const PEER_ZONE: ZoneId = [2; 32];
const PEER_BLOCK_ID: u64 = 5; const PEER_BLOCK_ID: u64 = 5;
/// The peer's run has to start at its genesis, or every test built on
/// [`peer_chain`] stalls for the full peer-block timeout before failing.
const _: () = assert!(PEER_BLOCK_ID >= GENESIS_BLOCK_ID);
fn verifier() -> CrossZoneVerifier { fn verifier() -> CrossZoneVerifier {
verifier_with_pinned_keys(HashMap::new()) verifier_with_pinned_keys(HashMap::new())
@ -733,6 +780,27 @@ mod tests {
peer_msg(borsh::to_vec(block).expect("block serializes"), slot) peer_msg(borsh::to_vec(block).expect("block serializes"), slot)
} }
/// A hash-linked run from the peer's genesis whose last block,
/// `PEER_BLOCK_ID`, carries a `payload` emission. The run is what makes that
/// block deliverable.
fn peer_chain(payload: &[u8]) -> Vec<Block> {
let mut chain = linked_chain(PEER_BLOCK_ID.saturating_sub(GENESIS_BLOCK_ID));
let prev = chain.last().map(|block| block.header.hash);
chain.push(produce_dummy_block(
PEER_BLOCK_ID,
prev,
vec![emission(payload)],
));
chain
}
/// Caches a run so its last block sits inside the verified prefix.
async fn cache_chain(verifier: &CrossZoneVerifier, chain: Vec<Block>) {
for block in chain {
verifier.peers.insert(PEER_ZONE, block).await;
}
}
/// A peer-stream item whose inscription is not a decodable block. /// A peer-stream item whose inscription is not a decodable block.
fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) {
peer_msg(b"not a block".to_vec(), slot) peer_msg(b"not a block".to_vec(), slot)
@ -755,13 +823,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn verifies_dispatch_matching_a_peer_emission() { async fn verifies_dispatch_matching_a_peer_emission() {
let verifier = verifier(); let verifier = verifier();
verifier cache_chain(&verifier, peer_chain(b"hi")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier verifier
@ -775,13 +837,7 @@ mod tests {
let verifier = verifier(); let verifier = verifier();
// The peer block carries the real emission, but the block claims a // The peer block carries the real emission, but the block claims a
// different payload, so re-derivation does not reproduce it. // different payload, so re-derivation does not reproduce it.
verifier cache_chain(&verifier, peer_chain(b"real")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"real")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]); let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]);
let err = verifier.verify_block(&block).await.unwrap_err(); let err = verifier.verify_block(&block).await.unwrap_err();
@ -798,13 +854,7 @@ mod tests {
let mut keys = HashMap::new(); let mut keys = HashMap::new();
keys.insert(PEER_ZONE, signer); keys.insert(PEER_ZONE, signer);
let verifier = verifier_with_pinned_keys(keys); let verifier = verifier_with_pinned_keys(keys);
verifier cache_chain(&verifier, peer_chain(b"hi")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
verifier verifier
@ -819,13 +869,7 @@ mod tests {
let mut keys = HashMap::new(); let mut keys = HashMap::new();
keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap()); keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap());
let verifier = verifier_with_pinned_keys(keys); let verifier = verifier_with_pinned_keys(keys);
verifier cache_chain(&verifier, peer_chain(b"hi")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
let err = verifier.verify_block(&block).await.unwrap_err(); let err = verifier.verify_block(&block).await.unwrap_err();
@ -838,13 +882,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn accepts_replayed_dispatch_as_noop() { async fn accepts_replayed_dispatch_as_noop() {
let verifier = verifier(); let verifier = verifier();
verifier cache_chain(&verifier, peer_chain(b"hi")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]);
let keys = verifier let keys = verifier
@ -854,19 +892,12 @@ mod tests {
// Mark the delivery seen, as the ingest loop does once the block applies. // Mark the delivery seen, as the ingest loop does once the block applies.
verifier.record_seen(keys).await; verifier.record_seen(keys).await;
// Replace the peer block with a different emission so re-deriving the // A payload that cannot re-derive, under the key just recorded. Accepted
// replay would mismatch. The replay must still be accepted, proving it is // only by the seen-key short circuit, since the inbox no-ops it on
// the seen-key short-circuit (the inbox no-ops it on chain) and not a // chain; `unaccepted_dispatch_does_not_poison_seen` asserts the same
// successful re-derivation. // input is rejected when the key was never recorded, which is what makes
verifier // this one about the short circuit rather than re-derivation.
.peers let replay = produce_dummy_block(10, None, vec![dispatch(b"forged")]);
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"different")]),
)
.await;
let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]);
verifier verifier
.verify_block(&replay) .verify_block(&replay)
.await .await
@ -880,13 +911,7 @@ mod tests {
// its key to skip re-derivation, while the inbox, never having recorded // its key to skip re-derivation, while the inbox, never having recorded
// the key on chain, would deliver the forgery. // the key on chain, would deliver the forgery.
let verifier = verifier(); let verifier = verifier();
verifier cache_chain(&verifier, peer_chain(b"hi")).await;
.peers
.insert(
PEER_ZONE,
produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]),
)
.await;
// The dispatch verifies, but the block is not applied, so record_seen is // The dispatch verifies, but the block is not applied, so record_seen is
// not called (the ingest loop records only after an Applied outcome). // not called (the ingest loop records only after an Applied outcome).
@ -916,7 +941,7 @@ mod tests {
peer_block_msg(&chain[1], 1), peer_block_msg(&chain[1], 1),
]); ]);
let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None, None).await; let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await;
assert_eq!(pass.cursor, Some(Slot::from(1))); assert_eq!(pass.cursor, Some(Slot::from(1)));
assert_eq!(pass.stalled_at, None); assert_eq!(pass.stalled_at, None);
@ -972,7 +997,7 @@ mod tests {
peer_block_msg(&chain[2], 2), peer_block_msg(&chain[2], 2),
]); ]);
let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None, None).await; let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await;
assert_eq!(pass.cursor, Some(Slot::from(0))); assert_eq!(pass.cursor, Some(Slot::from(0)));
assert_eq!(pass.stalled_at, Some(Slot::from(1))); assert_eq!(pass.stalled_at, Some(Slot::from(1)));
@ -987,17 +1012,17 @@ mod tests {
// One slot can carry several messages; the second one fails. // One slot can carry several messages; the second one fails.
let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]); let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]);
let pass = let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, Some(Slot::from(6))).await;
consume_peer_stream(stream, PEER_ZONE, None, &peers, Some(Slot::from(6)), None).await;
// Slot 7 is re-read whole next pass, not resumed past the failure. // Slot 7 is re-read whole next pass, not resumed past the failure.
assert_eq!(pass.cursor, Some(Slot::from(6))); assert_eq!(pass.cursor, Some(Slot::from(6)));
} }
#[tokio::test] #[tokio::test]
async fn peer_reader_reads_past_a_slot_it_has_given_up_on() { async fn peer_reader_never_reads_past_a_slot_it_cannot_decode() {
// After DECODE_RETRY_LIMIT attempts the caller nominates the slot to // It used to give up after DECODE_RETRY_LIMIT attempts and read on,
// skip, so a permanently undecodable inscription cannot wedge the reader. // caching blocks the stalled run could never use. The watcher stops at
// the same hole, so neither side delivers across it.
let peers = PeerBlocks::default(); let peers = PeerBlocks::default();
let chain = linked_chain(3); let chain = linked_chain(3);
let stream = stream::iter(vec![ let stream = stream::iter(vec![
@ -1006,14 +1031,14 @@ mod tests {
peer_block_msg(&chain[2], 2), peer_block_msg(&chain[2], 2),
]); ]);
let pass = let pass = consume_peer_stream(stream, PEER_ZONE, None, &peers, None).await;
consume_peer_stream(stream, PEER_ZONE, None, &peers, None, Some(Slot::from(1))).await;
assert_eq!(pass.cursor, Some(Slot::from(2)), "the pass drains"); assert_eq!(pass.cursor, Some(Slot::from(0)), "the slot is held");
assert_eq!(pass.stalled_at, None); assert_eq!(pass.stalled_at, Some(Slot::from(1)));
// Block 3 is cached and servable, so dispatches referencing it verify. assert!(
assert!(peers.get(PEER_ZONE, 3).await.is_some()); peers.get(PEER_ZONE, 3).await.is_none(),
// But the hole stops the verified run, so block 2 is never called forged. "nothing past the hole is even read"
);
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1)); assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1));
} }
@ -1026,19 +1051,12 @@ mod tests {
undecodable_msg(1), undecodable_msg(1),
peer_block_msg(&chain[2], 2), peer_block_msg(&chain[2], 2),
]); ]);
consume_peer_stream( consume_peer_stream(stream, PEER_ZONE, None, &verifier.peers, None).await;
stream,
PEER_ZONE,
None,
&verifier.peers,
None,
Some(Slot::from(1)),
)
.await;
// Regression: the reader cached block 3, so the old `max(cached ids)` // Regression: block 2 used to be reported as forged, halting ingestion
// high-water mark reached 3 and a dispatch referencing block 2 was // permanently, because a `max(cached ids)` high-water mark counted
// rejected as forged, halting ingestion permanently. // blocks read past the undecodable slot. The reader now stops at that
// slot, so block 2 is simply unread, which is lag.
let err = verifier let err = verifier
.wait_for_peer_block(PEER_ZONE, 2) .wait_for_peer_block(PEER_ZONE, 2)
.await .await
@ -1049,12 +1067,38 @@ mod tests {
); );
} }
#[tokio::test(start_paused = true)]
async fn a_block_ahead_of_the_run_is_not_cached_and_not_delivered_from() {
// The other half of #677. A peer inscribes a block claiming an id its
// chain has not reached; it is well formed and correctly signed, so
// nothing about the block itself refuses it. Delivered, its message
// would burn the replay key the honest block at that id would later
// need, and the inbox would no-op the real message.
let verifier = verifier();
cache_chain(&verifier, linked_chain(2)).await;
let claimed = produce_dummy_block(9, None, vec![emission(b"hi")]);
assert!(
!verifier.peers.insert(PEER_ZONE, claimed).await,
"the reader takes the next block on the run, never one ahead of it"
);
assert!(verifier.peers.get(PEER_ZONE, 9).await.is_none());
let err = verifier
.wait_for_peer_block(PEER_ZONE, 9)
.await
.expect_err("a block off the verified run must not resolve");
assert!(
matches!(err, CrossZoneVerifyError::PeerUnavailable { .. }),
"a claimed id and a reader that is behind read alike, so this stalls rather than halting: {err}"
);
}
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn a_high_block_id_cannot_poison_the_forgery_test() { async fn a_high_block_id_cannot_poison_the_forgery_test() {
// A peer picks its own block ids, so one inscribed block claiming a huge // A peer picks its own block ids, so one inscribed block claiming a huge
// id would drive a `max(cached ids)` high-water mark past every real id // id would drive a `max(cached ids)` high-water mark past every real id
// and make each later dispatch look forged. It cannot extend the // and make each later dispatch look forged. It is not the block that
// verified run, so it is inert. // would continue the run, so it is not cached at all.
let verifier = verifier(); let verifier = verifier();
let chain = linked_chain(2); let chain = linked_chain(2);
verifier.peers.insert(PEER_ZONE, chain[0].clone()).await; verifier.peers.insert(PEER_ZONE, chain[0].clone()).await;
@ -1089,7 +1133,6 @@ mod tests {
None, None,
&verifier.peers, &verifier.peers,
None, None,
None,
) )
.await; .await;
assert_eq!(pass.cursor, None, "the failed slot is not skipped"); assert_eq!(pass.cursor, None, "the failed slot is not skipped");
@ -1104,7 +1147,6 @@ mod tests {
None, None,
&verifier.peers, &verifier.peers,
pass.cursor, pass.cursor,
None,
) )
.await; .await;
assert_eq!(pass.stalled_at, None); assert_eq!(pass.stalled_at, None);
@ -1122,13 +1164,19 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn an_equivocating_peer_cannot_replace_a_cached_block() { async fn an_equivocating_peer_cannot_replace_a_cached_block() {
let verifier = verifier(); let verifier = verifier();
let real = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); let mut chain = peer_chain(b"hi");
let impostor = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"forged")]); let real = chain.pop().expect("chain has a last block");
let impostor = produce_dummy_block(
PEER_BLOCK_ID,
Some(real.header.prev_block_hash),
vec![emission(b"forged")],
);
assert_ne!( assert_ne!(
real.header.hash, impostor.header.hash, real.header.hash, impostor.header.hash,
"the two blocks must differ, or this proves nothing" "the two blocks must differ, or this proves nothing"
); );
cache_chain(&verifier, chain).await;
assert!(verifier.peers.insert(PEER_ZONE, real.clone()).await); assert!(verifier.peers.insert(PEER_ZONE, real.clone()).await);
assert!( assert!(
!verifier.peers.insert(PEER_ZONE, impostor).await, !verifier.peers.insert(PEER_ZONE, impostor).await,
@ -1154,12 +1202,149 @@ mod tests {
.expect("equivocation must not halt ingestion of an honest dispatch"); .expect("equivocation must not halt ingestion of an honest dispatch");
} }
#[tokio::test]
async fn a_non_linking_block_at_the_run_head_does_not_lock_that_id_out() {
// The other side of first-write-wins: refusing the honest block 3 when
// it lands behind a claimed one would pin the run at 2 for the life of
// the store, and every later message from that peer would stall.
let peers = PeerBlocks::default();
let chain = linked_chain(3);
for block in chain.iter().take(2).cloned() {
peers.insert(PEER_ZONE, block).await;
}
let claimed = produce_dummy_block(3, Some(HashType([9; 32])), vec![emission(b"claimed")]);
assert!(peers.insert(PEER_ZONE, claimed).await);
assert_eq!(
peers.verified_prefix(PEER_ZONE).await,
Some(2),
"it cannot extend the run, which is what makes it inert but sticky"
);
let honest = chain[2].clone();
assert!(
peers.insert(PEER_ZONE, honest.clone()).await,
"the block that continues the run displaces the one that never could"
);
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(3));
assert_eq!(
peers.get(PEER_ZONE, 3).await.unwrap().header.hash,
honest.header.hash
);
}
#[tokio::test]
async fn a_block_ahead_of_the_run_cannot_win_an_id_the_watcher_gave_to_another() {
// The two sides tie-break differently unless this reader stays strictly
// sequential. The peer is its own sequencer, so it knows block 3's hash
// before publishing it: it inscribes a block claiming id 4 and linking
// to 3, then 3, then its honest 4.
//
// Caching ahead, the prefix would walk 3 and then straight through the
// block held at 4, and the honest 4 would be refused when it landed.
// The watcher reads in order, so at tip 2 it passes over the block
// claiming 4 and never reconsiders it, then delivers from the honest 4.
// Two different blocks at one id, and the dispatch naming it re-derives
// against the wrong one and halts ingestion for good.
let peers = PeerBlocks::default();
let chain = linked_chain(4);
for block in chain.iter().take(2).cloned() {
peers.insert(PEER_ZONE, block).await;
}
let ahead = produce_dummy_block(4, Some(chain[2].header.hash), vec![emission(b"ahead")]);
assert!(!peers.insert(PEER_ZONE, ahead).await);
peers.insert(PEER_ZONE, chain[2].clone()).await;
peers.insert(PEER_ZONE, chain[3].clone()).await;
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(4));
assert_eq!(
peers.get(PEER_ZONE, 4).await.unwrap().header.hash,
chain[3].header.hash,
"the run holds the block the watcher delivered from"
);
}
#[tokio::test]
async fn a_block_below_the_peers_genesis_is_not_cached() {
// It is on no chain the run can walk, so nothing would ever certify it,
// and cached it would answer every later lookup at that id as though
// the peer had built it.
let peers = PeerBlocks::default();
let below = produce_dummy_block(0, None, vec![emission(b"below")]);
assert!(!peers.insert(PEER_ZONE, below).await);
for block in linked_chain(3) {
peers.insert(PEER_ZONE, block).await;
}
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(3));
assert!(peers.get(PEER_ZONE, 0).await.is_none());
// Under the run and unheld, which is the forgery signal, and the right
// one: the peer's chain starts at its genesis, so only a dispatch that
// invented the coordinate could name a block below it.
assert!(matches!(
peers.resolve(PEER_ZONE, 0).await,
PeerLookup::InsideRun
));
}
#[tokio::test]
async fn a_peer_whose_genesis_is_unread_resolves_to_behind() {
// Before the run has a first block there is nothing to place anything
// against, so every id is lag rather than forgery. Reading it the other
// way round is the original hole: any inscribed block would resolve as
// the peer's own, and any absent one as a forgery that halts.
let peers = PeerBlocks::default();
assert_eq!(peers.verified_prefix(PEER_ZONE).await, None);
assert!(matches!(
peers.resolve(PEER_ZONE, GENESIS_BLOCK_ID).await,
PeerLookup::Behind
));
assert!(matches!(
peers.resolve(PEER_ZONE, 9).await,
PeerLookup::Behind
));
}
#[tokio::test]
async fn a_block_inside_the_verified_run_is_never_displaced() {
// #648 in the other direction: once the run has walked a block, a later
// arrival at that id must not replace it, whatever it links to, or a
// dispatch naming it re-derives against the new one and halts ingestion.
let peers = PeerBlocks::default();
let chain = linked_chain(2);
for block in chain.iter().cloned() {
peers.insert(PEER_ZONE, block).await;
}
assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(2));
let impostor =
produce_dummy_block(2, Some(chain[0].header.hash), vec![emission(b"forged")]);
assert!(
!peers.insert(PEER_ZONE, impostor).await,
"it links to block 1 just as the held block does, and the run has certified the held one"
);
// The one that would slip through if displacement were allowed anywhere
// below the id that extends the run: it links to the run's own head, so
// every test but the id guard says take it.
let plausible =
produce_dummy_block(2, Some(chain[1].header.hash), vec![emission(b"forged")]);
assert!(
!peers.insert(PEER_ZONE, plausible).await,
"displacement fires only at the id that would extend the run, never inside it"
);
assert_eq!(
peers.get(PEER_ZONE, 2).await.unwrap().header.hash,
chain[1].header.hash
);
}
/// The reader re-reads a slot on every retry, so caching the same block /// The reader re-reads a slot on every retry, so caching the same block
/// twice must be a quiet no-op rather than equivocation. /// twice must be a quiet no-op rather than equivocation.
#[tokio::test] #[tokio::test]
async fn re_reading_the_same_block_is_not_equivocation() { async fn re_reading_the_same_block_is_not_equivocation() {
let verifier = verifier(); let verifier = verifier();
let block = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); let block = linked_chain(1).pop().expect("genesis block");
assert!(verifier.peers.insert(PEER_ZONE, block.clone()).await); assert!(verifier.peers.insert(PEER_ZONE, block.clone()).await);
assert!( assert!(
@ -1169,7 +1354,7 @@ mod tests {
assert_eq!( assert_eq!(
verifier verifier
.peers .peers
.get(PEER_ZONE, PEER_BLOCK_ID) .get(PEER_ZONE, GENESIS_BLOCK_ID)
.await .await
.unwrap() .unwrap()
.header .header
@ -1192,7 +1377,6 @@ mod tests {
None, None,
&verifier.peers, &verifier.peers,
None, None,
None,
) )
.await; .await;
@ -1211,7 +1395,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_block_not_signed_by_the_pinned_key_is_not_cached() { async fn a_block_not_signed_by_the_pinned_key_is_not_cached() {
let verifier = verifier(); let verifier = verifier();
let block = produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]); let block = linked_chain(1).pop().expect("genesis block");
let wrong_key = PublicKey::try_new([42; 32]).unwrap(); let wrong_key = PublicKey::try_new([42; 32]).unwrap();
let pass = consume_peer_stream( let pass = consume_peer_stream(
@ -1220,7 +1404,6 @@ mod tests {
Some(&wrong_key), Some(&wrong_key),
&verifier.peers, &verifier.peers,
None, None,
None,
) )
.await; .await;
@ -1239,11 +1422,14 @@ mod tests {
Some(&signer), Some(&signer),
&verifier.peers, &verifier.peers,
None, None,
None,
) )
.await; .await;
assert!( assert!(
verifier.peers.get(PEER_ZONE, PEER_BLOCK_ID).await.is_some(), verifier
.peers
.get(PEER_ZONE, GENESIS_BLOCK_ID)
.await
.is_some(),
"the pinned signer's own block is cached" "the pinned signer's own block is cached"
); );
} }

View File

@ -774,11 +774,11 @@ mod tests {
} }
/// The tip a watcher holds after delivering up to `block_id`. /// The tip a watcher holds after delivering up to `block_id`.
fn tip_at(block_id: u64) -> Option<PeerChainTip> { fn tip_at(block_id: u64) -> PeerChainTip {
Some(PeerChainTip { PeerChainTip {
block_id, block_id,
block_hash: chain_hash(block_id), block_hash: chain_hash(block_id),
}) }
} }
fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) {
@ -920,7 +920,7 @@ mod tests {
#[test] #[test]
fn only_the_next_block_off_the_tip_links() { fn only_the_next_block_off_the_tip_links() {
let tip = tip_at(2); let tip = Some(tip_at(2));
assert_eq!( assert_eq!(
link_against(tip, &chain_block(3), None), link_against(tip, &chain_block(3), None),
@ -988,7 +988,7 @@ mod tests {
let mut tampered = chain_block(3); let mut tampered = chain_block(3);
tampered.header.hash = HashType([9; 32]); tampered.header.hash = HashType([9; 32]);
assert!(matches!( assert!(matches!(
link_against(tip_at(2), &tampered, None), link_against(Some(tip_at(2)), &tampered, None),
Link::OffChain(_) Link::OffChain(_)
)); ));
} }
@ -1023,7 +1023,7 @@ mod tests {
} }
); );
assert_eq!( assert_eq!(
resume_from(tip_at(2), Some(Slot::from(7))), resume_from(Some(tip_at(2)), Some(Slot::from(7))),
Resume { Resume {
cursor: Some(Slot::from(7)), cursor: Some(Slot::from(7)),
clear_floor: false clear_floor: false
@ -1201,7 +1201,7 @@ mod tests {
let mut resumed_tip = dbio.get_cross_zone_peer_tip(PEER_ZONE).unwrap(); let mut resumed_tip = dbio.get_cross_zone_peer_tip(PEER_ZONE).unwrap();
assert_eq!( assert_eq!(
resumed_tip, resumed_tip,
tip_at(2), Some(tip_at(2)),
"the tip is durable, not just in memory" "the tip is durable, not just in memory"
); );
@ -1323,7 +1323,7 @@ mod tests {
Some(Slot::from(0)), Some(Slot::from(0)),
"the floor stays below it, so a fixed decoder recovers the messages" "the floor stays below it, so a fixed decoder recovers the messages"
); );
assert_eq!(tip, tip_at(1)); assert_eq!(tip, Some(tip_at(1)));
} }
#[tokio::test] #[tokio::test]
@ -1365,7 +1365,7 @@ mod tests {
], ],
"the key the peer aimed to burn is never recorded, and nothing else is held up" "the key the peer aimed to burn is never recorded, and nothing else is held up"
); );
assert_eq!(tip, tip_at(3)); assert_eq!(tip, Some(tip_at(3)));
} }
#[tokio::test] #[tokio::test]
@ -1400,7 +1400,7 @@ mod tests {
vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)], vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)],
"one delivery per id, whatever the peer publishes under it" "one delivery per id, whatever the peer publishes under it"
); );
assert_eq!(tip, tip_at(2)); assert_eq!(tip, Some(tip_at(2)));
} }
#[tokio::test] #[tokio::test]
@ -1431,7 +1431,7 @@ mod tests {
vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)], vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)],
"the fork is passed over and the peer's own chain continues" "the fork is passed over and the peer's own chain continues"
); );
assert_eq!(tip, tip_at(2)); assert_eq!(tip, Some(tip_at(2)));
} }
#[tokio::test] #[tokio::test]
@ -1489,7 +1489,7 @@ mod tests {
recorded_keys(&dbio), recorded_keys(&dbio),
vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)] vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)]
); );
assert_eq!(tip, tip_at(2)); assert_eq!(tip, Some(tip_at(2)));
} }
#[tokio::test] #[tokio::test]