diff --git a/lez/chain_state/src/chain.rs b/lez/chain_state/src/chain.rs index c96bacc26..fabf82528 100644 --- a/lez/chain_state/src/chain.rs +++ b/lez/chain_state/src/chain.rs @@ -14,12 +14,6 @@ use crate::{ apply::{Tip, apply_block}, }; -/// A head block plus the channel message that carried it. -pub struct HeadEntry { - pub this_msg: MsgId, - pub block: Block, -} - /// The head tier (reorg-able, from `adopted`/`orphaned`) over the final tier /// (irreversible, from `finalized`). /// @@ -33,7 +27,13 @@ pub struct ChainState { final_stall: Option, head_state: Arc, - head_blocks: Vec, + head_blocks: Vec, + + /// The channel tip as of the last processed sdk snapshot (a follow + /// update's checkpoint, or our own publish), block or not: an ignorable + /// inscription (garbage, an invalid block, a config op) moves the channel + /// tip without moving the head, and the next publish must chain on it. + channel_cursor: Option, } impl ChainState { @@ -53,6 +53,7 @@ impl ChainState { final_tip, head_blocks: Vec::new(), final_stall: None, + channel_cursor: None, } } @@ -93,10 +94,27 @@ impl ChainState { pub fn head_tip(&self) -> Option { self.head_blocks .last() - .map(|entry| Tip::from(&entry.block)) + .map(Tip::from) .or_else(|| self.final_tip.clone()) } + /// Parent the next inscription must be pinned on. The cursor alone: a + /// restored head block carries no `MsgId`, so the head is not a fallback. + #[must_use] + pub const fn pin_parent(&self) -> Option { + self.channel_cursor + } + + #[must_use] + pub const fn channel_cursor(&self) -> Option { + self.channel_cursor + } + + /// Moves the cursor to `msg`; callers pass inscriptions in channel order. + pub const fn set_channel_cursor(&mut self, msg: MsgId) { + self.channel_cursor = Some(msg); + } + #[must_use] pub fn final_tip(&self) -> Option { self.final_tip.clone() @@ -107,14 +125,13 @@ impl ChainState { self.final_stall.as_ref() } - /// Position of a head entry, matched by `MsgId` or block hash (restored - /// entries carry sentinel `MsgId`s; re-inscriptions arrive under fresh ones) - /// — always at the same claimed height: a hash or `MsgId` collision with a - /// different `block_id` is malformed and must fall through to validation. - fn head_position_of(&self, this_msg: MsgId, block: &Block) -> Option { - self.head_blocks.iter().position(|entry| { - entry.block.header.block_id == block.header.block_id - && (entry.this_msg == this_msg || entry.block.header.hash == block.header.hash) + /// Position of a head entry, matched by block hash at the same claimed + /// height: a hash collision with a different `block_id` is malformed and + /// must fall through to validation. Channel `MsgId`s are not part of the + /// match — a re-inscription changes the id but never the hash. + fn head_position_of(&self, block: &Block) -> Option { + self.head_blocks.iter().position(|held| { + held.header.block_id == block.header.block_id && held.header.hash == block.header.hash }) } @@ -125,8 +142,8 @@ impl ChainState { /// no orphan event required. /// /// On failure the head stays unchanged and no stall is recorded. - pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { - if self.head_position_of(this_msg, block).is_some() { + pub fn apply_adopted(&mut self, block: &Block) -> AcceptOutcome { + if self.head_position_of(block).is_some() { return AcceptOutcome::AlreadyApplied; } @@ -153,7 +170,7 @@ impl ChainState { let reorg_at = self .head_blocks .iter() - .position(|entry| entry.block.header.block_id >= block.header.block_id); + .position(|held| held.header.block_id >= block.header.block_id); let (mut scratch, tip) = match reorg_at { // continue from the tip None => (Arc::clone(&self.head_state), self.head_tip()), @@ -168,10 +185,7 @@ impl ChainState { self.head_blocks.truncate(idx); } self.head_state = scratch; - self.head_blocks.push(HeadEntry { - this_msg, - block: block.to_owned(), - }); + self.head_blocks.push(block.to_owned()); AcceptOutcome::Applied } Err(err) => AcceptOutcome::Parked(err), @@ -184,19 +198,19 @@ impl ChainState { /// the channel yet, so it may only *extend* the head. A head already at /// (or past) this height means a peer's block won the race on the /// channel — ours is stale and the caller drops it. - pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + pub fn apply_produced(&mut self, block: &Block) -> AcceptOutcome { if self .head_tip() .is_some_and(|tip| block.header.block_id <= tip.block_id) { return AcceptOutcome::AlreadyApplied; } - self.apply_adopted(this_msg, block) + self.apply_adopted(block) } /// Reverts an orphaned head block and everything after it, then re-derives head. - pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) { - if let Some(idx) = self.head_position_of(this_msg, block) { + pub fn revert_orphan(&mut self, block: &Block) { + if let Some(idx) = self.head_position_of(block) { self.head_blocks.truncate(idx); self.rederive_head(); } @@ -206,12 +220,12 @@ impl ChainState { /// then apply every `adopted` in order. Outcomes align with `adopted`. pub fn apply_channel_update( &mut self, - orphaned: &[(MsgId, Block)], - adopted: &[(MsgId, Block)], + orphaned: &[Block], + adopted: &[Block], ) -> Vec { let earliest = orphaned .iter() - .filter_map(|(msg, block)| self.head_position_of(*msg, block)) + .filter_map(|block| self.head_position_of(block)) .min(); if let Some(idx) = earliest { self.head_blocks.truncate(idx); @@ -219,38 +233,27 @@ impl ChainState { } adopted .iter() - .map(|(msg, block)| self.apply_adopted(*msg, block)) + .map(|block| self.apply_adopted(block)) .collect() } /// Rebuilds one head entry from a persisted block, applying it in place (the /// caller treats `Err` as fatal). - /// - /// The entry gets a hash-derived sentinel `MsgId` (the real one is not - /// persisted — that would need a sidecar `block_id -> MsgId` cell); later - /// orphan/finalize events correlate by block hash. pub fn restore_head_block(&mut self, block: Block) -> Result<(), BlockIngestError> { apply_block( self.head_tip().as_ref(), &block, Arc::make_mut(&mut self.head_state), )?; - let this_msg = MsgId::from(block.header.hash.0); - self.head_blocks.push(HeadEntry { this_msg, block }); + self.head_blocks.push(block); Ok(()) } /// A finalized inscription. In steady state the block is already in head and is /// moved into `final`; on backfill (not in head) it is applied directly and may /// set `final_stall`. - pub fn apply_finalized( - &mut self, - this_msg: MsgId, - block: &Block, - l1_slot: Slot, - ) -> AcceptOutcome { - // Match by `MsgId` or block hash (re-inscriptions, restored entries). - if let Some(idx) = self.head_position_of(this_msg, block) { + pub fn apply_finalized(&mut self, block: &Block, l1_slot: Slot) -> AcceptOutcome { + if let Some(idx) = self.head_position_of(block) { self.finalize_through(idx); return AcceptOutcome::Applied; } @@ -260,7 +263,7 @@ impl ChainState { if let Some(idx) = self .head_blocks .iter() - .position(|entry| entry.block.header.hash == block.header.prev_block_hash) + .position(|held| held.header.hash == block.header.prev_block_hash) { self.finalize_through(idx); } @@ -269,15 +272,15 @@ impl ChainState { /// Moves `head_blocks[0..=idx]` into the final tier (already validated in head). fn finalize_through(&mut self, idx: usize) { - let finalized: Vec = self.head_blocks.drain(0..=idx).collect(); - for entry in finalized { + let finalized: Vec = self.head_blocks.drain(0..=idx).collect(); + for block in finalized { apply_block( self.final_tip.as_ref(), - &entry.block, + &block, Arc::make_mut(&mut self.final_state), ) .expect("validated head block must apply to the final tier"); - self.final_tip = Some(Tip::from(&entry.block)); + self.final_tip = Some(Tip::from(&block)); } self.final_stall = None; } @@ -325,10 +328,10 @@ impl ChainState { fn replay_head_prefix(&self, count: usize) -> (Arc, Option) { let mut state = Arc::clone(&self.final_state); let mut tip = self.final_tip.clone(); - for entry in &self.head_blocks[..count] { - apply_block(tip.as_ref(), &entry.block, Arc::make_mut(&mut state)) + for block in &self.head_blocks[..count] { + apply_block(tip.as_ref(), block, Arc::make_mut(&mut state)) .expect("validated head blocks must replay"); - tip = Some(Tip::from(&entry.block)); + tip = Some(Tip::from(block)); } (state, tip) } @@ -364,10 +367,10 @@ mod tests { fn assert_head_matches_replay(chain: &ChainState) { let mut state = Arc::clone(&chain.final_state); let mut tip = chain.final_tip.clone(); - for entry in &chain.head_blocks { - apply_block(tip.as_ref(), &entry.block, Arc::make_mut(&mut state)) + for block in &chain.head_blocks { + apply_block(tip.as_ref(), block, Arc::make_mut(&mut state)) .expect("head blocks must replay"); - tip = Some(Tip::from(&entry.block)); + tip = Some(Tip::from(block)); } assert_eq!( borsh::to_vec(state.as_ref()).expect("state serializes"), @@ -382,12 +385,12 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); assert!(matches!( - chain.apply_adopted(msg(1), &genesis), + chain.apply_adopted(&genesis), AcceptOutcome::Applied )); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(2), &block2), + chain.apply_adopted(&block2), AcceptOutcome::Applied )); @@ -400,12 +403,12 @@ mod tests { fn adopted_bad_block_freezes_head_without_stall() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); // Skips ahead (id 3 while head tip is 1). let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(3), &bad), + chain.apply_adopted(&bad), AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { expected: 2, got: 3 @@ -422,10 +425,10 @@ mod tests { fn adopted_is_idempotent() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); assert!(matches!( - chain.apply_adopted(msg(1), &genesis), + chain.apply_adopted(&genesis), AcceptOutcome::AlreadyApplied )); assert_eq!(chain.head_tip().expect("head tip").block_id, 1); @@ -437,17 +440,17 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); + chain.apply_adopted(&block3); - chain.revert_orphan(msg(3), &block3); + chain.revert_orphan(&block3); assert_eq!(chain.head_tip().expect("head tip").block_id, 2); // A competing block 3 now applies cleanly on block 2. let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(13), &block3_prime), + chain.apply_adopted(&block3_prime), AcceptOutcome::Applied )); assert_eq!(chain.head_tip().expect("head tip").block_id, 3); @@ -459,12 +462,12 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); + chain.apply_adopted(&block3); let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); - let outcomes = chain.apply_channel_update(&[(msg(3), block3)], &[(msg(13), block3_prime)]); + let outcomes = chain.apply_channel_update(&[block3], &[block3_prime]); assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); assert_eq!(chain.head_tip().expect("head tip").block_id, 3); } @@ -475,13 +478,13 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); + chain.apply_adopted(&block3); // Finalize through block 2. assert!(matches!( - chain.apply_finalized(msg(2), &block2, slot(100)), + chain.apply_finalized(&block2, slot(100)), AcceptOutcome::Applied )); assert_eq!(chain.final_tip().expect("final tip").block_id, 2); @@ -494,7 +497,7 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); assert!(matches!( - chain.apply_finalized(msg(1), &genesis, slot(10)), + chain.apply_finalized(&genesis, slot(10)), AcceptOutcome::Applied )); assert_eq!(chain.final_tip().expect("final tip").block_id, 1); @@ -506,12 +509,12 @@ mod tests { fn invalid_finalized_block_sets_final_stall() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(&genesis, slot(10)); // Skip-ahead finalized block, not in head: parks the final tier. let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_finalized(msg(3), &bad, slot(20)), + chain.apply_finalized(&bad, slot(20)), AcceptOutcome::Parked(_) )); let stall = chain.final_stall().expect("final stall recorded"); @@ -527,20 +530,20 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&block3); let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); - chain.apply_adopted(msg(4), &block4); + chain.apply_adopted(&block4); // Orphaning block 3 drops the whole suffix (3 and 4). - chain.revert_orphan(msg(3), &block3); + chain.revert_orphan(&block3); assert_eq!(chain.head_tip().expect("head tip").block_id, 2); assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); @@ -557,17 +560,17 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&block3); let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); - chain.apply_adopted(msg(4), &block4); + chain.apply_adopted(&block4); // A competing branch replaces blocks 3 and 4; orphans arrive unordered. let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 20, &sign_key); @@ -575,10 +578,7 @@ mod tests { let tx4_prime = create_transaction_native_token_transfer(from, 2, to, 30, &sign_key); let block4_prime = produce_dummy_block(4, Some(block3_prime.header.hash), vec![tx4_prime]); - let outcomes = chain.apply_channel_update( - &[(msg(4), block4), (msg(3), block3)], - &[(msg(13), block3_prime), (msg(14), block4_prime)], - ); + let outcomes = chain.apply_channel_update(&[block4, block3], &[block3_prime, block4_prime]); assert!(matches!( outcomes.as_slice(), @@ -599,13 +599,13 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&block3); // The replacement branch arrives with no orphan events: the adopted // list alone reorgs the head. @@ -614,8 +614,7 @@ mod tests { let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 30, &sign_key); let block3_prime = produce_dummy_block(3, Some(block2_prime.header.hash), vec![tx3_prime]); - let outcomes = - chain.apply_channel_update(&[], &[(msg(12), block2_prime), (msg(13), block3_prime)]); + let outcomes = chain.apply_channel_update(&[], &[block2_prime, block3_prime]); assert!(matches!( outcomes.as_slice(), @@ -631,12 +630,12 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); let unknown = produce_dummy_block(9, Some(HashType([7; 32])), vec![]); - let outcomes = chain.apply_channel_update(&[(msg(99), unknown)], &[(msg(3), block3)]); + let outcomes = chain.apply_channel_update(&[unknown], &[block3]); assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); assert_eq!(chain.head_tip().expect("head tip").block_id, 3); @@ -652,19 +651,19 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&block3); // A valid competitor at height 2, no orphan events: the head reorgs // back onto it, dropping the old 2..=3 suffix and its transfers. let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(12), &block2_prime), + chain.apply_adopted(&block2_prime), AcceptOutcome::Applied )); let tip = chain.head_tip().expect("head tip"); @@ -683,18 +682,18 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); // A peer's block wins height 2 on the channel. let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_adopted(msg(2), &peer); + chain.apply_adopted(&peer); // Our own block at that height is not on the channel, so — unlike an // adopted competitor — it must not reorg the head onto itself. let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); assert!(matches!( - chain.apply_produced(msg(12), &ours), + chain.apply_produced(&ours), AcceptOutcome::AlreadyApplied )); assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash); @@ -706,11 +705,11 @@ mod tests { fn produced_block_extending_the_head_applies() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_produced(msg(2), &ours), + chain.apply_produced(&ours), AcceptOutcome::Applied )); assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash); @@ -723,15 +722,15 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); + chain.apply_adopted(&block3); // A competitor at height 2 with a bogus parent parks; the truncation // is not committed, so the 2..=3 suffix survives. let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); assert!(matches!( - chain.apply_adopted(msg(12), &bad), + chain.apply_adopted(&bad), AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) )); assert_eq!(chain.head_tip().expect("head tip").block_id, 3); @@ -743,14 +742,14 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); - chain.apply_finalized(msg(2), &block2, slot(20)); + chain.apply_finalized(&genesis, slot(10)); + chain.apply_finalized(&block2, slot(20)); // Finalized is irreversible: an adopted competitor at (or below) the // final tip is ignored, not reorged onto. let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(12), &block2_prime), + chain.apply_adopted(&block2_prime), AcceptOutcome::AlreadyApplied )); assert_eq!( @@ -761,6 +760,25 @@ mod tests { assert_head_matches_replay(&chain); } + /// The pin parent is the cursor alone: a head block never stands in for + /// it, so a restored placeholder id can never reach a publish. + #[test] + fn pin_parent_follows_the_cursor_and_never_the_head() { + let mut chain = ChainState::new(initial_state()); + assert_eq!(chain.pin_parent(), None); + + let block1 = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_adopted(&block1), + AcceptOutcome::Applied + )); + assert_eq!(chain.pin_parent(), None, "the head is not a pin source"); + + // Garbage moved the channel tip; the head stays, the pin follows. + chain.set_channel_cursor(msg(9)); + assert_eq!(chain.pin_parent(), Some(msg(9))); + } + #[test] fn restore_head_block_rebuilds_head_and_correlates_by_hash() { let accounts = initial_pub_accounts_private_keys(); @@ -769,7 +787,7 @@ mod tests { let sign_key = accounts[0].pub_sign_key.clone(); // Restart shape: final tier from a persisted snapshot, head rebuilt from - // stored blocks under hash-derived sentinel MsgIds. + // stored blocks with no MsgIds. let mut state = initial_state(); let genesis = produce_dummy_block(1, None, vec![]); apply_block(None, &genesis, &mut state).expect("genesis applies"); @@ -789,12 +807,12 @@ mod tests { // The L1 orphans restored block 3 under its real (unknown-to-us) MsgId: // correlated by hash, the revert works and a competitor applies. - chain.revert_orphan(msg(33), &block3); + chain.revert_orphan(&block3); assert_eq!(chain.head_tip().expect("head tip").block_id, 2); let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); assert!(matches!( - chain.apply_adopted(msg(13), &block3_prime), + chain.apply_adopted(&block3_prime), AcceptOutcome::Applied )); assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); @@ -812,7 +830,7 @@ mod tests { fn finalized_hash_alias_with_wrong_id_is_not_absorbed() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); // A malformed message reusing genesis's hash under a different claimed // id must not match the held entry as a re-delivery; it falls through @@ -820,7 +838,7 @@ mod tests { let mut alias = genesis.clone(); alias.header.block_id = 6; assert!(matches!( - chain.apply_finalized(msg(66), &alias, slot(10)), + chain.apply_finalized(&alias, slot(10)), AcceptOutcome::Parked(_) )); assert_eq!(chain.head_tip().expect("head tip").block_id, 1); @@ -834,14 +852,14 @@ mod tests { let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); + chain.apply_adopted(&block3); // Block 2 finalizes re-inscribed under a fresh MsgId: matched by hash, // finalized through, and the head above it survives. assert!(matches!( - chain.apply_finalized(msg(42), &block2, slot(5)), + chain.apply_finalized(&block2, slot(5)), AcceptOutcome::Applied )); assert_eq!(chain.final_tip().expect("final tip").block_id, 2); @@ -859,15 +877,15 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); - chain.apply_adopted(msg(3), &block3); + chain.apply_adopted(&block3); - chain.apply_finalized(msg(2), &block2, slot(10)); + chain.apply_finalized(&block2, slot(10)); // Head still reflects both transfers assert_eq!(chain.head_state().get_account_by_id(to).balance, 20020); @@ -880,12 +898,12 @@ mod tests { fn head_self_heals_with_valid_competitor_after_park() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); // Correct id, wrong parent: parked, head frozen at 1, no stall. let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); assert!(matches!( - chain.apply_adopted(msg(2), &bad), + chain.apply_adopted(&bad), AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) )); assert_eq!(chain.head_tip().expect("head tip").block_id, 1); @@ -893,10 +911,7 @@ mod tests { // A valid competitor at the same height applies without any reorg event. let good = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - assert!(matches!( - chain.apply_adopted(msg(12), &good), - AcceptOutcome::Applied - )); + assert!(matches!(chain.apply_adopted(&good), AcceptOutcome::Applied)); assert_eq!(chain.head_tip().expect("head tip").block_id, 2); assert_head_matches_replay(&chain); } @@ -905,13 +920,13 @@ mod tests { fn repeated_invalid_finalized_bumps_orphans_since() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(&genesis, slot(10)); let bad3 = produce_dummy_block(3, Some(genesis.header.hash), vec![]); - chain.apply_finalized(msg(3), &bad3, slot(20)); + chain.apply_finalized(&bad3, slot(20)); let bad5 = produce_dummy_block(5, Some(bad3.header.hash), vec![]); assert!(matches!( - chain.apply_finalized(msg(5), &bad5, slot(30)), + chain.apply_finalized(&bad5, slot(30)), AcceptOutcome::Parked(_) )); @@ -924,16 +939,16 @@ mod tests { fn valid_finalized_successor_clears_final_stall() { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(&genesis, slot(10)); let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); - chain.apply_finalized(msg(3), &bad, slot(20)); + chain.apply_finalized(&bad, slot(20)); assert!(chain.final_stall().is_some()); // The valid successor of the frozen final tip finalizes: stall clears. let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); assert!(matches!( - chain.apply_finalized(msg(2), &block2, slot(30)), + chain.apply_finalized(&block2, slot(30)), AcceptOutcome::Applied )); assert!(chain.final_stall().is_none()); @@ -949,13 +964,13 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&genesis); + chain.apply_adopted(&block2); assert!(chain.final_tip().is_none()); let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); assert!(matches!( - chain.apply_finalized(msg(3), &block3, slot(10)), + chain.apply_finalized(&block3, slot(10)), AcceptOutcome::Applied )); assert_eq!(chain.final_tip().expect("final tip").block_id, 3); @@ -972,16 +987,16 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); - chain.apply_finalized(msg(2), &block2, slot(20)); + chain.apply_finalized(&genesis, slot(10)); + chain.apply_finalized(&block2, slot(20)); // Below the tip, and at the tip with a matching hash: idempotent. assert!(matches!( - chain.apply_finalized(msg(41), &genesis, slot(30)), + chain.apply_finalized(&genesis, slot(30)), AcceptOutcome::AlreadyApplied )); assert!(matches!( - chain.apply_finalized(msg(42), &block2, slot(30)), + chain.apply_finalized(&block2, slot(30)), AcceptOutcome::AlreadyApplied )); assert!(chain.final_stall().is_none()); @@ -994,14 +1009,14 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_finalized(msg(1), &genesis, slot(10)); - chain.apply_finalized(msg(2), &block2, slot(20)); + chain.apply_finalized(&genesis, slot(10)); + chain.apply_finalized(&block2, slot(20)); // A different finalized block at the final height: finalized is // irreversible, so this is a genuine stall, not a re-delivery. let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); assert!(matches!( - chain.apply_finalized(msg(22), &block2_prime, slot(30)), + chain.apply_finalized(&block2_prime, slot(30)), AcceptOutcome::Parked(_) )); assert!(chain.final_stall().is_some()); @@ -1017,19 +1032,19 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); - chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_adopted(&genesis); + chain.apply_finalized(&genesis, slot(10)); // Head advances on a competing branch… let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - chain.apply_adopted(msg(2), &block2a); + chain.apply_adopted(&block2a); // …but a different block 2 finalizes. The finalized chain is // authoritative, so head rebases onto it. let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); assert!(matches!( - chain.apply_finalized(msg(22), &block2b, slot(20)), + chain.apply_finalized(&block2b, slot(20)), AcceptOutcome::Applied )); @@ -1048,11 +1063,11 @@ mod tests { let mut chain = ChainState::new(initial_state()); let genesis = produce_dummy_block(1, None, vec![]); - chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(&genesis); let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); - chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(&block2); assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); diff --git a/lez/chain_state/src/lib.rs b/lez/chain_state/src/lib.rs index 89357af0a..ce6988390 100644 --- a/lez/chain_state/src/lib.rs +++ b/lez/chain_state/src/lib.rs @@ -3,7 +3,7 @@ //! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model. pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip}; -pub use chain::{ChainState, HeadEntry}; +pub use chain::ChainState; pub use consistency::{ Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency, }; diff --git a/lez/sequencer/actors/executor/src/actor.rs b/lez/sequencer/actors/executor/src/actor.rs index 78340d8a9..98b651628 100644 --- a/lez/sequencer/actors/executor/src/actor.rs +++ b/lez/sequencer/actors/executor/src/actor.rs @@ -169,6 +169,13 @@ impl Mess return Ok(()); } + // The channel moved past our pin, so every publish this turn would be refused. + if let Some(tip) = self.sequencer.pin_behind_channel_tip().await { + self.sequencer.update_committee_absence().await; + info!("Skipping turn: channel tip {tip:?} moved past our pin; catching up first"); + return Ok(()); + } + info!("Our turn: producing a block and any committee update"); let id = self .sequencer diff --git a/lez/sequencer/actors/executor/src/tests.rs b/lez/sequencer/actors/executor/src/tests.rs index ec72c28aa..184d7a51f 100644 --- a/lez/sequencer/actors/executor/src/tests.rs +++ b/lez/sequencer/actors/executor/src/tests.rs @@ -143,6 +143,14 @@ fn prepare_mock_storage_with_empty_genesis() -> MockStorageActor { .expect_handle_get_zone_anchor() .returning(|_, _| Ok(None)); + mock_storage + .expect_handle_get_channel_cursor() + .returning(|_, _| Ok(None)); + + mock_storage + .expect_handle_get_slash_record_bytes() + .returning(|_, _| Ok(None)); + mock_storage .expect_handle_get_latest_block_meta() .returning(move |_, _| Ok(Some(genesis_block_meta.clone()))); diff --git a/lez/sequencer/actors/storage/src/actor.rs b/lez/sequencer/actors/storage/src/actor.rs index f4b7ddc03..f1a2e16e9 100644 --- a/lez/sequencer/actors/storage/src/actor.rs +++ b/lez/sequencer/actors/storage/src/actor.rs @@ -24,16 +24,15 @@ use crate::{ AddPendingCrossZoneDispatches, AddPendingDepositEvent, ApplyStoreUpdate, CleanPendingBlocksUpTo, ConsumeUnseenWithdrawCount, DbDump, DeadLetterDispatchRecord, DeleteBlock, DeleteCrossZonePeerFloor, DeleteZoneCheckpoint, DispatchFailure, - DropSettledCrossZoneDispatches, DumpDb, GetAllBlocks, GetBlock, GetCrossZonePeerFloorBytes, - GetCrossZonePeerTip, GetDeadLetterDispatchCount, GetDeadLetterDispatches, GetFinalSnapshot, - GetFirstBlockId, GetLastBlockId, GetLatestBlockMeta, GetLeeState, - GetPendingCrossZoneDispatches, GetPendingDepositEvents, GetPublishedHighWater, - GetSlashRecordBytes, GetTransactionByHash, GetZoneAnchor, GetZoneCheckpointBytes, - MarkBlockAsFinalized, - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, RaisePublishedHighWater, + DropSettledCrossZoneDispatches, DumpDb, GetAllBlocks, GetBlock, GetChannelCursor, + GetCrossZonePeerFloorBytes, GetCrossZonePeerTip, GetDeadLetterDispatchCount, + GetDeadLetterDispatches, GetFinalSnapshot, GetFirstBlockId, GetLastBlockId, + GetLatestBlockMeta, GetLeeState, GetPendingCrossZoneDispatches, GetPendingDepositEvents, + GetPublishedHighWater, GetSlashRecordBytes, GetTransactionByHash, GetZoneAnchor, + GetZoneCheckpointBytes, MarkBlockAsFinalized, PendingCrossZoneDispatchRecord, + PendingDepositEventRecord, PutSlashRecordBytes, RaisePublishedHighWater, RecordDispatchFailure, RecordNewBlock, ResetAllBlocksToPending, SetCrossZonePeerFloorBytes, - PutSlashRecordBytes, SetCrossZonePeerTip, SetZoneAnchor, SetZoneCheckpointBytes, - StoreUpdateOutcome, + SetCrossZonePeerTip, SetZoneAnchor, SetZoneCheckpointBytes, StoreUpdateOutcome, ZoneAnchorRecord, }, }; @@ -126,6 +125,7 @@ impl Message for StorageActor { &mut self, RecordNewBlock { block, + channel_cursor, withdrawals, state, checkpoint_bytes, @@ -133,8 +133,13 @@ impl Message for StorageActor { _ctx: &mut Context, ) -> Self::Reply { let withdrawals = withdrawals.into_iter().map(Into::into).collect::>(); - self.dbio() - .atomic_update(&block, &withdrawals, &state, checkpoint_bytes.as_deref())?; + self.dbio().atomic_update( + &block, + channel_cursor, + &withdrawals, + &state, + checkpoint_bytes.as_deref(), + )?; self.tx_index.update_from_block(&block); Ok(()) @@ -330,7 +335,9 @@ impl Message for StorageActor { PutSlashRecordBytes { bytes }: PutSlashRecordBytes, _ctx: &mut Context, ) -> Self::Reply { - self.dbio().put_slash_record_bytes(&bytes).map_err(Into::into) + self.dbio() + .put_slash_record_bytes(&bytes) + .map_err(Into::into) } } @@ -377,6 +384,18 @@ impl Message for StorageActor { } } +impl Message for StorageActor { + type Reply = Result>; + + async fn handle( + &mut self, + GetChannelCursor: GetChannelCursor, + _ctx: &mut Context, + ) -> Self::Reply { + self.dbio().channel_cursor().map_err(Into::into) + } +} + impl Message for StorageActor { type Reply = Result>; @@ -441,6 +460,7 @@ impl Message for StorageActor { let ApplyStoreUpdate { checkpoint, blocks, + channel_cursor, head_tip, head_state, final_snapshot, @@ -475,6 +495,7 @@ impl Message for StorageActor { let update = storage::sequencer::StoreUpdate { checkpoint: checkpoint.as_deref(), blocks: &blocks, + channel_cursor, head_tip: head_tip.as_ref(), head_state: &head_state, final_snapshot: final_snapshot diff --git a/lez/sequencer/actors/storage/src/mock.rs b/lez/sequencer/actors/storage/src/mock.rs index 455683376..f883aa74b 100644 --- a/lez/sequencer/actors/storage/src/mock.rs +++ b/lez/sequencer/actors/storage/src/mock.rs @@ -22,16 +22,15 @@ use crate::{ AddPendingCrossZoneDispatches, AddPendingDepositEvent, ApplyStoreUpdate, CleanPendingBlocksUpTo, ConsumeUnseenWithdrawCount, DbDump, DeadLetterDispatchRecord, DeleteBlock, DeleteCrossZonePeerFloor, DeleteZoneCheckpoint, DispatchFailure, - DropSettledCrossZoneDispatches, DumpDb, GetAllBlocks, GetBlock, GetCrossZonePeerFloorBytes, - GetCrossZonePeerTip, GetDeadLetterDispatchCount, GetDeadLetterDispatches, GetFinalSnapshot, - GetFirstBlockId, GetLastBlockId, GetLatestBlockMeta, GetLeeState, - GetPendingCrossZoneDispatches, GetPendingDepositEvents, GetPublishedHighWater, - GetSlashRecordBytes, GetTransactionByHash, GetZoneAnchor, GetZoneCheckpointBytes, - MarkBlockAsFinalized, - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, RaisePublishedHighWater, + DropSettledCrossZoneDispatches, DumpDb, GetAllBlocks, GetBlock, GetChannelCursor, + GetCrossZonePeerFloorBytes, GetCrossZonePeerTip, GetDeadLetterDispatchCount, + GetDeadLetterDispatches, GetFinalSnapshot, GetFirstBlockId, GetLastBlockId, + GetLatestBlockMeta, GetLeeState, GetPendingCrossZoneDispatches, GetPendingDepositEvents, + GetPublishedHighWater, GetSlashRecordBytes, GetTransactionByHash, GetZoneAnchor, + GetZoneCheckpointBytes, MarkBlockAsFinalized, PendingCrossZoneDispatchRecord, + PendingDepositEventRecord, PutSlashRecordBytes, RaisePublishedHighWater, RecordDispatchFailure, RecordNewBlock, ResetAllBlocksToPending, SetCrossZonePeerFloorBytes, - PutSlashRecordBytes, SetCrossZonePeerTip, SetZoneAnchor, SetZoneCheckpointBytes, - StoreUpdateOutcome, + SetCrossZonePeerTip, SetZoneAnchor, SetZoneCheckpointBytes, StoreUpdateOutcome, ZoneAnchorRecord, }, }; @@ -146,6 +145,12 @@ mockall::mock! { ctx: &mut Context> ) -> Result<()>; + pub fn handle_get_channel_cursor( + &mut self, + msg: GetChannelCursor, + ctx: &mut Context>> + ) -> Result>; + pub fn handle_get_published_high_water( &mut self, msg: GetPublishedHighWater, @@ -531,6 +536,18 @@ impl Message for MockStorageActor { } } +impl Message for MockStorageActor { + type Reply = Result>; + + async fn handle( + &mut self, + msg: GetChannelCursor, + ctx: &mut Context, + ) -> Self::Reply { + self.handle_get_channel_cursor(msg, ctx) + } +} + impl Message for MockStorageActor { type Reply = Result>; diff --git a/lez/sequencer/actors/storage/src/protocol.rs b/lez/sequencer/actors/storage/src/protocol.rs index a8ecf0456..26b756a2e 100644 --- a/lez/sequencer/actors/storage/src/protocol.rs +++ b/lez/sequencer/actors/storage/src/protocol.rs @@ -17,6 +17,9 @@ use crate::Result; /// Persists `block` with the effects it covers. pub struct RecordNewBlock { pub block: Block, + /// The `MsgId` the block was inscribed under; `None` leaves the stored + /// cursor untouched. + pub channel_cursor: Option<[u8; 32]>, pub withdrawals: Vec, pub state: Arc, pub checkpoint_bytes: Option>, @@ -73,6 +76,9 @@ pub struct SetZoneAnchor { pub struct GetPublishedHighWater; +/// The `MsgId` of the newest channel inscription processed, block or not. +pub struct GetChannelCursor; + /// Raises the published high water mark to `block_id`, never lowering it. pub struct RaisePublishedHighWater { pub block_id: BlockId, @@ -150,6 +156,10 @@ pub struct ApplyStoreUpdate { /// `(block, finalized)` payloads to write. pub blocks: Vec<(Block, bool)>, + /// The `MsgId` of the newest inscription this update processed, block or + /// not; `None` leaves the stored cursor untouched. + pub channel_cursor: Option<[u8; 32]>, + /// Head tip to pin the stored chain to; `None` only for an empty chain. pub head_tip: Option, /// State after the last applied block. diff --git a/lez/sequencer/actors/storage/src/tests.rs b/lez/sequencer/actors/storage/src/tests.rs index f46849bf3..ce4753a7f 100644 --- a/lez/sequencer/actors/storage/src/tests.rs +++ b/lez/sequencer/actors/storage/src/tests.rs @@ -20,6 +20,7 @@ async fn spawn_with_blocks(path: &Path, blocks: Vec) -> ActorRef>> + Message> + Message>> + + Message>> + Message> + Message>> + Message> diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index fb5a1008f..bacc74254 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -38,7 +38,7 @@ use logos_blockchain_zone_sdk::{ sequencer::{ ChannelUpdateTx, DepositInfo, Event, FinalizedOp, FundingConfig, InscriptionInfo, PendingTx, SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, - WithdrawInfo, ZoneSequencer, + WithdrawInfo, ZoneSequencer, channel_inscriptions, }, }; use tokio::sync::{mpsc, oneshot, watch}; @@ -52,23 +52,28 @@ use crate::{config::BedrockConfig, task_group::TaskGroup}; const PUBLISH_INBOX_CAPACITY: usize = 32; /// Everything one `Event::BlocksProcessed` carries, with inscription payloads -/// decoded into `(MsgId, Block)` pairs. +/// decoded into blocks. /// /// One struct rather than a sink per effect, because the `checkpoint` and /// everything it covers must reach the store in a single write. pub struct FollowUpdate { /// Resume cursor for this event. Persist only together with the effects - /// below, never ahead of them. + /// below, never ahead of them. Its `last_msg_id` is the channel tip on + /// the view this update leaves behind — non-block entries and the rewind + /// after an orphan included — and is what the next publish pins on. pub checkpoint: SequencerCheckpoint, - /// Inscriptions newly on the followed L1 branch, in channel order: they - /// extend (or, after a reorg, replace part of) the `head` tier. - pub adopted: Vec<(MsgId, Block)>, - /// Inscriptions dropped from the branch by an L1 reorg: their blocks are - /// reverted from the `head` and their user txs resubmitted to the mempool. - pub orphaned: Vec<(MsgId, Block)>, - /// Inscriptions whose containing L1 block reached finality: their blocks - /// move into the irreversible `final` tier. - pub finalized: Vec<(MsgId, Block)>, + /// Blocks newly on the followed L1 branch, in channel order; they extend + /// or replace part of the `head` tier. Non-block entries (garbage, a + /// config op) surface only through the checkpoint's tip. No inscription + /// ids ride along: blocks correlate by hash (a re-inscription changes the + /// id, never the hash), and the only publishable id is the checkpoint's. + pub adopted: Vec, + /// Blocks dropped from the branch by an L1 reorg: reverted from the + /// `head`, their user txs resubmitted to the mempool. + pub orphaned: Vec, + /// Blocks whose containing L1 block reached finality: they move into the + /// irreversible `final` tier. + pub finalized: Vec, /// Finalized Bedrock deposit events, to record and mint on L2. pub deposits: Vec, /// Finalized Bedrock withdraw events, to reconcile against local intents. @@ -147,6 +152,17 @@ pub trait LocalBlockPublisherTrait: Sized + Sync { withdrawals: Vec, ) -> Result; + /// Publish `block` as an inscription chained on `parent`, rather than on + /// whatever the channel tip happens to be when the publish is serviced. + /// + /// L1 rejects it if the tip moved, which costs the turn and leaves the + /// height free. + fn publish_block_chained_on( + &self, + block: &Block, + parent: MsgId, + ) -> impl Future> + Send; + /// Create the channel and write `block` into it in one Mantle tx. Only valid /// while the channel does not exist, and `keys[0]` must be this sequencer's /// own key, since creation hands the first turn to index 0. @@ -199,6 +215,9 @@ pub trait LocalBlockPublisherTrait: Sized + Sync { /// channel does not exist there. Drives the startup frontier check. async fn channel_tip_slot(&self) -> Result>; + /// Live channel tip message id; `None` if the channel does not exist. + async fn channel_tip_message(&self) -> Result>; + /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used /// for the startup consistency check and reconstruction. Pass `None` to read /// from the channel's genesis. @@ -243,6 +262,14 @@ impl ZoneSdkPublisher { .map_err(|_closed| anyhow!("Drive task dropped the response"))? } + /// Reads the live channel state; `None` if the channel does not exist. + async fn live_channel_state(&self) -> Result> { + self.node + .channel_state(self.channel_id) + .await + .context("Failed to read channel state") + } + /// Inscribes raw bytes on the channel. Only a test that provokes an offence /// needs it. #[cfg(feature = "test-utils")] @@ -301,6 +328,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let (command_tx, mut command_rx): (CommandSender, _) = mpsc::channel(PUBLISH_INBOX_CAPACITY); + let channel_id = config.channel_id; let driver_cancellation = CancellationToken::new(); let driver_guard = driver_cancellation.clone().drop_guard(); let drive_task = tokio::spawn(async move { @@ -411,8 +439,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let adopted = channel_update .adopted .iter() - .filter_map(channel_update_inscription) - .filter_map(block_from_inscription) + .flat_map(|tx| adopted_blocks(tx, channel_id)) .collect(); let orphaned = channel_update .orphaned @@ -431,7 +458,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { match op { FinalizedOp::Inscription(inscription) => { match block_from_inscription(&inscription) { - Some(entry) => finalized_blocks.push(entry), + Some(block) => finalized_blocks.push(block), // A `ChannelConfig` arrives // empty and offends nobody. None if >::as_ref( @@ -515,6 +542,48 @@ impl BlockPublisherTrait for ZoneSdkPublisher { .await } + async fn publish_block_chained_on( + &self, + block: &Block, + parent: MsgId, + ) -> Result { + let data = borsh::to_vec(block).context("Failed to serialize block")?; + let inscription: Inscription = data + .try_into() + .context("Block data exceeds maximum allowed size")?; + + let inscribe_op = InscriptionOp { + channel_id: self.channel_id, + inscription, + parent, + signer: self.bedrock_signing_key.public_key(), + }; + let msg_id = inscribe_op.id(); + + let funded = fund_ops( + &self.node, + self.funding_key, + self.priority_fee, + [Op::ChannelInscribe(inscribe_op)], + ) + .await?; + let mantle_tx = funded.funded_tx; + + let signature = self + .bedrock_signing_key + .sign_payload(mantle_tx.hash().as_signing_bytes().as_ref()); + let mut ops_proofs: OpsProofs = OpProof::Ed25519Sig(signature).into(); + if let Some(transfer_proof) = funded.transfer_proof { + ops_proofs + .try_push(transfer_proof) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + } + + let tx = Box::new(SignedMantleTx::new(mantle_tx, ops_proofs)); + self.dispatch(|resp| Command::SubmitSignedTx { tx, msg_id, resp }) + .await + } + async fn publish_genesis_creating_channel( &self, block: &Block, @@ -593,10 +662,8 @@ impl BlockPublisherTrait for ZoneSdkPublisher { async fn accredited_keys(&self) -> Result, Slot)>> { Ok(self - .node - .channel_state(self.channel_id) - .await - .context("Failed to read channel state")? + .live_channel_state() + .await? .map(|state| (state.accredited_keys.to_vec(), state.tip_slot))) } @@ -660,12 +727,14 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } async fn channel_tip_slot(&self) -> Result> { + Ok(self.live_channel_state().await?.map(|state| state.tip_slot)) + } + + async fn channel_tip_message(&self) -> Result> { Ok(self - .node - .channel_state(self.channel_id) - .await - .context("Failed to read channel state")? - .map(|state| state.tip_slot)) + .live_channel_state() + .await? + .map(|state| state.tip_message)) } async fn read_channel_after( @@ -683,13 +752,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher { /// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are /// logged and skipped. -fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> { +fn block_from_inscription(inscription: &InscriptionInfo) -> Option { borsh::from_slice::(&inscription.payload) .inspect_err(|err| { warn!("Failed to deserialize block from inscription: {err:?}"); }) .ok() - .map(|block| (inscription.this_msg, block)) +} + +/// Every block an adopted tx carries, in op order. Non-block entries are +/// dropped: they reach consumers as the checkpoint's tip, not as payloads. +fn adopted_blocks(tx: &ChannelUpdateTx, channel_id: ChannelId) -> Vec { + let entry = |inscription: &InscriptionInfo| { + if >::as_ref(&inscription.payload).is_empty() { + None + } else { + block_from_inscription(inscription) + } + }; + match tx { + ChannelUpdateTx::Inscription(info) => entry(info).into_iter().collect(), + ChannelUpdateTx::AtomicWithdraw(bundle) => entry(&bundle.inscription).into_iter().collect(), + ChannelUpdateTx::Custom(signed_tx) => channel_inscriptions(signed_tx, channel_id) + .iter() + .filter_map(entry) + .collect(), + } } /// Channel notes the withdraws bundled with a published tx release; empty for a diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 85e16d524..a1a095adb 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -11,12 +11,13 @@ use sequencer_storage_actor::{ protocol::{ CleanPendingBlocksUpTo, DeadLetterDispatchRecord, DeleteBlock, DeleteZoneCheckpoint, DispatchFailure, DispatchOrigin, DropSettledCrossZoneDispatches, GetAllBlocks, GetBlock, - GetDeadLetterDispatchCount, GetDeadLetterDispatches, GetFinalSnapshot, GetFirstBlockId, - GetLastBlockId, GetLatestBlockMeta, GetLeeState, GetPendingCrossZoneDispatches, - GetPendingDepositEvents, GetPublishedHighWater, GetZoneAnchor, GetZoneCheckpointBytes, - MarkBlockAsFinalized, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, - RaisePublishedHighWater, RecordDispatchFailure, RecordNewBlock, ResetAllBlocksToPending, - SetZoneAnchor, SetZoneCheckpointBytes, WithdrawalReconciliationKey, ZoneAnchorRecord, + GetChannelCursor, GetDeadLetterDispatchCount, GetDeadLetterDispatches, GetFinalSnapshot, + GetFirstBlockId, GetLastBlockId, GetLatestBlockMeta, GetLeeState, + GetPendingCrossZoneDispatches, GetPendingDepositEvents, GetPublishedHighWater, + GetZoneAnchor, GetZoneCheckpointBytes, MarkBlockAsFinalized, + PendingCrossZoneDispatchRecord, PendingDepositEventRecord, RaisePublishedHighWater, + RecordDispatchFailure, RecordNewBlock, ResetAllBlocksToPending, SetZoneAnchor, + SetZoneCheckpointBytes, WithdrawalReconciliationKey, ZoneAnchorRecord, }, }; @@ -79,6 +80,7 @@ impl SequencerStore { pub async fn record_new_block( &mut self, block: Block, + channel_cursor: Option<[u8; 32]>, withdrawals: Vec, state: Arc, checkpoint_bytes: Option>, @@ -86,6 +88,7 @@ impl SequencerStore { self.storage_ref .ask(RecordNewBlock { block, + channel_cursor, withdrawals, state, checkpoint_bytes, @@ -179,6 +182,15 @@ impl SequencerStore { .map_err(Into::into) } + /// The `MsgId` of the newest channel inscription processed, or `None` if + /// none was recorded. + pub async fn channel_cursor(&self) -> Result> { + self.storage_ref + .ask(GetChannelCursor) + .await + .map_err(Into::into) + } + /// Raises the published high water mark to `block_id`, never lowering it. pub async fn raise_published_high_water(&self, block_id: u64) -> Result<()> { self.storage_ref @@ -310,6 +322,7 @@ mod tests { storage_ref .ask(RecordNewBlock { block: genesis.clone(), + channel_cursor: None, withdrawals: vec![], state: Arc::new(testnet_initial_state::initial_state()), checkpoint_bytes: None, @@ -350,7 +363,7 @@ mod tests { let block_hash = block.header.hash; store - .record_new_block(block.clone(), vec![], Arc::new(V03State::new()), None) + .record_new_block(block.clone(), None, vec![], Arc::new(V03State::new()), None) .await .unwrap(); @@ -376,7 +389,7 @@ mod tests { let block_id = block.header.block_id; store - .record_new_block(block.clone(), vec![], Arc::new(V03State::new()), None) + .record_new_block(block.clone(), None, vec![], Arc::new(V03State::new()), None) .await .unwrap(); diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index c03cbf789..60d77d143 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -672,6 +672,7 @@ mod tests { storage_ref .ask(RecordNewBlock { block: produce_dummy_block(0, None, vec![]), + channel_cursor: None, withdrawals: vec![], state: Arc::new(lee::V03State::new()), checkpoint_bytes: None, diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 0a789f937..8e31f1611 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -147,6 +147,8 @@ pub struct SequencerCore< slash_record: slashing::SlashRecord, /// Signs this node's approval of a slash. bedrock_signing_key: block_publisher::Ed25519Key, + /// Our newest published inscription; a pin on it is fresh before the channel shows it. + own_tip_msg: Option, } impl SequencerCore { @@ -191,6 +193,23 @@ impl SequencerCore { panic!("Stored block {block_id} does not replay while restoring chain state: {err}") }); } + if let Some(cursor) = store + .channel_cursor() + .await + .unwrap_or_else(|err| panic!("Failed to read the stored channel cursor: {err:#}")) + { + chain.set_channel_cursor(MsgId::from(cursor)); + } else if let Some(checkpoint) = store + .get_zone_checkpoint() + .await + .unwrap_or_else(|err| panic!("Failed to read the stored zone checkpoint: {err:#}")) + { + // A store from before the cursor cell existed still pins: the sdk + // checkpoint carries the channel tip it was built on. + chain.set_channel_cursor(checkpoint.last_msg_id); + } else { + // Nothing followed yet; the bootstrap publishes seed the pin. + } // The replayed head must reproduce the persisted state, else store // and config disagree (e.g. edited genesis actions). @@ -222,6 +241,7 @@ impl SequencerCore { storage_ref .ask(RecordNewBlock { block, + channel_cursor: None, withdrawals: Vec::new(), state: Arc::new(state), checkpoint_bytes: None, @@ -401,6 +421,13 @@ impl SequencerCore { block.header.block_id ) }); + // The checkpoint's tip is what this publish left the channel + // at (for the channel-creating bundle, its last tip-advancing + // op), and the next publish must pin on it. + chain + .lock() + .await + .set_channel_cursor(outcome.checkpoint.last_msg_id); last_checkpoint = Some(outcome.checkpoint); store .raise_published_high_water(block.header.block_id) @@ -430,6 +457,7 @@ impl SequencerCore { committee_absence: committee_discovery::CommitteeAbsence::default(), slash_record, bedrock_signing_key, + own_tip_msg: None, }; sequencer_core_metrics::record_chain_height(sequencer_core.chain_height().await); @@ -550,7 +578,14 @@ impl SequencerCore { // follow events interleave safely — both paths apply idempotently // and persist under this same lock. let mut chain = chain.lock().await; - Self::apply_reconstructed_block(store.storage_ref(), &mut chain, &block, slot).await?; + Self::apply_reconstructed_block( + store.storage_ref(), + &mut chain, + zone_block.id, + &block, + slot, + ) + .await?; } // The channel exists once it has a tip; only when it has none is this @@ -568,6 +603,7 @@ impl SequencerCore { async fn apply_reconstructed_block( storage_ref: &ActorRef, chain: &mut ChainState, + this_msg: MsgId, block: &Block, slot: Slot, ) -> Result<()> { @@ -624,8 +660,12 @@ impl SequencerCore { // Above the final tier the head is reorg-able, so finalized history // wins: `apply_finalized` finalizes the matching prefix and rebases the // head onto what the channel settled. Validation happens inside it. - match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) { - AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {} + match chain.apply_finalized(block, slot) { + AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => { + // The channel replays in order, so this inscription is the tip + // so far. + chain.set_channel_cursor(this_msg); + } AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { return Err(anyhow!( "Channel block {block_id} does not extend local tip {:?}: {err}", @@ -655,6 +695,7 @@ impl SequencerCore { storage_ref .ask(ApplyStoreUpdate { blocks: vec![(block.clone(), true)], + channel_cursor: Some(this_msg.into()), head_tip, head_state: chain.share_head_state(), final_snapshot: final_meta.map(|meta| (chain.share_final_state(), meta)), @@ -734,6 +775,7 @@ impl SequencerCore { block, withdrawals, committee_update, + parent, } = self .build_block_from_mempool(live_accredited_keys.as_deref()) .await @@ -743,11 +785,23 @@ impl SequencerCore { this_msg, checkpoint, released_notes, - } = self - .block_publisher - .publish_block(&block, withdrawals) - .await - .context("Failed to publish block to Bedrock")?; + } = match parent { + // Chained on the channel tip the cursor sat on when the block was + // built, so a tip that moved since costs this turn instead of + // inscribing a second block at a height the channel already + // carries. + Some(parent) if withdrawals.is_empty() => { + self.block_publisher + .publish_block_chained_on(&block, parent) + .await + } + _ => { + self.block_publisher + .publish_block(&block, withdrawals) + .await + } + } + .context("Failed to publish block to Bedrock")?; // The inscription is on L1 from here on, whatever the head does with the // block below, so this height must never be published again. @@ -872,12 +926,16 @@ impl SequencerCore { let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?; let mut chain = self.chain.lock().await; - match chain.apply_produced(this_msg, &block) { + match chain.apply_produced(&block) { AcceptOutcome::Applied => { let block_id = block.header.block_id; + // Our inscription is the channel tip from here. + chain.set_channel_cursor(this_msg); + self.own_tip_msg = Some(this_msg); self.store .record_new_block( block, + Some(this_msg.into()), withdrawal_reconciliation_keys, chain.share_head_state(), Some(checkpoint_bytes), @@ -1034,9 +1092,11 @@ impl SequencerCore { finalize_unstake_txs, slash_txs, committee_update, + parent, ) = { let chain = self.chain.lock().await; let tip = chain.head_tip(); + let parent = chain.pin_parent(); let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| { head.block_id .checked_add(1) @@ -1079,6 +1139,7 @@ impl SequencerCore { &self.bedrock_signing_key, ), committee_update, + parent, ) }; @@ -1305,6 +1366,7 @@ impl SequencerCore { block, withdrawals, committee_update, + parent, }) } @@ -1500,6 +1562,23 @@ impl SequencerCore { (self.next_block_height().await <= high_water).then_some(high_water) } + /// The live channel tip when it has moved past our pin, meaning the next + /// publish would be refused and the caller should skip its turn. + pub async fn pin_behind_channel_tip(&self) -> Option { + let pin = self.chain.lock().await.pin_parent()?; + if self.own_tip_msg == Some(pin) { + return None; + } + match self.block_publisher.channel_tip_message().await { + Ok(Some(tip)) if tip != pin => Some(tip), + Ok(_) => None, + Err(err) => { + warn!("Failed to read the channel tip, leaving the refusal to the publish: {err:#}"); + None + } + } + } + /// Shared handle to the two-tier follow state, for tests to drive the /// follow path directly. #[cfg(all(test, feature = "mock"))] @@ -1512,6 +1591,9 @@ struct BlockWithMeta { block: Block, withdrawals: Vec, committee_update: Option>, + /// The channel tip the cursor sat on when this block was built, read under + /// the same lock as its height. + parent: Option, } /// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt @@ -1622,10 +1704,7 @@ async fn apply_follow_update( // head. The rewind below is the part that costs something. let head_before = chain.head_tip().map(|tip| tip.block_id); if !orphaned.is_empty() { - let ids: Vec = orphaned - .iter() - .map(|(_, block)| block.header.block_id) - .collect(); + let ids: Vec = orphaned.iter().map(|block| block.header.block_id).collect(); debug!( "Channel orphaned {} block(s) {:?}..={:?}, head tip is {head_before:?}", ids.len(), @@ -1634,12 +1713,17 @@ async fn apply_follow_update( ); } - // Outcomes align with `adopted`. - let outcomes = chain.apply_channel_update(&orphaned, &adopted); + // Orphans first, then adopted blocks in channel order. Outcomes align + // with `adopted`. + let _no_adoptions = chain.apply_channel_update(&orphaned, &[]); + let outcomes: Vec = adopted + .iter() + .map(|block| chain.apply_adopted(block)) + .collect(); // An adoption that does not apply freezes the head where it is, and // every later one then fails the same way. Nothing else reports it. - for ((_, block), outcome) in adopted.iter().zip(&outcomes) { + for (block, outcome) in adopted.iter().zip(&outcomes) { if let AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) = outcome { warn!( "Adopted block {} did not apply, head stays at {:?}: {err}", @@ -1659,7 +1743,7 @@ async fn apply_follow_update( .iter() .zip(&outcomes) .filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied)) - .map(|((_, block), _)| (block, false)) + .map(|(block, _)| (block, false)) .collect(); // Only blocks the final tier holds drive the bookkeeping below: a parked @@ -1667,13 +1751,13 @@ async fn apply_follow_update( // or dropping its deposit records would lose them for good. let mut irreversible: Vec<&Block> = Vec::new(); let mut final_advanced = false; - for (this_msg, block) in &finalized { + for block in &finalized { // FIXME: thread the finalized inscription's L1 slot instead of // `Slot::from(0)`; only used for the invalid-finalized stall. // logos-blockchain PR #3147 surfaces it as `FinalizedTx.l1_slot` — // wire it through `FollowUpdate::finalized` once the zone-sdk pin is // bumped past that (a separate PR). - match chain.apply_finalized(*this_msg, block, Slot::from(0)) { + match chain.apply_finalized(block, Slot::from(0)) { AcceptOutcome::Applied => { to_persist.push((block, true)); irreversible.push(block); @@ -1687,6 +1771,11 @@ async fn apply_follow_update( } } + // The checkpoint rode in with this delta, so its `last_msg_id` is the + // channel tip on the view just applied: the surviving entry after an + // orphan, and entries the head never carries (garbage, config ops). + chain.set_channel_cursor(checkpoint.last_msg_id); + // User txs of orphaned blocks, returned to the mempool below. // // Computed after the finalized tier has advanced, and only for blocks @@ -1698,8 +1787,8 @@ async fn apply_follow_update( let final_height = chain.final_tip().map(|tip| tip.block_id); let resubmit_txs: Vec = orphaned .iter() - .filter(|(_, block)| final_height.is_none_or(|id| block.header.block_id > id)) - .flat_map(|(_, block)| resubmittable_txs(block)) + .filter(|block| final_height.is_none_or(|id| block.header.block_id > id)) + .flat_map(resubmittable_txs) .collect(); // Snapshot the advanced final tier so a restart re-anchors on it. @@ -1711,18 +1800,17 @@ async fn apply_follow_update( let head_tip_id = head_tip.as_ref().map_or(0, |tip| tip.id); // zone-sdk drops an orphan from its pending set, so a height above the new - // head is ours to write again once the channel holds nothing there. + // head is ours to write again once the channel holds nothing there. An + // in-flight publish above the head cannot land to reclaim it: it is + // pinned on an entry this rewind dropped. let head_height = head_tip.as_ref().map(|tip| tip.id); let orphans_above_head: Vec<&Block> = orphaned .iter() - .map(|(_, block)| block) .filter(|block| head_height.is_none_or(|id| block.header.block_id > id)) .collect(); - let none_back_on_channel = orphans_above_head.iter().all(|block| { - !adopted - .iter() - .any(|(_, a)| a.header.hash == block.header.hash) - }); + let none_back_on_channel = orphans_above_head + .iter() + .all(|block| !adopted.iter().any(|a| a.header.hash == block.header.hash)); // An adoption that parked sits above the head without being orphaned. let all_adopted_applied = outcomes.iter().all(|outcome| { matches!( @@ -1768,6 +1856,7 @@ async fn apply_follow_update( .into_iter() .map(|(block, fin)| (block.clone(), fin)) .collect(), + channel_cursor: chain.channel_cursor().map(Into::into), head_tip, head_state: chain.share_head_state(), final_snapshot: final_meta.map(|meta| (chain.share_final_state(), meta)), diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index e5d046b35..428fa33b0 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -1,4 +1,10 @@ -use std::time::Duration; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use anyhow::Result; use common::block::Block; @@ -32,6 +38,11 @@ pub struct MockBlockPublisher { messages: Vec<(ZoneMessage, Slot)>, /// Canned signer; `None` means nothing can be attributed. inscription_signer: Option, + /// Last entry a publish left the channel at, for the pinned-parent check. + channel_tip: Arc>>, + /// When set, fails every publish, as zone-sdk does for an atomic withdraw + /// at this revision. + publish_fails: Arc, } impl MockBlockPublisher { @@ -50,6 +61,8 @@ impl MockBlockPublisher { tip_slot, messages, inscription_signer: None, + channel_tip: Arc::new(Mutex::new(None)), + publish_fails: Arc::new(AtomicBool::new(false)), } } @@ -59,6 +72,32 @@ impl MockBlockPublisher { self.inscription_signer = Some(signer); self } + + /// Makes every later publish fail. + pub fn fail_publishes(&self) { + self.publish_fails.store(true, Ordering::Relaxed); + } + + /// Moves the canned channel tip, as an L1 reorg dropping inscriptions does. + pub fn set_channel_tip(&self, tip: Option) { + *self.channel_tip.lock().expect("channel tip lock poisoned") = tip; + } + + /// Records `block` as the channel tip and reports what its publish produced. + fn landed(&self, block: &Block, released_notes: Vec) -> Result { + anyhow::ensure!( + !self.publish_fails.load(Ordering::Relaxed), + "Canned publish failure for block {}", + block.header.block_id + ); + let this_msg = mock_msg_of(block); + *self.channel_tip.lock().expect("channel tip lock poisoned") = Some(this_msg); + Ok(PublishOutcome { + this_msg, + checkpoint: checkpoint_at(this_msg), + released_notes, + }) + } } impl BlockPublisherTrait for MockBlockPublisher { @@ -83,6 +122,8 @@ impl BlockPublisherTrait for MockBlockPublisher { tip_slot: Some(Slot::from(0)), messages: Vec::new(), inscription_signer: None, + channel_tip: Arc::new(Mutex::new(None)), + publish_fails: Arc::new(AtomicBool::new(false)), }) } @@ -94,11 +135,22 @@ impl BlockPublisherTrait for MockBlockPublisher { // Deterministic per-block id so head dedup behaves in tests. // // TODO: should we allow more "mockability" here? - Ok(PublishOutcome { - this_msg: MsgId::from(block.header.hash.0), - checkpoint: mock_checkpoint(), - released_notes: mock_released_notes(&withdrawals), - }) + self.landed(block, mock_released_notes(&withdrawals)) + } + + /// Mirrors L1: the inscription only lands while `parent` is still the tip. + async fn publish_block_chained_on( + &self, + block: &Block, + parent: MsgId, + ) -> Result { + let tip = *self.channel_tip.lock().expect("channel tip lock poisoned"); + anyhow::ensure!( + tip.is_none_or(|tip| tip == parent), + "Block {} is chained on an entry that is no longer the channel tip", + block.header.block_id + ); + self.landed(block, Vec::new()) } async fn publish_genesis_creating_channel( @@ -142,6 +194,10 @@ impl BlockPublisherTrait for MockBlockPublisher { Ok(self.tip_slot) } + async fn channel_tip_message(&self) -> Result> { + Ok(*self.channel_tip.lock().expect("channel tip lock poisoned")) + } + async fn read_channel_after( &self, after_slot: Option, @@ -170,15 +226,30 @@ pub(crate) fn mock_released_notes(withdrawals: &[WithdrawArg]) -> Vec { .collect() } -/// A zeroed checkpoint, for [`MockBlockPublisher::publish_block`] and for tests -/// building a [`crate::block_publisher::FollowUpdate`]. Tests only assert *that* -/// a checkpoint was persisted alongside its effects, never what is in it. +/// The `MsgId` the mock assigns a published block: its hash, so tests can +/// recompute it. Real ids hash the inscription op (parent, payload, signer) +/// and change on re-inscription — never derivable from the block. #[must_use] -pub(crate) fn mock_checkpoint() -> SequencerCheckpoint { +pub(crate) fn mock_msg_of(block: &Block) -> MsgId { + MsgId::from(block.header.hash.0) +} + +/// A checkpoint reporting `tip` as the channel tip, as the sdk builds one for +/// each publish outcome and follow update. +#[must_use] +pub(crate) fn checkpoint_at(tip: MsgId) -> SequencerCheckpoint { SequencerCheckpoint { - last_msg_id: MsgId::from([0; 32]), + last_msg_id: tip, pending_txs: Vec::new(), lib: HeaderId::from([0; 32]), lib_slot: Slot::from(0), } } + +/// [`checkpoint_at`] a zeroed tip, for follow updates whose tests never +/// publish pinned on what they leave behind. +#[cfg(test)] +#[must_use] +pub(crate) fn mock_checkpoint() -> SequencerCheckpoint { + checkpoint_at(MsgId::from([0; 32])) +} diff --git a/lez/sequencer/core/src/slashing.rs b/lez/sequencer/core/src/slashing.rs index f83e28853..0c6cca754 100644 --- a/lez/sequencer/core/src/slashing.rs +++ b/lez/sequencer/core/src/slashing.rs @@ -9,10 +9,10 @@ use std::{ use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; +use kameo::actor::ActorRef; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use log::{error, warn}; use logos_blockchain_zone_sdk::Slot; -use kameo::actor::ActorRef; use sequencer_stake_core::{SequencerKey, SlashApproval}; use sequencer_storage_actor::{ StorageActorTrait, @@ -112,7 +112,6 @@ impl SlashRecord { .copied() .collect() } - } fn encoded(offences: &Offences) -> Vec { @@ -147,7 +146,9 @@ pub(crate) async fn attribute_offences ActorRef { let mut mock = MockStorageActor::new(); @@ -261,9 +265,6 @@ mod tests { MockStorageActor::spawn(mock) } - const INSCRIPTION: [u8; 32] = [7; 32]; - const SLOT: u64 = 42; - fn offender_signing_key() -> Ed25519Key { Ed25519Key::from_bytes(&[3; 32]) } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 7e3d6cd05..e333292c8 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -47,7 +47,7 @@ use crate::{ }, deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch, extract_cross_zone_dispatch_key, finalize_unstake_is_includable, is_sequencer_only_program, - mock::{SequencerCoreWithMockClients, mock_checkpoint}, + mock::{SequencerCoreWithMockClients, checkpoint_at, mock_checkpoint, mock_msg_of}, resubmittable_txs, }; @@ -381,6 +381,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { storage_ref .ask(RecordNewBlock { block: genesis_block, + channel_cursor: None, withdrawals: vec![], state: Arc::new(genesis_state), checkpoint_bytes: None, @@ -583,7 +584,7 @@ async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() { &mempool_handle, FollowUpdate { adopted: vec![], - orphaned: vec![(MsgId::from(minted_block.header.hash.0), minted_block)], + orphaned: vec![minted_block], ..empty_follow_update() }, ) @@ -600,6 +601,9 @@ async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() { "the receipt reverts with the orphaned block" ); + // Orphaning it takes the channel tip back with it. + sequencer.block_publisher().set_channel_tip(None); + // Next turn: the still-pending record is drained and re-minted on the new // head, exactly once. let replacement = sequencer.run_production_turn().await.unwrap(); @@ -914,7 +918,8 @@ async fn a_redelivered_record_is_dropped_once_its_delivery_is_irreversible() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)], + checkpoint: checkpoint_at(mock_msg_of(&delivery_block)), + finalized: vec![delivery_block], ..empty_follow_update() }, ) @@ -1302,7 +1307,7 @@ async fn a_stake_only_moves_the_committee_once_it_has_finalized() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from(genesis.header.hash.0), genesis)], + finalized: vec![genesis], ..empty_follow_update() }, ) @@ -2276,7 +2281,7 @@ async fn follow_update_persists_the_checkpoint_with_its_effects() { &sequencer.chain(), &mempool_handle, FollowUpdate { - adopted: vec![(MsgId::from([1; 32]), peer_block)], + adopted: vec![peer_block], ..empty_follow_update() }, ) @@ -2296,6 +2301,362 @@ async fn follow_update_persists_the_checkpoint_with_its_effects() { assert!(sequencer.store.block_at_id(2).await.unwrap().is_some()); } +/// A publish that never reaches the channel must leave its height free, or the +/// mark outlives the block and the node skips every later turn. +#[tokio::test] +async fn a_failed_publish_leaves_its_height_free() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = start_sequencer(config).await; + + let first = sequencer.run_production_turn().await.unwrap(); + let mark = sequencer.store.published_high_water().await.unwrap(); + assert_eq!(mark, Some(first)); + + sequencer.block_publisher().fail_publishes(); + let failed = sequencer.run_production_turn().await; + assert!(failed.is_err(), "the canned publish failure must surface"); + assert_eq!( + sequencer.store.published_high_water().await.unwrap(), + mark, + "a block that never reached the channel must not claim its height" + ); + assert!( + sequencer.rewound_below_published().await.is_none(), + "the next turn must still be allowed to run" + ); +} + +/// A head rewound after the turn gate has already passed must not republish a +/// height the channel already carries. +#[tokio::test] +async fn a_rewind_after_the_turn_gate_does_not_republish_a_taken_height() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + sequencer.run_production_turn().await.unwrap(); + let published_tip = sequencer.run_production_turn().await.unwrap(); + assert_eq!( + sequencer.store.published_high_water().await.unwrap(), + Some(published_tip) + ); + + // The tip is orphaned but stays inscribed, so the mark must hold. + let tip_block = sequencer + .store + .block_at_id(published_tip) + .await + .unwrap() + .unwrap(); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(MsgId::from([8_u8; 32])), + orphaned: vec![tip_block.clone()], + adopted: vec![tip_block], + ..empty_follow_update() + }, + ) + .await; + + // Rewind the head without touching the mark, as a reorg landing mid-turn + // would. + let readopted = sequencer + .store + .block_at_id(published_tip) + .await + .unwrap() + .unwrap(); + sequencer.chain().lock().await.revert_orphan(&readopted); + assert_eq!(sequencer.next_block_height().await, published_tip); + assert_eq!( + sequencer.store.published_high_water().await.unwrap(), + Some(published_tip), + "the mark still covers the height the turn is about to reuse" + ); + + let republished = sequencer.run_production_turn().await; + assert!( + republished.is_err(), + "a turn must not inscribe a height the mark already covers" + ); + assert_eq!( + sequencer.store.published_high_water().await.unwrap(), + Some(published_tip), + "the refused turn leaves the mark untouched" + ); +} + +/// A block is chained on the entry its head sat on, so a tip that moved between +/// building and publishing refuses the inscription instead of taking a height +/// the channel already carries. +#[tokio::test] +async fn a_block_is_refused_when_the_channel_tip_moved_under_it() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = start_sequencer(config).await; + + let first = sequencer.run_production_turn().await.unwrap(); + let mark = sequencer.store.published_high_water().await.unwrap(); + assert_eq!(mark, Some(first)); + + // Someone else's inscription took the tip since our head was built. + sequencer + .block_publisher() + .set_channel_tip(Some(MsgId::from([42_u8; 32]))); + + let refused = sequencer.run_production_turn().await; + assert!( + refused.is_err(), + "a block chained on a stale entry must not be inscribed" + ); + assert_eq!( + sequencer.store.published_high_water().await.unwrap(), + mark, + "the refused block leaves its height free" + ); +} + +/// A skippable inscription (garbage, a config op) owns the channel tip without +/// moving the head. The cursor follows it, so the next block still lands — +/// pinned on the junk entry, its content chained on the last valid block. +#[tokio::test] +async fn production_chains_on_an_ignorable_inscription_at_the_tip() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + let first = sequencer.run_production_turn().await.unwrap(); + + // A peer's garbage inscription takes the tip; the sdk reports it only as + // the checkpoint's tip, with an empty delta. + let junk = MsgId::from([42_u8; 32]); + sequencer.block_publisher().set_channel_tip(Some(junk)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(junk), + ..empty_follow_update() + }, + ) + .await; + + let next = sequencer + .run_production_turn() + .await + .expect("the pin must follow the channel tip past an ignorable inscription"); + assert_eq!(next, first + 1, "the junk owns no height"); +} + +/// A reorg that drops the entry the pin names must fall back to the entry the +/// reorg left behind, or every later publish is refused on a dead parent. +#[tokio::test] +async fn an_orphan_of_the_pinned_block_rewinds_to_the_surviving_entry() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + let block2 = sequencer.store.block_at_id(2).await.unwrap().unwrap(); + let block3 = sequencer.store.block_at_id(3).await.unwrap().unwrap(); + let block2_msg = mock_msg_of(&block2); + let block3_msg = mock_msg_of(&block3); + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(block3_msg), + "our newest publish owns the tip" + ); + + // The reorg drops our newest inscription; the one below it still stands, + // and the checkpoint names it. + sequencer + .block_publisher() + .set_channel_tip(Some(block2_msg)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(block2_msg), + orphaned: vec![block3], + ..empty_follow_update() + }, + ) + .await; + + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(block2_msg), + "the pin must rewind onto the entry still on the branch" + ); + sequencer + .run_production_turn() + .await + .expect("the next block must pin on the entry the reorg left at the tip"); +} + +/// An orphaned entry that is not a block never reaches `orphaned` — nothing in +/// the head reverts for it — so only the checkpoint can rewind the pin off it. +#[tokio::test] +async fn an_orphan_of_an_ignorable_entry_rewinds_the_pin() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + sequencer.run_production_turn().await.unwrap(); + let block2 = sequencer.store.block_at_id(2).await.unwrap().unwrap(); + let block2_msg = mock_msg_of(&block2); + + // A peer's garbage inscription takes the tip without moving the head. + let junk = MsgId::from([42_u8; 32]); + sequencer.block_publisher().set_channel_tip(Some(junk)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(junk), + ..empty_follow_update() + }, + ) + .await; + assert_eq!(sequencer.chain().lock().await.pin_parent(), Some(junk)); + + // The reorg drops only the garbage, so the head sees nothing at all. + sequencer + .block_publisher() + .set_channel_tip(Some(block2_msg)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(block2_msg), + ..empty_follow_update() + }, + ) + .await; + + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(block2_msg), + "the pin must come off an entry no report could revert" + ); + sequencer + .run_production_turn() + .await + .expect("the next block must pin on the block the garbage sat on"); +} + +/// Two ignorable entries stacked on the channel: dropping the newer one must +/// land the pin on the older, which neither tier can name — the checkpoint +/// alone holds it. +#[tokio::test] +async fn an_orphan_of_the_newest_ignorable_entry_falls_back_to_the_one_below() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + sequencer.run_production_turn().await.unwrap(); + let first_junk = MsgId::from([41_u8; 32]); + let second_junk = MsgId::from([42_u8; 32]); + sequencer + .block_publisher() + .set_channel_tip(Some(second_junk)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(second_junk), + ..empty_follow_update() + }, + ) + .await; + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(second_junk) + ); + + // The reorg drops only the newer garbage. + sequencer + .block_publisher() + .set_channel_tip(Some(first_junk)); + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(first_junk), + ..empty_follow_update() + }, + ) + .await; + + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(first_junk), + "the pin must land on the ignorable entry the reorg left at the tip" + ); + sequencer + .run_production_turn() + .await + .expect("the next block must pin on the surviving ignorable entry"); +} + +/// A fully finalized channel still pins: a pin of `None` is not "unpinned is +/// fine", it selects the racy publish. And the LIB-pruning orphan report that +/// follows finalization must not move the pin off an entry the channel holds. +#[tokio::test] +async fn the_pin_stays_on_a_finalized_entry_through_its_pruning_report() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = start_sequencer(config).await; + + sequencer.run_production_turn().await.unwrap(); + let block2 = sequencer.store.block_at_id(2).await.unwrap().unwrap(); + let block2_msg = mock_msg_of(&block2); + + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(block2_msg), + finalized: vec![block2.clone()], + ..empty_follow_update() + }, + ) + .await; + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(block2_msg), + "the finalized entry carries the pin" + ); + + // LIB pruning reports our finalized inscription as orphaned a poll or two + // later; the channel still holds it, so the checkpoint's tip stays put. + apply_follow_update( + sequencer.block_store().storage_ref(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + checkpoint: checkpoint_at(block2_msg), + orphaned: vec![block2], + ..empty_follow_update() + }, + ) + .await; + + assert_eq!( + sequencer.chain().lock().await.pin_parent(), + Some(block2_msg), + "an entry the channel still holds keeps the pin" + ); + sequencer + .run_production_turn() + .await + .expect("the next block must pin on the finalized tip"); +} + /// zone-sdk does not resubmit an orphan, so an orphan report that re-adopts /// nothing frees the height for the next turn. A re-adopted block keeps it. #[tokio::test] @@ -2322,8 +2683,8 @@ async fn a_dropped_orphan_frees_the_published_height() { &sequencer.chain(), &mempool_handle, FollowUpdate { - orphaned: vec![(MsgId::from([0_u8; 32]), produced[1].clone())], - adopted: vec![(MsgId::from([1_u8; 32]), produced[1].clone())], + orphaned: vec![produced[1].clone()], + adopted: vec![produced[1].clone()], ..empty_follow_update() }, ) @@ -2341,14 +2702,13 @@ async fn a_dropped_orphan_frees_the_published_height() { &sequencer.chain(), &mempool_handle, FollowUpdate { - orphaned: produced - .iter() - .map(|block| (MsgId::from([0_u8; 32]), block.clone())) - .collect(), + orphaned: produced.iter().map(Clone::clone).collect(), ..empty_follow_update() }, ) .await; + // Dropping them takes the channel tip back with them. + sequencer.block_publisher().set_channel_tip(None); assert_eq!(sequencer.next_block_height().await, first); assert_eq!( @@ -2388,7 +2748,7 @@ async fn a_readopted_block_above_the_head_keeps_the_published_height() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from([1_u8; 32]), produced[0].clone())], + finalized: vec![produced[0].clone()], ..empty_follow_update() }, ) @@ -2401,11 +2761,8 @@ async fn a_readopted_block_above_the_head_keeps_the_published_height() { &sequencer.chain(), &mempool_handle, FollowUpdate { - orphaned: produced[1..] - .iter() - .map(|block| (MsgId::from([0_u8; 32]), block.clone())) - .collect(), - adopted: vec![(MsgId::from([2_u8; 32]), produced[2].clone())], + orphaned: produced[1..].iter().map(Clone::clone).collect(), + adopted: vec![produced[2].clone()], ..empty_follow_update() }, ) @@ -2488,7 +2845,7 @@ async fn follow_adopted_peer_block_applies_and_persists() { &sequencer.chain(), &mempool_handle, FollowUpdate { - adopted: vec![(MsgId::from([1; 32]), peer_block.clone())], + adopted: vec![peer_block.clone()], ..empty_follow_update() }, ) @@ -2538,7 +2895,7 @@ async fn follow_redelivery_of_own_block_is_deduped() { &sequencer.chain(), &mempool_handle, FollowUpdate { - adopted: vec![(MsgId::from(block2.header.hash.0), block2)], + adopted: vec![block2], ..empty_follow_update() }, ) @@ -2581,7 +2938,7 @@ async fn follow_orphan_reverts_head_and_requeues_user_txs() { &mempool_handle, FollowUpdate { adopted: vec![], - orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + orphaned: vec![block2], ..empty_follow_update() }, ) @@ -2638,7 +2995,7 @@ async fn follow_orphan_of_a_finalized_block_requeues_nothing() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from(block2.header.hash.0), block2.clone())], + finalized: vec![block2.clone()], ..empty_follow_update() }, ) @@ -2648,7 +3005,7 @@ async fn follow_orphan_of_a_finalized_block_requeues_nothing() { &sequencer.chain(), &mempool_handle, FollowUpdate { - orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + orphaned: vec![block2], ..empty_follow_update() }, ) @@ -2692,7 +3049,7 @@ async fn follow_finalized_own_block_moves_final_tier_and_marks_store() { FollowUpdate { adopted: vec![], orphaned: vec![], - finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + finalized: vec![block2], ..empty_follow_update() }, ) @@ -2748,7 +3105,7 @@ async fn follow_finalized_delivery_drops_its_pending_record() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)], + finalized: vec![delivery_block], ..empty_follow_update() }, ) @@ -2797,7 +3154,7 @@ async fn a_parked_finalized_block_does_not_drop_a_dispatch_record() { &sequencer.chain(), &mempool_handle, FollowUpdate { - finalized: vec![(MsgId::from([9; 32]), parked)], + finalized: vec![parked], ..empty_follow_update() }, ) @@ -2836,7 +3193,7 @@ async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() { FollowUpdate { adopted: vec![], orphaned: vec![], - finalized: vec![(MsgId::from([2; 32]), peer_block.clone())], + finalized: vec![peer_block.clone()], ..empty_follow_update() }, ) @@ -2902,7 +3259,7 @@ async fn parked_finalized_block_neither_sweeps_the_store_nor_drops_its_deposit_r FollowUpdate { adopted: vec![], orphaned: vec![], - finalized: vec![(MsgId::from([9; 32]), parked)], + finalized: vec![parked], ..empty_follow_update() }, ) @@ -2974,8 +3331,8 @@ async fn restart_restores_head_tier_and_recovers_from_orphan() { &sequencer.chain(), &mempool_handle, FollowUpdate { - adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())], - orphaned: vec![(MsgId::from([20; 32]), block2)], + adopted: vec![block2_prime.clone()], + orphaned: vec![block2], ..empty_follow_update() }, ) @@ -3030,7 +3387,7 @@ async fn restart_reanchors_on_the_persisted_final_snapshot() { FollowUpdate { adopted: vec![], orphaned: vec![], - finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + finalized: vec![block2], ..empty_follow_update() }, ) @@ -3061,11 +3418,7 @@ async fn record_produced_block_skips_persistence_on_lost_race() { // A peer block wins height 2 while "our" block is in flight. let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); - sequencer - .chain() - .lock() - .await - .apply_adopted(MsgId::from([9; 32]), &peer_block); + sequencer.chain().lock().await.apply_adopted(&peer_block); // Our competing block at the same height: same parent, different content. let acc1 = initial_public_user_accounts()[0].account_id; @@ -3080,7 +3433,7 @@ async fn record_produced_block_skips_persistence_on_lost_race() { let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); sequencer .record_produced_block( - MsgId::from(our_block.header.hash.0), + mock_msg_of(&our_block), our_block.clone(), vec![], &mock_checkpoint(), @@ -3103,7 +3456,7 @@ async fn record_produced_block_skips_persistence_when_block_no_longer_chains() { let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]); sequencer .record_produced_block( - MsgId::from(stale.header.hash.0), + mock_msg_of(&stale), stale.clone(), vec![], &mock_checkpoint(), @@ -3144,12 +3497,9 @@ async fn follow_update_persists_blocks_meta_and_state_atomically() { &sequencer.chain(), &mempool_handle, FollowUpdate { - adopted: vec![ - (MsgId::from([2; 32]), block2.clone()), - (MsgId::from([3; 32]), block3.clone()), - ], + adopted: vec![block2.clone(), block3.clone()], orphaned: vec![], - finalized: vec![(MsgId::from([2; 32]), block2)], + finalized: vec![block2], ..empty_follow_update() }, ) diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index d61d12480..aa0c71b38 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -362,13 +362,10 @@ async fn reconstruction_ignores_a_duplicate_height_the_final_tier_settled() { // Sequencer B: the cold-start backfill finalizes A's chain into its store. let (seq_b, mempool_b) = start_sequencer(setup_sequencer_config()).await; - let mut finalized: Vec<(MsgId, Block)> = Vec::new(); + let mut finalized: Vec = Vec::new(); for id in seq_b.block_store().genesis_id()..=tip_a.id { let block = seq_a.block_store().block_at_id(id).await.unwrap().unwrap(); - finalized.push(( - MsgId::from([u8::try_from(id).expect("should be u8"); 32]), - block, - )); + finalized.push(block); } apply_follow_update( seq_b.block_store().storage_ref(), @@ -469,7 +466,7 @@ async fn reconstruction_replaces_a_conflicting_head_block_with_finalized_history &seq_b.chain(), &mempool_b, FollowUpdate { - adopted: vec![(MsgId::from([7_u8; 32]), competitor)], + adopted: vec![competitor], ..empty_follow_update() }, ) diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 62bbcbcbc..d22d472f7 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -21,18 +21,18 @@ 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, LatestBlockMetaCellOwned, - LatestBlockMetaCellRef, LegacyPendingCrossZoneDispatchesCellOwned, PeerChainTip, - PeerFloorCellOwned, PeerFloorCellRef, PeerTipCell, PeerZoneKey, - PendingCrossZoneDispatchCellOwned, PendingCrossZoneDispatchCellRef, - PendingCrossZoneDispatchCountCell, PendingCrossZoneDispatchRecord, - PendingDepositEventRecord, PendingDepositEventsCellOwned, PendingDepositEventsCellRef, - PublishedHighWaterCell, SlashRecordCellOwned, SlashRecordCellRef, UnseenWithdrawCountCell, - WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, - ZoneSdkCheckpointCellRef, + ChannelCursorCell, DeadLetterCrossZoneDispatchCountCell, + DeadLetterCrossZoneDispatchesCellOwned, DeadLetterCrossZoneDispatchesCellRef, + DeadLetterDispatchRecord, DispatchOrigin, FinalBlockMetaCellOwned, FinalBlockMetaCellRef, + FinalLeeStateCellOwned, FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, + LegacyPendingCrossZoneDispatchesCellOwned, PeerChainTip, PeerFloorCellOwned, + PeerFloorCellRef, PeerTipCell, PeerZoneKey, PendingCrossZoneDispatchCellOwned, + PendingCrossZoneDispatchCellRef, PendingCrossZoneDispatchCountCell, + PendingCrossZoneDispatchRecord, PendingDepositEventRecord, PendingDepositEventsCellOwned, + PendingDepositEventsCellRef, PublishedHighWaterCell, SlashRecordCellOwned, + SlashRecordCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey, ZoneAnchorCell, + ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, }, }; @@ -81,6 +81,8 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; /// channel. Never decreases, and deliberately survives the block pruning a /// head rewind performs. pub const DB_META_PUBLISHED_HIGH_WATER_KEY: &str = "published_high_water"; +/// The `MsgId` of the newest channel inscription processed, block or not. +pub const DB_META_CHANNEL_CURSOR_KEY: &str = "channel_cursor"; /// How many cross-zone deliveries may be pending at once. /// @@ -180,6 +182,10 @@ pub struct StoreUpdate<'update> { /// `(block, finalized)` payloads to write. pub blocks: &'update [(&'update Block, bool)], + /// The `MsgId` of the newest inscription this update processed, block or + /// not; `None` leaves the stored cursor untouched. + pub channel_cursor: Option<[u8; 32]>, + /// Head tip to pin the stored chain to; `None` only for an empty chain. pub head_tip: Option<&'update BlockMeta>, /// State after the last applied block. @@ -217,6 +223,7 @@ impl<'update> StoreUpdate<'update> { Self { checkpoint: None, blocks: &[], + channel_cursor: None, head_tip: None, head_state, final_snapshot: None, @@ -581,6 +588,13 @@ impl RocksDBIO { self.put(&PublishedHighWaterCell(block_id), ()) } + /// The `MsgId` of the newest channel inscription processed, or `None` if + /// none was recorded. + pub fn channel_cursor(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) + } + pub fn get_zone_anchor(&self) -> DbResult> { Ok(self.get_opt::(())?.map(|cell| cell.0)) } @@ -1400,6 +1414,7 @@ impl RocksDBIO { let StoreUpdate { checkpoint, blocks, + channel_cursor, head_tip, head_state, final_snapshot, @@ -1429,6 +1444,9 @@ impl RocksDBIO { { self.put_batch(&PublishedHighWaterCell(cap), (), &mut batch)?; } + if let Some(cursor) = channel_cursor { + self.put_batch(&ChannelCursorCell(cursor), (), &mut batch)?; + } // Every block payload this update writes, keyed by id so a block that // is both explicitly written and swept by `finalized_up_to` is written @@ -1553,6 +1571,7 @@ impl RocksDBIO { pub fn atomic_update( &self, block: &Block, + channel_cursor: Option<[u8; 32]>, withdrawals: &[WithdrawalReconciliationKey], state: &V03State, checkpoint: Option<&[u8]>, @@ -1560,6 +1579,7 @@ impl RocksDBIO { self.store_update(&StoreUpdate { checkpoint, blocks: &[(block, false)], + channel_cursor, head_tip: Some(&BlockMeta::from(block)), new_withdraw_intents: withdrawals, ..StoreUpdate::new(state) diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 6014fbcc9..9d0b35bad 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -9,8 +9,8 @@ use crate::{ error::DbError, 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_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY, + DB_META_CHANNEL_CURSOR_KEY, DB_META_CROSS_ZONE_PEER_FLOOR_KEY, + DB_META_CROSS_ZONE_PEER_TIP_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_DISPATCH_COUNT_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, @@ -163,6 +163,28 @@ impl SimpleWritableCell for PublishedHighWaterCell { } } +/// The `MsgId` of the newest channel inscription processed, block or not — +/// the parent the next produced block is pinned on. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct ChannelCursorCell(pub [u8; 32]); + +impl SimpleStorableCell for ChannelCursorCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_CHANNEL_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ChannelCursorCell {} + +impl SimpleWritableCell for ChannelCursorCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize channel cursor".to_owned())) + }) + } +} + #[derive(BorshDeserialize)] pub struct LatestBlockMetaCellOwned(pub BlockMeta); diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 5a53dd24f..480a83d24 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -27,7 +27,7 @@ fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) { let dbio = RocksDBIO::open_or_create(path).unwrap(); // The same write any block takes: the first one into an empty store starts // its chain. - dbio.atomic_update(&genesis, &[], &state_with_balance(100), None) + dbio.atomic_update(&genesis, None, &[], &state_with_balance(100), None) .unwrap(); (dbio, genesis) } @@ -77,6 +77,31 @@ fn stored_balance(dbio: &RocksDBIO) -> u128 { .balance } +/// The channel cursor has to outlive the process: a restart that cannot +/// recover it has nothing to chain the next publish onto. +#[test] +fn channel_cursor_survives_reopening_the_store() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + assert_eq!( + dbio.channel_cursor().unwrap(), + None, + "a store written without a cursor has none to report" + ); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.atomic_update(&block2, Some([7; 32]), &[], &state_with_balance(200), None) + .unwrap(); + drop(dbio); + + let reopened = RocksDBIO::open_or_create(temp_dir.path()).unwrap(); + assert_eq!( + reopened.channel_cursor().unwrap(), + Some([7; 32]), + "the cursor must come back after a restart" + ); +} + #[test] fn store_followed_block_persists_new_block_and_state() { let temp_dir = tempdir().unwrap(); @@ -1121,8 +1146,14 @@ fn produced_block_persists_its_publish_checkpoint() { let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - dbio.atomic_update(&block2, &[], &state_with_balance(200), Some(b"cp-produced")) - .unwrap(); + dbio.atomic_update( + &block2, + None, + &[], + &state_with_balance(200), + Some(b"cp-produced"), + ) + .unwrap(); // Storing the block without the checkpoint would let a restart restore a // pending set that no longer holds the inscription we just published. @@ -1152,7 +1183,7 @@ fn produced_block_below_disk_head_pins_meta_and_prunes() { // pins the tip meta to the produced block and drops the stale suffix in // the same write, mirroring the follow path. let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - dbio.atomic_update(&block2b, &[], &state_with_balance(400), None) + dbio.atomic_update(&block2b, None, &[], &state_with_balance(400), None) .unwrap(); let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); @@ -1189,7 +1220,7 @@ fn the_first_block_written_starts_the_chain() { assert_eq!(dbio.get_meta_first_block_in_db().unwrap(), None); let genesis = produce_dummy_block(1, None, vec![]); - dbio.atomic_update(&genesis, &[], &state_with_balance(100), None) + dbio.atomic_update(&genesis, None, &[], &state_with_balance(100), None) .expect("seed"); assert_eq!(dbio.get_meta_first_block_in_db().unwrap(), Some(1)); @@ -1202,7 +1233,7 @@ fn the_first_block_written_starts_the_chain() { // A later block extends the chain rather than restarting it. let second = produce_dummy_block(2, Some(genesis.header.hash), vec![]); - dbio.atomic_update(&second, &[], &state_with_balance(100), None) + dbio.atomic_update(&second, None, &[], &state_with_balance(100), None) .expect("extend"); assert_eq!(dbio.get_meta_first_block_in_db().unwrap(), Some(1)); assert_eq!(dbio.get_meta_last_block_in_db().unwrap(), Some(2)); diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 5d33e2d31..dac5bd914 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 5cb942b78..e73611cb0 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -718,6 +718,12 @@ impl ZoneTestContextBuilder { .await .context("Encountered an error while waiting for genesis to be published")?; + // Followers must not start before the channel exists on Bedrock, or + // they race a second channel-create. + wait_until_channel_exists(bedrock_addr, mn_config.bedrock_channel) + .await + .context("Encountered an error while waiting for the channel to land on Bedrock")?; + log::info!("Passed wait untill genesis"); sequencer_addrs.push(leader_addr); @@ -1073,6 +1079,35 @@ async fn wait_until_genesis(client: &SequencerClient) -> Result<()> { .with_context(|| "Timed out waiting for genesis")? } +async fn wait_until_channel_exists( + bedrock_addr: SocketAddr, + channel_id: ChannelId, +) -> Result<()> { + log::info!("Waiting for the channel to land on Bedrock"); + + let bedrock_config = sequencer_core::config::BedrockConfig { + channel_id, + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + funding_key: config::bedrock_funding_key(), + auth: None, + priority_fee: sequencer_core::config::default_priority_fee(), + }; + let wait = async { + loop { + if sequencer_core::block_publisher::read_channel_state(&bedrock_config) + .await? + .is_some() + { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(360), wait) + .await + .with_context(|| "Timed out waiting for the channel to land on Bedrock")? +} + #[expect(clippy::too_many_arguments, reason = "No need to repackage fields")] async fn build_sequencer_components( partial_config: SequencerPartialConfig,