fix(lez/sequencer)!: publish blocks chained on an explicit parent to stop reorgs landing wrong block ids

This commit is contained in:
Sergio Chouhy
2026-08-23 20:25:38 -03:00
parent 6214821857
commit 4f43eff794
22 changed files with 1133 additions and 334 deletions
+171 -156
View File
@@ -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<StallReason>,
head_state: Arc<V03State>,
head_blocks: Vec<HeadEntry>,
head_blocks: Vec<Block>,
/// 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<MsgId>,
}
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<Tip> {
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<MsgId> {
self.channel_cursor
}
#[must_use]
pub const fn channel_cursor(&self) -> Option<MsgId> {
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<Tip> {
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<usize> {
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<usize> {
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<AcceptOutcome> {
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<HeadEntry> = self.head_blocks.drain(0..=idx).collect();
for entry in finalized {
let finalized: Vec<Block> = 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<V03State>, Option<Tip>) {
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);
+1 -1
View File
@@ -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,
};
@@ -169,6 +169,13 @@ impl<BP: BlockPublisherTrait + Send + Sync + 'static, S: StorageActorTrait> 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
@@ -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())));
+33 -12
View File
@@ -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<RecordNewBlock> for StorageActor {
&mut self,
RecordNewBlock {
block,
channel_cursor,
withdrawals,
state,
checkpoint_bytes,
@@ -133,8 +133,13 @@ impl Message<RecordNewBlock> for StorageActor {
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
let withdrawals = withdrawals.into_iter().map(Into::into).collect::<Vec<_>>();
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<PutSlashRecordBytes> for StorageActor {
PutSlashRecordBytes { bytes }: PutSlashRecordBytes,
_ctx: &mut Context<Self, Self::Reply>,
) -> 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<SetZoneAnchor> for StorageActor {
}
}
impl Message<GetChannelCursor> for StorageActor {
type Reply = Result<Option<[u8; 32]>>;
async fn handle(
&mut self,
GetChannelCursor: GetChannelCursor,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
self.dbio().channel_cursor().map_err(Into::into)
}
}
impl Message<GetPublishedHighWater> for StorageActor {
type Reply = Result<Option<BlockId>>;
@@ -441,6 +460,7 @@ impl Message<ApplyStoreUpdate> for StorageActor {
let ApplyStoreUpdate {
checkpoint,
blocks,
channel_cursor,
head_tip,
head_state,
final_snapshot,
@@ -475,6 +495,7 @@ impl Message<ApplyStoreUpdate> 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
+26 -9
View File
@@ -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<Self, Result<()>>
) -> Result<()>;
pub fn handle_get_channel_cursor(
&mut self,
msg: GetChannelCursor,
ctx: &mut Context<Self, Result<Option<[u8; 32]>>>
) -> Result<Option<[u8; 32]>>;
pub fn handle_get_published_high_water(
&mut self,
msg: GetPublishedHighWater,
@@ -531,6 +536,18 @@ impl Message<SetZoneAnchor> for MockStorageActor {
}
}
impl Message<GetChannelCursor> for MockStorageActor {
type Reply = Result<Option<[u8; 32]>>;
async fn handle(
&mut self,
msg: GetChannelCursor,
ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
self.handle_get_channel_cursor(msg, ctx)
}
}
impl Message<GetPublishedHighWater> for MockStorageActor {
type Reply = Result<Option<BlockId>>;
@@ -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<WithdrawalReconciliationKey>,
pub state: Arc<V03State>,
pub checkpoint_bytes: Option<Vec<u8>>,
@@ -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<BlockMeta>,
/// State after the last applied block.
@@ -20,6 +20,7 @@ async fn spawn_with_blocks(path: &Path, blocks: Vec<Block>) -> ActorRef<StorageA
storage_ref
.ask(RecordNewBlock {
block,
channel_cursor: None,
withdrawals: vec![],
state: Arc::new(V03State::new()),
checkpoint_bytes: None,
@@ -73,6 +74,7 @@ async fn recorded_transaction_is_looked_up_by_hash() {
storage_ref
.ask(RecordNewBlock {
block,
channel_cursor: None,
withdrawals: vec![],
state: Arc::new(V03State::new()),
checkpoint_bytes: None,
@@ -148,6 +150,7 @@ async fn replaced_block_leaves_no_stale_index_entries() {
.ask(ApplyStoreUpdate {
checkpoint: None,
blocks: vec![(adopted.clone(), false)],
channel_cursor: None,
head_tip: Some(BlockMeta::from(&adopted)),
head_state: Arc::new(V03State::new()),
final_snapshot: None,
+9 -9
View File
@@ -13,16 +13,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,
},
};
@@ -51,6 +50,7 @@ pub trait StorageActorTrait:
+ Message<GetZoneAnchor, Reply = Result<Option<ZoneAnchorRecord>>>
+ Message<SetZoneAnchor, Reply = Result<()>>
+ Message<GetPublishedHighWater, Reply = Result<Option<BlockId>>>
+ Message<GetChannelCursor, Reply = Result<Option<[u8; 32]>>>
+ Message<RaisePublishedHighWater, Reply = Result<()>>
+ Message<GetPendingDepositEvents, Reply = Result<Vec<PendingDepositEventRecord>>>
+ Message<AddPendingDepositEvent, Reply = Result<bool>>
+114 -26
View File
@@ -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<Block>,
/// Blocks dropped from the branch by an L1 reorg: reverted from the
/// `head`, their user txs resubmitted to the mempool.
pub orphaned: Vec<Block>,
/// Blocks whose containing L1 block reached finality: they move into the
/// irreversible `final` tier.
pub finalized: Vec<Block>,
/// Finalized Bedrock deposit events, to record and mint on L2.
pub deposits: Vec<DepositInfo>,
/// Finalized Bedrock withdraw events, to reconcile against local intents.
@@ -147,6 +152,17 @@ pub trait LocalBlockPublisherTrait: Sized + Sync {
withdrawals: Vec<WithdrawArg>,
) -> Result<PublishOutcome>;
/// 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<Output = Result<PublishOutcome>> + 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<Option<Slot>>;
/// Live channel tip message id; `None` if the channel does not exist.
async fn channel_tip_message(&self) -> Result<Option<MsgId>>;
/// 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<Option<ChannelState>> {
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 <Inscription as AsRef<[u8]>>::as_ref(
@@ -515,6 +542,48 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
.await
}
async fn publish_block_chained_on(
&self,
block: &Block,
parent: MsgId,
) -> Result<PublishOutcome> {
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<Option<(Vec<Ed25519PublicKey>, 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<Option<Slot>> {
Ok(self.live_channel_state().await?.map(|state| state.tip_slot))
}
async fn channel_tip_message(&self) -> Result<Option<MsgId>> {
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<Block> {
borsh::from_slice::<Block>(&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<Block> {
let entry = |inscription: &InscriptionInfo| {
if <Inscription as AsRef<[u8]>>::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
+21 -8
View File
@@ -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<S: StorageActorTrait> SequencerStore<S> {
pub async fn record_new_block(
&mut self,
block: Block,
channel_cursor: Option<[u8; 32]>,
withdrawals: Vec<WithdrawalReconciliationKey>,
state: Arc<V03State>,
checkpoint_bytes: Option<Vec<u8>>,
@@ -86,6 +88,7 @@ impl<S: StorageActorTrait> SequencerStore<S> {
self.storage_ref
.ask(RecordNewBlock {
block,
channel_cursor,
withdrawals,
state,
checkpoint_bytes,
@@ -179,6 +182,15 @@ impl<S: StorageActorTrait> SequencerStore<S> {
.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<Option<[u8; 32]>> {
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();
@@ -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,
+117 -28
View File
@@ -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<MsgId>,
}
impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
@@ -191,6 +193,23 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
storage_ref
.ask(RecordNewBlock {
block,
channel_cursor: None,
withdrawals: Vec::new(),
state: Arc::new(state),
checkpoint_bytes: None,
@@ -401,6 +421,13 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
// 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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
async fn apply_reconstructed_block(
storage_ref: &ActorRef<S>,
chain: &mut ChainState,
this_msg: MsgId,
block: &Block,
slot: Slot,
) -> Result<()> {
@@ -624,8 +660,12 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
// 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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
block,
withdrawals,
committee_update,
parent,
} = self
.build_block_from_mempool(live_accredited_keys.as_deref())
.await
@@ -743,11 +785,23 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
&self.bedrock_signing_key,
),
committee_update,
parent,
)
};
@@ -1305,6 +1366,7 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
block,
withdrawals,
committee_update,
parent,
})
}
@@ -1500,6 +1562,23 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
(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<MsgId> {
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<WithdrawArg>,
committee_update: Option<Vec<sequencer_stake_core::SequencerKey>>,
/// The channel tip the cursor sat on when this block was built, read under
/// the same lock as its height.
parent: Option<MsgId>,
}
/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt
@@ -1622,10 +1704,7 @@ async fn apply_follow_update<S: StorageActorTrait>(
// 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<u64> = orphaned
.iter()
.map(|(_, block)| block.header.block_id)
.collect();
let ids: Vec<u64> = 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<S: StorageActorTrait>(
);
}
// 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<AcceptOutcome> = 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<S: StorageActorTrait>(
.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<S: StorageActorTrait>(
// 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<S: StorageActorTrait>(
}
}
// 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<S: StorageActorTrait>(
let final_height = chain.final_tip().map(|tip| tip.block_id);
let resubmit_txs: Vec<LeeTransaction> = 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<S: StorageActorTrait>(
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<S: StorageActorTrait>(
.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)),
+82 -11
View File
@@ -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<Ed25519PublicKey>,
/// Last entry a publish left the channel at, for the pinned-parent check.
channel_tip: Arc<Mutex<Option<MsgId>>>,
/// When set, fails every publish, as zone-sdk does for an atomic withdraw
/// at this revision.
publish_fails: Arc<AtomicBool>,
}
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<MsgId>) {
*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<NoteId>) -> Result<PublishOutcome> {
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<PublishOutcome> {
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<Option<MsgId>> {
Ok(*self.channel_tip.lock().expect("channel tip lock poisoned"))
}
async fn read_channel_after(
&self,
after_slot: Option<Slot>,
@@ -170,15 +226,30 @@ pub(crate) fn mock_released_notes(withdrawals: &[WithdrawArg]) -> Vec<NoteId> {
.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]))
}
+7 -6
View File
@@ -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<u8> {
@@ -147,7 +146,9 @@ pub(crate) async fn attribute_offences<BP: BlockPublisherTrait, S: StorageActorT
"Inscription at slot {slot} attributed to {}",
hex::encode(offender)
);
record.attribute(storage_ref, inscription, slot, offender).await;
record
.attribute(storage_ref, inscription, slot, offender)
.await;
}
Ok(())
@@ -251,6 +252,9 @@ mod tests {
use super::*;
use crate::mock::MockBlockPublisher;
const INSCRIPTION: [u8; 32] = [7; 32];
const SLOT: u64 = 42;
/// A storage actor that holds no record and accepts every write.
fn storage() -> ActorRef<MockStorageActor> {
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])
}
+392 -42
View File
@@ -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()
},
)
@@ -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<Block> = 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()
},
)
+32 -12
View File
@@ -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<Option<[u8; 32]>> {
self.get_opt::<ChannelCursorCell>(())
.map(|val| val.map(|cell| cell.0))
}
pub fn get_zone_anchor(&self) -> DbResult<Option<ZoneAnchorRecord>> {
Ok(self.get_opt::<ZoneAnchorCell>(())?.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)
+24 -2
View File
@@ -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<Vec<u8>> {
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);
+37 -6
View File
@@ -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));
Binary file not shown.
+35
View File
@@ -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,