fix(indexer): accept a replayed cross-zone dispatch instead of halting

The Option B verifier returned Err on a cross-zone dispatch whose message key
was already in its seen set, and that halts indexer ingestion. A legitimate
re-delivery, for example after a sequencer restart re-injects, could then
permanently stall an honest indexer. The inbox already treats a re-delivered
message as an idempotent no-op on chain, so the verifier now continues past an
already-seen key instead of bailing. Forgery detection on first-seen dispatches
is unchanged.

Also notes that one pinned block-signing key per peer is sufficient until
decentralized sequencing, and strengthens the replay test so it only passes via
the seen-key short-circuit.
This commit is contained in:
moudyellaz 2026-07-13 11:06:49 +02:00
parent b8e21ed276
commit de2dc8e04d
2 changed files with 35 additions and 23 deletions

View File

@ -51,8 +51,7 @@ impl PeerBlocks {
}
/// The highest block id this reader has finalized for `zone`, or `None` if it
/// has read nothing yet. Used to tell forgery (we have read past the
/// referenced block and it is absent) from lag (we simply have not caught up).
/// has read nothing yet.
async fn highest_seen(&self, zone: ZoneId) -> Option<u64> {
self.chains
.read()
@ -65,13 +64,17 @@ impl PeerBlocks {
/// The indexer-side Option B verifier.
///
/// For every cross-zone dispatch in a block it re-derives the transaction from
/// the peer's finalized block and rejects it if the bytes differ (a forgery) or
/// the message was already delivered (a replay), so delivery no longer relies on
/// trusting the sequencer.
/// the peer's finalized block and rejects it if the bytes differ (a forgery), so
/// delivery no longer relies on trusting the sequencer. A replay of an
/// already-delivered message is accepted, since the inbox no-ops it on chain.
#[derive(Clone)]
pub struct CrossZoneVerifier {
self_zone: ZoneId,
/// Pinned block-signing key per peer zone, enforced during re-derivation.
/// One key per peer is sufficient while a zone has a single sequencer; key
/// sets with rotation come in with decentralized sequencing. The pin is
/// largely redundant given Bedrock's turn-based write authorization, so it is
/// optional: a peer with no configured key is not signature-checked.
peer_pubkeys: HashMap<ZoneId, PublicKey>,
peers: PeerBlocks,
seen: Arc<RwLock<HashSet<MessageKey>>>,
@ -113,7 +116,8 @@ impl CrossZoneVerifier {
}
/// Verifies every cross-zone dispatch in a block, returning `Err` on the
/// first forged or replayed dispatch. The caller halts ingestion on error.
/// first forged dispatch. The caller halts ingestion on error. A replay of an
/// already-delivered message is accepted, since the inbox no-ops it on chain.
pub async fn verify_block(&self, block: &Block) -> Result<()> {
for tx in &block.body.transactions {
let Some(msg) = Self::decode_dispatch(tx) else {
@ -121,11 +125,12 @@ impl CrossZoneVerifier {
};
let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index);
// A replay of an already-verified dispatch is an idempotent no-op on
// chain (the inbox skips a message it has already seen in its shard),
// so accept it and skip re-derivation rather than halting the indexer
// on a legitimate re-delivery, e.g. after a sequencer restart.
if self.seen.read().await.contains(&key) {
bail!(
"cross-zone replay: message {} re-delivered",
hex::encode(key)
);
continue;
}
let expected = self.rederive(&msg).await?;
@ -225,11 +230,6 @@ impl CrossZoneVerifier {
/// If the block is cached, return it. If our peer reader has already
/// finalized past `block_id` and we still do not have it, the reference is to
/// a block that does not exist on the peer chain, a forgery, so reject now.
/// Otherwise the reader simply has not caught up yet: keep waiting, since a
/// legitimate dispatch is only injected after its peer block finalized and
/// our reader of the same finalized chain will see it too. Rejecting on a
/// timeout here would turn a lagging reader into a permanent halt of an
/// honest message.
async fn wait_for_peer_block(&self, zone: ZoneId, block_id: u64) -> Result<Block> {
let mut waited = Duration::ZERO;
loop {
@ -474,7 +474,7 @@ mod tests {
}
#[tokio::test]
async fn rejects_replayed_dispatch() {
async fn accepts_replayed_dispatch_as_noop() {
let verifier = verifier();
verifier
.peers
@ -490,11 +490,22 @@ mod tests {
.await
.expect("first delivery verifies");
// Replace the peer block with a different emission so re-deriving the
// replay would mismatch. The replay must still be accepted, proving it is
// the seen-key short-circuit (the inbox no-ops it on chain) and not a
// successful re-derivation.
verifier
.peers
.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")]);
let err = verifier.verify_block(&replay).await.unwrap_err();
assert!(
err.to_string().contains("replay"),
"unexpected error: {err}"
);
verifier
.verify_block(&replay)
.await
.expect("a replay is accepted as an on-chain no-op");
}
}

View File

@ -257,8 +257,9 @@ impl IndexerCore {
};
// Option B: re-derive and verify every cross-zone dispatch
// before applying the block. A forged or replayed dispatch
// halts ingestion rather than persisting an invalid state.
// before applying the block. A forged dispatch halts ingestion
// rather than persisting an invalid state; a replay is accepted
// since the inbox no-ops it on chain.
if let Some(verifier) = &self.verifier
&& let Err(err) = verifier.verify_block(&block).await
{