From c245434f093942c2e060529b900f7d9bdacdddbd Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 02:57:34 +0200 Subject: [PATCH 01/10] feat(sequencer): dead-letter the cross-zone deliveries this node gives up on --- lez/sequencer/core/src/lib.rs | 37 ++- lez/sequencer/core/src/tests.rs | 37 ++- lez/storage/src/sequencer/mod.rs | 196 +++++++++++++-- lez/storage/src/sequencer/sequencer_cells.rs | 116 ++++++++- lez/storage/src/sequencer/tests.rs | 242 +++++++++++++++++-- 5 files changed, 571 insertions(+), 57 deletions(-) diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 93de58b05..9a1639867 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -33,10 +33,10 @@ pub use mock::SequencerCoreWithMockClients; use num_bigint::BigUint; pub use storage::error::DbError; use storage::sequencer::{ - RocksDBIO, StoreUpdate, + DispatchFailure, RocksDBIO, StoreUpdate, sequencer_cells::{ - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, WithdrawalReconciliationKey, - ZoneAnchorRecord, + DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, + WithdrawalReconciliationKey, ZoneAnchorRecord, }, }; @@ -1074,8 +1074,9 @@ impl SequencerCore { /// A delivery's payload and target accounts are chosen on the peer zone and /// validated by nobody in between, so one can fail for good; but a failure /// can equally be a property of the moment, so give up only after several. - /// Giving up drops the record, which is also what keeps a peer from growing - /// the pending list with deliveries that can never execute. + /// Giving up moves the record to the dead letter, which is what keeps a peer + /// from growing the pending list with deliveries that can never execute + /// while still leaving the delivery somewhere an operator can find it. fn count_dispatch_failure(&self, tx: &LeeTransaction) { let Some(message) = extract_cross_zone_dispatch(tx) else { return; @@ -1085,17 +1086,33 @@ impl SequencerCore { message.src_block_id, message.src_tx_index, ); + let origin = DispatchOrigin { + src_zone: message.src_zone, + src_block_id: message.src_block_id, + src_tx_index: message.src_tx_index, + }; match self .store .dbio() - .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES) + .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES, origin) { - Ok(true) => error!( - "Giving up on cross-zone delivery {} after {RETIRE_DISPATCH_AFTER_FAILURES} failed attempts; it will not be retried", + Ok(DispatchFailure::Retired(record)) => error!( + "Giving up on cross-zone delivery {} from peer zone {} block {} transaction {} ({} bytes) after {} failed attempts. This node will not retry it; unless another sequencer carries it, the message is not delivered. Kept in the dead letter.", + hex::encode(key), + hex::encode(origin.src_zone), + origin.src_block_id, + origin.src_tx_index, + record.transaction_bytes, + record.failed_attempts + ), + Ok(DispatchFailure::Retried { failed_attempts }) => warn!( + "Cross-zone delivery {} failed to execute ({failed_attempts} of {RETIRE_DISPATCH_AFTER_FAILURES} attempts), will retry next block", hex::encode(key) ), - Ok(false) => warn!( - "Cross-zone delivery {} failed to execute, will retry next block", + // Not a give-up: the ordinary case is a delivery that already + // settled, so its record is gone and there is nothing left to lose. + Ok(DispatchFailure::Absent) => debug!( + "Cross-zone delivery {} failed to execute but has no pending record; nothing to count", hex::encode(key) ), Err(err) => error!( diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index ae0a6c31b..f7fa98344 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -25,7 +25,7 @@ use logos_blockchain_zone_sdk::sequencer::DepositInfo; use mempool::MemPoolHandle; use ping_core::{ReceiverInstruction, ping_record_pda}; use storage::sequencer::sequencer_cells::{ - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, + DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, }; use tempfile::tempdir; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; @@ -697,15 +697,42 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() ); } - // The attempt at the limit gives up on it, and giving up drops the record. - // Anything else leaves an entry no later block can ever remove, which is how - // a peer that can make deliveries fail would grow this list without bound. + // The attempt at the limit gives up on it, which takes the record out of the + // pending list. Anything else leaves an entry no later block can ever + // remove, which is how a peer that can make deliveries fail would grow this + // list without bound. sequencer.produce_new_block().await.unwrap(); assert!( pending_dispatches(&sequencer).is_empty(), - "giving up on a delivery must drop its record, not flag it" + "giving up on a delivery must take its record out of the pending list" ); + // A dispatch that fails execution is left out of the block, so the dead + // letter is the only place recording that this happened at all. The origin + // is the point of the record: it is what identifies which message stopped + // being attempted, and this is the only place that builds one. + let dbio = sequencer.store.dbio(); + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), 1); + assert_eq!( + dead_letters[0].origin, + DispatchOrigin { + src_zone: PEER_ZONE, + src_block_id: 13, + src_tx_index: 0, + } + ); + assert_eq!( + dead_letters[0].message_key, + cross_zone_inbox_core::message_key(&PEER_ZONE, 13, 0) + ); + assert!(dead_letters[0].transaction_bytes > 0); + assert_eq!( + dead_letters[0].failed_attempts, + RETIRE_DISPATCH_AFTER_FAILURES + ); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); + // And nothing re-feeds it, so it stops costing a guest execution per block. let block_id = sequencer.produce_new_block().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index c771ea556..c6a2d66a7 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -20,6 +20,8 @@ use crate::{ cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, error::DbError, sequencer::sequencer_cells::{ + DeadLetterCrossZoneDispatchCountCell, DeadLetterCrossZoneDispatchesCellOwned, + DeadLetterCrossZoneDispatchesCellRef, DeadLetterDispatchRecord, DispatchOrigin, FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerChainTip, PeerFloorCellOwned, @@ -55,6 +57,12 @@ pub const DB_META_CROSS_ZONE_PEER_TIP_KEY: &str = "cross_zone_peer_tip"; /// Key base for storing cross-zone deliveries the watcher has recorded but /// which are not yet known to be irreversibly delivered. pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_dispatches"; +/// Key base for storing cross-zone deliveries this node has given up on. +pub const DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY: &str = "dead_letter_cross_zone_dispatches"; +/// Key base for counting every cross-zone delivery given up on, including ones +/// since evicted from the retained list or reconciled out of it. +pub const DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY: &str = + "dead_letter_cross_zone_dispatch_count"; /// Key base for counting unseen L2 withdraw intents. pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; @@ -73,6 +81,20 @@ pub const DB_META_PUBLISHED_HIGH_WATER_KEY: &str = "published_high_water"; /// delivery floor and reads the slot again later. pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096; +/// How many given-up-on cross-zone deliveries are kept for inspection. +/// +/// Retaining them is the point, but a peer chooses how many deliveries fail, so +/// this list cannot be unbounded any more than the pending one can. At the cap +/// the oldest is dropped, which keeps the entries an operator reaching for this +/// after an alert actually wants. Nothing is concealed by that: every +/// retirement is counted separately and that count does not evict. +/// +/// A count is a real bound here only because a record identifies a delivery +/// instead of carrying it. Each is a fixed 84 bytes, so the whole list is 21 KB +/// at the cap, bounded in bytes as well as in entries. That matters because it +/// is one value rewritten under the lock that block production needs. +pub const MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES: usize = 256; + /// Key base for storing the LEE state. pub const DB_LEE_STATE_KEY: &str = "lee_state"; /// Key base for storing the LEE state at the last L1-finalized block. @@ -83,6 +105,22 @@ pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta"; /// Name of state column family. pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; +/// What counting a failed production attempt did to a delivery's record. +/// +/// Three outcomes rather than a bool because the caller reports each +/// differently and only one of them means this node stopped trying. A delivery +/// that has already settled has no pending record, so it is [`Self::Absent`] +/// rather than a give-up. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DispatchFailure { + /// Counted; the delivery is still pending and will be attempted again. + Retried { failed_attempts: u32 }, + /// Given up on: moved out of the pending list and into the dead letter. + Retired(Box), + /// No pending record, so nothing was counted and nothing was given up on. + Absent, +} + /// A single key/value entry from a column family, used inside [`DbDump`]. #[derive(BorshSerialize, BorshDeserialize)] pub struct DbDumpEntry { @@ -742,38 +780,111 @@ impl RocksDBIO { Ok(accepted) } - /// Counts a failed production attempt against a delivery, dropping its - /// record once it reaches `retire_at`. Returns whether it was dropped. + /// Counts a failed production attempt against a delivery, retiring it once + /// it reaches `retire_at`. /// - /// Dropped rather than flagged: a retired record is one the drain will never - /// turn into a block transaction again, so nothing would ever remove it, and - /// a peer that can make deliveries fail could grow the list without bound. - /// The delivery is given up on either way; this way the cost is a log line - /// rather than a permanent entry. + /// Retiring moves the record out of the pending list and into the dead + /// letter. The pending list has to lose it, or the drain would re-feed a + /// transaction that never executes for ever; the dead letter is what keeps + /// the delivery identifiable, since a dispatch that fails execution is left + /// out of the block and so leaves no trace anywhere else. It is bounded on + /// its own terms, so a peer that can make deliveries fail still cannot grow + /// the store without limit. /// - /// A delivery with no record is already retired as far as this is concerned: - /// there is nothing left to count against. - pub fn record_dispatch_failure(&self, message_key: [u8; 32], retire_at: u32) -> DbResult { + /// A delivery with no pending record is reported as [`DispatchFailure::Absent`] + /// rather than as a retirement: there is nothing to count against, and the + /// two are different events. It is the ordinary shape of a delivery that + /// settled and then failed to execute on a later attempt. + pub fn record_dispatch_failure( + &self, + message_key: [u8; 32], + retire_at: u32, + origin: DispatchOrigin, + ) -> DbResult { let _pending = self.lock_pending_records(); let mut records = self.get_pending_cross_zone_dispatches()?; let Some(position) = records .iter() .position(|record| record.message_key == message_key) else { - return Ok(true); + return Ok(DispatchFailure::Absent); }; - let attempts = { + let failed_attempts = { let record = &mut records[position]; record.failed_attempts = record.failed_attempts.saturating_add(1); record.failed_attempts }; - let retired = attempts >= retire_at; - if retired { - records.remove(position); + if failed_attempts < retire_at { + self.put_pending_cross_zone_dispatches(&records)?; + return Ok(DispatchFailure::Retried { failed_attempts }); } - self.put_pending_cross_zone_dispatches(&records)?; - Ok(retired) + + let retired = records.remove(position); + let dead_letter = DeadLetterDispatchRecord { + message_key, + origin, + failed_attempts, + transaction_bytes: u32::try_from(retired.transaction.len()).unwrap_or(u32::MAX), + }; + + // One entry per delivery, not per retirement. The same delivery can be + // recorded again after its record is gone, since a watcher rebuilding a + // peer tip re-reads that channel from the peer's genesis and a delivery + // that never executes never reaches the inbox seen-set to be recognised + // as delivered. Without this, one message that always fails would fill + // the list with copies of itself and evict every other one. + let mut dead_letters = self.get_dead_letter_cross_zone_dispatches()?; + if !dead_letters + .iter() + .any(|record| record.message_key == message_key) + { + dead_letters.push(dead_letter.clone()); + while dead_letters.len() > MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES { + dead_letters.remove(0); + } + } + // Counted per retirement even so: it measures how often this node gave + // up, which the retained list cannot, since that both evicts and drops + // entries whose delivery later settles. + let count = self + .get_dead_letter_cross_zone_dispatch_count()? + .saturating_add(1); + + // One batch: the record leaving the pending list and arriving in the + // dead letter is one event, and a crash between the two halves would + // either lose the message silently or leave the drain retrying a + // delivery already recorded as given up on. + let mut batch = WriteBatch::default(); + self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; + self.put_batch( + &DeadLetterCrossZoneDispatchesCellRef(&dead_letters), + (), + &mut batch, + )?; + self.put_batch(&DeadLetterCrossZoneDispatchCountCell(count), (), &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to retire a cross-zone dispatch into the dead letter".to_owned()), + ) + })?; + + Ok(DispatchFailure::Retired(Box::new(dead_letter))) + } + + /// The cross-zone deliveries given up on and still retained, oldest first. + pub fn get_dead_letter_cross_zone_dispatches(&self) -> DbResult> { + Ok(self + .get_opt::(())? + .map_or_else(Vec::new, |cell| cell.0)) + } + + /// Every cross-zone delivery given up on, including ones since evicted. + pub fn get_dead_letter_cross_zone_dispatch_count(&self) -> DbResult { + Ok(self + .get_opt::(())? + .map_or(0, |cell| cell.0)) } /// Drops the records of deliveries that are settled for good, outside any @@ -796,12 +907,56 @@ impl RocksDBIO { records.retain(|record| !to_remove.contains(&record.message_key)); let removed = before.saturating_sub(records.len()); + // Both lists in one batch, for the same reason the retire path batches: + // a crash between them leaves the pending record gone and a dead letter + // behind saying the delivery was abandoned, and nothing recomputes these + // keys on a later pass to correct it. + let mut batch = WriteBatch::default(); if removed > 0 { - self.put_pending_cross_zone_dispatches(&records)?; + self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; + } + self.stage_reconciled_dead_letters(&to_remove, &mut batch)?; + if !batch.is_empty() { + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to drop settled cross-zone dispatches".to_owned()), + ) + })?; } Ok(removed) } + /// Stages the removal of dead letters whose delivery turned out to settle. + /// + /// Giving up is this node's decision and every sequencer makes it alone, + /// against its own head and its own mempool ordering, so a delivery this one + /// stopped attempting can still reach a block another one produced. Left + /// alone, the entry would report that delivery as abandoned for as long as + /// the store lives, and nothing else removes one. + /// + /// The count is deliberately not decremented. It records how often this node + /// gave up, which stays true whatever happened next. + fn stage_reconciled_dead_letters( + &self, + settled: &std::collections::HashSet<&[u8; 32]>, + batch: &mut WriteBatch, + ) -> DbResult { + let mut dead_letters = self.get_dead_letter_cross_zone_dispatches()?; + let before = dead_letters.len(); + dead_letters.retain(|record| !settled.contains(&record.message_key)); + let reconciled = before.saturating_sub(dead_letters.len()); + + if reconciled > 0 { + self.put_batch( + &DeadLetterCrossZoneDispatchesCellRef(&dead_letters), + (), + batch, + )?; + } + Ok(reconciled) + } + /// Drops the pending records of deliveries that just became irreversible, /// staged into `batch` so they go with the update that made them so. /// @@ -827,6 +982,11 @@ impl RocksDBIO { if removed > 0 { self.put_pending_cross_zone_dispatches_batch(&records, batch)?; } + + // A settled delivery this node had given up on is not one it abandoned, + // and this is the path that catches the ordinary case: another sequencer + // carries it into a block that then becomes irreversible. + self.stage_reconciled_dead_letters(&to_remove, batch)?; Ok(removed) } diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 098d561bd..7b066f2b3 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -9,10 +9,12 @@ use crate::{ sequencer::{ CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_CROSS_ZONE_PEER_TIP_KEY, - DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, - DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, - DB_META_PUBLISHED_HIGH_WATER_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, - DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, + DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY, + DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, + DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_PUBLISHED_HIGH_WATER_KEY, + DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY, + DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -294,9 +296,10 @@ pub struct PendingCrossZoneDispatchRecord { /// A dispatch's payload and target accounts are chosen on the peer zone and /// validated by nobody in between, so one can fail for good. A failure can /// equally be a property of the moment, so a single one is not enough to - /// give up on a delivery. Once too many accumulate the record is dropped - /// rather than flagged, since a delivery nothing will retry is also a - /// delivery nothing would ever remove. + /// give up on a delivery. Once too many accumulate the record leaves this + /// list, which the drain re-feeds every turn, and a + /// [`DeadLetterDispatchRecord`] is kept in its place so the delivery this + /// node stopped attempting is still identifiable. pub failed_attempts: u32, } @@ -347,6 +350,105 @@ impl SimpleWritableCell for PendingCrossZoneDispatchesCellRef<'_> { } } +/// Which peer message a delivery carried, kept so a lost one can be traced back +/// to the peer block it was in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct DispatchOrigin { + pub src_zone: PeerZoneKey, + pub src_block_id: u64, + pub src_tx_index: u32, +} + +/// A cross-zone delivery this node has given up on. +/// +/// A dispatch that fails execution is left out of the block, so unlike every +/// other failure in the pipeline this one leaves no on-chain trace that it was +/// ever attempted. This record is what makes it observable rather than a log +/// line that scrolls away. +/// +/// It identifies the message rather than carrying it. The peer block and +/// transaction index are enough to read the message back off the peer channel, +/// and the encoded transaction is chosen by the peer zone and can exceed this +/// node's whole block size limit, so retaining it would bound the list in +/// entries while leaving it unbounded in bytes. +/// +/// Giving up is this node's decision, not the network's: another sequencer may +/// carry the same delivery successfully, and a record here is dropped again if +/// that happens. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct DeadLetterDispatchRecord { + pub message_key: [u8; 32], + pub origin: DispatchOrigin, + /// Attempts made before giving up, recording the policy that was in force + /// rather than distinguishing one retirement from another. + pub failed_attempts: u32, + /// Size of the delivery transaction that would not execute, which is the + /// diagnostic for the one failure mode that is about size. + pub transaction_bytes: u32, +} + +#[derive(BorshDeserialize)] +pub struct DeadLetterCrossZoneDispatchesCellOwned(pub Vec); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchesCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for DeadLetterCrossZoneDispatchesCellOwned {} + +#[derive(BorshSerialize)] +pub struct DeadLetterCrossZoneDispatchesCellRef<'records>(pub &'records [DeadLetterDispatchRecord]); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchesCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for DeadLetterCrossZoneDispatchesCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize dead-letter cross-zone dispatches cell".to_owned()), + ) + }) + } +} + +/// Deliveries given up on since this store was created. +/// +/// Counted separately from the retained list because that list both evicts at +/// its cap and drops entries whose delivery later settles, so its length is not +/// how many times this node has given up. A node that gave up hundreds of times +/// would otherwise look like one that gave up at the cap. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct DeadLetterCrossZoneDispatchCountCell(pub u64); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchCountCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for DeadLetterCrossZoneDispatchCountCell {} + +impl SimpleWritableCell for DeadLetterCrossZoneDispatchCountCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize dead-letter cross-zone dispatch count".to_owned()), + ) + }) + } +} + #[derive(BorshDeserialize)] pub struct PendingDepositEventsCellOwned(pub Vec); diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 385f7ffec..900ca860e 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -41,6 +41,15 @@ fn dispatch_record(seed: u8) -> PendingCrossZoneDispatchRecord { PendingCrossZoneDispatchRecord::recorded([seed; 32], vec![seed; 4]) } +/// The peer coordinates a dead letter carries, distinct per seed. +fn dispatch_origin(seed: u8) -> DispatchOrigin { + DispatchOrigin { + src_zone: [seed; 32], + src_block_id: u64::from(seed), + src_tx_index: u32::from(seed), + } +} + /// A distinct message key per index, for filling the pending list. fn key_from_index(index: usize) -> [u8; 32] { let mut key = [0_u8; 32]; @@ -606,7 +615,7 @@ fn finalized_dispatch_records_are_removed_by_message_key() { } #[test] -fn record_dispatch_failure_drops_the_record_at_the_limit() { +fn record_dispatch_failure_retires_the_record_at_the_limit() { let temp_dir = tempdir().unwrap(); let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); @@ -616,36 +625,235 @@ fn record_dispatch_failure_drops_the_record_at_the_limit() { dbio.add_pending_cross_zone_dispatches(vec![record, survivor.clone()]) .unwrap(); - assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); assert_eq!( - dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts, - 1, + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Retried { failed_attempts: 1 }, "a failure short of the limit is counted, not given up on" ); - assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); - assert!( - dbio.record_dispatch_failure(key, 3).unwrap(), - "the third failure is the one it is given up on" + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts, + 1 ); + assert_eq!( + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Retried { failed_attempts: 2 } + ); + let DispatchFailure::Retired(retired) = dbio + .record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap() + else { + panic!("the third failure is the one it is given up on"); + }; + assert_eq!(retired.message_key, key); + assert_eq!(retired.origin, dispatch_origin(1)); + assert_eq!(retired.failed_attempts, 3); - // Dropped rather than flagged: a delivery the drain will never feed into a - // block again is one nothing would ever remove, so flagging it would let a - // peer that can make deliveries fail grow the list without bound. + // It has to leave the pending list, which the drain re-feeds every turn, or + // a delivery that can never execute would be retried for ever. assert_eq!( dbio.get_pending_cross_zone_dispatches().unwrap(), vec![survivor], - "giving up on a delivery drops its record and leaves the others alone" + "giving up on a delivery takes its record out and leaves the others alone" ); - // A key with no record reads as given up on: there is nothing left to count - // against, and nothing will feed it into a block. - assert!( - dbio.record_dispatch_failure(key, 3).unwrap(), - "a failure against a dropped delivery must not re-create its record" + // A key with no record is not a give-up: nothing was counted and nothing was + // abandoned. This is the shape of a delivery that settled and then failed a + // later attempt. + assert_eq!( + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Absent, + "a failure against a retired delivery must not re-create its record" ); assert_eq!(dbio.get_pending_cross_zone_dispatches().unwrap().len(), 1); } +#[test] +fn a_retired_dispatch_moves_into_the_dead_letter_identified_by_its_origin() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + let encoded_len = u32::try_from(record.transaction.len()).unwrap(); + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + for _ in 0..3 { + dbio.record_dispatch_failure(key, 3, dispatch_origin(7)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), 1); + assert_eq!(dead_letters[0].message_key, key); + assert_eq!( + dead_letters[0].origin, + dispatch_origin(7), + "the peer coordinates are what let the message be read back off the peer channel" + ); + assert_eq!(dead_letters[0].transaction_bytes, encoded_len); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn a_dead_letter_is_dropped_once_its_delivery_settles_elsewhere() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(7)) + .unwrap(); + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatches().unwrap().len(), + 1 + ); + + // Every sequencer decides to give up alone, against its own head, so a + // delivery this node stopped attempting can still reach a block another one + // produced. Reporting it as abandoned for ever afterwards is the failure + // this guards against. + dbio.drop_settled_cross_zone_dispatches(&[key]).unwrap(); + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + + // The count is how often this node gave up, which stays true whatever + // happened next, and is what keeps the list readable as "still outstanding". + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn a_dead_letter_is_dropped_by_the_settlement_path_inside_a_store_update() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(7)) + .unwrap(); + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatches().unwrap().len(), + 1 + ); + + // The ordinary route, unlike the standalone drop: a block carrying the + // delivery becomes irreversible and the update that records that also + // reconciles the dead letter, in the same batch. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_update(&StoreUpdate { + blocks: &[(&block2, true)], + remove_dispatch_records: &[key], + ..StoreUpdate::new(&state_with_balance(200)) + }) + .unwrap(); + + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn one_delivery_that_always_fails_takes_one_dead_letter_slot() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // A watcher rebuilding a peer tip re-reads that channel from the peer's + // genesis, and a delivery that never executes never reaches the inbox + // seen-set to be recognised as delivered, so the same one is recorded and + // retired again. Without dedupe it would fill the list with copies of itself + // and evict every other message that was given up on. + let key = key_from_index(1); + let other = key_from_index(2); + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + other, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(other, 1, dispatch_origin(2)) + .unwrap(); + + for _ in 0..5 { + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + key, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(1)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!( + dead_letters.len(), + 2, + "one entry per delivery, not per retirement" + ); + assert_eq!( + dead_letters[0].message_key, other, + "the other message is not evicted" + ); + + // The count still measures give-ups, so the repetition remains visible. + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 6); +} + +#[test] +fn dead_letters_evict_the_oldest_at_the_cap_but_keep_counting() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let retirements = MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES + 3; + for index in 0..retirements { + let key = key_from_index(index); + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + key, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(1)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES); + assert_eq!( + dead_letters[0].message_key, + key_from_index(3), + "the oldest retained entry is the fourth retirement, the first three having been evicted" + ); + assert_eq!( + dead_letters[dead_letters.len() - 1].message_key, + key_from_index(retirements - 1), + "the newest retirement is kept" + ); + + // What eviction must not do is hide that the evicted ones happened: a node + // that lost hundreds of messages would otherwise look like one that lost the + // cap. + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), + u64::try_from(retirements).unwrap() + ); +} + #[test] fn repeated_withdrawal_key_in_one_update_folds_once_per_occurrence() { let temp_dir = tempdir().unwrap(); From e274a4b29d402097a5a75563c1d637d83075a508 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 10:13:30 +0200 Subject: [PATCH 02/10] feat(sequencer): count and expose the cross-zone deliveries given up on --- Cargo.lock | 1 + lez/sequencer/core/metrics/src/names.rs | 2 + lez/sequencer/core/metrics/src/record.rs | 25 ++++++ lez/sequencer/core/src/lib.rs | 83 +++++++++++++++---- lez/sequencer/core/src/tests.rs | 5 ++ lez/sequencer/service/protocol/Cargo.toml | 1 + lez/sequencer/service/protocol/src/lib.rs | 27 ++++++ lez/sequencer/service/rpc/src/lib.rs | 13 ++- lez/sequencer/service/src/service.rs | 30 ++++++- monitoring/grafana/dashboards/sequencer.json | 80 ++++++++++++++++++ .../dashboard_gen/src/dashboards/sequencer.rs | 41 +++++++++ 11 files changed, 286 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4c52e89d..38d42a39d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9573,6 +9573,7 @@ dependencies = [ "hex", "lee", "lee_core", + "serde", "serde_with", ] diff --git a/lez/sequencer/core/metrics/src/names.rs b/lez/sequencer/core/metrics/src/names.rs index 92b1595eb..980f2c8ce 100644 --- a/lez/sequencer/core/metrics/src/names.rs +++ b/lez/sequencer/core/metrics/src/names.rs @@ -7,3 +7,5 @@ pub const MEMPOOL_TRANSACTION_APPLICATION_TIME: &str = "mempool_transaction_application_time_seconds"; pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block"; pub const MEMPOOL_FAILED_TRANSACTIONS_TOTAL: &str = "mempool_failed_transactions_total"; +pub const CROSS_ZONE_DISPATCHES_RETIRED_TOTAL: &str = "cross_zone_dispatches_retired_total"; +pub const CROSS_ZONE_DEAD_LETTER_DISPATCHES: &str = "cross_zone_dead_letter_dispatches"; diff --git a/lez/sequencer/core/metrics/src/record.rs b/lez/sequencer/core/metrics/src/record.rs index 96b3304ed..39b47a38a 100644 --- a/lez/sequencer/core/metrics/src/record.rs +++ b/lez/sequencer/core/metrics/src/record.rs @@ -48,6 +48,8 @@ impl From for TxKind { pub fn init() { blocks_produced_total_counter().increment(0); mempool_failed_transactions_total_counter().increment(0); + cross_zone_dispatches_retired_total_counter().increment(0); + record_cross_zone_dead_letter_dispatches(0); record_mempool_size(0); record_chain_height(0); @@ -165,3 +167,26 @@ fn mempool_failed_transactions_total_counter() -> Counter { pub fn increment_mempool_failed_transactions_total() { mempool_failed_transactions_total_counter().increment(1); } + +fn cross_zone_dispatches_retired_total_counter() -> Counter { + counter!( + description: "Cross-zone deliveries this sequencer gave up on after repeated execution failures", + unit: Unit::Count, + names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL + ) +} + +pub fn increment_cross_zone_dispatches_retired_total() { + cross_zone_dispatches_retired_total_counter().increment(1); +} + +/// Retained dead letters, which both evict at their cap and drop when a +/// delivery turns out to settle, so this falls as well as rises. +pub fn record_cross_zone_dead_letter_dispatches(count: usize) { + gauge!( + description: "Given-up-on cross-zone deliveries currently retained for inspection", + unit: Unit::Count, + names::CROSS_ZONE_DEAD_LETTER_DISPATCHES + ) + .set(u64::try_from(count).expect("Dead letter count should fit into u64") as f64); +} diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 9a1639867..159e43e0f 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -32,6 +32,9 @@ use mempool::{MemPool, MemPoolHandle}; pub use mock::SequencerCoreWithMockClients; use num_bigint::BigUint; pub use storage::error::DbError; +// Re-exported because `cross_zone_dead_letters` returns it and the service +// crate does not depend on `storage`, so it could not otherwise name the type. +pub use storage::sequencer::sequencer_cells::DeadLetterDispatchRecord; use storage::sequencer::{ DispatchFailure, RocksDBIO, StoreUpdate, sequencer_cells::{ @@ -333,6 +336,7 @@ impl SequencerCore { }; sequencer_core_metrics::record_chain_height(sequencer_core.chain_height()); + record_dead_letter_gauge(&sequencer_core.store.dbio()); (sequencer_core, mempool_handle) } @@ -794,18 +798,22 @@ impl SequencerCore { (prev, height, chain.head_state().clone(), pending) }; - if !settled.is_empty() - && let Err(err) = self + if !settled.is_empty() { + if let Err(err) = self .store .dbio() .drop_settled_cross_zone_dispatches(&settled) - { - // Only bookkeeping: the deliveries themselves are irreversible, and - // the next turn tries again. - warn!( - "Failed to drop {} settled delivery record(s): {err:#}", - settled.len() - ); + { + // Only bookkeeping: the deliveries themselves are irreversible, + // and the next turn tries again. + warn!( + "Failed to drop {} settled delivery record(s): {err:#}", + settled.len() + ); + } + // A settled delivery may be one this node had given up on, which + // takes its dead letter with it. + record_dead_letter_gauge(&self.store.dbio()); } let mut valid_transactions = Vec::new(); @@ -1096,15 +1104,19 @@ impl SequencerCore { .dbio() .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES, origin) { - Ok(DispatchFailure::Retired(record)) => error!( - "Giving up on cross-zone delivery {} from peer zone {} block {} transaction {} ({} bytes) after {} failed attempts. This node will not retry it; unless another sequencer carries it, the message is not delivered. Kept in the dead letter.", - hex::encode(key), - hex::encode(origin.src_zone), - origin.src_block_id, - origin.src_tx_index, - record.transaction_bytes, - record.failed_attempts - ), + Ok(DispatchFailure::Retired(record)) => { + sequencer_core_metrics::increment_cross_zone_dispatches_retired_total(); + record_dead_letter_gauge(&self.store.dbio()); + error!( + "Giving up on cross-zone delivery {} from peer zone {} block {} transaction {} ({} bytes) after {} failed attempts. This node will not retry it; unless another sequencer carries it, the message is not delivered. Kept in the dead letter.", + hex::encode(key), + hex::encode(origin.src_zone), + origin.src_block_id, + origin.src_tx_index, + record.transaction_bytes, + record.failed_attempts + ); + } Ok(DispatchFailure::Retried { failed_attempts }) => warn!( "Cross-zone delivery {} failed to execute ({failed_attempts} of {RETIRE_DISPATCH_AFTER_FAILURES} attempts), will retry next block", hex::encode(key) @@ -1122,6 +1134,19 @@ impl SequencerCore { } } + /// The deliveries this node has given up on, with how many times it has done + /// so, which is the larger number once entries evict or reconcile away. + /// + /// Retained is read first so the pair can only skew towards a total that + /// leads its list, which is an ordinary evicted or settled state. The other + /// order would report entries against a total of zero. + pub fn cross_zone_dead_letters(&self) -> Result<(u64, Vec), DbError> { + let dbio = self.store.dbio(); + let retained = dbio.get_dead_letter_cross_zone_dispatches()?; + let total = dbio.get_dead_letter_cross_zone_dispatch_count()?; + Ok((total, retained)) + } + /// A weak reference to this sequencer's store, for a shutdown path that /// needs to observe the database actually closing rather than infer it. #[must_use] @@ -1238,6 +1263,24 @@ fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) /// relies on a valid successor or a restart. `ChainState` never emits /// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a /// follow-up. +/// Publishes how many given-up-on deliveries are retained. +/// +/// Read from the store rather than tracked in memory because the list falls as +/// well as rises: it evicts at its cap, and an entry is dropped when its +/// delivery turns out to settle on a block another sequencer produced. Called +/// only where one of those can have happened, since it costs a read and a +/// decode. +fn record_dead_letter_gauge(dbio: &RocksDBIO) { + match dbio.get_dead_letter_cross_zone_dispatches() { + Ok(records) => { + sequencer_core_metrics::record_cross_zone_dead_letter_dispatches(records.len()); + } + Err(err) => { + warn!("Failed to read the cross-zone dead letter for its gauge: {err:#}"); + } + } +} + fn apply_follow_update( dbio: &RocksDBIO, chain: &Mutex, @@ -1416,6 +1459,10 @@ fn apply_follow_update( }; sequencer_core_metrics::record_chain_height(head_height); + // This is the runtime path that reconciles: a delivery this node gave up on + // reaches a block another sequencer produced, and finalizing that block + // drops its dead letter. + record_dead_letter_gauge(dbio); if outcome.accepted_deposits > 0 { info!( diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index f7fa98344..d8d4e8a84 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -733,6 +733,11 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() ); assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); + // The same view the RPC serves, so an operator sees what the store holds. + let (total_retired, retained) = sequencer.cross_zone_dead_letters().unwrap(); + assert_eq!(total_retired, 1); + assert_eq!(retained, dead_letters); + // And nothing re-feeds it, so it stops costing a guest execution per block. let block_id = sequencer.produce_new_block().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); diff --git a/lez/sequencer/service/protocol/Cargo.toml b/lez/sequencer/service/protocol/Cargo.toml index ced19e755..1eb413d0b 100644 --- a/lez/sequencer/service/protocol/Cargo.toml +++ b/lez/sequencer/service/protocol/Cargo.toml @@ -13,4 +13,5 @@ lee.workspace = true lee_core.workspace = true hex.workspace = true +serde.workspace = true serde_with.workspace = true diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index ce669d312..e608f81aa 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -5,11 +5,38 @@ use std::{fmt::Display, str::FromStr}; pub use common::{HashType, block::Block, transaction::LeeTransaction}; pub use lee::{Account, AccountId, ProgramId}; pub use lee_core::{BlockId, Commitment, CommitmentSetDigest, MembershipProof, account::Nonce}; +use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; #[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)] pub struct ChannelId(pub [u8; 32]); +/// A cross-zone delivery a sequencer gave up on after repeated execution +/// failures. +/// +/// Identifies the message rather than carrying it: the peer zone, block id and +/// transaction index are what locate it on the peer's channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneDeadLetter { + pub message_key: HashType, + pub src_zone: ChannelId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub failed_attempts: u32, + pub transaction_bytes: u32, +} + +/// What a sequencer has given up delivering. +/// +/// `total_retired` counts every give-up; `retained` holds the ones still kept. +/// The two differ once entries evict at the cap, or once a delivery this node +/// abandoned settles on a block another sequencer produced. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneDeadLetterReport { + pub total_retired: u64, + pub retained: Vec, +} + impl Display for ChannelId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let hex_string = hex::encode(self.0); diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index a1d2acfb4..6af9cb807 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned; #[cfg(feature = "client")] pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder}; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - LeeTransaction, MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, + CrossZoneDeadLetterReport, HashType, LeeTransaction, MembershipProof, Nonce, ProgramId, }; #[cfg(all(not(feature = "server"), not(feature = "client")))] @@ -90,4 +90,13 @@ pub trait Rpc { #[method(name = "getChannelId")] async fn get_channel_id(&self) -> Result; + + /// The cross-zone deliveries this sequencer has given up on. + /// + /// Its own method rather than folded into `checkHealth`: one undeliverable + /// peer message must not read as an unhealthy node. + #[method(name = "getCrossZoneDeadLetters")] + async fn get_cross_zone_dead_letters( + &self, + ) -> Result; } diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index e55735c07..6afe696be 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -12,8 +12,8 @@ use sequencer_core::{ DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait, }; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, + CrossZoneDeadLetter, CrossZoneDeadLetterReport, HashType, MembershipProof, Nonce, ProgramId, }; use tokio::sync::Mutex; @@ -217,6 +217,32 @@ impl sequencer_service_rpc::Rpc let channel_id = self.sequencer.lock().await.block_publisher().channel_id(); Ok(ChannelId(*channel_id.as_ref())) } + + async fn get_cross_zone_dead_letters( + &self, + ) -> Result { + let (total_retired, records) = self + .sequencer + .lock() + .await + .cross_zone_dead_letters() + .map_err(|err| internal_error(&err))?; + + Ok(CrossZoneDeadLetterReport { + total_retired, + retained: records + .into_iter() + .map(|record| CrossZoneDeadLetter { + message_key: HashType(record.message_key), + src_zone: ChannelId(record.origin.src_zone), + src_block_id: record.origin.src_block_id, + src_tx_index: record.origin.src_tx_index, + failed_attempts: record.failed_attempts, + transaction_bytes: record.transaction_bytes, + }) + .collect(), + }) + } } fn internal_error(err: &DbError) -> ErrorObjectOwned { diff --git a/monitoring/grafana/dashboards/sequencer.json b/monitoring/grafana/dashboards/sequencer.json index ede0ae4bd..afc6c2756 100644 --- a/monitoring/grafana/dashboards/sequencer.json +++ b/monitoring/grafana/dashboards/sequencer.json @@ -346,6 +346,86 @@ ], "title": "Submitted vs failed transactions (per minute)", "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "red" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 0, "y": 41 }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "cross_zone_dispatches_retired_total", + "legendFormat": "given up on", + "refId": "A" + } + ], + "title": "Cross-zone deliveries given up on since startup", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 6, "y": 41 }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "cross_zone_dead_letter_dispatches", + "legendFormat": "retained", + "refId": "A" + } + ], + "title": "Dead letters retained", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, + "unit": "short", + "min": 0.0 + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "given up on" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] + } + ] + }, + "gridPos": { "h": 7, "w": 12, "x": 12, "y": 41 }, + "id": 13, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(cross_zone_dispatches_retired_total[$__rate_interval]) * 60", + "legendFormat": "given up on", + "refId": "A" + } + ], + "title": "Cross-zone deliveries given up on (per minute)", + "type": "timeseries" } ], "refresh": "5s", diff --git a/tools/dashboard_gen/src/dashboards/sequencer.rs b/tools/dashboard_gen/src/dashboards/sequencer.rs index 0520251c0..2098e876d 100644 --- a/tools/dashboard_gen/src/dashboards/sequencer.rs +++ b/tools/dashboard_gen/src/dashboards/sequencer.rs @@ -192,4 +192,45 @@ pub fn dashboard() -> Dashboard { ), ], ) + .row( + 7, + [ + // A dispatch that fails execution is left out of the block, so + // nothing on chain records that a delivery was abandoned. These + // are the only signal that it happened. + Panel::stat("Cross-zone deliveries given up on since startup") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("red")) + .target( + Target::new( + sequencer_core_metrics::names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL, + ) + .legend("given up on"), + ), + Panel::stat("Dead letters retained") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("orange")) + .target( + Target::new( + sequencer_core_metrics::names::CROSS_ZONE_DEAD_LETTER_DISPATCHES, + ) + .legend("retained"), + ), + Panel::timeseries("Cross-zone deliveries given up on (per minute)") + .width(12) + .unit(Unit::Short) + .min(0.0) + .target(rate_per_min( + sequencer_core_metrics::names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL, + "given up on", + )) + .with_override( + FieldOverride::by_name("given up on").color(Color::fixed("red")), + ), + ], + ) } From af2a025b8f5c2676a42474455772c9cf161447c5 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 11:01:16 +0200 Subject: [PATCH 03/10] feat(cross-zone)!: bind a delivery to the peer block hash it came from BREAKING CHANGE: `CrossZoneMessage` gains `src_block_hash`, changing the risc0 encoding of `cross_zone_inbox::Instruction::Dispatch` and the inbox image id, and with it the inbox config and seen-shard PDA addresses. A pending dispatch record written by earlier code no longer names a registered program, so it fails production and is dead-lettered rather than delivered. Drain the pending list before upgrading, or accept that in-flight deliveries are lost. --- .../tests/cross_zone_ingress_guard.rs | 1 + .../tests/cross_zone_state_machine.rs | 8 +++ lez/cross_zone/src/lib.rs | 30 ++++++--- lez/indexer/core/src/cross_zone_verifier.rs | 62 ++++++++++++++++--- lez/programs/cross_zone_inbox/core/src/lib.rs | 7 +++ lez/sequencer/core/src/cross_zone_watcher.rs | 62 ++++++++++++++++--- lez/sequencer/core/src/tests.rs | 21 +++++-- 7 files changed, 163 insertions(+), 28 deletions(-) diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 86731477f..1da931379 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -44,6 +44,7 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { let msg = CrossZoneMessage { src_zone: [2; 32], src_block_id: 1, + src_block_hash: [7; 32], src_tx_index: 0, src_program_id: [9; 8], target_program_id: programs::ping_receiver().id(), diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index f9d92a312..d8748d478 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -27,6 +27,9 @@ use ping_core::{ReceiverInstruction, ping_record_pda}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; const RECIPIENT: [u8; 32] = [9; 32]; +/// The source block a delivery names. These tests drive the guest directly, so +/// there is no peer block to hash and any fixed value does. +const SRC_BLOCK_HASH: [u8; 32] = [7; 32]; /// State registering the cross-zone builtins these tests exercise. fn base_state() -> V03State { @@ -129,6 +132,7 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, src_program_id: [9_u32; 8], target_program_id: receiver_id, @@ -261,6 +265,7 @@ fn inbox_dispatch_mints_wrapped_token() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, src_program_id: [9_u32; 8], target_program_id: wrapped_token_id, @@ -326,6 +331,7 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, // The emitter a user can drive directly, aimed at the bridge's target. src_program_id: programs::ping_sender().id(), @@ -384,6 +390,7 @@ fn a_mint_from_the_routed_emitter_is_accepted() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, src_program_id: bridge_lock_id, target_program_id: wrapped_token_id, @@ -462,6 +469,7 @@ fn mint_replay_rejected() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index, src_program_id: [9_u32; 8], target_program_id: wrapped_token_id, diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 38f35d24c..71212f3b8 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -31,6 +31,22 @@ pub struct Emission { pub payload: Vec, } +/// Where a delivery came from on the peer chain. +/// +/// One struct so the watcher and the verifier fill the same field list. They +/// must produce byte-identical dispatch transactions for the same emission, and +/// a field one side sets differently is exactly how that breaks. +/// +/// `src_block_hash` is the block's recomputed hash on both sides, never the +/// `header.hash` it declares, which its signature does not cover. +pub struct EmissionSource { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_block_hash: [u8; 32], + pub src_tx_index: u32, + pub src_program_id: ProgramId, +} + /// Whether a program may only be invoked by sequencer-origin transactions. /// /// The cross-zone inbox is injected solely by the watcher; a user-submitted call @@ -118,19 +134,17 @@ fn build_inbox_dispatch_tx( /// Option B check). #[must_use] pub fn build_dispatch_from_emission( - src_zone: ZoneId, - src_block_id: u64, - src_tx_index: u32, - src_program_id: ProgramId, + source: &EmissionSource, target_program_id: ProgramId, target_accounts: &[[u8; 32]], payload: Vec, ) -> lee::PublicTransaction { let msg = CrossZoneMessage { - src_zone, - src_block_id, - src_tx_index, - src_program_id, + src_zone: source.src_zone, + src_block_id: source.src_block_id, + src_block_hash: source.src_block_hash, + src_tx_index: source.src_tx_index, + src_program_id: source.src_program_id, target_program_id, payload, l1_inclusion_witness: None, diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 9dcfe2eb5..dfed644e8 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::anyhow; use common::{block::Block, transaction::LeeTransaction}; -use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, }; @@ -455,11 +455,18 @@ impl CrossZoneVerifier { ))); } + // Recomputed here rather than read from `msg`, which would make the + // field attest to itself. `accept_peer_block` already proved this equals + // the `header.hash` of every cached block, so it costs one hash and the + // fact stays local. Ok(build_dispatch_from_emission( - msg.src_zone, - msg.src_block_id, - msg.src_tx_index, - message.program_id, + &EmissionSource { + src_zone: msg.src_zone, + src_block_id: msg.src_block_id, + src_block_hash: peer_block.recompute_hash().0, + src_tx_index: msg.src_tx_index, + src_program_id: message.program_id, + }, emission.target_program_id, &emission.target_accounts, emission.payload, @@ -808,12 +815,29 @@ mod tests { /// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`. fn dispatch(payload: &[u8]) -> LeeTransaction { + dispatch_naming_block_hash(payload, source_block_hash(payload)) + } + + /// The recomputed hash of the `PEER_BLOCK_ID` block carrying `payload`, + /// which is what an honest watcher puts in the dispatch. + fn source_block_hash(payload: &[u8]) -> [u8; 32] { + peer_chain(payload) + .last() + .expect("chain reaches PEER_BLOCK_ID") + .recompute_hash() + .0 + } + + fn dispatch_naming_block_hash(payload: &[u8], src_block_hash: [u8; 32]) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); LeeTransaction::Public(build_dispatch_from_emission( - PEER_ZONE, - PEER_BLOCK_ID, - 0, - programs::ping_sender().id(), + &EmissionSource { + src_zone: PEER_ZONE, + src_block_id: PEER_BLOCK_ID, + src_block_hash, + src_tx_index: 0, + src_program_id: programs::ping_sender().id(), + }, receiver_id, &[ping_record_pda(receiver_id).into_value()], payload.to_vec(), @@ -832,6 +856,26 @@ mod tests { .expect("dispatch matching the peer emission verifies"); } + #[tokio::test] + async fn rejects_dispatch_naming_the_wrong_source_block_hash() { + let verifier = verifier(); + cache_chain(&verifier, peer_chain(b"hi")).await; + + // Everything else matches the peer's block, so only the claimed source + // hash is wrong. The verifier recomputes it from the block it resolved + // rather than reading the field, which is what makes this detectable at + // all; trusting the message would make the field attest to itself. + let block = + produce_dummy_block(9, None, vec![dispatch_naming_block_hash(b"hi", [0xab; 32])]); + assert!( + matches!( + verifier.verify_block(&block).await, + Err(CrossZoneVerifyError::Forged(_)) + ), + "a delivery claiming a source block hash the peer block does not have is forged" + ); + } + #[tokio::test] async fn rejects_dispatch_with_no_matching_emission() { let verifier = verifier(); diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index b3ea8d596..d27773a28 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -69,6 +69,13 @@ pub struct CrossZoneConfig { pub struct CrossZoneMessage { pub src_zone: ZoneId, pub src_block_id: u64, + /// The source block's recomputed hash, never the `header.hash` it declares. + /// + /// The signature does not cover that field, so a correctly signed block can + /// carry a bogus one. Both the watcher and the verifier hash the block's + /// contents themselves and fill this from that, so the two agree on it + /// without either trusting what the peer wrote. + pub src_block_hash: [u8; 32], pub src_tx_index: u32, pub src_program_id: ProgramId, pub target_program_id: ProgramId, diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index e21641a6a..3d350a60e 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1,7 +1,7 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; -use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; @@ -468,7 +468,7 @@ where ); } Link::Next(block_hash) => { - if !record_block_deliveries(&block, peer, dbio) { + if !record_block_deliveries(&block, block_hash, peer, dbio) { // Recording a delivery is what makes it survive the // mempool. Letting the pass finish here would move // the floor past this slot on a store that just @@ -545,7 +545,15 @@ fn advance_cursor(dbio: &RocksDBIO, peer_zone: [u8; 32], cursor: &mut Option bool { +/// +/// `block_hash` is the value [`link_against`] recomputed from the block's own +/// contents, not `block.header.hash`, which the signature does not cover. +fn record_block_deliveries( + block: &Block, + block_hash: HashType, + peer: &PeerContext, + dbio: &RocksDBIO, +) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; let allowed_routes = peer.allowed_routes.as_slice(); @@ -583,10 +591,13 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) let src_tx_index = u32::try_from(index).unwrap_or(u32::MAX); let dispatch = build_dispatch_from_emission( - peer_zone, - block.header.block_id, - src_tx_index, - message.program_id, + &EmissionSource { + src_zone: peer_zone, + src_block_id: block.header.block_id, + src_block_hash: block_hash.0, + src_tx_index, + src_program_id: message.program_id, + }, emission.target_program_id, &emission.target_accounts, emission.payload, @@ -1145,6 +1156,43 @@ mod tests { ); } + #[tokio::test] + async fn a_recorded_delivery_names_the_hash_the_watcher_validated() { + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + let records = dbio.get_pending_cross_zone_dispatches().unwrap(); + assert_eq!(records.len(), 1, "the delivery must be recorded"); + let tx = borsh::from_slice::(&records[0].transaction).unwrap(); + let LeeTransaction::Public(public_tx) = tx else { + panic!("a dispatch is a public transaction"); + }; + let Ok(cross_zone_inbox_core::Instruction::Dispatch(msg)) = + risc0_zkvm::serde::from_slice(&public_tx.message().instruction_data) + else { + panic!("the recorded transaction is an inbox dispatch"); + }; + + // The block this delivery came from, which is what the indexer + // recomputes independently when it re-derives the same transaction. + // Naming a different block is what makes the two disagree. + assert_eq!( + msg.src_block_hash, + chain_block(1).recompute_hash().0, + "the delivery names the block the watcher read it from" + ); + } + #[tokio::test] async fn a_delivery_that_cannot_be_recorded_holds_the_floor() { let (_dir, dbio) = store(); diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index d8d4e8a84..ac8f16d93 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -208,16 +208,29 @@ fn ping_payload(payload: &[u8]) -> Vec { fn dispatch_tx(src_block_id: u64, payload: Vec) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); LeeTransaction::Public(cross_zone::build_dispatch_from_emission( - PEER_ZONE, - src_block_id, - 0, - programs::ping_sender().id(), + &cross_zone::EmissionSource { + src_zone: PEER_ZONE, + src_block_id, + src_block_hash: peer_block_hash(src_block_id), + src_tx_index: 0, + src_program_id: programs::ping_sender().id(), + }, receiver_id, &[ping_record_pda(receiver_id).into_value()], payload, )) } +/// A stand-in for the peer block's recomputed hash, distinct per block id. +/// +/// These records are seeded straight into the store rather than read off a peer +/// channel, so no real block exists to hash. Only its consistency matters. +fn peer_block_hash(src_block_id: u64) -> [u8; 32] { + let mut hash = [0_u8; 32]; + hash[..8].copy_from_slice(&src_block_id.to_le_bytes()); + hash +} + /// The pending record the watcher would leave behind for that dispatch. fn dispatch_record(src_block_id: u64, payload: Vec) -> PendingCrossZoneDispatchRecord { let tx = dispatch_tx(src_block_id, payload); From 5cbd821261b022abff3cab7f7514d1b08909f10e Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 11:48:56 +0200 Subject: [PATCH 04/10] fix(cross-zone)!: shard the inbox seen-set per peer block, keyed by tx index Closes #676. BREAKING CHANGE: the seen-shard PDA address and its data layout both change, so every message delivered under the old layout becomes deliverable again at the new address, and the old shards are orphaned. A fresh genesis is required. --- .../tests/cross_zone_state_machine.rs | 129 ++++++++++++- lez/indexer/core/src/cross_zone_verifier.rs | 77 ++++++-- lez/programs/cross_zone_inbox/core/src/lib.rs | 169 +++++++++++++++--- lez/programs/cross_zone_inbox/src/main.rs | 23 ++- lez/sequencer/core/src/lib.rs | 20 ++- 5 files changed, 367 insertions(+), 51 deletions(-) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index d8748d478..94488f409 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -14,7 +14,7 @@ use std::collections::BTreeMap; use cross_zone_inbox_core::{ CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, - inbox_config_account_id, inbox_seen_shard_account_id, message_key, + inbox_config_account_id, inbox_seen_shard_account_id, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; use lee::{ @@ -447,12 +447,13 @@ fn mint_replay_rejected() { ); seed_wrapped_config(&mut state); - // Seed the seen-shard as already containing this message's key, so the inbox - // takes the replay no-op branch. The shard is inbox-owned (claimed on a prior - // delivery), so the guest leaves it untouched. + // Seed the seen-shard as already holding this delivery, so the inbox takes + // the replay no-op branch. The shard is inbox-owned (claimed on a prior + // delivery) and bound to the same source block, so the guest leaves it + // untouched. let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let mut shard = SeenShard::default(); - shard.insert(message_key(&src_zone, src_block_id, src_tx_index)); + shard.insert(SRC_BLOCK_HASH, src_tx_index); state = state.with_public_accounts([( seen_id, Account { @@ -511,3 +512,121 @@ fn mint_replay_rejected() { assert_eq!(shard_after, shard, "replay must not modify the seen-shard"); } } + +/// A peer that publishes two different blocks claiming one block id gets at most +/// one of them delivered from. +/// +/// The shard's address covers the zone and the block id but not which block +/// claimed them, so both resolve to the same account. The first delivery binds +/// it to its own source block and the second cannot execute against it. Failing +/// is the point: were the second merely no-op'd as a replay, a peer could pick +/// which of two messages at one coordinate the target program ever sees. +#[test] +fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { + let inbox_id = programs::cross_zone_inbox().id(); + let receiver_id = programs::ping_receiver().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + let other_block_hash = [8_u8; 32]; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); + + // The shard as the first delivery left it: bound to SRC_BLOCK_HASH, holding + // that block's transaction 0. + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let mut shard = SeenShard::default(); + shard.insert(SRC_BLOCK_HASH, 0); + state = state.with_public_accounts([( + seen_id, + Account { + program_owner: inbox_id, + balance: 0, + data: shard + .to_bytes() + .try_into() + .expect("shard fits in account data"), + nonce: 0_u128.into(), + }, + )]); + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"from-the-other-block".to_vec(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + // A different transaction index, so this is not a replay: only the source + // block differs from what the shard is bound to. + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: other_block_hash, + src_tx_index: 1, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload, + l1_inclusion_witness: None, + }; + + let record_id = ping_record_pda(receiver_id); + let message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id, record_id], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + assert!( + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), + "a delivery from a block the shard is not bound to must not execute" + ); + + // The control, so the refusal above is the binding and not the shape of the + // transaction: the same second delivery, differing only in naming the block + // the shard is bound to, executes and is recorded alongside the first. + let control_words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"from-the-bound-block".to_vec(), + }) + .expect("serialize ping instruction"); + let control_msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 1, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload: control_words + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect(), + l1_inclusion_witness: None, + }; + let control_message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id, record_id], + vec![], + InboxInstruction::Dispatch(control_msg), + ) + .expect("build dispatch message"); + let control_tx = PublicTransaction::new(control_message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&control_tx, &state, 1, 0) + .expect("a second delivery from the bound block executes"); + let public_diff = diff.public_diff(); + let seen_after = public_diff + .get(&seen_id) + .expect("the shard records the new delivery"); + let shard_after = + SeenShard::from_bytes(&seen_after.data.clone().into_inner()).expect("seen shard decodes"); + assert!(shard_after.contains(0), "the first delivery is still there"); + assert!(shard_after.contains(1), "and the second is recorded"); + assert_eq!( + shard_after.src_block_hash, SRC_BLOCK_HASH, + "a shard stays bound to the block that claimed it" + ); +} diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index dfed644e8..d91d0051e 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -63,6 +63,16 @@ pub enum CrossZoneVerifyError { }, } +/// What the verifier treats as one delivery for the purpose of skipping +/// re-derivation. +/// +/// The replay key and the source block it came from. The inbox no-ops a replay +/// only when the shard it lands in is bound to that same block, so skipping on +/// the key alone would wave through a dispatch the guest will refuse, and the +/// block would then park and hold ingestion. Both sides have to agree on what a +/// replay is. +type SeenKey = (MessageKey, [u8; 32]); + /// One peer zone's cached blocks, plus how far this reader has read them as an /// unbroken hash-linked run from the peer's genesis. #[derive(Default)] @@ -278,7 +288,7 @@ pub struct CrossZoneVerifier { /// optional: a peer with no configured key is not signature-checked. peer_pubkeys: HashMap, peers: PeerBlocks, - seen: Arc>>, + seen: Arc>>, } impl CrossZoneVerifier { @@ -330,17 +340,14 @@ impl CrossZoneVerifier { /// forged dispatch reuse it to skip re-derivation while the inbox delivers the /// forgery. A key already seen is a replay the inbox no-ops, so it is accepted /// without re-derivation rather than halting on a legitimate re-delivery. - pub async fn verify_block( - &self, - block: &Block, - ) -> Result, CrossZoneVerifyError> { + pub async fn verify_block(&self, block: &Block) -> Result, CrossZoneVerifyError> { let mut verified = Vec::new(); for tx in &block.body.transactions { let Some(msg) = Self::decode_dispatch(tx) else { continue; }; - let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + let key = seen_key(&msg); if self.seen.read().await.contains(&key) { debug!( "Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)", @@ -375,7 +382,7 @@ impl CrossZoneVerifier { /// Marks the given dispatch keys seen, so a later replay of them is accepted /// without re-derivation. Call only after the block that carried them has been /// applied on chain (see [`Self::verify_block`]). - pub async fn record_seen(&self, keys: Vec) { + pub async fn record_seen(&self, keys: Vec) { if keys.is_empty() { return; } @@ -543,6 +550,14 @@ struct PeerPass { /// without recomputing it a peer can assert links it never built. The key check /// applies only when one is pinned, mirroring the watcher; it subsumes the hash /// check, but a peer with no pinned key still gets that one. +/// The delivery `msg` identifies, for the seen set. +fn seen_key(msg: &CrossZoneMessage) -> SeenKey { + ( + message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index), + msg.src_block_hash, + ) +} + fn accept_peer_block( block: &Block, peer_zone: ZoneId, @@ -936,18 +951,54 @@ mod tests { // Mark the delivery seen, as the ingest loop does once the block applies. verifier.record_seen(keys).await; - // A payload that cannot re-derive, under the key just recorded. Accepted - // only by the seen-key short circuit, since the inbox no-ops it on - // chain; `unaccepted_dispatch_does_not_poison_seen` asserts the same - // input is rejected when the key was never recorded, which is what makes - // this one about the short circuit rather than re-derivation. - let replay = produce_dummy_block(10, None, vec![dispatch(b"forged")]); + // A payload that cannot re-derive, under the key just recorded, which + // now names the source block as well as the coordinates. Accepted only + // by the seen-key short circuit, since the inbox no-ops it on chain; + // `unaccepted_dispatch_does_not_poison_seen` asserts the same input is + // rejected when the key was never recorded, which is what makes this one + // about the short circuit rather than re-derivation. + let replay = produce_dummy_block( + 10, + None, + vec![dispatch_naming_block_hash( + b"forged", + source_block_hash(b"hi"), + )], + ); verifier .verify_block(&replay) .await .expect("a replay is accepted as an on-chain no-op"); } + #[tokio::test] + async fn a_seen_coordinate_does_not_excuse_a_different_source_block() { + let verifier = verifier(); + cache_chain(&verifier, peer_chain(b"hi")).await; + + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let keys = verifier.verify_block(&first).await.expect("first verifies"); + verifier.record_seen(keys).await; + + // Same zone, block id and transaction index as the delivery just seen, + // but naming a different source block. The inbox refuses this rather + // than no-opping it, since the shard is bound to the other block, so + // skipping re-derivation here would wave through a dispatch that then + // parks the block and holds ingestion. + let other = produce_dummy_block( + 10, + None, + vec![dispatch_naming_block_hash(b"hi", [0xab; 32])], + ); + assert!( + matches!( + verifier.verify_block(&other).await, + Err(CrossZoneVerifyError::Forged(_)) + ), + "the seen set must agree with the guest on what counts as a replay" + ); + } + #[tokio::test] async fn unaccepted_dispatch_does_not_poison_seen() { // A dispatch verified in a block that never applies (e.g. one that parks) diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index d27773a28..de3684a65 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -7,12 +7,14 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; -/// Source blocks per seen-set shard, so no single seen account grows without bound. -pub const EPOCH_BLOCKS: u64 = 10_000; - const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; -const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; +/// Not `/00/`, which keyed a shard by `src_block_id / 10_000`: an epoch and a +/// block id collide under one domain, so a shard carrying the older layout would +/// land at a new address and decode as nonsense. Belt and braces, since the +/// image id is what actually relocates every PDA here, and it moves with any +/// change to this crate. +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -122,12 +124,43 @@ impl InboxConfig { } } -/// The replay keys seen for one `(src_zone, epoch)` shard. +/// What one peer block has already delivered. +/// +/// One shard per peer block, holding transaction indices rather than message +/// keys. The shard's own address binds `(src_zone, src_block_id)`, so hashing +/// those into a 32-byte key and storing it back inside that account records +/// nothing the address does not. +/// +/// Indices rather than keys are what make a per-block shard affordable, not +/// free. A shard costs a new account plus a 36-byte header, so at one delivery +/// per peer block it holds more state per message than one shard shared across +/// ten thousand blocks did, and it breaks even around five. What it buys is that +/// a shard cannot saturate: at 32 bytes per delivery, one peer block's own +/// messages could overflow the account, and the guest's only way to say so is a +/// panic that costs the message. #[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct SeenShard(pub BTreeSet); +pub struct SeenShard { + /// Recomputed hash of the peer block this shard records deliveries from. + /// All-zero until the first delivery claims it. + pub src_block_hash: [u8; 32], + /// Indices of that block's transactions already delivered. + pub delivered: BTreeSet, +} impl SeenShard { - /// Decodes a shard from account data; empty data is an empty shard. + /// Deliveries one shard can hold before it exceeds `DATA_MAX_LENGTH`. + /// + /// Borsh is 32 bytes of hash, a 4-byte count, then 4 bytes per index, so + /// this is exactly the 100 KiB an account may carry. + /// + /// What keeps it out of reach is the L1 inscription limit rather than + /// anything a peer configures: a whole block is inscribed as one op, capped + /// near 1.75 MiB, and a minimal emitting transaction is about 257 bytes, so + /// one peer block tops out around 7,100 deliveries. Raising that L1 cap past + /// roughly 6.3 MiB would put this back in reach. + pub const MAX_DELIVERIES: usize = 25_591; + + /// Decodes a shard from account data; empty data is an unclaimed shard. pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { if bytes.is_empty() { return Ok(Self::default()); @@ -140,14 +173,35 @@ impl SeenShard { borsh::to_vec(self).expect("SeenShard serializes") } + /// Whether a delivery from the block with this hash may be recorded here. + /// + /// An unclaimed shard binds to whoever claims it first. Unclaimed is the + /// whole value being default rather than the hash being all zero, so a shard + /// that has recorded anything can never read as unclaimed even if a hash + /// somehow were. #[must_use] - pub fn contains(&self, key: &MessageKey) -> bool { - self.0.contains(key) + pub fn binds(&self, src_block_hash: &[u8; 32]) -> bool { + *self == Self::default() || self.src_block_hash == *src_block_hash } - /// Inserts a key; returns true if it was newly inserted. - pub fn insert(&mut self, key: MessageKey) -> bool { - self.0.insert(key) + #[must_use] + pub fn contains(&self, src_tx_index: u32) -> bool { + self.delivered.contains(&src_tx_index) + } + + /// Binds the shard if unclaimed and records the delivery; returns true if it + /// was newly recorded. + /// + /// A hash the shard does not bind records nothing. The guest asserts + /// [`Self::binds`] before reaching this, so that refusal is a backstop + /// against a later caller rebinding a claimed shard and quietly erasing + /// which peer block delivered what. + pub fn insert(&mut self, src_block_hash: [u8; 32], src_tx_index: u32) -> bool { + if !self.binds(&src_block_hash) { + return false; + } + self.src_block_hash = src_block_hash; + self.delivered.insert(src_tx_index) } } @@ -214,7 +268,7 @@ pub const fn inbox_config_seed() -> PdaSeed { PdaSeed::new(INBOX_CONFIG_SEED) } -/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +/// The seen-set shard for the peer block the message came from. #[must_use] pub fn inbox_seen_shard_account_id( inbox_id: ProgramId, @@ -225,15 +279,17 @@ pub fn inbox_seen_shard_account_id( } /// Seed of the seen-shard PDA, exposed so the guest can claim the account. +/// +/// One shard per peer block, so what it can hold is bounded by that block, and a +/// peer cannot accumulate deliveries into one account across many of them. #[must_use] pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; - let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS); let mut bytes = [0_u8; 72]; bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN); bytes[32..64].copy_from_slice(src_zone); - bytes[64..].copy_from_slice(&src_epoch.to_le_bytes()); + bytes[64..].copy_from_slice(&src_block_id.to_le_bytes()); let seed: [u8; 32] = Impl::hash_bytes(&bytes) .as_bytes() @@ -243,6 +299,8 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { } #[cfg(test)] mod tests { + use lee_core::account::data::DATA_MAX_LENGTH; + use super::*; fn zone(b: u8) -> ZoneId { @@ -315,15 +373,86 @@ mod tests { } #[test] - fn seen_shards_split_on_epoch_boundary() { + fn every_peer_block_gets_its_own_seen_shard() { let id: ProgramId = [9; 8]; assert_eq!( - inbox_seen_shard_account_id(id, &zone(1), 0), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(1), 7), ); assert_ne!( - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(1), 8), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(2), 7), + ); + } + + #[test] + fn a_shard_binds_to_the_first_block_that_claims_it() { + let mut shard = SeenShard::default(); + assert!(shard.binds(&[1; 32]), "an unclaimed shard binds to anyone"); + assert!(shard.binds(&[2; 32])); + + shard.insert([1; 32], 0); + assert!(shard.binds(&[1; 32]), "and to that block thereafter"); + assert!( + !shard.binds(&[2; 32]), + "a second block claiming the same block id cannot share this shard" + ); + } + + #[test] + fn a_shard_records_deliveries_by_transaction_index() { + let mut shard = SeenShard::default(); + assert!(!shard.contains(3)); + assert!(shard.insert([1; 32], 3)); + assert!(shard.contains(3)); + assert!( + !shard.insert([1; 32], 3), + "a replay of the same delivery records nothing new" + ); + assert!(shard.insert([1; 32], 4)); + } + + #[test] + fn an_unclaimed_shard_reads_as_empty_and_round_trips() { + assert_eq!( + SeenShard::from_bytes(&[]).expect("empty data decodes"), + SeenShard::default(), + "an absent account is an unclaimed shard, not a decode failure" + ); + + let mut shard = SeenShard::default(); + shard.insert([5; 32], 1); + shard.insert([5; 32], 9); + assert_eq!( + SeenShard::from_bytes(&shard.to_bytes()).expect("shard decodes"), + shard + ); + } + + #[test] + fn a_full_shard_fits_in_account_data() { + let mut shard = SeenShard::default(); + for index in 0..SeenShard::MAX_DELIVERIES { + shard.insert([5; 32], u32::try_from(index).expect("index fits")); + } + let max = usize::try_from(DATA_MAX_LENGTH.as_u64()).expect("cap fits in usize"); + assert_eq!( + shard.to_bytes().len(), + max, + "MAX_DELIVERIES is exactly what an account can carry" + ); + + shard.insert( + [5; 32], + u32::try_from(SeenShard::MAX_DELIVERIES).expect("index fits"), + ); + assert!( + shard.to_bytes().len() > max, + "and one more does not fit, so the guest would fail rather than truncate" ); } } diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 4dec4478b..292c4eccf 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,6 +1,6 @@ use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, - inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key, + inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, }; use lee_core::{ account::{Account, AccountWithMetadata}, @@ -94,16 +94,31 @@ fn dispatch( "No route from this source program to this target program for this peer" ); - let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); let mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); - let already_seen = shard.contains(&key); + + // One block id, one delivering block. The shard's address binds the zone and + // the block id but not which block claimed them, so an equivocating peer's + // two blocks at one id resolve to this same account. The first to deliver + // binds it to its own hash and the second aborts here, rather than the two + // sharing a replay set while being different blocks. + // + // Before the replay check, not after. A mismatched hash has to fail the + // transaction; reaching the replay branch first would turn a delivery from + // the wrong block into a silent no-op, which is what the indexer's own + // already-seen short circuit would then wave through. + assert!( + shard.binds(&msg.src_block_hash), + "Seen shard is bound to a different peer block at this block id" + ); + + let already_seen = shard.contains(msg.src_tx_index); // On replay this is a no-op: the seen shard is untouched and no call is made. let (seen_post, chained_calls) = if already_seen { (unchanged(&seen), vec![]) } else { - shard.insert(key); + shard.insert(msg.src_block_hash, msg.src_tx_index); let mut seen_account = seen.account.clone(); seen_account.data = shard .to_bytes() diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 159e43e0f..26f0de2c0 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1230,10 +1230,16 @@ fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> boo /// Whether a cross-zone delivery is already on the chain we are building on. /// -/// The inbox records every delivered message key in a seen shard and no-ops a -/// replay, so that shard is the same kind of answer the deposit receipt gives: -/// state, not bookkeeping. An orphan reverts the entry with the block, so the -/// next turn re-delivers with nothing of ours to unwind. +/// The inbox records each peer block's delivered transaction indices in that +/// block's seen shard and no-ops a replay, so the shard is the same kind of +/// answer the deposit receipt gives: state, not bookkeeping. An orphan reverts +/// the entry with the block, so the next turn re-delivers with nothing of ours +/// to unwind. +/// +/// Both halves matter. A shard bound to a different peer block is not this +/// delivery's replay record; it is what will make this delivery abort, and +/// calling that delivered would drop the record instead of retrying it and +/// dead-lettering it where an operator can see it. fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) -> bool { let shard_id = cross_zone_inbox_core::inbox_seen_shard_account_id( programs::cross_zone_inbox().id(), @@ -1242,11 +1248,7 @@ fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) ); state.get_account_by_id_ref(shard_id).is_some_and(|shard| { cross_zone_inbox_core::SeenShard::from_bytes(shard.data.as_ref()).is_ok_and(|seen| { - seen.contains(&cross_zone_inbox_core::message_key( - &message.src_zone, - message.src_block_id, - message.src_tx_index, - )) + seen.binds(&message.src_block_hash) && seen.contains(message.src_tx_index) }) }) } From 69a5aa7899039385dbff086f282e00bf064945f3 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 14:56:55 +0200 Subject: [PATCH 05/10] fix(cross-zone)!: cap a single wrapped-token mint Closes #678. BREAKING CHANGE: `wrapped_token` and `bridge_lock` image ids move, relocating the wrapped-token config and every holding PDA. A lock above the cap is now refused at the source rather than escrowing balance the destination will not mint. --- .../tests/cross_zone_state_machine.rs | 125 +++++++++++------- lez/programs/bridge_lock/src/main.rs | 10 +- lez/programs/wrapped_token/core/src/lib.rs | 18 +++ lez/programs/wrapped_token/src/main.rs | 9 +- 4 files changed, 110 insertions(+), 52 deletions(-) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 94488f409..6d0b64e09 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -97,14 +97,86 @@ fn seed_wrapped_config(state: &mut V03State) { /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone /// payload (risc0 words, little-endian bytes). fn mint_payload() -> Vec { + mint_payload_of(LOCK_AMOUNT) +} + +fn mint_payload_of(amount: u128) -> Vec { let mint = wrapped_token_core::Instruction::Mint { recipient: RECIPIENT, - amount: LOCK_AMOUNT, + amount, }; let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); words.iter().flat_map(|word| word.to_le_bytes()).collect() } +/// Runs a bridge mint of `amount` through the inbox, as the watcher would. +fn dispatch_mint(amount: u128) -> Result { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); + seed_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload_of(amount), + l1_inclusion_witness: None, + }; + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id), + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) +} + +/// One message must not be able to pin a holding near `u128::MAX`, which would +/// make every later honest mint to that recipient overflow and fail for good. +#[test] +fn a_mint_above_the_cap_is_rejected() { + assert!( + dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT + 1).is_err(), + "an amount over the per-mint cap must not execute" + ); +} + +#[test] +fn a_mint_at_the_cap_is_accepted() { + let diff = dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT) + .expect("the cap itself is a legitimate amount"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!(minted, wrapped_token_core::MAX_MINT_AMOUNT); +} + /// Drives `cross_zone_inbox::Dispatch` directly through the state machine /// (no watcher) and asserts the message is delivered to `ping_receiver`, which /// records the payload into its own PDA. @@ -245,54 +317,9 @@ fn lock_escrows_balance_and_emits_to_outbox() { /// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. #[test] fn inbox_dispatch_mints_wrapped_token() { - let inbox_id = programs::cross_zone_inbox().id(); - let wrapped_token_id = programs::wrapped_token().id(); - - let self_zone = [1_u8; 32]; - let src_zone = [2_u8; 32]; - let src_block_id = 5; - - let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, - ); - seed_wrapped_config(&mut state); - - let msg = CrossZoneMessage { - src_zone, - src_block_id, - src_block_hash: SRC_BLOCK_HASH, - src_tx_index: 0, - src_program_id: [9_u32; 8], - target_program_id: wrapped_token_id, - payload: mint_payload(), - l1_inclusion_witness: None, - }; - - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); - let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); - let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - - let message = Message::try_new( - inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], - vec![], - InboxInstruction::Dispatch(msg), - ) - .expect("build dispatch message"); - let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); - - let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) - .expect("dispatch must validate and execute"); + let diff = dispatch_mint(LOCK_AMOUNT).expect("dispatch must validate and execute"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); let minted = wrapped_token_core::read_balance( &diff.public_diff()[&holding_id].data.clone().into_inner(), ); diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index 8b176ee56..58d173516 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -4,7 +4,7 @@ use lee_core::{ account::AccountWithMetadata, program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; -use wrapped_token_core::Instruction as WrappedInstruction; +use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT}; fn main() { let ( @@ -44,6 +44,14 @@ fn main() { mint_amount, amount, "locked amount must equal the wrapped mint amount" ); + // Refused here rather than on the destination, where the mint would fail + // after this side had already escrowed the balance. Nothing releases an + // escrow, so an amount the destination will not mint has to fail before the + // debit, in the submitter's own transaction where they can see it. + assert!( + amount <= MAX_MINT_AMOUNT, + "locked amount exceeds what the wrapped token will mint" + ); // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index a95a5ca0a..8fa525f11 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -8,6 +8,24 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; +/// The most one mint may credit. +/// +/// The amount is chosen on the peer zone and the balance is a `u128`, so without +/// a bound a single delivery can push a holding to within a hair of the maximum. +/// Every honest mint to that recipient then overflows, and an overflow is a guest +/// panic, so each one fails execution and is eventually given up on. The holding +/// is unusable for inbound transfers for good, at a cost to the attacker of one +/// message. +/// +/// A cap does not put the maximum out of reach, it makes reaching it cost 2^64 +/// deliveries rather than one. +/// +/// `u64::MAX` is a bound the bridge imposes rather than one native balances +/// already obey: `Balance` is a `u128` and the faucet is seeded at its maximum, +/// so a larger amount is representable. `bridge_lock` refuses one at the source +/// so it fails in the submitter's transaction rather than after escrowing. +pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; + const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 19095e393..88cb17887 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -3,8 +3,8 @@ use lee_core::{ program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, - minter_bytes, read_balance, read_minter, + Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed, + holding_account_id, holding_seed, minter_bytes, read_balance, read_minter, }; fn main() { @@ -70,6 +70,11 @@ fn mint( "second account must be the recipient holding PDA" ); + assert!( + amount <= MAX_MINT_AMOUNT, + "mint amount exceeds the per-mint cap" + ); + // The backstop against accumulation, which the per-mint cap does not bound. let new_balance = read_balance(&holding.account.data.clone().into_inner()) .checked_add(amount) .expect("wrapped-token balance overflow"); From bb54bd0581bbdc7af443ba3302334a6d4181eaa1 Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 18:30:14 +0200 Subject: [PATCH 06/10] docs: simplify comments --- .../tests/cross_zone_state_machine.rs | 22 ++--- lez/cross_zone/src/lib.rs | 9 +- lez/indexer/core/src/cross_zone_verifier.rs | 46 ++++------ lez/programs/bridge_lock/src/main.rs | 7 +- lez/programs/cross_zone_inbox/core/src/lib.rs | 55 +++++------- lez/programs/cross_zone_inbox/src/main.rs | 13 ++- lez/programs/wrapped_token/core/src/lib.rs | 20 ++--- lez/sequencer/core/metrics/src/record.rs | 4 +- lez/sequencer/core/src/cross_zone_watcher.rs | 5 +- lez/sequencer/core/src/lib.rs | 65 +++++++------- lez/sequencer/core/src/tests.rs | 12 +-- lez/sequencer/service/protocol/src/lib.rs | 12 ++- lez/storage/src/sequencer/mod.rs | 84 ++++++++----------- lez/storage/src/sequencer/sequencer_cells.rs | 40 ++++----- lez/storage/src/sequencer/tests.rs | 12 +-- .../dashboard_gen/src/dashboards/sequencer.rs | 5 +- 16 files changed, 163 insertions(+), 248 deletions(-) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 6d0b64e09..119d09cb2 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -27,8 +27,7 @@ use ping_core::{ReceiverInstruction, ping_record_pda}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; const RECIPIENT: [u8; 32] = [9; 32]; -/// The source block a delivery names. These tests drive the guest directly, so -/// there is no peer block to hash and any fixed value does. +/// These tests drive the guest directly, so any fixed source-block hash does. const SRC_BLOCK_HASH: [u8; 32] = [7; 32]; /// State registering the cross-zone builtins these tests exercise. @@ -540,14 +539,11 @@ fn mint_replay_rejected() { } } -/// A peer that publishes two different blocks claiming one block id gets at most -/// one of them delivered from. +/// A peer publishing two blocks at one block id gets at most one delivered from. /// -/// The shard's address covers the zone and the block id but not which block -/// claimed them, so both resolve to the same account. The first delivery binds -/// it to its own source block and the second cannot execute against it. Failing -/// is the point: were the second merely no-op'd as a replay, a peer could pick -/// which of two messages at one coordinate the target program ever sees. +/// Both resolve to the same shard account; the first binds it. Failing rather +/// than no-opping is the point: a replay no-op would let a peer choose which of +/// two messages at one coordinate the target program ever sees. #[test] fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let inbox_id = programs::cross_zone_inbox().id(); @@ -561,8 +557,7 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let mut state = base_state(); seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); - // The shard as the first delivery left it: bound to SRC_BLOCK_HASH, holding - // that block's transaction 0. + // The shard as the first delivery left it: bound, holding transaction 0. let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let mut shard = SeenShard::default(); shard.insert(SRC_BLOCK_HASH, 0); @@ -613,9 +608,8 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { "a delivery from a block the shard is not bound to must not execute" ); - // The control, so the refusal above is the binding and not the shape of the - // transaction: the same second delivery, differing only in naming the block - // the shard is bound to, executes and is recorded alongside the first. + // Control: the same delivery naming the bound block executes, so the refusal + // above is the binding and not the transaction's shape. let control_words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { payload: b"from-the-bound-block".to_vec(), }) diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 71212f3b8..7ea3fdf0f 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -33,12 +33,11 @@ pub struct Emission { /// Where a delivery came from on the peer chain. /// -/// One struct so the watcher and the verifier fill the same field list. They -/// must produce byte-identical dispatch transactions for the same emission, and -/// a field one side sets differently is exactly how that breaks. +/// One struct so the watcher and the verifier fill the same field list: their +/// dispatch transactions for one emission must be byte-identical. /// -/// `src_block_hash` is the block's recomputed hash on both sides, never the -/// `header.hash` it declares, which its signature does not cover. +/// `src_block_hash` is the recomputed hash on both sides, never the declared +/// `header.hash`, which the signature does not cover. pub struct EmissionSource { pub src_zone: ZoneId, pub src_block_id: u64, diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index d91d0051e..1cc48ee17 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -63,14 +63,9 @@ pub enum CrossZoneVerifyError { }, } -/// What the verifier treats as one delivery for the purpose of skipping -/// re-derivation. -/// -/// The replay key and the source block it came from. The inbox no-ops a replay -/// only when the shard it lands in is bound to that same block, so skipping on -/// the key alone would wave through a dispatch the guest will refuse, and the -/// block would then park and hold ingestion. Both sides have to agree on what a -/// replay is. +/// The replay key plus the source block, which is what the inbox treats as one +/// delivery. Skipping re-derivation on the key alone would wave through a +/// dispatch the guest refuses, parking the block and holding ingestion. type SeenKey = (MessageKey, [u8; 32]); /// One peer zone's cached blocks, plus how far this reader has read them as an @@ -462,10 +457,8 @@ impl CrossZoneVerifier { ))); } - // Recomputed here rather than read from `msg`, which would make the - // field attest to itself. `accept_peer_block` already proved this equals - // the `header.hash` of every cached block, so it costs one hash and the - // fact stays local. + // Recomputed rather than read from `msg`, which would make the field + // attest to itself. Ok(build_dispatch_from_emission( &EmissionSource { src_zone: msg.src_zone, @@ -542,6 +535,13 @@ struct PeerPass { stalled_at: Option, } +fn seen_key(msg: &CrossZoneMessage) -> SeenKey { + ( + message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index), + msg.src_block_hash, + ) +} + /// Whether a block read off a peer's channel may enter the cache. The channel /// authorizes who may write, not what they may claim. /// @@ -550,14 +550,6 @@ struct PeerPass { /// without recomputing it a peer can assert links it never built. The key check /// applies only when one is pinned, mirroring the watcher; it subsumes the hash /// check, but a peer with no pinned key still gets that one. -/// The delivery `msg` identifies, for the seen set. -fn seen_key(msg: &CrossZoneMessage) -> SeenKey { - ( - message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index), - msg.src_block_hash, - ) -} - fn accept_peer_block( block: &Block, peer_zone: ZoneId, @@ -876,10 +868,8 @@ mod tests { let verifier = verifier(); cache_chain(&verifier, peer_chain(b"hi")).await; - // Everything else matches the peer's block, so only the claimed source - // hash is wrong. The verifier recomputes it from the block it resolved - // rather than reading the field, which is what makes this detectable at - // all; trusting the message would make the field attest to itself. + // Only the claimed source hash is wrong. Detectable because the verifier + // recomputes it from the resolved block instead of reading the field. let block = produce_dummy_block(9, None, vec![dispatch_naming_block_hash(b"hi", [0xab; 32])]); assert!( @@ -980,11 +970,9 @@ mod tests { let keys = verifier.verify_block(&first).await.expect("first verifies"); verifier.record_seen(keys).await; - // Same zone, block id and transaction index as the delivery just seen, - // but naming a different source block. The inbox refuses this rather - // than no-opping it, since the shard is bound to the other block, so - // skipping re-derivation here would wave through a dispatch that then - // parks the block and holds ingestion. + // Same coordinates as the delivery just seen, different source block. + // The inbox refuses rather than no-ops it, so skipping re-derivation + // would wave through a dispatch that parks the block. let other = produce_dummy_block( 10, None, diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index 58d173516..ec4ac7b4a 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -44,10 +44,9 @@ fn main() { mint_amount, amount, "locked amount must equal the wrapped mint amount" ); - // Refused here rather than on the destination, where the mint would fail - // after this side had already escrowed the balance. Nothing releases an - // escrow, so an amount the destination will not mint has to fail before the - // debit, in the submitter's own transaction where they can see it. + // Before the debit, not on the destination: nothing releases an escrow, so + // an amount the destination will not mint has to fail in the submitter's own + // transaction. assert!( amount <= MAX_MINT_AMOUNT, "locked amount exceeds what the wrapped token will mint" diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index de3684a65..c0dcf64b2 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -9,11 +9,9 @@ use serde::{Deserialize, Serialize}; const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; -/// Not `/00/`, which keyed a shard by `src_block_id / 10_000`: an epoch and a -/// block id collide under one domain, so a shard carrying the older layout would -/// land at a new address and decode as nonsense. Belt and braces, since the -/// image id is what actually relocates every PDA here, and it moves with any -/// change to this crate. +/// `/01/` because `/00/` keyed shards by epoch: an epoch and a block id are +/// indistinguishable under one domain. Belt and braces, since the image id +/// already relocates every PDA in this crate whenever the crate changes. const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. @@ -126,18 +124,13 @@ impl InboxConfig { /// What one peer block has already delivered. /// -/// One shard per peer block, holding transaction indices rather than message -/// keys. The shard's own address binds `(src_zone, src_block_id)`, so hashing -/// those into a 32-byte key and storing it back inside that account records -/// nothing the address does not. +/// Indices, not message keys: the shard's address already binds +/// `(src_zone, src_block_id)`, so a key stored inside it adds nothing. /// -/// Indices rather than keys are what make a per-block shard affordable, not -/// free. A shard costs a new account plus a 36-byte header, so at one delivery -/// per peer block it holds more state per message than one shard shared across -/// ten thousand blocks did, and it breaks even around five. What it buys is that -/// a shard cannot saturate: at 32 bytes per delivery, one peer block's own -/// messages could overflow the account, and the guest's only way to say so is a -/// panic that costs the message. +/// A shard costs an account plus a 36-byte header and breaks even against a +/// shared shard at about five deliveries. What that buys is saturation +/// resistance: at 32 bytes per delivery one peer block could overflow the +/// account, and the guest's only answer is a panic that costs the message. #[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct SeenShard { /// Recomputed hash of the peer block this shard records deliveries from. @@ -153,11 +146,10 @@ impl SeenShard { /// Borsh is 32 bytes of hash, a 4-byte count, then 4 bytes per index, so /// this is exactly the 100 KiB an account may carry. /// - /// What keeps it out of reach is the L1 inscription limit rather than - /// anything a peer configures: a whole block is inscribed as one op, capped - /// near 1.75 MiB, and a minimal emitting transaction is about 257 bytes, so - /// one peer block tops out around 7,100 deliveries. Raising that L1 cap past - /// roughly 6.3 MiB would put this back in reach. + /// Out of reach only because of the L1 inscription cap: a block inscribes as + /// one op near 1.75 MiB and a minimal emitting transaction is about 257 + /// bytes, capping a peer block near 7,100 deliveries. Raising that L1 cap + /// past roughly 6.3 MiB puts this back in reach. pub const MAX_DELIVERIES: usize = 25_591; /// Decodes a shard from account data; empty data is an unclaimed shard. @@ -175,10 +167,9 @@ impl SeenShard { /// Whether a delivery from the block with this hash may be recorded here. /// - /// An unclaimed shard binds to whoever claims it first. Unclaimed is the - /// whole value being default rather than the hash being all zero, so a shard - /// that has recorded anything can never read as unclaimed even if a hash - /// somehow were. + /// An unclaimed shard binds to its first claimant. Unclaimed is the whole + /// value being default, not the hash being zero, so a shard holding any + /// delivery can never read as unclaimed. #[must_use] pub fn binds(&self, src_block_hash: &[u8; 32]) -> bool { *self == Self::default() || self.src_block_hash == *src_block_hash @@ -189,13 +180,11 @@ impl SeenShard { self.delivered.contains(&src_tx_index) } - /// Binds the shard if unclaimed and records the delivery; returns true if it - /// was newly recorded. + /// Binds the shard if unclaimed and records the delivery; true if new. /// - /// A hash the shard does not bind records nothing. The guest asserts - /// [`Self::binds`] before reaching this, so that refusal is a backstop - /// against a later caller rebinding a claimed shard and quietly erasing - /// which peer block delivered what. + /// A non-binding hash records nothing. The guest already asserts + /// [`Self::binds`], so this is a backstop against a future caller rebinding + /// a claimed shard and erasing which peer block delivered what. pub fn insert(&mut self, src_block_hash: [u8; 32], src_tx_index: u32) -> bool { if !self.binds(&src_block_hash) { return false; @@ -280,8 +269,8 @@ pub fn inbox_seen_shard_account_id( /// Seed of the seen-shard PDA, exposed so the guest can claim the account. /// -/// One shard per peer block, so what it can hold is bounded by that block, and a -/// peer cannot accumulate deliveries into one account across many of them. +/// One shard per peer block, so a peer cannot accumulate deliveries from many +/// blocks into one account. #[must_use] pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 292c4eccf..4d2181c23 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -97,15 +97,12 @@ fn dispatch( let mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); - // One block id, one delivering block. The shard's address binds the zone and - // the block id but not which block claimed them, so an equivocating peer's - // two blocks at one id resolve to this same account. The first to deliver - // binds it to its own hash and the second aborts here, rather than the two - // sharing a replay set while being different blocks. + // One block id, one delivering block. The address binds the zone and block + // id but not which block claimed them, so an equivocating peer's two blocks + // at one id land here; the first binds the shard and the second aborts. // - // Before the replay check, not after. A mismatched hash has to fail the - // transaction; reaching the replay branch first would turn a delivery from - // the wrong block into a silent no-op, which is what the indexer's own + // Before the replay check, not after: reaching the replay branch first would + // turn a wrong-block delivery into a silent no-op, which the indexer's // already-seen short circuit would then wave through. assert!( shard.binds(&msg.src_block_hash), diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index 8fa525f11..2d5e0d775 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -10,20 +10,14 @@ use serde::{Deserialize, Serialize}; /// The most one mint may credit. /// -/// The amount is chosen on the peer zone and the balance is a `u128`, so without -/// a bound a single delivery can push a holding to within a hair of the maximum. -/// Every honest mint to that recipient then overflows, and an overflow is a guest -/// panic, so each one fails execution and is eventually given up on. The holding -/// is unusable for inbound transfers for good, at a cost to the attacker of one -/// message. +/// The peer zone chooses the amount and the balance is a `u128`, so unbounded +/// one delivery pins a holding near the maximum, every later honest mint +/// overflows into a guest panic, and the holding is bricked for inbound +/// transfers at a cost of one message. The cap does not remove that ceiling, it +/// makes reaching it cost 2^64 deliveries instead of one. /// -/// A cap does not put the maximum out of reach, it makes reaching it cost 2^64 -/// deliveries rather than one. -/// -/// `u64::MAX` is a bound the bridge imposes rather than one native balances -/// already obey: `Balance` is a `u128` and the faucet is seeded at its maximum, -/// so a larger amount is representable. `bridge_lock` refuses one at the source -/// so it fails in the submitter's transaction rather than after escrowing. +/// `u64::MAX` is the bridge's bound, not one native balances obey. `bridge_lock` +/// refuses a larger amount at the source so it fails before escrowing. pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; diff --git a/lez/sequencer/core/metrics/src/record.rs b/lez/sequencer/core/metrics/src/record.rs index 39b47a38a..e6f7e8d33 100644 --- a/lez/sequencer/core/metrics/src/record.rs +++ b/lez/sequencer/core/metrics/src/record.rs @@ -180,8 +180,8 @@ pub fn increment_cross_zone_dispatches_retired_total() { cross_zone_dispatches_retired_total_counter().increment(1); } -/// Retained dead letters, which both evict at their cap and drop when a -/// delivery turns out to settle, so this falls as well as rises. +/// Retained dead letters. A gauge, not a counter: eviction and reconciliation +/// make this fall as well as rise. pub fn record_cross_zone_dead_letter_dispatches(count: usize) { gauge!( description: "Given-up-on cross-zone deliveries currently retained for inspection", diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index 3d350a60e..0ddc4b6d3 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1183,9 +1183,8 @@ mod tests { panic!("the recorded transaction is an inbox dispatch"); }; - // The block this delivery came from, which is what the indexer - // recomputes independently when it re-derives the same transaction. - // Naming a different block is what makes the two disagree. + // The indexer recomputes this independently when it re-derives the same + // transaction; a different block here is what makes the two disagree. assert_eq!( msg.src_block_hash, chain_block(1).recompute_hash().0, diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 26f0de2c0..b9269e980 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1082,9 +1082,8 @@ impl SequencerCore { /// A delivery's payload and target accounts are chosen on the peer zone and /// validated by nobody in between, so one can fail for good; but a failure /// can equally be a property of the moment, so give up only after several. - /// Giving up moves the record to the dead letter, which is what keeps a peer - /// from growing the pending list with deliveries that can never execute - /// while still leaving the delivery somewhere an operator can find it. + /// Giving up moves the record to the dead letter: a peer cannot grow the + /// pending list with deliveries that never execute, and it stays findable. fn count_dispatch_failure(&self, tx: &LeeTransaction) { let Some(message) = extract_cross_zone_dispatch(tx) else { return; @@ -1134,12 +1133,11 @@ impl SequencerCore { } } - /// The deliveries this node has given up on, with how many times it has done - /// so, which is the larger number once entries evict or reconcile away. + /// The deliveries this node has given up on, and how many times it has. /// /// Retained is read first so the pair can only skew towards a total that - /// leads its list, which is an ordinary evicted or settled state. The other - /// order would report entries against a total of zero. + /// leads its list, an ordinary evicted or settled state. The other order + /// would report entries against a total of zero. pub fn cross_zone_dead_letters(&self) -> Result<(u64, Vec), DbError> { let dbio = self.store.dbio(); let retained = dbio.get_dead_letter_cross_zone_dispatches()?; @@ -1230,16 +1228,14 @@ fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> boo /// Whether a cross-zone delivery is already on the chain we are building on. /// -/// The inbox records each peer block's delivered transaction indices in that -/// block's seen shard and no-ops a replay, so the shard is the same kind of -/// answer the deposit receipt gives: state, not bookkeeping. An orphan reverts -/// the entry with the block, so the next turn re-delivers with nothing of ours -/// to unwind. +/// The inbox records each peer block's delivered indices in that block's seen +/// shard and no-ops a replay, so the shard is the same kind of answer the +/// deposit receipt gives: state, not bookkeeping. An orphan reverts the entry +/// with the block, so the next turn re-delivers with nothing to unwind. /// /// Both halves matter. A shard bound to a different peer block is not this -/// delivery's replay record; it is what will make this delivery abort, and -/// calling that delivered would drop the record instead of retrying it and -/// dead-lettering it where an operator can see it. +/// delivery's replay record, it is what will make it abort, and calling that +/// delivered would drop the record instead of dead-lettering it. fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) -> bool { let shard_id = cross_zone_inbox_core::inbox_seen_shard_account_id( programs::cross_zone_inbox().id(), @@ -1253,6 +1249,22 @@ fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) }) } +/// Publishes how many given-up-on deliveries are retained. +/// +/// Read from the store because the list falls as well as rises (eviction, and +/// reconciliation when a delivery settles elsewhere). Costs a read and a decode, +/// so call it only where one of those can have happened. +fn record_dead_letter_gauge(dbio: &RocksDBIO) { + match dbio.get_dead_letter_cross_zone_dispatches() { + Ok(records) => { + sequencer_core_metrics::record_cross_zone_dead_letter_dispatches(records.len()); + } + Err(err) => { + warn!("Failed to read the cross-zone dead letter for its gauge: {err:#}"); + } + } +} + /// Feed one channel delta into the follow state and mirror it to the store: /// revert orphaned, then apply and persist adopted and finalized blocks. /// Production builds on this same head. Wired to the publisher via @@ -1265,24 +1277,6 @@ fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) /// relies on a valid successor or a restart. `ChainState` never emits /// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a /// follow-up. -/// Publishes how many given-up-on deliveries are retained. -/// -/// Read from the store rather than tracked in memory because the list falls as -/// well as rises: it evicts at its cap, and an entry is dropped when its -/// delivery turns out to settle on a block another sequencer produced. Called -/// only where one of those can have happened, since it costs a read and a -/// decode. -fn record_dead_letter_gauge(dbio: &RocksDBIO) { - match dbio.get_dead_letter_cross_zone_dispatches() { - Ok(records) => { - sequencer_core_metrics::record_cross_zone_dead_letter_dispatches(records.len()); - } - Err(err) => { - warn!("Failed to read the cross-zone dead letter for its gauge: {err:#}"); - } - } -} - fn apply_follow_update( dbio: &RocksDBIO, chain: &Mutex, @@ -1461,9 +1455,8 @@ fn apply_follow_update( }; sequencer_core_metrics::record_chain_height(head_height); - // This is the runtime path that reconciles: a delivery this node gave up on - // reaches a block another sequencer produced, and finalizing that block - // drops its dead letter. + // The runtime reconcile path: finalizing another sequencer's block drops the + // dead letter of a delivery this node gave up on. record_dead_letter_gauge(dbio); if outcome.accepted_deposits > 0 { diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index ac8f16d93..751053f85 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -221,10 +221,8 @@ fn dispatch_tx(src_block_id: u64, payload: Vec) -> LeeTransaction { )) } -/// A stand-in for the peer block's recomputed hash, distinct per block id. -/// -/// These records are seeded straight into the store rather than read off a peer -/// channel, so no real block exists to hash. Only its consistency matters. +/// A stand-in for the peer block's recomputed hash, distinct per block id. These +/// records are seeded into the store, so no real block exists to hash. fn peer_block_hash(src_block_id: u64) -> [u8; 32] { let mut hash = [0_u8; 32]; hash[..8].copy_from_slice(&src_block_id.to_le_bytes()); @@ -720,10 +718,8 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() "giving up on a delivery must take its record out of the pending list" ); - // A dispatch that fails execution is left out of the block, so the dead - // letter is the only place recording that this happened at all. The origin - // is the point of the record: it is what identifies which message stopped - // being attempted, and this is the only place that builds one. + // The dead letter is the only record that this happened, and the origin is + // what identifies which message stopped being attempted. let dbio = sequencer.store.dbio(); let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); assert_eq!(dead_letters.len(), 1); diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index e608f81aa..37f70415b 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -11,11 +11,10 @@ use serde_with::{DeserializeFromStr, SerializeDisplay}; #[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)] pub struct ChannelId(pub [u8; 32]); -/// A cross-zone delivery a sequencer gave up on after repeated execution -/// failures. +/// A cross-zone delivery a sequencer gave up on after repeated failures. /// -/// Identifies the message rather than carrying it: the peer zone, block id and -/// transaction index are what locate it on the peer's channel. +/// Identifies the message rather than carrying it: zone, block id and tx index +/// locate it on the peer's channel. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CrossZoneDeadLetter { pub message_key: HashType, @@ -28,9 +27,8 @@ pub struct CrossZoneDeadLetter { /// What a sequencer has given up delivering. /// -/// `total_retired` counts every give-up; `retained` holds the ones still kept. -/// The two differ once entries evict at the cap, or once a delivery this node -/// abandoned settles on a block another sequencer produced. +/// `total_retired` counts every give-up, `retained` only the ones still kept; +/// they diverge on eviction and on reconciliation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CrossZoneDeadLetterReport { pub total_retired: u64, diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index c6a2d66a7..90f074145 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -83,16 +83,13 @@ pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096; /// How many given-up-on cross-zone deliveries are kept for inspection. /// -/// Retaining them is the point, but a peer chooses how many deliveries fail, so -/// this list cannot be unbounded any more than the pending one can. At the cap -/// the oldest is dropped, which keeps the entries an operator reaching for this -/// after an alert actually wants. Nothing is concealed by that: every -/// retirement is counted separately and that count does not evict. +/// A peer chooses how many deliveries fail, so this cannot be unbounded. The +/// oldest evicts at the cap, and nothing is concealed by that: retirements are +/// counted separately and the count does not evict. /// -/// A count is a real bound here only because a record identifies a delivery -/// instead of carrying it. Each is a fixed 84 bytes, so the whole list is 21 KB -/// at the cap, bounded in bytes as well as in entries. That matters because it -/// is one value rewritten under the lock that block production needs. +/// An entry count bounds bytes only because a record identifies a delivery +/// rather than carrying it. At a fixed 84 bytes each the list is 21 KB, which +/// matters because it is one value rewritten under the block-production lock. pub const MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES: usize = 256; /// Key base for storing the LEE state. @@ -107,10 +104,8 @@ pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; /// What counting a failed production attempt did to a delivery's record. /// -/// Three outcomes rather than a bool because the caller reports each -/// differently and only one of them means this node stopped trying. A delivery -/// that has already settled has no pending record, so it is [`Self::Absent`] -/// rather than a give-up. +/// Three outcomes rather than a bool: only one means this node stopped trying, +/// and a settled delivery has no record, so it is [`Self::Absent`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DispatchFailure { /// Counted; the delivery is still pending and will be attempted again. @@ -783,18 +778,14 @@ impl RocksDBIO { /// Counts a failed production attempt against a delivery, retiring it once /// it reaches `retire_at`. /// - /// Retiring moves the record out of the pending list and into the dead - /// letter. The pending list has to lose it, or the drain would re-feed a - /// transaction that never executes for ever; the dead letter is what keeps - /// the delivery identifiable, since a dispatch that fails execution is left - /// out of the block and so leaves no trace anywhere else. It is bounded on - /// its own terms, so a peer that can make deliveries fail still cannot grow - /// the store without limit. + /// The pending list has to lose the record, or the drain re-feeds a + /// transaction that never executes for ever. The dead letter keeps the + /// delivery identifiable, since a dispatch that fails execution is left out + /// of the block and leaves no trace elsewhere, and it is bounded separately. /// - /// A delivery with no pending record is reported as [`DispatchFailure::Absent`] - /// rather than as a retirement: there is nothing to count against, and the - /// two are different events. It is the ordinary shape of a delivery that - /// settled and then failed to execute on a later attempt. + /// No pending record gives [`DispatchFailure::Absent`], not a retirement: + /// the ordinary shape of a delivery that settled and then failed a later + /// attempt. pub fn record_dispatch_failure( &self, message_key: [u8; 32], @@ -828,12 +819,10 @@ impl RocksDBIO { transaction_bytes: u32::try_from(retired.transaction.len()).unwrap_or(u32::MAX), }; - // One entry per delivery, not per retirement. The same delivery can be - // recorded again after its record is gone, since a watcher rebuilding a - // peer tip re-reads that channel from the peer's genesis and a delivery - // that never executes never reaches the inbox seen-set to be recognised - // as delivered. Without this, one message that always fails would fill - // the list with copies of itself and evict every other one. + // One entry per delivery, not per retirement. A watcher rebuilding a + // peer tip re-reads from the peer's genesis, and a never-executing + // delivery never reaches the seen-set, so the same one retires again; + // undeduped it would evict every other entry with copies of itself. let mut dead_letters = self.get_dead_letter_cross_zone_dispatches()?; if !dead_letters .iter() @@ -844,17 +833,15 @@ impl RocksDBIO { dead_letters.remove(0); } } - // Counted per retirement even so: it measures how often this node gave - // up, which the retained list cannot, since that both evicts and drops - // entries whose delivery later settles. + // Counted per retirement even so: the retained list evicts and drops + // settled entries, so its length is not how often this node gave up. let count = self .get_dead_letter_cross_zone_dispatch_count()? .saturating_add(1); - // One batch: the record leaving the pending list and arriving in the - // dead letter is one event, and a crash between the two halves would - // either lose the message silently or leave the drain retrying a - // delivery already recorded as given up on. + // One batch: a crash between the two halves either loses the message + // silently or leaves the drain retrying a delivery already recorded as + // given up on. let mut batch = WriteBatch::default(); self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; self.put_batch( @@ -907,10 +894,8 @@ impl RocksDBIO { records.retain(|record| !to_remove.contains(&record.message_key)); let removed = before.saturating_sub(records.len()); - // Both lists in one batch, for the same reason the retire path batches: - // a crash between them leaves the pending record gone and a dead letter - // behind saying the delivery was abandoned, and nothing recomputes these - // keys on a later pass to correct it. + // Both lists in one batch, as in `record_dispatch_failure`: nothing + // recomputes these keys on a later pass to fix a torn write. let mut batch = WriteBatch::default(); if removed > 0 { self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; @@ -929,14 +914,12 @@ impl RocksDBIO { /// Stages the removal of dead letters whose delivery turned out to settle. /// - /// Giving up is this node's decision and every sequencer makes it alone, - /// against its own head and its own mempool ordering, so a delivery this one - /// stopped attempting can still reach a block another one produced. Left - /// alone, the entry would report that delivery as abandoned for as long as - /// the store lives, and nothing else removes one. + /// Every sequencer gives up alone, against its own head, so a delivery this + /// one abandoned can still reach another's block. Nothing else removes an + /// entry, so without this it reports as abandoned for the store's lifetime. /// - /// The count is deliberately not decremented. It records how often this node - /// gave up, which stays true whatever happened next. + /// The count is deliberately not decremented: it records how often this node + /// gave up, which stays true. fn stage_reconciled_dead_letters( &self, settled: &std::collections::HashSet<&[u8; 32]>, @@ -983,9 +966,8 @@ impl RocksDBIO { self.put_pending_cross_zone_dispatches_batch(&records, batch)?; } - // A settled delivery this node had given up on is not one it abandoned, - // and this is the path that catches the ordinary case: another sequencer - // carries it into a block that then becomes irreversible. + // The ordinary case: another sequencer carried a delivery this node gave + // up on into a block that just became irreversible. self.stage_reconciled_dead_letters(&to_remove, batch)?; Ok(removed) } diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 7b066f2b3..8cbfe012d 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -297,9 +297,8 @@ pub struct PendingCrossZoneDispatchRecord { /// validated by nobody in between, so one can fail for good. A failure can /// equally be a property of the moment, so a single one is not enough to /// give up on a delivery. Once too many accumulate the record leaves this - /// list, which the drain re-feeds every turn, and a - /// [`DeadLetterDispatchRecord`] is kept in its place so the delivery this - /// node stopped attempting is still identifiable. + /// list (the drain re-feeds it every turn) for a + /// [`DeadLetterDispatchRecord`], which keeps the delivery identifiable. pub failed_attempts: u32, } @@ -361,29 +360,25 @@ pub struct DispatchOrigin { /// A cross-zone delivery this node has given up on. /// -/// A dispatch that fails execution is left out of the block, so unlike every -/// other failure in the pipeline this one leaves no on-chain trace that it was -/// ever attempted. This record is what makes it observable rather than a log -/// line that scrolls away. +/// A dispatch that fails execution is left out of the block, so nothing on chain +/// records that it was attempted; this is the only durable trace. /// -/// It identifies the message rather than carrying it. The peer block and -/// transaction index are enough to read the message back off the peer channel, -/// and the encoded transaction is chosen by the peer zone and can exceed this -/// node's whole block size limit, so retaining it would bound the list in -/// entries while leaving it unbounded in bytes. +/// It identifies the message rather than carrying it: the peer block and index +/// are enough to read it back off the channel, and the encoded transaction is +/// peer-chosen and can exceed a whole block, which would leave the list bounded +/// in entries but unbounded in bytes. /// -/// Giving up is this node's decision, not the network's: another sequencer may -/// carry the same delivery successfully, and a record here is dropped again if -/// that happens. +/// Giving up is this node's decision, not the network's, so an entry is dropped +/// again if another sequencer carries the same delivery. #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct DeadLetterDispatchRecord { pub message_key: [u8; 32], pub origin: DispatchOrigin, - /// Attempts made before giving up, recording the policy that was in force - /// rather than distinguishing one retirement from another. + /// Attempts made before giving up, so the record carries the policy that was + /// in force at the time. pub failed_attempts: u32, - /// Size of the delivery transaction that would not execute, which is the - /// diagnostic for the one failure mode that is about size. + /// Size of the delivery transaction that would not execute, the diagnostic + /// for size-related failures. pub transaction_bytes: u32, } @@ -422,10 +417,9 @@ impl SimpleWritableCell for DeadLetterCrossZoneDispatchesCellRef<'_> { /// Deliveries given up on since this store was created. /// -/// Counted separately from the retained list because that list both evicts at -/// its cap and drops entries whose delivery later settles, so its length is not -/// how many times this node has given up. A node that gave up hundreds of times -/// would otherwise look like one that gave up at the cap. +/// Separate from the retained list, which evicts at its cap and drops settled +/// entries: a node that gave up hundreds of times would otherwise look like one +/// that gave up at the cap. #[derive(BorshSerialize, BorshDeserialize)] pub struct DeadLetterCrossZoneDispatchCountCell(pub u64); diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 900ca860e..2d4d2ed34 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -719,10 +719,7 @@ fn a_dead_letter_is_dropped_once_its_delivery_settles_elsewhere() { 1 ); - // Every sequencer decides to give up alone, against its own head, so a - // delivery this node stopped attempting can still reach a block another one - // produced. Reporting it as abandoned for ever afterwards is the failure - // this guards against. + // A delivery this node gave up on can still reach another sequencer's block. dbio.drop_settled_cross_zone_dispatches(&[key]).unwrap(); assert!( dbio.get_dead_letter_cross_zone_dispatches() @@ -775,11 +772,8 @@ fn one_delivery_that_always_fails_takes_one_dead_letter_slot() { let temp_dir = tempdir().unwrap(); let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); - // A watcher rebuilding a peer tip re-reads that channel from the peer's - // genesis, and a delivery that never executes never reaches the inbox - // seen-set to be recognised as delivered, so the same one is recorded and - // retired again. Without dedupe it would fill the list with copies of itself - // and evict every other message that was given up on. + // A watcher rebuilding a peer tip re-reads from genesis, so the same + // never-executing delivery retires repeatedly (see `record_dispatch_failure`). let key = key_from_index(1); let other = key_from_index(2); dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( diff --git a/tools/dashboard_gen/src/dashboards/sequencer.rs b/tools/dashboard_gen/src/dashboards/sequencer.rs index 2098e876d..b75f120d3 100644 --- a/tools/dashboard_gen/src/dashboards/sequencer.rs +++ b/tools/dashboard_gen/src/dashboards/sequencer.rs @@ -195,9 +195,8 @@ pub fn dashboard() -> Dashboard { .row( 7, [ - // A dispatch that fails execution is left out of the block, so - // nothing on chain records that a delivery was abandoned. These - // are the only signal that it happened. + // A failed dispatch is left out of the block, so nothing on chain + // records it. These panels are the only signal. Panel::stat("Cross-zone deliveries given up on since startup") .width(6) .unit(Unit::Short) From abc1a57e1400e2d8dad975acd65bdc8ff233b32d Mon Sep 17 00:00:00 2001 From: moudyellaz Date: Fri, 7 Aug 2026 22:23:59 +0200 Subject: [PATCH 07/10] chore: regenerate artifacts and the prebuilt sequencer fixture --- artifacts/lez/programs/bridge_lock.bin | Bin 416100 -> 416560 bytes artifacts/lez/programs/cross_zone_inbox.bin | Bin 497252 -> 493452 bytes artifacts/lez/programs/wrapped_token.bin | Bin 406008 -> 406408 bytes .../fixtures/prebuilt_sequencer_db.dump | Bin 686986 -> 687140 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index 6dda67f01709879050edb3a871689660effb95c7..e55654b98428c0558a9fa7b108845522a4d12d65 100644 GIT binary patch delta 93158 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