fix(lez/sequencer): use slot to track finalized config ops

This commit is contained in:
Sergio Chouhy
2026-08-21 11:33:01 -03:00
parent c1644a8cc5
commit cade003868
4 changed files with 51 additions and 30 deletions
+5 -5
View File
@@ -155,8 +155,9 @@ pub trait LocalBlockPublisherTrait: Sized + Sync {
) -> Result<PublishOutcome>;
/// 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<Vec<Ed25519PublicKey>>;
/// 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<Option<(Vec<Ed25519PublicKey>, 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<Vec<Ed25519PublicKey>> {
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")?
.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<Ed25519PublicKey>) -> Result<()> {
+13 -9
View File
@@ -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<SequencerKey, u64>,
}
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<SequencerKey>,
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<u64>) -> bool {
pub fn removal_is_final(&self, key: SequencerKey, finalized_slot: Option<u64>) -> 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<u64>,
finalized_slot: Option<u64>,
) -> 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.
+31 -14
View File
@@ -687,15 +687,15 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
pub async fn update_committee_absence(
&mut self,
) -> Option<Vec<sequencer_stake_core::SequencerKey>> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
}
/// 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<Vec<sequencer_stake_core::SequencerKey>> {
) -> Option<(Vec<sequencer_stake_core::SequencerKey>, 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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
})
})
.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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
mut working_state,
pending_dispatches,
finalize_unstake_txs,
finalized_height,
committee_update,
) = {
let chain = self.chain.lock().await;
@@ -1038,11 +1046,20 @@ impl<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
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<BP: BlockPublisherTrait, S: StorageActorTrait> SequencerCore<BP, S> {
&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<u64>,
finalized_slot: Option<u64>,
) -> 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,
)
}
+2 -2
View File
@@ -98,8 +98,8 @@ impl BlockPublisherTrait for MockBlockPublisher {
self.publish_block(block, Vec::new()).await
}
async fn accredited_keys(&self) -> Result<Vec<Ed25519PublicKey>> {
Ok(Vec::new())
async fn accredited_keys(&self) -> Result<Option<(Vec<Ed25519PublicKey>, Slot)>> {
Ok(self.tip_slot.map(|slot| (Vec::new(), slot)))
}
async fn submit_channel_config(&self, _new_keys: Vec<Ed25519PublicKey>) -> Result<()> {