Skip to main content

linera_chain/
chain.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
6    sync::Arc,
7};
8
9use allocative::Allocative;
10use linera_base::{
11    crypto::{CryptoHash, ValidatorPublicKey},
12    data_types::{
13        ApplicationDescription, ApplicationPermissions, ArithmeticError, Blob, BlockHeight, Cursor,
14        Epoch, NonCanonicalBTreeMap, NonCanonicalBTreeSet, OracleResponse, Timestamp,
15    },
16    ensure,
17    hashed::Hashed,
18    identifiers::{AccountOwner, ApplicationId, BlobType, ChainId, GenericApplicationId, StreamId},
19    ownership::ChainOwnership,
20    time::{Duration, Instant},
21};
22use linera_execution::{
23    committee::Committee, ExecutionRuntimeContext, ExecutionStateView, Message, Operation,
24    PreparedCheckpoint, Query, QueryContext, QueryOutcome, ResourceController, ResourceTracker,
25    ServiceRuntimeEndpoint, TransactionTracker,
26};
27use linera_views::{
28    context::Context,
29    log_view::LogView,
30    map_view::{CustomMapView, MapView},
31    reentrant_collection_view::{ReadGuardedView, ReentrantCollectionView},
32    register_view::RegisterView,
33    set_view::SetView,
34    views::{ClonableView, RootView, View},
35};
36use serde::{Deserialize, Serialize};
37use tracing::{info, instrument, warn};
38
39use crate::{
40    block::{Block, ConfirmedBlock},
41    block_tracker::BlockExecutionTracker,
42    data_types::{
43        BlockExecutionOutcome, BundleExecutionPolicy, BundleFailurePolicy, ChainAndHeight,
44        IncomingBundle, MessageAction, MessageBundle, ProposedBlock, Transaction,
45    },
46    inbox::{InboxError, InboxStateView},
47    manager::ChainManager,
48    outbox::OutboxStateView,
49    pending_blobs::PendingBlobsView,
50    ChainError, ChainExecutionContext, ExecutionError, ExecutionResultExt,
51};
52
53#[cfg(test)]
54#[path = "unit_tests/chain_tests.rs"]
55mod chain_tests;
56
57#[cfg(with_metrics)]
58use linera_base::prometheus_util::MeasureLatency;
59
60/// The protocol phase a block is executed in. Recorded as the `phase` label on the
61/// block-execution metrics so the three distinct paths — staging a proposal, validating a
62/// received proposal, and committing a confirmed certificate — are separate time series in
63/// Prometheus.
64///
65/// Every path that executes a block must name its phase explicitly: there is no `Default`,
66/// so a new caller cannot compile without choosing one, and no execution can land in an
67/// unlabeled or silently-mislabeled bucket.
68///
69/// The label strings are derived from the variant names in `snake_case`; they are part of the
70/// metrics wire format, so `metrics_label_values_are_stable` pins them against an accidental
71/// variant rename.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
73#[strum(serialize_all = "snake_case")]
74pub enum BlockExecutionPhase {
75    /// A block proposer staging (building) its own block (`stage_block_execution`).
76    StageProposal,
77    /// A validator validating a received block proposal (`handle_block_proposal`).
78    HandleProposal,
79    /// A validator executing a confirmed certificate before committing it
80    /// (`process_confirmed_block`).
81    HandleConfirmed,
82}
83
84/// What a call to [`ChainStateView::execute_block`] is doing, carrying exactly the inputs that
85/// are legal for that phase.
86///
87/// Bundling the phase together with the replayed oracle responses and the bundle-execution
88/// policy makes the illegal combinations unrepresentable: only [`StageProposal`] may choose a
89/// policy (and thus `AutoRetry`), and only [`HandleConfirmed`] carries oracle responses to
90/// replay — so "replay oracle responses while auto-retrying" cannot be constructed.
91///
92/// [`StageProposal`]: BlockExecution::StageProposal
93/// [`HandleConfirmed`]: BlockExecution::HandleConfirmed
94pub enum BlockExecution {
95    /// A proposer staging (building) its own block. The bundle-failure policy is caller-chosen
96    /// (and may be `AutoRetry`); oracle responses are computed fresh, never replayed.
97    StageProposal {
98        /// How to handle failing bundles while building the proposal.
99        policy: BundleExecutionPolicy,
100    },
101    /// A validator validating a received block proposal. Bundles must abort on failure (the
102    /// proposal is fixed) and oracle responses are computed fresh.
103    HandleProposal,
104    /// A validator executing a confirmed certificate before committing it. Bundles must abort
105    /// on failure and the certificate's recorded oracle responses are replayed for determinism.
106    HandleConfirmed {
107        /// The oracle responses recorded in the certificate, replayed to reproduce the outcome.
108        oracle_responses: Vec<Vec<OracleResponse>>,
109    },
110}
111
112impl BlockExecution {
113    /// The protocol phase this execution represents, used to label execution metrics and spans.
114    pub fn phase(&self) -> BlockExecutionPhase {
115        match self {
116            BlockExecution::StageProposal { .. } => BlockExecutionPhase::StageProposal,
117            BlockExecution::HandleProposal => BlockExecutionPhase::HandleProposal,
118            BlockExecution::HandleConfirmed { .. } => BlockExecutionPhase::HandleConfirmed,
119        }
120    }
121
122    /// Splits into the oracle responses to replay (if any) and the bundle-execution policy.
123    fn into_oracle_and_policy(self) -> (Option<Vec<Vec<OracleResponse>>>, BundleExecutionPolicy) {
124        match self {
125            BlockExecution::StageProposal { policy } => (None, policy),
126            BlockExecution::HandleProposal => (None, BundleExecutionPolicy::committed()),
127            BlockExecution::HandleConfirmed { oracle_responses } => {
128                (Some(oracle_responses), BundleExecutionPolicy::committed())
129            }
130        }
131    }
132}
133
134#[cfg(with_metrics)]
135pub(crate) mod metrics {
136    use linera_base::prometheus_util::{
137        exponential_bucket_interval, register_histogram_vec, register_int_counter_vec,
138    };
139    use linera_execution::ResourceTracker;
140    use prometheus::{HistogramVec, IntCounterVec};
141
142    /// Tracks block execution metrics in Prometheus, labeled by the execution `phase`.
143    pub(crate) fn track_block_metrics(
144        tracker: &ResourceTracker,
145        phase: super::BlockExecutionPhase,
146    ) {
147        let phase: &[&str] = &[phase.into()];
148        NUM_BLOCKS_EXECUTED.with_label_values(phase).inc();
149        WASM_FUEL_USED_PER_BLOCK
150            .with_label_values(phase)
151            .observe(tracker.wasm_fuel as f64);
152        EVM_FUEL_USED_PER_BLOCK
153            .with_label_values(phase)
154            .observe(tracker.evm_fuel as f64);
155        VM_NUM_READS_PER_BLOCK
156            .with_label_values(phase)
157            .observe(tracker.read_operations as f64);
158        VM_BYTES_READ_PER_BLOCK
159            .with_label_values(phase)
160            .observe(tracker.bytes_read as f64);
161        VM_BYTES_WRITTEN_PER_BLOCK
162            .with_label_values(phase)
163            .observe(tracker.bytes_written as f64);
164    }
165
166    linera_base::declare_metrics! {
167        pub static NUM_BLOCKS_EXECUTED: IntCounterVec =
168            register_int_counter_vec(
169                "num_blocks_executed",
170                "Number of blocks executed",
171                &["phase"],
172            );
173
174        pub static BLOCK_EXECUTION_LATENCY: HistogramVec =
175            register_histogram_vec(
176                "block_execution_latency",
177                "Block execution latency",
178                &["phase"],
179                exponential_bucket_interval(50.0_f64, 10_000_000.0),
180            );
181
182        #[cfg(with_metrics)]
183        pub static MESSAGE_EXECUTION_LATENCY: HistogramVec =
184            register_histogram_vec(
185                "message_execution_latency",
186                "Message execution latency",
187                &["phase"],
188                exponential_bucket_interval(0.1_f64, 50_000.0),
189            );
190
191        pub static OPERATION_EXECUTION_LATENCY: HistogramVec =
192            register_histogram_vec(
193                "operation_execution_latency",
194                "Operation execution latency",
195                &["phase"],
196                exponential_bucket_interval(0.1_f64, 50_000.0),
197            );
198
199        pub static WASM_FUEL_USED_PER_BLOCK: HistogramVec =
200            register_histogram_vec(
201                "wasm_fuel_used_per_block",
202                "Wasm fuel used per block",
203                &["phase"],
204                exponential_bucket_interval(10.0, 100_000_000.0),
205            );
206
207        pub static EVM_FUEL_USED_PER_BLOCK: HistogramVec =
208            register_histogram_vec(
209                "evm_fuel_used_per_block",
210                "EVM fuel used per block",
211                &["phase"],
212                exponential_bucket_interval(10.0, 100_000_000.0),
213            );
214
215        pub static VM_NUM_READS_PER_BLOCK: HistogramVec =
216            register_histogram_vec(
217                "vm_num_reads_per_block",
218                "VM number of reads per block",
219                &["phase"],
220                exponential_bucket_interval(0.1, 100.0),
221            );
222
223        pub static VM_BYTES_READ_PER_BLOCK: HistogramVec =
224            register_histogram_vec(
225                "vm_bytes_read_per_block",
226                "VM number of bytes read per block",
227                &["phase"],
228                exponential_bucket_interval(0.1, 10_000_000.0),
229            );
230
231        pub static VM_BYTES_WRITTEN_PER_BLOCK: HistogramVec =
232            register_histogram_vec(
233                "vm_bytes_written_per_block",
234                "VM number of bytes written per block",
235                &["phase"],
236                exponential_bucket_interval(0.1, 10_000_000.0),
237            );
238
239        pub static STATE_HASH_COMPUTATION_LATENCY: HistogramVec =
240            register_histogram_vec(
241                "state_hash_computation_latency",
242                "Time to recompute the state hash, in microseconds",
243                &["phase"],
244                exponential_bucket_interval(1.0, 2_000_000.0),
245            );
246
247        pub static NUM_OUTBOXES: HistogramVec =
248            register_histogram_vec(
249                "num_outboxes",
250                "Number of outboxes",
251                &[],
252                exponential_bucket_interval(1.0, 1_000_000.0),
253            );
254
255        pub static OUTBOX_COUNTERS_SIZE: HistogramVec =
256            register_histogram_vec(
257                "outbox_counters_size",
258                "Number of entries in the outbox_counters map (in-flight message heights)",
259                &[],
260                exponential_bucket_interval(1.0, 1_000_000.0),
261            );
262    }
263}
264
265/// The BCS-serialized size of an empty [`Block`].
266pub(crate) const EMPTY_BLOCK_SIZE: usize = 94;
267
268/// A set of fully-tracked chains. Wrapped in [`Hashed`] (as `Hashed<ChainIdSet>`) so the hash that
269/// identifies the set — stored in [`ChainStateView::outbox_index_tracked_hash`] to detect when the
270/// outbox indices must be reconciled — is computed once when the tracked set changes rather than on
271/// every cross-chain operation. The hash is order-independent because `BTreeSet` iterates in sorted
272/// order.
273#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
274pub struct ChainIdSet(pub BTreeSet<ChainId>);
275
276impl linera_base::crypto::BcsHashable<'_> for ChainIdSet {}
277
278impl std::ops::Deref for ChainIdSet {
279    type Target = BTreeSet<ChainId>;
280
281    fn deref(&self) -> &Self::Target {
282        &self.0
283    }
284}
285
286/// The event indices we track for a stream, maintained whenever a block is processed
287/// (executed or merely preprocessed).
288#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Allocative)]
289#[cfg_attr(with_graphql, derive(async_graphql::SimpleObject))]
290pub struct StreamCounts {
291    /// The lowest event index still guaranteed to be readable (if it exists): the index of the
292    /// first event published to this stream since the most recent checkpoint. Earlier events may
293    /// have been pruned by a checkpoint and are not available to nodes that bootstrapped from it.
294    /// When there are no events yet, this equals [`next_index`](Self::next_index).
295    pub first_index: u32,
296    /// The next event index we expect to see, i.e. the lowest for which no event is known yet.
297    /// May be ahead of the last executed block on sparse chains.
298    pub next_index: u32,
299}
300
301/// A view accessing the state of a chain.
302#[cfg_attr(
303    with_graphql,
304    derive(async_graphql::SimpleObject),
305    graphql(cache_control(no_cache))
306)]
307#[derive(Debug, RootView, ClonableView, Allocative)]
308#[allocative(bound = "C")]
309pub struct ChainStateView<C>
310where
311    C: Clone + Context + 'static,
312{
313    /// Execution state, including system and user applications.
314    pub execution_state: ExecutionStateView<C>,
315
316    /// Block-chaining state.
317    pub tip_state: RegisterView<C, ChainTipState>,
318
319    /// Consensus state.
320    pub manager: ChainManager<C>,
321    /// Pending validated block that is still missing blobs.
322    /// The incomplete set of blobs for the pending validated block.
323    pub pending_validated_blobs: PendingBlobsView<C>,
324    /// The incomplete sets of blobs for upcoming proposals.
325    pub pending_proposed_blobs: ReentrantCollectionView<C, AccountOwner, PendingBlobsView<C>>,
326
327    /// Hashes of all known blocks in this chain, indexed by their height. A block at
328    /// `height < next_block_height` is executed; a block at `height >= next_block_height`
329    /// is preprocessed (verified but not yet executed) and may not be contiguous.
330    pub block_hashes: CustomMapView<C, BlockHeight, CryptoHash>,
331    /// Sender chain and height of all certified blocks known as a receiver (local ordering).
332    pub received_log: LogView<C, ChainAndHeight>,
333    /// The number of `received_log` entries we have synchronized, for each validator.
334    pub received_certificate_trackers: RegisterView<C, HashMap<ValidatorPublicKey, u64>>,
335
336    /// Mailboxes used to receive messages indexed by their origin.
337    pub inboxes: ReentrantCollectionView<C, ChainId, InboxStateView<C>>,
338    /// Mailboxes used to send messages, indexed by their target.
339    pub outboxes: ReentrantCollectionView<C, ChainId, OutboxStateView<C>>,
340    /// The event indices we track per stream: the next expected index (could be ahead of the
341    /// last executed block in sparse chains) and the lowest readable index since the most
342    /// recent checkpoint.
343    pub next_expected_events: MapView<C, StreamId, StreamCounts>,
344    /// Number of outgoing messages in flight for each block height.
345    /// We use a `RegisterView` to prioritize speed for small maps.
346    pub outbox_counters: RegisterView<C, NonCanonicalBTreeMap<BlockHeight, u32>>,
347    /// Outboxes with at least one pending message. This allows us to avoid loading all outboxes.
348    pub nonempty_outboxes: RegisterView<C, NonCanonicalBTreeSet<ChainId>>,
349
350    /// Inboxes with at least one pending added bundle. This allows us to avoid loading all inboxes.
351    pub nonempty_inboxes: RegisterView<C, NonCanonicalBTreeSet<ChainId>>,
352
353    /// The local wall-clock time when this chain's state was last established — by
354    /// executing block 0, or by installing a checkpoint snapshot (on bootstrap or reset).
355    /// Used to prevent reset-on-incorrect-outcome from looping: if not enough time has
356    /// elapsed since the last reset, the error is returned instead.
357    pub chain_initialized_at: RegisterView<C, Timestamp>,
358
359    /// The height at which the next block can be preprocessed: one past the highest
360    /// height in `block_hashes` (executed or preprocessed), or `next_block_height` if
361    /// `block_hashes` is empty.
362    ///
363    /// Maintained as an O(1) shortcut for `next_height_to_preprocess`, since
364    /// `CustomMapView` does not yet expose a `last_index` lookup. Once
365    /// `linera-views` gains efficient first/last key support, this field can be
366    /// removed in favor of `block_hashes.last_index()`.
367    pub next_height_to_preprocess: RegisterView<C, BlockHeight>,
368
369    /// The height of the most recent checkpoint block applied to this chain, if any.
370    /// Maintained by `apply_confirmed_block` whenever a block starting with
371    /// `SystemOperation::Checkpoint` is executed.
372    pub latest_checkpoint_height: RegisterView<C, Option<BlockHeight>>,
373
374    /// Hashes of pre-checkpoint sender blocks the chain has seen a checkpoint cert
375    /// vouch for via `outbox_block_hashes`, but whose actual cert bytes are not yet
376    /// in storage. The worker errors a checkpoint push with
377    /// `BlocksNotFound` when this set is non-empty, then accepts each
378    /// referenced cert (regardless of its own — possibly revoked — epoch) and
379    /// removes the entry. Once the set is empty, the checkpoint restoration can run
380    /// end-to-end.
381    pub pre_checkpoint_block_trust: SetView<C, CryptoHash>,
382
383    /// The hash of the set of fully-tracked chains that `nonempty_outboxes` and
384    /// `outbox_counters` were last reconciled against. On a client these two indices only hold
385    /// entries for tracked targets; when the tracked set changes this hash stops matching and the
386    /// indices are reconciled (`reconcile_outbox_index`). `None` means
387    /// they have never been filtered — a pre-existing database entry (migration), or a validator
388    /// that tracks all chains and never filters.
389    pub outbox_index_tracked_hash: RegisterView<C, Option<CryptoHash>>,
390
391    // A lower bound, not an exact record: progress is reported asynchronously, so a crash loses
392    // whatever was not folded in. Deliberate — a re-sent block the destination already has is
393    // skipped on an integer compare, whereas under-sending would leave a permanent gap.
394    /// The highest block of this chain pushed to each other committee validator. A validator with
395    /// no entry is queried before its first push; ones that leave the committee are pruned.
396    pub exported_heights: RegisterView<C, NonCanonicalBTreeMap<ValidatorPublicKey, BlockHeight>>,
397}
398
399/// Block-chaining state.
400#[cfg_attr(with_graphql, derive(async_graphql::SimpleObject))]
401#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize, Allocative)]
402pub struct ChainTipState {
403    /// Hash of the latest certified block in this chain, if any.
404    pub block_hash: Option<CryptoHash>,
405    /// Sequence number tracking blocks.
406    pub next_block_height: BlockHeight,
407}
408
409impl ChainTipState {
410    /// Checks that the proposed block is suitable, i.e. at the expected height and with the
411    /// expected parent.
412    pub fn verify_block_chaining(&self, new_block: &ProposedBlock) -> Result<(), ChainError> {
413        ensure!(
414            new_block.height == self.next_block_height,
415            ChainError::UnexpectedBlockHeight {
416                expected_block_height: self.next_block_height,
417                found_block_height: new_block.height
418            }
419        );
420        ensure!(
421            new_block.previous_block_hash == self.block_hash,
422            ChainError::UnexpectedPreviousBlockHash
423        );
424        Ok(())
425    }
426
427    /// Returns `true` if the validated block's height is below the tip height. Returns an error if
428    /// it is higher than the tip.
429    pub fn already_validated_block(&self, height: BlockHeight) -> Result<bool, ChainError> {
430        ensure!(
431            self.next_block_height >= height,
432            ChainError::MissingEarlierBlocks {
433                current_block_height: self.next_block_height,
434            }
435        );
436        Ok(self.next_block_height > height)
437    }
438}
439
440impl<C> ChainStateView<C>
441where
442    C: Context + Clone + 'static,
443    C::Extra: ExecutionRuntimeContext,
444{
445    /// Returns the [`ChainId`] of the chain this [`ChainStateView`] represents.
446    pub fn chain_id(&self) -> ChainId {
447        self.context().extra().chain_id()
448    }
449
450    #[instrument(skip_all, fields(
451        chain_id = %self.chain_id(),
452    ))]
453    /// Executes the given query against an application on this chain.
454    pub async fn query_application(
455        &mut self,
456        local_time: Timestamp,
457        query: Query,
458        service_runtime_endpoint: Option<&mut ServiceRuntimeEndpoint>,
459    ) -> Result<QueryOutcome, ChainError> {
460        let context = QueryContext {
461            chain_id: self.chain_id(),
462            next_block_height: self.tip_state.get().next_block_height,
463            local_time,
464        };
465        self.execution_state
466            .query_application(context, query, service_runtime_endpoint)
467            .await
468            .with_execution_context(ChainExecutionContext::Query)
469    }
470
471    #[instrument(skip_all, fields(
472        chain_id = %self.chain_id(),
473        application_id = %application_id
474    ))]
475    /// Returns the description of the application with the given ID.
476    pub async fn describe_application(
477        &mut self,
478        application_id: ApplicationId,
479    ) -> Result<ApplicationDescription, ChainError> {
480        self.execution_state
481            .system
482            .describe_application(application_id, &mut TransactionTracker::default())
483            .await
484            .with_execution_context(ChainExecutionContext::DescribeApplication)
485    }
486
487    #[instrument(skip_all, fields(
488        chain_id = %self.chain_id(),
489        target = %target,
490        height = %height
491    ))]
492    /// Marks all messages sent to `target` up to the given height as received, returning whether
493    /// the outbox changed.
494    pub async fn mark_messages_as_received(
495        &mut self,
496        target: &ChainId,
497        height: BlockHeight,
498        tracked: Option<&ChainIdSet>,
499    ) -> Result<bool, ChainError> {
500        let mut outbox = self.outboxes.try_load_entry_mut(target).await?;
501        let updates = outbox.mark_messages_as_received(height).await?;
502        if updates.is_empty() {
503            return Ok(false);
504        }
505        // `outbox_counters` is keyed by block height and shared across all recipients of that
506        // block, but only counts targets we index: every chain on a validator (`tracked == None`),
507        // or tracked targets on a client. An untracked target was never counted, so confirming it
508        // must NOT touch the counters at all — a present `counter[height]` belongs to a tracked
509        // sibling recipient of the same block and must be left intact. We only drain the queue
510        // (done above) for such a target.
511        if tracked.is_none_or(|tracked| tracked.contains(target)) {
512            for update in updates {
513                let counter = self
514                    .outbox_counters
515                    .get_mut()
516                    .get_mut(&update)
517                    .ok_or_else(|| {
518                        ChainError::CorruptedChainState("message counter should be present".into())
519                    })?;
520                *counter = counter.checked_sub(1).ok_or(ArithmeticError::Underflow)?;
521                if *counter == 0 {
522                    // Important for the test in `all_messages_delivered_up_to`.
523                    self.outbox_counters.get_mut().remove(&update);
524                }
525            }
526        }
527        if outbox.queue.count() == 0 {
528            self.nonempty_outboxes.get_mut().remove(target);
529            // If the outbox is empty and not ahead of the executed blocks, remove it.
530            if *outbox.next_height_to_schedule.get() <= self.tip_state.get().next_block_height {
531                self.outboxes.remove_entry(target)?;
532            }
533        }
534        #[cfg(with_metrics)]
535        metrics::NUM_OUTBOXES
536            .with_label_values(&[])
537            .observe(self.nonempty_outboxes.get().len() as f64);
538        #[cfg(with_metrics)]
539        metrics::OUTBOX_COUNTERS_SIZE
540            .with_label_values(&[])
541            .observe(self.outbox_counters.get().len() as f64);
542        Ok(true)
543    }
544
545    /// Returns true if there are no more outgoing messages in flight up to the given
546    /// block height.
547    pub fn all_messages_delivered_up_to(&self, height: BlockHeight) -> bool {
548        tracing::debug!(
549            "Messages left in {:.8}'s outbox: {:?}",
550            self.chain_id(),
551            self.outbox_counters.get()
552        );
553        if let Some((key, _)) = self.outbox_counters.get().first_key_value() {
554            key > &height
555        } else {
556            true
557        }
558    }
559
560    /// Invariant for the states of active chains.
561    pub async fn is_active(&self) -> Result<bool, ChainError> {
562        Ok(self.execution_state.system.is_active().await?)
563    }
564
565    /// Initializes the chain if it is not active yet.
566    pub async fn initialize_if_needed(&mut self, local_time: Timestamp) -> Result<(), ChainError> {
567        let chain_id = self.chain_id();
568        // Initialize ourselves.
569        if self
570            .execution_state
571            .system
572            .initialize_chain(chain_id)
573            .await
574            .with_execution_context(ChainExecutionContext::Block)?
575        {
576            // The chain was already initialized.
577            return Ok(());
578        }
579        let maybe_committee = self
580            .execution_state
581            .system
582            .current_committee()
583            .await
584            .with_execution_context(ChainExecutionContext::Block)?;
585        // Last, reset the consensus state based on the current ownership.
586        self.manager.reset(
587            self.execution_state.system.ownership.get().await?.clone(),
588            BlockHeight(0),
589            local_time,
590            maybe_committee
591                .iter()
592                .flat_map(|(_, committee)| committee.account_keys_and_weights()),
593        )?;
594        Ok(())
595    }
596
597    /// Inserts `(height, hash)` into `block_hashes` and updates the
598    /// `next_height_to_preprocess` register accordingly. Every write to
599    /// `block_hashes` must go through this helper so the register stays in sync.
600    fn insert_block_hash(
601        &mut self,
602        height: BlockHeight,
603        hash: CryptoHash,
604    ) -> Result<(), ChainError> {
605        self.block_hashes.insert(&height, hash)?;
606        if *self.next_height_to_preprocess.get() <= height {
607            self.next_height_to_preprocess.set(height.try_add_one()?);
608        }
609        Ok(())
610    }
611
612    /// Attempts to process a new `bundle` of messages from the given `origin`. Returns an
613    /// internal error if the bundle doesn't appear to be new, based on the sender's
614    /// height. The value `local_time` is specific to each validator and only used for
615    /// round timeouts.
616    ///
617    /// Returns `true` if incoming `Subscribe` messages created new outbox entries.
618    #[instrument(skip_all, fields(
619        chain_id = %self.chain_id(),
620        origin = %origin,
621        bundle_height = %bundle.height
622    ))]
623    pub async fn receive_message_bundle_with_inbox(
624        &mut self,
625        inbox: &mut InboxStateView<C>,
626        origin: &ChainId,
627        bundle: MessageBundle,
628        local_time: Timestamp,
629        add_to_received_log: bool,
630    ) -> Result<(), ChainError> {
631        assert!(!bundle.messages.is_empty());
632        let chain_id = self.chain_id();
633        tracing::trace!(
634            "Processing new messages from {origin} at height {}",
635            bundle.height,
636        );
637        let chain_and_height = ChainAndHeight {
638            chain_id: *origin,
639            height: bundle.height,
640        };
641
642        match self.initialize_if_needed(local_time).await {
643            Ok(_) => (),
644            // if the only issue was that we couldn't initialize the chain because of a
645            // missing chain description blob, we might still want to update the inbox
646            Err(ChainError::ExecutionError(exec_err, _))
647                if matches!(*exec_err, ExecutionError::BlobsNotFound(ref blobs)
648                if blobs.iter().all(|blob_id| {
649                    blob_id.blob_type == BlobType::ChainDescription && blob_id.hash == chain_id.0
650                })) => {}
651            err => {
652                return err;
653            }
654        }
655
656        // Process the inbox bundle and update the inbox state.
657        let newly_added = inbox
658            .add_bundle(bundle)
659            .await
660            .map_err(|error| match error {
661                InboxError::ViewError(error) => ChainError::ViewError(error),
662                error => ChainError::CorruptedChainState(format!(
663                    "while processing messages in certified block: {error}"
664                )),
665            })?;
666        if newly_added {
667            self.nonempty_inboxes.get_mut().insert(*origin);
668        }
669
670        // Remember the certificate for future validator/client synchronizations.
671        if add_to_received_log {
672            self.received_log.push(chain_and_height);
673        }
674        Ok(())
675    }
676
677    /// Updates the `received_log` trackers.
678    pub fn update_received_certificate_trackers(
679        &mut self,
680        new_trackers: BTreeMap<ValidatorPublicKey, u64>,
681    ) {
682        for (name, tracker) in new_trackers {
683            self.received_certificate_trackers
684                .get_mut()
685                .entry(name)
686                .and_modify(|t| {
687                    // Because several synchronizations could happen in parallel, we need to make
688                    // sure to never go backward.
689                    if tracker > *t {
690                        *t = tracker;
691                    }
692                })
693                .or_insert(tracker);
694        }
695    }
696
697    /// Returns the current epoch and committee of this chain.
698    pub async fn current_committee(&self) -> Result<(Epoch, Arc<Committee>), ChainError> {
699        let chain_id = self.chain_id();
700        self.execution_state
701            .system
702            .current_committee()
703            .await
704            .with_execution_context(ChainExecutionContext::Block)?
705            .ok_or(ChainError::InactiveChain(chain_id))
706    }
707
708    /// Returns the ownership configuration of this chain.
709    pub async fn ownership(&self) -> Result<&ChainOwnership, ChainError> {
710        Ok(self.execution_state.system.ownership.get().await?)
711    }
712
713    /// Removes the incoming message bundles in the block from the inboxes.
714    ///
715    /// If `must_be_present` is `true`, an error is returned if any of the bundles have not been
716    /// added to the inbox yet. So this should be `true` if the bundles are in a block _proposal_,
717    /// and `false` if the block is already confirmed.
718    #[instrument(skip_all, fields(
719        chain_id = %self.chain_id(),
720    ))]
721    pub async fn remove_bundles_from_inboxes(
722        &mut self,
723        timestamp: Timestamp,
724        must_be_present: bool,
725        incoming_bundles: impl IntoIterator<Item = &IncomingBundle>,
726    ) -> Result<(), ChainError> {
727        let chain_id = self.chain_id();
728        let mut bundles_by_origin: BTreeMap<_, Vec<&MessageBundle>> = Default::default();
729        for IncomingBundle { bundle, origin, .. } in incoming_bundles {
730            ensure!(
731                bundle.timestamp <= timestamp,
732                ChainError::IncorrectBundleTimestamp {
733                    chain_id,
734                    bundle_timestamp: bundle.timestamp,
735                    block_timestamp: timestamp,
736                }
737            );
738            let bundles = bundles_by_origin.entry(*origin).or_default();
739            bundles.push(bundle);
740        }
741        let origins = bundles_by_origin.keys().copied().collect::<Vec<_>>();
742        let inboxes = self.inboxes.try_load_entries_mut(&origins).await?;
743        // When the bundles must already be present (block proposals), collect *every* missing
744        // `(origin, height)` rather than bailing on the first, so the caller can be told the
745        // full set of cross-chain updates to fetch in a single round-trip.
746        let mut missing_bundles = Vec::new();
747        for ((origin, bundles), mut inbox) in bundles_by_origin.into_iter().zip(inboxes) {
748            tracing::trace!(
749                "Removing [{}] from inbox for {origin}",
750                bundles
751                    .iter()
752                    .map(|bundle| bundle.height.to_string())
753                    .collect::<Vec<_>>()
754                    .join(", ")
755            );
756            for bundle in bundles {
757                // Mark the message as processed in the inbox.
758                let was_present = inbox
759                    .remove_bundle(bundle)
760                    .await
761                    .map_err(|error| (chain_id, origin, error))?;
762                if must_be_present && !was_present {
763                    missing_bundles.push((origin, bundle.height));
764                }
765            }
766            inbox.observe_size_metric();
767            if inbox.added_bundles.count() == 0 {
768                self.nonempty_inboxes.get_mut().remove(&origin);
769            }
770        }
771        ensure!(
772            missing_bundles.is_empty(),
773            ChainError::MissingCrossChainUpdates {
774                chain_id,
775                bundles: missing_bundles,
776            }
777        );
778        Ok(())
779    }
780
781    /// Returns the chain IDs of all recipients for which a message is waiting in the outbox.
782    pub fn nonempty_outbox_chain_ids(&self) -> Vec<ChainId> {
783        self.nonempty_outboxes.get().iter().copied().collect()
784    }
785
786    /// Returns the outboxes for the given targets, or an error if any of them are missing.
787    pub async fn load_outboxes(
788        &self,
789        targets: &[ChainId],
790    ) -> Result<Vec<ReadGuardedView<OutboxStateView<C>>>, ChainError> {
791        let vec_of_options = self.outboxes.try_load_entries(targets).await?;
792        let optional_vec = vec_of_options.into_iter().collect::<Option<Vec<_>>>();
793        optional_vec.ok_or_else(|| ChainError::CorruptedChainState("Missing outboxes".into()))
794    }
795
796    /// Reconciles the `nonempty_outboxes` and `outbox_counters` indices from the retained outbox
797    /// queues, rebuilding only when the tracked set changed since the last call (i.e. the stored
798    /// [`Self::outbox_index_tracked_hash`] no longer matches). The per-target outbox *queues* in
799    /// `outboxes` are always kept; only these indices are filtered. Returns whether a rebuild
800    /// actually happened, so a read-only caller can skip persisting when nothing changed.
801    pub async fn reconcile_outbox_index(
802        &mut self,
803        tracked: Option<&Hashed<ChainIdSet>>,
804    ) -> Result<bool, ChainError> {
805        if *self.outbox_index_tracked_hash.get() == tracked.map(|tracked| tracked.hash()) {
806            return Ok(false);
807        }
808        self.rebuild_outbox_index(tracked).await?;
809        Ok(true)
810    }
811
812    /// Rebuilds `nonempty_outboxes` and `outbox_counters` from the retained outbox queues for the
813    /// tracked set (every retained queue on a validator, `tracked == None`), and stamps
814    /// [`Self::outbox_index_tracked_hash`]. Unlike [`Self::reconcile_outbox_index`] this always
815    /// rebuilds, so callers that have just rewritten the queues can refresh the indices regardless
816    /// of the stored stamp.
817    async fn rebuild_outbox_index(
818        &mut self,
819        tracked: Option<&Hashed<ChainIdSet>>,
820    ) -> Result<(), ChainError> {
821        self.nonempty_outboxes.get_mut().clear();
822        self.outbox_counters.get_mut().clear();
823        // In full mode (`None`) there is no tracked subset to iterate, so re-index from the keys of
824        // every retained outbox queue.
825        let targets = match tracked {
826            Some(tracked) => tracked.inner().iter().copied().collect::<Vec<_>>(),
827            None => self.outboxes.indices().await?,
828        };
829        for target in &targets {
830            let heights = {
831                let Some(outbox) = self.outboxes.try_load_entry(target).await? else {
832                    continue;
833                };
834                outbox.queue.elements().await?
835            };
836            if heights.is_empty() {
837                continue;
838            }
839            for height in heights {
840                *self.outbox_counters.get_mut().entry(height).or_default() += 1;
841            }
842            self.nonempty_outboxes.get_mut().insert(*target);
843        }
844        self.outbox_index_tracked_hash
845            .set(tracked.map(|tracked| tracked.hash()));
846        Ok(())
847    }
848
849    /// Returns whether the outbox index is already reconciled to `tracked` (the stored hash
850    /// matches), so the read-only network-actions path can read it without a write-lock rebuild.
851    pub fn outbox_index_is_reconciled(&self, tracked: Option<&Hashed<ChainIdSet>>) -> bool {
852        *self.outbox_index_tracked_hash.get() == tracked.map(|tracked| tracked.hash())
853    }
854
855    /// Executes a block with a specified policy for handling bundle failures.
856    #[expect(clippy::too_many_arguments)]
857    #[instrument(skip_all, fields(
858        chain_id = %block.chain_id,
859        block_height = %block.height
860    ))]
861    async fn execute_block_inner(
862        chain: &mut ExecutionStateView<C>,
863        block_hashes: &CustomMapView<C, BlockHeight, CryptoHash>,
864        block: &mut ProposedBlock,
865        local_time: Timestamp,
866        round: Option<u32>,
867        published_blobs: &[Blob],
868        replaying_oracle_responses: Option<Vec<Vec<OracleResponse>>>,
869        exec_policy: BundleExecutionPolicy,
870        phase: BlockExecutionPhase,
871        checkpoint_origin_cursors: Vec<(ChainId, Cursor)>,
872        checkpoint_inbox_cursors: Vec<(ChainId, Cursor)>,
873        checkpoint_outbox_block_hashes: Vec<CryptoHash>,
874    ) -> Result<(BlockExecutionOutcome, ResourceTracker, HashSet<ChainId>), ChainError> {
875        #[cfg(with_metrics)]
876        let block_execution_latency =
877            metrics::BLOCK_EXECUTION_LATENCY.with_label_values(&[phase.into()]);
878        #[cfg(with_metrics)]
879        let _execution_latency = block_execution_latency.measure_latency_us();
880
881        // Resolve the current epoch's resource policy first: `prepare_checkpoint` needs
882        // `maximum_blob_size` to chunk the dump, and `current_committee` is a pure read
883        // so it can run before the dump without tainting the inner view's pending-changes
884        // set.
885        let committee_policy = chain
886            .system
887            .current_committee()
888            .await
889            .with_execution_context(ChainExecutionContext::Block)?
890            .ok_or_else(|| ChainError::InactiveChain(block.chain_id))?
891            .1
892            .policy()
893            .clone();
894
895        // Pre-block hook: if this block contains a `SystemOperation::Checkpoint`, dump the
896        // execution state from storage *now*, before any block-level mutation taints the
897        // inner view's pending-changes set. The matching operation handler will publish
898        // the resulting blobs without re-dumping; subsequent fees and other state changes
899        // accumulate normally and end up persisted with the override hash on save. The
900        // inbox snapshot was already taken by the outer `execute_block` and passed in as
901        // `checkpoint_origin_cursors`; we bundle the two halves into a
902        // [`PreparedCheckpoint`] for the handler.
903        let prepared_checkpoint = if block.starts_with_checkpoint() {
904            let blobs = chain
905                .prepare_checkpoint(committee_policy.maximum_blob_size)
906                .await
907                .with_execution_context(ChainExecutionContext::Block)?;
908            Some(PreparedCheckpoint {
909                blobs,
910                origin_cursors: checkpoint_origin_cursors,
911                inbox_cursors: checkpoint_inbox_cursors,
912                outbox_block_hashes: checkpoint_outbox_block_hashes,
913            })
914        } else {
915            None
916        };
917
918        chain.system.progress.get_mut().timestamp = block.timestamp;
919
920        let start_epoch = *chain.system.epoch.get();
921
922        let mut resource_controller = ResourceController::new(
923            Arc::new(committee_policy),
924            ResourceTracker::default(),
925            block.authenticated_owner,
926        );
927
928        for blob in published_blobs {
929            let blob_id = blob.id();
930            resource_controller
931                .policy()
932                .check_blob_size(blob.content())
933                .with_execution_context(ChainExecutionContext::Block)?;
934            chain.system.used_blobs.insert(&blob_id)?;
935        }
936
937        let mut block_execution_tracker = BlockExecutionTracker::new(
938            &mut resource_controller,
939            published_blobs
940                .iter()
941                .map(|blob| (blob.id(), blob))
942                .collect(),
943            local_time,
944            replaying_oracle_responses,
945            block,
946            phase,
947        )?;
948        if let Some(prepared) = prepared_checkpoint {
949            block_execution_tracker.set_prepared_checkpoint(prepared);
950        }
951
952        // Extract failure-policy parameters from exec_policy.
953        let (max_failures, never_reject_application_ids) = match &exec_policy.on_failure {
954            BundleFailurePolicy::Abort => (0, Arc::new(HashSet::new())),
955            BundleFailurePolicy::AutoRetry {
956                max_failures,
957                never_reject_application_ids,
958            } => (*max_failures, never_reject_application_ids.clone()),
959        };
960        let auto_retry = !matches!(exec_policy.on_failure, BundleFailurePolicy::Abort);
961        let mut failure_count = 0u32;
962        let mut never_reject_discarded_origins = HashSet::new();
963
964        let time_budget = exec_policy.time_budget;
965        let mut cumulative_bundle_time = Duration::ZERO;
966
967        let mut i = 0;
968        while i < block.transactions.len() {
969            let transaction = &mut block.transactions[i];
970            let is_bundle = matches!(transaction, Transaction::ReceiveMessages(_));
971            let is_stream_update = transaction.is_update_stream();
972
973            // If we have a time budget and it's been exceeded, discard remaining bundles.
974            if is_bundle && time_budget.is_some_and(|budget| cumulative_bundle_time >= budget) {
975                info!(
976                    ?cumulative_bundle_time,
977                    ?time_budget,
978                    "Time budget for bundle staging exceeded, discarding remaining bundles"
979                );
980                Self::discard_remaining_bundles(block, i, None);
981                continue;
982            }
983
984            let checkpoint = if auto_retry && (is_bundle || is_stream_update) {
985                Some((
986                    chain.clone_unchecked()?,
987                    block_execution_tracker.create_checkpoint(),
988                ))
989            } else {
990                None
991            };
992
993            let bundle_start = if is_bundle && time_budget.is_some() {
994                Some(Instant::now())
995            } else {
996                None
997            };
998
999            let result = block_execution_tracker
1000                .execute_transaction(&*transaction, round, chain)
1001                .await;
1002
1003            if let Some(start) = bundle_start {
1004                cumulative_bundle_time += start.elapsed();
1005            }
1006
1007            // If the transaction executed successfully, we move on to the next one.
1008            // On transient errors (e.g. missing blobs) we fail, so it can be retried after
1009            // syncing. In auto-retry mode, we can discard or reject message bundles that failed
1010            // with non-transient errors.
1011            match (result, transaction, checkpoint) {
1012                (Ok(()), _, _) => {
1013                    i += 1;
1014                }
1015                (
1016                    Err(ChainError::ExecutionError(error, _context)),
1017                    Transaction::ReceiveMessages(incoming_bundle),
1018                    Some((saved_chain, saved_tracker)),
1019                ) if !error.is_transient_error() && error.is_limit_error() && i > 0 => {
1020                    // Restore checkpoint.
1021                    *chain = saved_chain;
1022                    block_execution_tracker.restore_checkpoint(&saved_tracker);
1023                    failure_count += 1;
1024                    // If we've exceeded max failures, discard all remaining message bundles.
1025                    let maybe_sender = if failure_count > max_failures {
1026                        info!(
1027                            failure_count,
1028                            max_failures,
1029                            "Exceeded max bundle failures, discarding all remaining message \
1030                            bundles and stream updates"
1031                        );
1032                        Self::discard_remaining_stream_updates(block, i);
1033                        None
1034                    } else {
1035                        // Not the first - discard it and same-sender subsequent bundles.
1036                        info!(
1037                            %error,
1038                            index = i,
1039                            origin = %incoming_bundle.origin,
1040                            "Message bundle exceeded block limits and will be discarded for \
1041                            retry in a later block"
1042                        );
1043                        Some(incoming_bundle.origin)
1044                    };
1045                    Self::discard_remaining_bundles(block, i, maybe_sender);
1046                    // Do not increment i - the next transaction is now at i.
1047                }
1048                (
1049                    Err(ChainError::ExecutionError(error, context)),
1050                    Transaction::ReceiveMessages(incoming_bundle),
1051                    Some((saved_chain, saved_tracker)),
1052                ) if !error.is_transient_error() => {
1053                    // Restore checkpoint.
1054                    *chain = saved_chain;
1055                    block_execution_tracker.restore_checkpoint(&saved_tracker);
1056
1057                    let all_messages_never_reject = !never_reject_application_ids.is_empty()
1058                        && incoming_bundle.messages().all(|posted_msg| {
1059                            never_reject_application_ids
1060                                .contains(&posted_msg.message.application_id())
1061                        });
1062                    if (all_messages_never_reject || incoming_bundle.bundle.is_protected())
1063                        && incoming_bundle.action != MessageAction::Reject
1064                    {
1065                        let origin = incoming_bundle.origin;
1066                        never_reject_discarded_origins.insert(origin);
1067                        warn!(
1068                            %error,
1069                            index = i,
1070                            %origin,
1071                            "Message bundle cannot be rejected (protected or never-reject); \
1072                            discarding the bundle (and same-sender subsequent bundles) for retry \
1073                            in a later block"
1074                        );
1075                        Self::discard_remaining_bundles(block, i, Some(origin));
1076                    } else if incoming_bundle.action == MessageAction::Reject {
1077                        // Failed rejected bundles fail the block.
1078                        return Err(ChainError::ExecutionError(error, context));
1079                    } else {
1080                        // Reject the bundle: either a non-limit error, or the first bundle
1081                        // exceeded limits (and is inherently too large for any block).
1082                        info!(
1083                            %error,
1084                            index = i,
1085                            origin = %incoming_bundle.origin,
1086                            "Message bundle failed to execute and will be rejected"
1087                        );
1088                        incoming_bundle.action = MessageAction::Reject;
1089                    }
1090                    // Do not increment i - retry the transaction after modification.
1091                }
1092                (
1093                    Err(ChainError::ExecutionError(error, _context)),
1094                    transaction,
1095                    Some((saved_chain, saved_tracker)),
1096                ) if transaction.is_update_stream()
1097                    && !error.is_transient_error()
1098                    && error.is_limit_error()
1099                    && i > 0 =>
1100                {
1101                    // Restore checkpoint.
1102                    *chain = saved_chain;
1103                    block_execution_tracker.restore_checkpoint(&saved_tracker);
1104                    failure_count += 1;
1105                    if failure_count > max_failures {
1106                        info!(
1107                            failure_count,
1108                            max_failures,
1109                            "Exceeded max failures, discarding all remaining stream updates and \
1110                            message bundles"
1111                        );
1112                        Self::discard_remaining_bundles(block, i, None);
1113                        Self::discard_remaining_stream_updates(block, i);
1114                    } else {
1115                        info!(
1116                            %error,
1117                            index = i,
1118                            "UpdateStream exceeded block limits, discarding for retry"
1119                        );
1120                        block.transactions.remove(i);
1121                    }
1122                    // Do not increment i - the next transaction is now at i.
1123                }
1124                (Err(e), _, _) => return Err(e),
1125            };
1126        }
1127
1128        // This can only happen if all transactions were incoming bundles that all got discarded
1129        // due to resource limit errors. This is unlikely in practice but theoretically possible.
1130        ensure!(!block.transactions.is_empty(), ChainError::EmptyBlock);
1131
1132        // A block may advance the epoch at most once, so that consecutive blocks never skip
1133        // an epoch: the child of a block in epoch `e` is at most in epoch `e + 1`.
1134        let end_epoch = *chain.system.epoch.get();
1135        ensure!(
1136            end_epoch.0 <= start_epoch.0.saturating_add(1),
1137            ChainError::MultipleEpochAdvances {
1138                start_epoch,
1139                end_epoch,
1140            }
1141        );
1142
1143        let recipients = block_execution_tracker.recipients();
1144        let non_ack_tx_indices = block_execution_tracker.non_checkpoint_ack_tx_indices();
1145        let mut recipient_heights = Vec::new();
1146        for (recipient, height) in chain
1147            .previous_message_blocks
1148            .multi_get_pairs(recipients)
1149            .await?
1150        {
1151            // Only `CheckpointAck`-only blocks are excluded from the chain-level
1152            // tracking. Otherwise the recipient never acknowledges (a
1153            // `CheckpointAck` doesn't trigger a return `CheckpointAck`), so the
1154            // entry would never get trimmed. Off-chain outbox bookkeeping further
1155            // down still queues these for delivery; only the
1156            // `previous_message_blocks` / `unfinalized_message_blocks` chain skips
1157            // them.
1158            if let Some(tx_indices) = non_ack_tx_indices.get(&recipient) {
1159                chain
1160                    .previous_message_blocks
1161                    .insert(&recipient, block.height)?;
1162                // Track each non-CheckpointAck bundle's cursor as pending
1163                // acknowledgement from the recipient. We don't know this block's
1164                // hash yet (we're mid-execution); the checkpoint pre-block hook
1165                // resolves heights to hashes via `block_hashes` when it builds the
1166                // oracle response.
1167                let mut cursors = chain
1168                    .system
1169                    .unfinalized_message_blocks
1170                    .get(&recipient)
1171                    .await?
1172                    .unwrap_or_default();
1173                for index in tx_indices {
1174                    cursors.insert(Cursor {
1175                        height: block.height,
1176                        index: *index,
1177                    });
1178                }
1179                chain
1180                    .system
1181                    .unfinalized_message_blocks
1182                    .insert(&recipient, cursors)?;
1183            }
1184            if let Some(height) = height {
1185                recipient_heights.push((recipient, height));
1186            }
1187        }
1188        let hashes = block_hashes
1189            .multi_get(recipient_heights.iter().map(|(_, height)| height))
1190            .await?;
1191        let mut previous_message_blocks = BTreeMap::new();
1192        for (hash, (recipient, height)) in hashes.into_iter().zip(recipient_heights) {
1193            let hash = hash.ok_or_else(|| {
1194                ChainError::CorruptedChainState("missing entry in block_hashes".into())
1195            })?;
1196            previous_message_blocks.insert(recipient, (hash, height));
1197        }
1198
1199        let streams = block_execution_tracker.event_streams();
1200        let mut stream_heights = Vec::new();
1201        for (stream, height) in chain.previous_event_blocks.multi_get_pairs(streams).await? {
1202            chain.previous_event_blocks.insert(&stream, block.height)?;
1203            if let Some(height) = height {
1204                stream_heights.push((stream, height));
1205            }
1206        }
1207        let hashes = block_hashes
1208            .multi_get(stream_heights.iter().map(|(_, height)| height))
1209            .await?;
1210        let mut previous_event_blocks = BTreeMap::new();
1211        for (hash, (stream, height)) in hashes.into_iter().zip(stream_heights) {
1212            let hash = hash.ok_or_else(|| {
1213                ChainError::CorruptedChainState("missing entry in block_hashes".into())
1214            })?;
1215            previous_event_blocks.insert(stream, (hash, height));
1216        }
1217
1218        let state_hash = {
1219            #[cfg(with_metrics)]
1220            let state_hash_latency =
1221                metrics::STATE_HASH_COMPUTATION_LATENCY.with_label_values(&[phase.into()]);
1222            #[cfg(with_metrics)]
1223            let _hash_latency = state_hash_latency.measure_latency_us();
1224            chain.crypto_hash_mut().await?
1225        };
1226
1227        let (messages, oracle_responses, events, blobs, operation_results, resource_tracker) =
1228            block_execution_tracker.finalize(block.transactions.len());
1229
1230        Ok((
1231            BlockExecutionOutcome {
1232                messages,
1233                previous_message_blocks,
1234                previous_event_blocks,
1235                state_hash,
1236                oracle_responses,
1237                events,
1238                blobs,
1239                operation_results,
1240            },
1241            resource_tracker,
1242            never_reject_discarded_origins,
1243        ))
1244    }
1245
1246    fn discard_remaining_stream_updates(block: &mut ProposedBlock, mut index: usize) {
1247        while index < block.transactions.len() {
1248            if block.transactions[index].is_update_stream() {
1249                block.transactions.remove(index);
1250            } else {
1251                index += 1;
1252            }
1253        }
1254    }
1255
1256    fn discard_remaining_bundles(
1257        block: &mut ProposedBlock,
1258        mut index: usize,
1259        maybe_origin: Option<ChainId>,
1260    ) {
1261        while index < block.transactions.len() {
1262            if matches!(
1263                &block.transactions[index],
1264                Transaction::ReceiveMessages(bundle)
1265                if maybe_origin.is_none_or(|origin| bundle.origin == origin)
1266            ) {
1267                block.transactions.remove(index);
1268            } else {
1269                index += 1;
1270            }
1271        }
1272    }
1273
1274    /// Executes a block with a specified policy for handling bundle failures.
1275    ///
1276    /// This method supports automatic retry with checkpointing when bundles fail:
1277    /// - For limit errors (block too large, fuel exceeded, etc.): the bundle is discarded
1278    ///   so it can be retried in a later block, unless it's the first transaction
1279    ///   (which gets rejected as inherently too large).
1280    /// - For non-limit errors: the bundle is rejected (triggering bounced messages).
1281    /// - After `max_failures` failed bundles, all remaining message bundles are discarded.
1282    ///
1283    /// The block may be modified to reflect the actual executed transactions.
1284    #[instrument(skip_all, fields(
1285        chain_id = %self.chain_id(),
1286        block_height = %block.height
1287    ))]
1288    pub async fn execute_block(
1289        &mut self,
1290        mut block: ProposedBlock,
1291        local_time: Timestamp,
1292        round: Option<u32>,
1293        published_blobs: &[Blob],
1294        execution: BlockExecution,
1295    ) -> Result<
1296        (
1297            ProposedBlock,
1298            BlockExecutionOutcome,
1299            ResourceTracker,
1300            HashSet<ChainId>,
1301        ),
1302        ChainError,
1303    > {
1304        assert_eq!(
1305            block.chain_id,
1306            self.execution_state.context().extra().chain_id()
1307        );
1308
1309        self.initialize_if_needed(local_time).await?;
1310
1311        let chain_timestamp = self.execution_state.system.progress.get().timestamp;
1312        ensure!(
1313            chain_timestamp <= block.timestamp,
1314            ChainError::InvalidBlockTimestamp {
1315                parent: chain_timestamp,
1316                new: block.timestamp
1317            }
1318        );
1319        ensure!(!block.transactions.is_empty(), ChainError::EmptyBlock);
1320
1321        ensure!(
1322            block.published_blob_ids()
1323                == published_blobs
1324                    .iter()
1325                    .map(|blob| blob.id())
1326                    .collect::<BTreeSet<_>>(),
1327            ChainError::InternalError("published_blobs mismatch".to_string())
1328        );
1329
1330        if *self.execution_state.system.closed.get() {
1331            ensure!(block.has_only_rejected_messages(), ChainError::ClosedChain);
1332        }
1333
1334        Self::check_app_permissions(
1335            self.execution_state
1336                .system
1337                .application_permissions
1338                .get()
1339                .await?,
1340            &block,
1341        )?;
1342
1343        ensure!(
1344            !block
1345                .transactions
1346                .iter()
1347                .skip(1)
1348                .any(Transaction::is_checkpoint),
1349            ChainError::CheckpointPreconditionFailed(
1350                "Checkpoint must be the first transaction in its block",
1351            )
1352        );
1353        let (origin_cursors, inbox_cursors, outbox_block_hashes) = if block.starts_with_checkpoint()
1354        {
1355            self.check_checkpoint_preconditions().await?;
1356            let origin_cursors = self.collect_inbox_cursors().await?;
1357            let inbox_cursors = self.collect_all_inbox_cursors().await?;
1358            let hashes = self.collect_unfinalized_block_hashes().await?;
1359            (origin_cursors, inbox_cursors, hashes)
1360        } else {
1361            (Vec::new(), Vec::new(), Vec::new())
1362        };
1363
1364        let phase = execution.phase();
1365        let (replaying_oracle_responses, policy) = execution.into_oracle_and_policy();
1366        let (outcome, tracker, never_reject_origins) = Self::execute_block_inner(
1367            &mut self.execution_state,
1368            &self.block_hashes,
1369            &mut block,
1370            local_time,
1371            round,
1372            published_blobs,
1373            replaying_oracle_responses,
1374            policy,
1375            phase,
1376            origin_cursors,
1377            inbox_cursors,
1378            outbox_block_hashes,
1379        )
1380        .await?;
1381
1382        Ok((block, outcome, tracker, never_reject_origins))
1383    }
1384
1385    /// Snapshots `(origin, next_cursor_to_remove)` for each chain we've received a
1386    /// non-`Checkpoint` message from since our last own checkpoint. Used by the
1387    /// pre-block hook so the matching operation handler can emit a
1388    /// [`SystemMessage::CheckpointAck`] to each origin chain. Iterating
1389    /// `pending_checkpoint_ack_targets` (instead of every inbox) is what prevents the
1390    /// notification ping-pong: a chain that only ever sent us `Checkpoint`s is
1391    /// excluded here, so we never reply with a `Checkpoint` of our own.
1392    async fn collect_inbox_cursors(&self) -> Result<Vec<(ChainId, Cursor)>, ChainError> {
1393        let targets = self
1394            .execution_state
1395            .system
1396            .pending_checkpoint_ack_targets
1397            .indices()
1398            .await?;
1399        let mut cursors = Vec::with_capacity(targets.len());
1400        for origin in targets {
1401            let Some(inbox) = self.inboxes.try_load_entry(&origin).await? else {
1402                continue;
1403            };
1404            cursors.push((origin, *inbox.next_cursor_to_remove.get()));
1405        }
1406        Ok(cursors)
1407    }
1408
1409    /// Snapshots `(origin, next_cursor_to_remove)` for every inbox with a non-default
1410    /// `next_cursor_to_remove`. Recorded in the checkpoint's oracle response so a
1411    /// bootstrapping node can seed each inbox's `restored_cursor` and silently drop
1412    /// any sender re-pushes whose effects are already in the restored execution state.
1413    async fn collect_all_inbox_cursors(&self) -> Result<Vec<(ChainId, Cursor)>, ChainError> {
1414        let origins = self.inboxes.indices().await?;
1415        let mut cursors = Vec::new();
1416        for origin in origins {
1417            let Some(inbox) = self.inboxes.try_load_entry(&origin).await? else {
1418                continue;
1419            };
1420            let cursor = *inbox.next_cursor_to_remove.get();
1421            if cursor != Cursor::default() {
1422                cursors.push((origin, cursor));
1423            }
1424        }
1425        Ok(cursors)
1426    }
1427
1428    /// Re-populates `outboxes`, `outbox_counters`, and `nonempty_outboxes` from
1429    /// the on-chain `unfinalized_message_blocks` map after a checkpoint
1430    /// bootstrap. Called once after `execution_state.restore_from_content` so
1431    /// the freshly-restored chain can pick up cross-chain delivery for
1432    /// pre-checkpoint messages — the off-chain outbox state isn't part of the
1433    /// certified checkpoint blob, so without this a bootstrapped node would
1434    /// silently stop pushing pending messages forward.
1435    pub async fn restore_outboxes_from_unfinalized(
1436        &mut self,
1437        tracked: Option<&Hashed<ChainIdSet>>,
1438    ) -> Result<(), ChainError> {
1439        // A lagging validator hit by a checkpoint push may already have outbox
1440        // queues from blocks it processed before the gap. Clear them so the
1441        // rebuild from `unfinalized_message_blocks` doesn't append duplicate
1442        // heights onto stale queues (the counters/nonempty set below `set`
1443        // wholesale, but the queues are appended to per recipient).
1444        let prior_recipients = self.outboxes.indices().await?;
1445        for recipient in prior_recipients {
1446            let mut outbox = self.outboxes.try_load_entry_mut(&recipient).await?;
1447            outbox.queue.clear();
1448            outbox.next_height_to_schedule.set(BlockHeight::ZERO);
1449        }
1450        let entries = self
1451            .execution_state
1452            .system
1453            .unfinalized_message_blocks
1454            .index_values()
1455            .await?;
1456        for (recipient, cursors) in entries {
1457            if cursors.is_empty() {
1458                continue;
1459            }
1460            // Dedup by height: the outbox queue tracks block heights only (multiple
1461            // bundles at the same height share a single queue entry).
1462            let heights = cursors
1463                .into_iter()
1464                .map(|cursor| cursor.height)
1465                .collect::<BTreeSet<_>>();
1466            let mut outbox = self.outboxes.try_load_entry_mut(&recipient).await?;
1467            for height in &heights {
1468                outbox.queue.push_back(*height);
1469            }
1470            let max_height = *heights
1471                .last()
1472                .expect("the empty case was filtered out above");
1473            outbox
1474                .next_height_to_schedule
1475                .set(max_height.try_add_one()?);
1476        }
1477        // The queues are now authoritative; rebuild the tracked-only indices from them.
1478        self.rebuild_outbox_index(tracked).await?;
1479        Ok(())
1480    }
1481
1482    /// Collects the hashes of every block on this chain still listed in the on-chain
1483    /// `unfinalized_message_blocks` map. The checkpoint pre-block hook calls this to
1484    /// build the oracle response's `outbox_block_hashes`, so the checkpoint
1485    /// certificate transitively re-certifies those older (possibly revoked-epoch)
1486    /// blocks.
1487    async fn collect_unfinalized_block_hashes(&self) -> Result<Vec<CryptoHash>, ChainError> {
1488        let heights = self.collect_unfinalized_heights().await?;
1489        let mut hashes = Vec::with_capacity(heights.len());
1490        for height in heights {
1491            let hash = self.block_hashes.get(&height).await?.ok_or_else(|| {
1492                ChainError::CorruptedChainState(format!(
1493                    "missing entry in block_hashes at height {height}"
1494                ))
1495            })?;
1496            hashes.push(hash);
1497        }
1498        Ok(hashes)
1499    }
1500
1501    /// Returns the sorted, deduplicated set of block heights referenced by the on-chain
1502    /// `unfinalized_message_blocks` map (one entry per height even if multiple bundles
1503    /// at that height are still unfinalized). Used both when building the checkpoint
1504    /// oracle response (to resolve heights to hashes via `block_hashes`) and on the
1505    /// bootstrap path (to zip with the certified `outbox_block_hashes` from the
1506    /// response).
1507    pub async fn collect_unfinalized_heights(&self) -> Result<BTreeSet<BlockHeight>, ChainError> {
1508        let mut heights = BTreeSet::new();
1509        let entries = self
1510            .execution_state
1511            .system
1512            .unfinalized_message_blocks
1513            .index_values()
1514            .await?;
1515        for (_, per_recipient) in entries {
1516            heights.extend(per_recipient.into_iter().map(|cursor| cursor.height));
1517        }
1518        Ok(heights)
1519    }
1520
1521    /// Applies an execution outcome to the chain, updating the outboxes, state hash and chain
1522    /// manager. This does not touch the execution state itself, which must be updated separately.
1523    /// Returns the set of event streams that were updated as a result of applying the block.
1524    #[instrument(skip_all, fields(
1525        chain_id = %self.chain_id(),
1526        block_height = %block.inner().inner().header.height
1527    ))]
1528    pub async fn apply_confirmed_block(
1529        &mut self,
1530        block: &ConfirmedBlock,
1531        local_time: Timestamp,
1532        tracked: Option<&ChainIdSet>,
1533    ) -> Result<BTreeSet<StreamId>, ChainError> {
1534        let hash = block.inner().hash();
1535        let block = block.inner().inner();
1536        if block.header.height == BlockHeight::ZERO {
1537            self.chain_initialized_at.set(local_time);
1538        }
1539        let updated_streams = self.process_emitted_events(block).await?;
1540        self.process_outgoing_messages(block, tracked).await?;
1541
1542        // Last, reset the consensus state based on the current ownership.
1543        self.reset_chain_manager(block.header.height.try_add_one()?, local_time)
1544            .await?;
1545
1546        // Advance to next block height.
1547        let tip = self.tip_state.get_mut();
1548        tip.block_hash = Some(hash);
1549        tip.next_block_height.try_add_assign_one()?;
1550        self.insert_block_hash(block.header.height, hash)?;
1551        if block.body.starts_with_checkpoint() {
1552            self.latest_checkpoint_height.set(Some(block.header.height));
1553        }
1554        Ok(updated_streams)
1555    }
1556
1557    /// Adds a block to `block_hashes` as preprocessed, and updates the outboxes where possible.
1558    /// Returns the set of streams that were updated as a result of preprocessing the block.
1559    #[instrument(skip_all, fields(
1560        chain_id = %self.chain_id(),
1561        block_height = %block.inner().inner().header.height
1562    ))]
1563    pub async fn preprocess_block(
1564        &mut self,
1565        block: &ConfirmedBlock,
1566        tracked: Option<&ChainIdSet>,
1567    ) -> Result<BTreeSet<StreamId>, ChainError> {
1568        let hash = block.inner().hash();
1569        let block = block.inner().inner();
1570        let height = block.header.height;
1571        if height < self.tip_state.get().next_block_height {
1572            return Ok(BTreeSet::new());
1573        }
1574        self.process_outgoing_messages(block, tracked).await?;
1575        let updated_streams = self.process_emitted_events(block).await?;
1576        self.insert_block_hash(height, hash)?;
1577        Ok(updated_streams)
1578    }
1579
1580    /// Verifies that the block is valid according to the chain's application permission settings.
1581    #[instrument(skip_all, fields(
1582        block_height = %block.height,
1583        num_transactions = %block.transactions.len()
1584    ))]
1585    fn check_app_permissions(
1586        app_permissions: &ApplicationPermissions,
1587        block: &ProposedBlock,
1588    ) -> Result<(), ChainError> {
1589        let mut mandatory = app_permissions
1590            .mandatory_applications
1591            .iter()
1592            .copied()
1593            .collect::<HashSet<ApplicationId>>();
1594        for transaction in &block.transactions {
1595            match transaction {
1596                Transaction::ExecuteOperation(operation)
1597                    if operation.is_exempt_from_permissions() =>
1598                {
1599                    mandatory.clear()
1600                }
1601                Transaction::ExecuteOperation(operation) => {
1602                    ensure!(
1603                        app_permissions.can_execute_operations(&operation.application_id()),
1604                        ChainError::AuthorizedApplications(
1605                            app_permissions.execute_operations.clone().unwrap()
1606                        )
1607                    );
1608                    if let Operation::User { application_id, .. } = operation {
1609                        mandatory.remove(application_id);
1610                    }
1611                }
1612                Transaction::ReceiveMessages(incoming_bundle)
1613                    if incoming_bundle.action == MessageAction::Accept =>
1614                {
1615                    for pending in incoming_bundle.messages() {
1616                        if let Message::User { application_id, .. } = &pending.message {
1617                            mandatory.remove(application_id);
1618                        }
1619                    }
1620                }
1621                Transaction::ReceiveMessages(_) => {}
1622            }
1623        }
1624        ensure!(
1625            mandatory.is_empty(),
1626            ChainError::MissingMandatoryApplications(mandatory.into_iter().collect())
1627        );
1628        Ok(())
1629    }
1630
1631    /// Validates the chain-state-level preconditions for a `SystemOperation::Checkpoint`:
1632    /// no *system* event stream tracker is set.
1633    ///
1634    /// The structural invariant that `Checkpoint` must be the *first* transaction in its
1635    /// block is enforced unconditionally in `execute_block`, independently of these
1636    /// preconditions. Sender-side event conditions are validated inside
1637    /// `ExecutionStateView::prepare_checkpoint`.
1638    async fn check_checkpoint_preconditions(&self) -> Result<(), ChainError> {
1639        let mut had_system_event_tracker = false;
1640        self.next_expected_events
1641            .for_each_index_while(|stream_id| {
1642                if matches!(stream_id.application_id, GenericApplicationId::System) {
1643                    had_system_event_tracker = true;
1644                    Ok(false)
1645                } else {
1646                    Ok(true)
1647                }
1648            })
1649            .await?;
1650        ensure!(
1651            !had_system_event_tracker,
1652            ChainError::CheckpointPreconditionFailed("chain has consumed system events")
1653        );
1654
1655        Ok(())
1656    }
1657
1658    /// Returns the hashes of all blocks we have at the given heights, in input order.
1659    /// Unknown heights are skipped.
1660    #[instrument(skip_all, fields(
1661        chain_id = %self.chain_id(),
1662        next_block_height = %self.tip_state.get().next_block_height,
1663    ))]
1664    pub async fn block_hashes_for_heights(
1665        &self,
1666        heights: impl IntoIterator<Item = BlockHeight>,
1667    ) -> Result<Vec<CryptoHash>, ChainError> {
1668        let heights = heights.into_iter().collect::<Vec<_>>();
1669        Ok(self
1670            .block_hashes
1671            .multi_get(&heights)
1672            .await?
1673            .into_iter()
1674            .flatten()
1675            .collect())
1676    }
1677
1678    /// Resets the chain manager for the next block height.
1679    async fn reset_chain_manager(
1680        &mut self,
1681        next_height: BlockHeight,
1682        local_time: Timestamp,
1683    ) -> Result<(), ChainError> {
1684        let maybe_committee = self
1685            .execution_state
1686            .system
1687            .current_committee()
1688            .await
1689            .with_execution_context(ChainExecutionContext::Block)?;
1690        let ownership = self.execution_state.system.ownership.get().await?.clone();
1691        let fallback_owners = maybe_committee
1692            .iter()
1693            .flat_map(|(_, committee)| committee.account_keys_and_weights());
1694        self.pending_validated_blobs.clear();
1695        self.pending_proposed_blobs.clear();
1696        self.manager
1697            .reset(ownership, next_height, local_time, fallback_owners)
1698    }
1699
1700    /// Updates the outboxes with the messages sent in the block.
1701    ///
1702    /// Returns the set of all recipients.
1703    #[instrument(skip_all, fields(
1704        chain_id = %self.chain_id(),
1705        block_height = %block.header.height
1706    ))]
1707    async fn process_outgoing_messages(
1708        &mut self,
1709        block: &Block,
1710        tracked: Option<&ChainIdSet>,
1711    ) -> Result<Vec<ChainId>, ChainError> {
1712        // Record the messages of the execution. Messages are understood within an
1713        // application.
1714        let recipients = block.recipients();
1715        let block_height = block.header.height;
1716        let next_height = self.tip_state.get().next_block_height;
1717
1718        // Update the outboxes. Every recipient's per-target outbox queue is updated, but the
1719        // `nonempty_outboxes` and `outbox_counters` indices are only populated for targets we track.
1720        let targets = recipients.into_iter().collect::<Vec<_>>();
1721        let outboxes = self.outboxes.try_load_entries_mut(&targets).await?;
1722        let mut scheduled_tracked = Vec::new();
1723        for (mut outbox, target) in outboxes.into_iter().zip(&targets) {
1724            if block_height > next_height {
1725                // There may be a gap in the chain before this block. We can only add it to this
1726                // outbox if the previous message to the same recipient has already been added.
1727                if *outbox.next_height_to_schedule.get() > block_height {
1728                    continue; // We already added this recipient's messages to the outbox.
1729                }
1730                let maybe_prev_hash = match outbox.next_height_to_schedule.get().try_sub_one().ok()
1731                {
1732                    Some(height) => {
1733                        Some(self.block_hashes.get(&height).await?.ok_or_else(|| {
1734                            ChainError::CorruptedChainState("missing entry in block_hashes".into())
1735                        })?)
1736                    }
1737                    None => None, // No message to that sender was added yet.
1738                };
1739                // Only schedule if this block contains the next message for that recipient.
1740                match (
1741                    maybe_prev_hash,
1742                    block.body.previous_message_blocks.get(target),
1743                ) {
1744                    (None, None) => {
1745                        // No previous message block expected and none indicated by the outbox -
1746                        // all good
1747                    }
1748                    (Some(_), None) => {
1749                        // The outbox already has a previous height for this recipient,
1750                        // but this block's body recorded no predecessor — that means
1751                        // this is the first non-`CheckpointAck` send to this recipient,
1752                        // even though earlier `CheckpointAck`-only blocks have already
1753                        // been added to the off-chain outbox. We can still schedule:
1754                        // the bundle will carry `previous_height = None`, which the
1755                        // receiver accepts as "first ever".
1756                    }
1757                    (None, Some((_, prev_msg_block_height))) => {
1758                        // We have no previously processed block in the outbox, but we are
1759                        // expecting one - this could be due to an empty outbox having been pruned.
1760                        // Only process the outbox if the height of the previous message block is
1761                        // lower than the tip
1762                        if *prev_msg_block_height >= next_height {
1763                            continue;
1764                        }
1765                    }
1766                    (Some(ref prev_hash), Some((prev_msg_block_hash, _))) => {
1767                        // Only process the outbox if the hashes match. A mismatch can
1768                        // arise legitimately when intermediate `CheckpointAck`-only
1769                        // blocks sit in the off-chain outbox but are skipped from
1770                        // `body.previous_message_blocks`; same fallback as the
1771                        // `(Some, None)` arm above.
1772                        if prev_hash != prev_msg_block_hash {
1773                            continue;
1774                        }
1775                    }
1776                }
1777            }
1778            if outbox.schedule_message(block_height)?
1779                && tracked.is_none_or(|set| set.contains(target))
1780            {
1781                scheduled_tracked.push(*target);
1782            }
1783            #[cfg(with_metrics)]
1784            crate::outbox::metrics::OUTBOX_SIZE
1785                .with_label_values(&[])
1786                .observe(outbox.queue.count() as f64);
1787        }
1788
1789        if !scheduled_tracked.is_empty() {
1790            // All scheduled messages are at `block_height`.
1791            let scheduled_count =
1792                u32::try_from(scheduled_tracked.len()).map_err(|_| ArithmeticError::Overflow)?;
1793            *self
1794                .outbox_counters
1795                .get_mut()
1796                .entry(block_height)
1797                .or_default() += scheduled_count;
1798            let nonempty_outboxes = self.nonempty_outboxes.get_mut();
1799            for target in &scheduled_tracked {
1800                nonempty_outboxes.insert(*target);
1801            }
1802        }
1803
1804        #[cfg(with_metrics)]
1805        metrics::NUM_OUTBOXES
1806            .with_label_values(&[])
1807            .observe(self.nonempty_outboxes.get().len() as f64);
1808        #[cfg(with_metrics)]
1809        metrics::OUTBOX_COUNTERS_SIZE
1810            .with_label_values(&[])
1811            .observe(self.outbox_counters.get().len() as f64);
1812        Ok(targets)
1813    }
1814
1815    /// Updates the per-stream event trackers from the events emitted by the block: advances
1816    /// `next_index` over contiguous new events (which might not be contiguous when preprocessing
1817    /// a block), and advances each stream's readable floor (`first_index`) when the block is the
1818    /// first to emit to it since a checkpoint. Returns the set of updated event streams.
1819    #[instrument(skip_all, fields(
1820        chain_id = %self.chain_id(),
1821        block_height = %block.header.height
1822    ))]
1823    async fn process_emitted_events(
1824        &mut self,
1825        block: &Block,
1826    ) -> Result<BTreeSet<StreamId>, ChainError> {
1827        // A stream's events within a single block are a contiguous run (each event takes the
1828        // next sequential index), so the lowest and highest index it emits here are all we need.
1829        let mut emitted_ranges = BTreeMap::<StreamId, (u32, u32)>::new();
1830        for event in block.body.events.iter().flatten() {
1831            emitted_ranges
1832                .entry(event.stream_id.clone())
1833                .and_modify(|(lo, hi)| {
1834                    *lo = (*lo).min(event.index);
1835                    *hi = (*hi).max(event.index);
1836                })
1837                .or_insert((event.index, event.index));
1838        }
1839        let mut stream_ids = Vec::new();
1840        let mut ranges = Vec::new();
1841        for (stream_id, range) in emitted_ranges {
1842            stream_ids.push(stream_id);
1843            ranges.push(range);
1844        }
1845
1846        let mut updated_streams = BTreeSet::new();
1847        for ((stream_id, counts), (lo, hi)) in self
1848            .next_expected_events
1849            .multi_get_pairs(stream_ids)
1850            .await?
1851            .into_iter()
1852            .zip(ranges)
1853        {
1854            let mut counts = counts.unwrap_or_default();
1855            let next = hi.saturating_add(1);
1856            if block.body.previous_event_blocks.contains_key(&stream_id) {
1857                // The stream already published since the most recent checkpoint, so we expect its
1858                // events to continue contiguously. If they don't, we have a gap (missing events
1859                // since the checkpoint) and leave the tracker untouched; the floor is preserved.
1860                if lo != counts.next_index {
1861                    continue;
1862                }
1863                counts.next_index = next;
1864            } else if lo >= counts.first_index {
1865                // First block to emit to this stream since the most recent checkpoint (which
1866                // clears `previous_event_blocks`): `lo` is the new readable floor, everything
1867                // earlier was pruned. The `>=` guard keeps a checkpoint seen out of order — an
1868                // earlier one preprocessed after a later one — from lowering the floor.
1869                counts.first_index = lo;
1870                counts.next_index = counts.next_index.max(next);
1871            } else {
1872                // An earlier checkpoint era, already superseded by a later one we recorded.
1873                continue;
1874            }
1875            updated_streams.insert(stream_id.clone());
1876            self.next_expected_events.insert(&stream_id, counts)?;
1877        }
1878        Ok(updated_streams)
1879    }
1880}
1881
1882#[test]
1883fn empty_block_size() {
1884    let size = bcs::serialized_size(&crate::block::Block::new(
1885        crate::test::make_first_block(
1886            linera_execution::test_utils::dummy_chain_description(0).id(),
1887        ),
1888        crate::data_types::BlockExecutionOutcome::default(),
1889    ))
1890    .unwrap();
1891    assert_eq!(size, EMPTY_BLOCK_SIZE);
1892}