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