diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index d2909e09f..059a760a2 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -155,8 +155,9 @@ pub trait LocalBlockPublisherTrait: Sized + Sync { ) -> Result; /// Live (adopted, possibly not yet finalized) accredited-key snapshot for - /// this channel, read directly from the connected Bedrock node. - async fn accredited_keys(&self) -> Result>; + /// this channel with the slot its tip sits at, from one read of the + /// connected Bedrock node. `None` if the channel does not exist. + async fn accredited_keys(&self) -> Result, Slot)>>; /// Submit a committee `ChannelConfigOp` as its own, independent Mantle /// tx (not bundled with any block publish). `new_keys` is the full @@ -551,14 +552,13 @@ impl BlockPublisherTrait for ZoneSdkPublisher { .await } - async fn accredited_keys(&self) -> Result> { + async fn accredited_keys(&self) -> Result, Slot)>> { Ok(self .node .channel_state(self.channel_id) .await .context("Failed to read channel state")? - .map(|state| state.accredited_keys.to_vec()) - .unwrap_or_default()) + .map(|state| (state.accredited_keys.to_vec(), state.tip_slot))) } async fn submit_channel_config(&self, new_keys: Vec) -> Result<()> { diff --git a/lez/sequencer/core/src/committee_discovery.rs b/lez/sequencer/core/src/committee_discovery.rs index 372e8b692..54602573a 100644 --- a/lez/sequencer/core/src/committee_discovery.rs +++ b/lez/sequencer/core/src/committee_discovery.rs @@ -5,36 +5,40 @@ use std::collections::{BTreeMap, BTreeSet}; use log::warn; use sequencer_stake_core::{PendingUnstake, SequencerKey, SequencerStakeConfig, StakeRecord}; -/// When each leaving key was first seen gone from the live committee. +/// The channel slot each leaving key was first seen gone from the live committee at. +/// +/// In memory only: a restart re-stamps at the current tip and costs a pending +/// exit one more finality delay. #[derive(Debug, Default)] pub struct CommitteeAbsence { absent_since: BTreeMap, } impl CommitteeAbsence { - /// Records one look at the live committee, taken at block height `height`. + /// Records one look at the live committee, taken with the channel tip at + /// `tip_slot`. pub fn observe( &mut self, live: &[SequencerKey], leaving: &BTreeSet, - height: u64, + tip_slot: u64, ) { self.absent_since.retain(|key, _| leaving.contains(key)); for key in leaving { if live.contains(key) { self.absent_since.remove(key); } else { - self.absent_since.entry(*key).or_insert(height); + self.absent_since.entry(*key).or_insert(tip_slot); } } } - /// Whether a block built after `key` was seen leaving has finalized. + /// Whether the channel slot that first showed `key` gone is irreversible. #[must_use] - pub fn removal_is_final(&self, key: SequencerKey, finalized_height: Option) -> bool { + pub fn removal_is_final(&self, key: SequencerKey, finalized_slot: Option) -> bool { self.absent_since .get(&key) - .is_some_and(|absent_since| finalized_height > Some(*absent_since)) + .is_some_and(|absent_since| finalized_slot > Some(*absent_since)) } } @@ -113,7 +117,7 @@ pub fn finalize_unstake_is_valid( state: &lee::V03State, ownership_id: lee::AccountId, absence: &CommitteeAbsence, - finalized_height: Option, + finalized_slot: Option, ) -> bool { let Some(record) = stake_record(state, ownership_id) else { return true; @@ -128,7 +132,7 @@ pub fn finalize_unstake_is_valid( }; let fully_drains = entry.net_stake() == 0; - !fully_drains || absence.removal_is_final(record.sequencer_key, finalized_height) + !fully_drains || absence.removal_is_final(record.sequencer_key, finalized_slot) } /// Keys whose pending release takes them out of the committee. diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 2fd76ed04..7b7b7db85 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -687,15 +687,15 @@ impl SequencerCore { pub async fn update_committee_absence( &mut self, ) -> Option> { - let live_accredited_keys = self.live_accredited_sequencer_keys().await; - if let Some(live) = live_accredited_keys.as_deref() { + let snapshot = self.live_accredited_sequencer_keys().await; + if let Some((live, tip_slot)) = snapshot.as_ref() { let leaving = self .with_state(committee_discovery::keys_leaving_the_committee) .await; - let height = self.chain_height().await; - self.committee_absence.observe(live, &leaving, height); + self.committee_absence + .observe(live, &leaving, tip_slot.into_inner()); } - live_accredited_keys + snapshot.map(|(keys, _)| keys) } /// Runs everything this sequencer owes its turn: builds a block from @@ -747,12 +747,13 @@ impl SequencerCore { } /// Live committee snapshot for gating `FinalizeUnstake` inclusion and - /// committee updates. `None` if it could not be read. + /// committee updates, with the channel tip slot it was read at. `None` if + /// the channel is missing or unreadable. async fn live_accredited_sequencer_keys( &self, - ) -> Option> { + ) -> Option<(Vec, Slot)> { match self.block_publisher.accredited_keys().await { - Ok(keys) => Some( + Ok(Some((keys, tip_slot))) => Some(( keys.iter() .filter_map(|key| { sequencer_stake_core::SequencerKey::new(key.to_bytes()).or_else(|| { @@ -764,7 +765,15 @@ impl SequencerCore { }) }) .collect(), - ), + tip_slot, + )), + Ok(None) => { + warn!( + "No channel to read a live committee from; skipping FinalizeUnstake inclusion \ + and committee updates this round" + ); + None + } Err(err) => { warn!( "Failed to read live committee snapshot; skipping FinalizeUnstake inclusion \ @@ -997,7 +1006,6 @@ impl SequencerCore { mut working_state, pending_dispatches, finalize_unstake_txs, - finalized_height, committee_update, ) = { let chain = self.chain.lock().await; @@ -1038,11 +1046,20 @@ impl SequencerCore { chain.head_state().clone(), pending, build_finalize_unstake_txs(chain.head_state()), - chain.final_tip().map(|final_tip| final_tip.block_id), committee_update, ) }; + // The last irreversible bedrock slot. + let finalized_slot = self + .store + .get_zone_checkpoint() + .await + .inspect_err(|err| warn!("Failed to read the zone checkpoint: {err:#}")) + .ok() + .flatten() + .map(|checkpoint| checkpoint.lib_slot.into_inner()); + if !settled.is_empty() { if let Err(err) = self .store @@ -1185,7 +1202,7 @@ impl SequencerCore { &working_state, &tx, &self.committee_absence, - finalized_height, + finalized_slot, ) { continue; } @@ -2152,7 +2169,7 @@ fn finalize_unstake_is_includable( state: &lee::V03State, tx: &LeeTransaction, committee_absence: &committee_discovery::CommitteeAbsence, - finalized_height: Option, + finalized_slot: Option, ) -> bool { let Some(ownership_id) = finalize_unstake_ownership_account(tx) else { return true; @@ -2161,7 +2178,7 @@ fn finalize_unstake_is_includable( state, ownership_id, committee_absence, - finalized_height, + finalized_slot, ) } diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 4cfe6775d..a63f39400 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -98,8 +98,8 @@ impl BlockPublisherTrait for MockBlockPublisher { self.publish_block(block, Vec::new()).await } - async fn accredited_keys(&self) -> Result> { - Ok(Vec::new()) + async fn accredited_keys(&self) -> Result, Slot)>> { + Ok(self.tip_slot.map(|slot| (Vec::new(), slot))) } async fn submit_channel_config(&self, _new_keys: Vec) -> Result<()> {