mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
fix(lez/sequencer)!: publish blocks chained on an explicit parent to stop reorgs landing wrong block ids
This commit is contained in:
+171
-156
@@ -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);
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user