Skip to main content

linera_core/chain_worker/
state.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The state and functionality of a chain worker.
5
6use std::{
7    borrow::Cow,
8    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
9    sync::{self, Arc},
10};
11
12use futures::future::Either;
13#[cfg(with_metrics)]
14use linera_base::prometheus_util::MeasureLatency as _;
15use linera_base::{
16    crypto::{CryptoHash, ValidatorPublicKey},
17    data_types::{
18        ApplicationDescription, ArithmeticError, Blob, BlockHeight, Epoch, OracleResponse, Round,
19        Timestamp,
20    },
21    ensure,
22    hashed::Hashed,
23    identifiers::{AccountOwner, ApplicationId, BlobId, BlobType, ChainId, EventId, StreamId},
24};
25use linera_cache::{Arc as CacheArc, UniqueValueCache, ValueCache};
26use linera_chain::{
27    data_types::{
28        BlockProposal, BundleExecutionPolicy, IncomingBundle, MessageAction, MessageBundle,
29        OriginalProposal, ProposalContent, ProposedBlock,
30    },
31    manager::{self, ManagerSafetySnapshot},
32    types::{
33        Block, ConfirmedBlock, ConfirmedBlockCertificate, TimeoutCertificate,
34        ValidatedBlockCertificate,
35    },
36    BlockExecutionPhase, ChainError, ChainExecutionContext, ChainIdSet, ChainStateView,
37    ChainTipState, ExecutionResultExt as _, StreamCounts,
38};
39use linera_execution::{
40    system::{EpochEventData, EventSubscriptions, EPOCH_STREAM_NAME},
41    ExecutionRuntimeContext as _, ExecutionStateView, Query, QueryContext, QueryOutcome,
42    ResourceTracker, ServiceRuntimeEndpoint,
43};
44use linera_storage::{Clock as _, Storage};
45use linera_views::{
46    batch::Batch,
47    context::{Context, InactiveContext},
48    store::WritableKeyValueStore as _,
49    views::{ReplaceContext as _, RootView as _, View as _},
50};
51use tokio::sync::oneshot;
52use tracing::{debug, info, instrument, trace, warn};
53
54use crate::{
55    chain_worker::{handle::AtomicTimestamp, ChainWorkerConfig, DeliveryNotifier},
56    client::{ChainModes, ListeningMode},
57    data_types::{ChainInfo, ChainInfoQuery, ChainInfoResponse, CrossChainRequest},
58    worker::{BatchRequest, NetworkActions, Notification, Reason, WorkerError},
59};
60
61/// Type alias for event subscriptions result.
62pub(crate) type EventSubscriptionsResult = Vec<((ChainId, StreamId), EventSubscriptions)>;
63
64#[cfg(with_metrics)]
65mod metrics {
66    use std::sync::LazyLock;
67
68    use linera_base::prometheus_util::{
69        exponential_bucket_interval, exponential_bucket_latencies, register_histogram,
70        register_histogram_vec, register_int_counter, register_int_counter_vec,
71    };
72    use prometheus::{Histogram, HistogramVec, IntCounter, IntCounterVec};
73
74    pub static CREATE_NETWORK_ACTIONS_LATENCY: LazyLock<Histogram> = LazyLock::new(|| {
75        register_histogram(
76            "create_network_actions_latency",
77            "Time (ms) to create network actions",
78            exponential_bucket_latencies(10_000.0),
79        )
80    });
81
82    pub static NUM_INBOXES: LazyLock<HistogramVec> = LazyLock::new(|| {
83        register_histogram_vec(
84            "num_inboxes",
85            "Number of inboxes",
86            &[],
87            exponential_bucket_interval(1.0, 10_000.0),
88        )
89    });
90
91    pub static BLOCK_PROPOSALS_RECEIVED_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
92        register_int_counter(
93            "block_proposals_received_total",
94            "Total number of block proposals received by the worker",
95        )
96    });
97
98    pub static BLOCK_PROPOSALS_REJECTED_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
99        register_int_counter_vec(
100            "block_proposals_rejected_total",
101            "Total number of block proposals rejected by the worker, labelled by error type",
102            &["error_type"],
103        )
104    });
105}
106
107/// The state of the chain worker.
108pub(crate) struct ChainWorkerState<StorageClient>
109where
110    StorageClient: Storage,
111{
112    config: ChainWorkerConfig,
113    storage: StorageClient,
114    chain: ChainStateView<StorageClient::Context>,
115    service_runtime_endpoint: Option<ServiceRuntimeEndpoint>,
116    /// The background task running the service runtime. Must be kept alive for the
117    /// lifetime of the worker: the pool `Guard` wrapper returns the thread-pool slot
118    /// when dropped, so dropping this early lets the pool schedule unrelated work on a
119    /// thread that is still running the service runtime.
120    service_runtime_task: Option<web_thread_pool::Task<()>>,
121    /// Timestamp of the last access.
122    /// Used by the keep-alive task to determine when the worker has been idle.
123    /// Wrapped in `Arc` so the keep-alive task can read it without acquiring
124    /// the `RwLock`.
125    last_access: Arc<AtomicTimestamp>,
126    block_values: Arc<ValueCache<CryptoHash, ConfirmedBlock>>,
127    execution_state_cache:
128        Option<Arc<UniqueValueCache<CryptoHash, ExecutionStateView<InactiveContext>>>>,
129    chain_modes: Option<Arc<sync::RwLock<ChainModes>>>,
130    delivery_notifier: DeliveryNotifier,
131    knows_chain_is_active: bool,
132    /// Set to `true` if a database `save` failure has left storage potentially
133    /// inconsistent.
134    poisoned: bool,
135}
136
137/// The result of processing a cross-chain update.
138pub(crate) enum CrossChainUpdateResult {
139    /// The update was applied up to the given height. The caller must save.
140    Updated(BlockHeight),
141    /// All bundles were already received; nothing to do.
142    NothingToDo,
143    /// A gap was detected in the inbox for messages from `origin`. If
144    /// `allow_revert_confirm` is enabled, a `RevertConfirm` request should be sent
145    /// to retransmit bundles starting from `retransmit_from`.
146    GapDetected {
147        origin: ChainId,
148        retransmit_from: BlockHeight,
149    },
150}
151
152/// Whether the block was processed or skipped. Used for metrics.
153pub enum BlockOutcome {
154    Processed,
155    Preprocessed,
156    Skipped,
157}
158
159/// How to handle a confirmed block.
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum ProcessConfirmedBlockMode {
162    /// Execute the block if it is contiguous (or bridgeable via a checkpoint);
163    /// otherwise preprocess it. Used by validators and by any caller that
164    /// wants graceful fallback when there's a gap.
165    Auto,
166    /// Execute the block. Fail with [`WorkerError::InvalidBlockChaining`] if
167    /// there's a gap that can't be bridged. Use when the caller knows the
168    /// block must be contiguous and wants a hard error otherwise.
169    Execute,
170    /// Only preprocess the block, never execute it — even if it would be
171    /// contiguous. Used by the client for sender-chain blocks it is not
172    /// otherwise tracking, since their execution state is irrelevant.
173    Preprocess,
174}
175
176impl<StorageClient> ChainWorkerState<StorageClient>
177where
178    StorageClient: Storage + Clone + 'static,
179{
180    /// Creates a new [`ChainWorkerState`] using the provided `storage` client.
181    #[instrument(skip_all, fields(
182        chain_id = %chain_id
183    ))]
184    #[expect(clippy::too_many_arguments)]
185    pub(crate) async fn load(
186        config: ChainWorkerConfig,
187        storage: StorageClient,
188        block_values: Arc<ValueCache<CryptoHash, ConfirmedBlock>>,
189        execution_state_cache: Option<
190            Arc<UniqueValueCache<CryptoHash, ExecutionStateView<InactiveContext>>>,
191        >,
192        chain_modes: Option<Arc<sync::RwLock<ChainModes>>>,
193        delivery_notifier: DeliveryNotifier,
194        chain_id: ChainId,
195        service_runtime_endpoint: Option<ServiceRuntimeEndpoint>,
196        service_runtime_task: Option<web_thread_pool::Task<()>>,
197    ) -> Result<Self, WorkerError> {
198        let chain = storage.load_chain(chain_id).await?;
199
200        Ok(ChainWorkerState {
201            config,
202            storage,
203            chain,
204            service_runtime_endpoint,
205            service_runtime_task,
206            last_access: Arc::new(AtomicTimestamp::now()),
207            block_values,
208            execution_state_cache,
209            chain_modes,
210            delivery_notifier,
211            knows_chain_is_active: false,
212            poisoned: false,
213        })
214    }
215
216    /// Returns the [`ChainId`] of the chain handled by this worker.
217    fn chain_id(&self) -> ChainId {
218        self.chain.chain_id()
219    }
220
221    /// Returns a reference to the chain state view.
222    pub(crate) fn chain(&self) -> &ChainStateView<StorageClient::Context> {
223        &self.chain
224    }
225
226    /// Resolves the committee that signed certificates at the given epoch by
227    /// walking the admin chain's epoch event stream — works even after the
228    /// epoch has been revoked, so long as the admin chain still has the event.
229    /// Surfaces `EventsNotFound` (as a chained `ChainError::ExecutionError`)
230    /// when the admin event isn't local, so the caller's retry/sync paths can
231    /// fetch it before re-asking.
232    async fn committee_for_epoch(
233        &self,
234        epoch: Epoch,
235    ) -> Result<linera_execution::committee::Committee, WorkerError> {
236        let hash = self
237            .chain
238            .execution_state
239            .context()
240            .extra()
241            .get_committee_hashes(epoch..=epoch)
242            .await
243            .map_err(|error| {
244                ChainError::ExecutionError(Box::new(error), ChainExecutionContext::Block)
245            })?
246            .remove(&epoch)
247            .ok_or_else(|| {
248                ChainError::InternalError(format!(
249                    "missing committee for epoch {epoch}; this is a bug"
250                ))
251            })?;
252        let committee = self
253            .chain
254            .execution_state
255            .context()
256            .extra()
257            .get_or_load_committee_by_hash(hash)
258            .await
259            .map_err(|error| {
260                ChainError::ExecutionError(Box::new(error), ChainExecutionContext::Block)
261            })?;
262        Ok((*committee).clone())
263    }
264
265    /// Filters bundles destined for this chain to drop ones already received and to refuse
266    /// ones whose epoch has been revoked on the admin chain.
267    ///
268    /// A revoked-epoch bundle is still accepted if (a) it has already been executed by
269    /// anticipation (`bundle.height <= last_anticipated_block_height`), or (b) a later
270    /// bundle in the same batch is in a still-trusted epoch — that bundle's certificate
271    /// transitively re-certifies all preceding ones via prev-hash chaining.
272    pub(crate) async fn select_message_bundles(
273        &self,
274        origin: &ChainId,
275        next_height_to_receive: BlockHeight,
276        last_anticipated_block_height: Option<BlockHeight>,
277        mut bundles: Vec<(Epoch, MessageBundle)>,
278    ) -> Result<Vec<MessageBundle>, WorkerError> {
279        let recipient = self.chain_id();
280        let mut latest_height = None;
281        let mut skipped_len = 0;
282        let mut trusted_len = 0;
283        for (i, (epoch, bundle)) in bundles.iter().enumerate() {
284            ensure!(
285                latest_height <= Some(bundle.height),
286                WorkerError::InvalidCrossChainRequest
287            );
288            latest_height = Some(bundle.height);
289            if bundle.height < next_height_to_receive {
290                skipped_len = i + 1;
291            }
292            let is_revoked = self
293                .storage
294                .is_epoch_revoked(*epoch)
295                .await
296                .map_err(|error| {
297                    WorkerError::ChainError(Box::new(ChainError::ExecutionError(
298                        Box::new(error),
299                        ChainExecutionContext::Block,
300                    )))
301                })?;
302            if !is_revoked || Some(bundle.height) <= last_anticipated_block_height {
303                trusted_len = i + 1;
304            }
305        }
306        if skipped_len > 0 {
307            let (_, sample_bundle) = &bundles[skipped_len - 1];
308            debug!(
309                "Ignoring repeated messages to {recipient:.8} from {origin:} at height {}",
310                sample_bundle.height,
311            );
312        }
313        if skipped_len < bundles.len() && trusted_len < bundles.len() {
314            let (sample_epoch, sample_bundle) = &bundles[trusted_len];
315            warn!(
316                "Refusing messages to {recipient:.8} from {origin:} at height {} \
317                 because the epoch {} is not trusted any more",
318                sample_bundle.height, sample_epoch,
319            );
320        }
321        Ok(if skipped_len < trusted_len {
322            bundles
323                .drain(skipped_len..trusted_len)
324                .map(|(_, bundle)| bundle)
325                .collect()
326        } else {
327            vec![]
328        })
329    }
330
331    /// Returns whether this chain is known to be active (initialized).
332    pub(crate) fn knows_chain_is_active(&self) -> bool {
333        self.knows_chain_is_active
334    }
335
336    /// Rolls back any uncommitted changes to the chain state.
337    pub(crate) fn rollback(&mut self) {
338        self.chain.rollback();
339    }
340
341    /// Returns `WorkerError::PoisonedWorker` if the worker is poisoned due to a database
342    /// `save` failure.
343    pub(crate) fn check_not_poisoned(&self) -> Result<(), WorkerError> {
344        ensure!(!self.poisoned, WorkerError::PoisonedWorker);
345        Ok(())
346    }
347
348    /// Updates the last-access timestamp to the current time.
349    pub(crate) fn touch(&self) {
350        self.last_access.store_now();
351    }
352
353    /// Returns a clone of the last-access `Arc`, for use by the keep-alive task.
354    pub(crate) fn last_access_arc(&self) -> Arc<AtomicTimestamp> {
355        Arc::clone(&self.last_access)
356    }
357
358    /// Drops the service runtime endpoint, signaling the runtime task to stop.
359    /// Returns the runtime task so the caller can await it outside the lock.
360    pub(crate) fn clear_service_runtime(&mut self) -> Option<web_thread_pool::Task<()>> {
361        self.service_runtime_endpoint.take();
362        self.service_runtime_task.take()
363    }
364
365    /// Returns the pending cross-chain network actions if the outbox index is already
366    /// reconciled to the current tracked set, or `None` if it must first be reconciled (which
367    /// needs a write lock).
368    pub(crate) async fn cross_chain_network_actions_if_reconciled(
369        &self,
370    ) -> Result<Option<NetworkActions>, WorkerError> {
371        let tracked = self.tracked_full_chains();
372        if !self.chain.outbox_index_is_reconciled(tracked.as_deref()) {
373            return Ok(None);
374        }
375        Ok(Some(
376            self.build_network_actions(None, tracked.as_deref().map(|h| h.inner()))
377                .await?,
378        ))
379    }
380
381    /// Reconciles the outbox index with the current tracked set, then returns the pending
382    /// cross-chain network actions for this chain, without initializing the chain's execution
383    /// state. Intended for callers that only need to re-emit cross-chain requests from the
384    /// outbox of a sender chain whose `ChainDescription` we may never have needed.
385    ///
386    /// This is the slow path of [`Self::cross_chain_network_actions_if_reconciled`].
387    #[instrument(skip_all, fields(chain_id = %self.chain_id()))]
388    pub(crate) async fn reconcile_and_cross_chain_network_actions(
389        &mut self,
390    ) -> Result<NetworkActions, WorkerError> {
391        let tracked = self.tracked_full_chains();
392        self.chain
393            .reconcile_outbox_index(tracked.as_deref())
394            .await?;
395        let actions = self
396            .build_network_actions(None, tracked.as_deref().map(|h| h.inner()))
397            .await?;
398        self.save().await?;
399        Ok(actions)
400    }
401
402    /// Handles a [`ChainInfoQuery`], potentially voting on the next block.
403    #[tracing::instrument(level = "debug", skip(self))]
404    pub(crate) async fn handle_chain_info_query(
405        &mut self,
406        query: ChainInfoQuery,
407    ) -> Result<ChainInfoResponse, WorkerError> {
408        if let Some((height, round)) = query.request_leader_timeout {
409            self.vote_for_leader_timeout(height, round).await?;
410        }
411        if query.request_fallback {
412            self.vote_for_fallback().await?;
413        }
414        self.prepare_chain_info_response(query).await
415    }
416
417    /// Returns the requested blob, if it belongs to the current locking block or pending proposal.
418    #[instrument(skip_all, fields(
419        chain_id = %self.chain_id(),
420        blob_id = %blob_id
421    ))]
422    pub(crate) async fn download_pending_blob(
423        &self,
424        blob_id: BlobId,
425    ) -> Result<CacheArc<Blob>, WorkerError> {
426        if let Some(blob) = self.chain.manager.pending_blob(&blob_id).await? {
427            return Ok(self.storage.cache_blob(blob));
428        }
429        self.storage
430            .read_blob(blob_id)
431            .await?
432            .ok_or(WorkerError::BlobsNotFound(vec![blob_id]))
433    }
434
435    /// Reads the blobs from the chain manager or from storage. Returns an error if any are
436    /// missing.
437    #[instrument(skip_all, fields(
438        chain_id = %self.chain_id()
439    ))]
440    async fn get_required_blobs(
441        &self,
442        required_blob_ids: impl IntoIterator<Item = BlobId>,
443        created_blobs: BTreeMap<BlobId, Blob>,
444    ) -> Result<BTreeMap<BlobId, Blob>, WorkerError> {
445        let maybe_blobs = self
446            .maybe_get_required_blobs(required_blob_ids, Some(created_blobs))
447            .await?;
448        let not_found_blob_ids = missing_blob_ids(&maybe_blobs);
449        ensure!(
450            not_found_blob_ids.is_empty(),
451            WorkerError::BlobsNotFound(not_found_blob_ids)
452        );
453        Ok(maybe_blobs
454            .into_iter()
455            .filter_map(|(blob_id, maybe_blob)| Some((blob_id, maybe_blob?)))
456            .collect())
457    }
458
459    /// Tries to read the blobs from the chain manager or storage. Returns `None` if not found.
460    #[instrument(skip_all, fields(
461        chain_id = %self.chain_id()
462    ))]
463    async fn maybe_get_required_blobs(
464        &self,
465        blob_ids: impl IntoIterator<Item = BlobId>,
466        mut created_blobs: Option<BTreeMap<BlobId, Blob>>,
467    ) -> Result<BTreeMap<BlobId, Option<Blob>>, WorkerError> {
468        let maybe_blobs = blob_ids.into_iter().collect::<BTreeSet<_>>();
469        let mut maybe_blobs = maybe_blobs
470            .into_iter()
471            .map(|x| (x, None))
472            .collect::<Vec<(BlobId, Option<Blob>)>>();
473
474        if let Some(blob_map) = &mut created_blobs {
475            for (blob_id, value) in &mut maybe_blobs {
476                if let Some(blob) = blob_map.remove(blob_id) {
477                    *value = Some(blob);
478                }
479            }
480        }
481
482        let (missing_indices, missing_blob_ids) = missing_indices_blob_ids(&maybe_blobs);
483        let second_block_blobs = self.chain.manager.pending_blobs(&missing_blob_ids).await?;
484        for (index, blob) in missing_indices.into_iter().zip(second_block_blobs) {
485            maybe_blobs[index].1 = blob;
486        }
487
488        let (missing_indices, missing_blob_ids) = missing_indices_blob_ids(&maybe_blobs);
489        let third_block_blobs = self
490            .chain
491            .pending_validated_blobs
492            .multi_get(&missing_blob_ids)
493            .await?;
494        for (index, blob) in missing_indices.into_iter().zip(third_block_blobs) {
495            maybe_blobs[index].1 = blob;
496        }
497
498        let (missing_indices, missing_blob_ids) = missing_indices_blob_ids(&maybe_blobs);
499        if !missing_indices.is_empty() {
500            let all_entries_pending_blobs = self
501                .chain
502                .pending_proposed_blobs
503                .try_load_all_entries()
504                .await?;
505            for (index, blob_id) in missing_indices.into_iter().zip(missing_blob_ids) {
506                for (_, pending_blobs) in &all_entries_pending_blobs {
507                    if let Some(blob) = pending_blobs.get(&blob_id).await? {
508                        maybe_blobs[index].1 = Some(blob);
509                        break;
510                    }
511                }
512            }
513        }
514
515        let (missing_indices, missing_blob_ids) = missing_indices_blob_ids(&maybe_blobs);
516        let fourth_block_blobs = self.storage.read_blobs(&missing_blob_ids).await?;
517        for (index, blob) in missing_indices.into_iter().zip(fourth_block_blobs) {
518            maybe_blobs[index].1 = blob.map(CacheArc::unwrap_or_clone);
519        }
520        Ok(maybe_blobs.into_iter().collect())
521    }
522
523    /// Creates cross-chain requests for a single recipient from its outbox.
524    #[instrument(skip_all, fields(
525        chain_id = %self.chain_id()
526    ))]
527    async fn create_cross_chain_actions_for_recipient(
528        &self,
529        recipient: ChainId,
530    ) -> Result<NetworkActions, WorkerError> {
531        let outbox = self.chain.outboxes.try_load_entry(&recipient).await?;
532        let Some(outbox) = outbox else {
533            return Ok(NetworkActions::default());
534        };
535        let heights = outbox.queue.elements().await?;
536        if heights.is_empty() {
537            return Ok(NetworkActions::default());
538        }
539        let heights_by_recipient = BTreeMap::from([(recipient, heights)]);
540        let cross_chain_requests = self
541            .create_cross_chain_requests(heights_by_recipient)
542            .await?;
543        Ok(NetworkActions {
544            cross_chain_requests,
545            notifications: Vec::new(),
546        })
547    }
548
549    /// Returns the set of chains tracked in full mode together with its memoized hash, or `None` if
550    /// every chain is implicitely in full-mode (e.g. on validator).
551    fn tracked_full_chains(&self) -> Option<Arc<Hashed<ChainIdSet>>> {
552        let chain_modes = self.chain_modes.as_ref()?;
553        let full = chain_modes
554            .read()
555            .expect("Panics should not happen while holding a lock to `chain_modes`")
556            .full();
557        Some(full)
558    }
559
560    /// Returns whether the given chain is tracked in full mode (always `true` on a validator),
561    /// i.e. whether its outbox should be indexed.
562    fn is_tracked(&self, chain_id: &ChainId) -> bool {
563        self.chain_modes.as_ref().is_none_or(|chain_modes| {
564            chain_modes
565                .read()
566                .expect("Panics should not happen while holding a lock to `chain_modes`")
567                .get(chain_id)
568                .is_some_and(ListeningMode::is_full)
569        })
570    }
571
572    /// Reconciles the chain's `nonempty_outboxes` and `outbox_counters` indices with the current
573    /// set of fully-tracked chains.
574    async fn reconcile_tracked_outboxes(
575        &mut self,
576    ) -> Result<Option<Arc<Hashed<ChainIdSet>>>, WorkerError> {
577        let full_chains = self.tracked_full_chains();
578        self.chain
579            .reconcile_outbox_index(full_chains.as_deref())
580            .await?;
581        Ok(full_chains)
582    }
583
584    /// Reconciles the outbox index with the current tracked set, then loads pending cross-chain
585    /// requests and adds `NewRound` notifications where appropriate.
586    async fn create_network_actions(
587        &mut self,
588        old_round: Option<Round>,
589    ) -> Result<NetworkActions, WorkerError> {
590        // Make the outbox index authoritative for the current tracked set first, so it already
591        // holds only `is_full` targets and needs no read-time filtering.
592        let tracked = self.reconcile_tracked_outboxes().await?;
593        self.build_network_actions(old_round, tracked.as_deref().map(|h| h.inner()))
594            .await
595    }
596
597    /// Builds the pending cross-chain actions from the already-reconciled outbox index.
598    async fn build_network_actions(
599        &self,
600        old_round: Option<Round>,
601        tracked: Option<&ChainIdSet>,
602    ) -> Result<NetworkActions, WorkerError> {
603        #[cfg(with_metrics)]
604        let _latency = metrics::CREATE_NETWORK_ACTIONS_LATENCY.measure_latency();
605        let mut heights_by_recipient = BTreeMap::<_, Vec<_>>::new();
606        let targets = self.chain.nonempty_outbox_chain_ids();
607        if let Some(tracked) = tracked {
608            if let Some(target) = targets.iter().find(|target| !tracked.contains(*target)) {
609                return Err(ChainError::CorruptedChainState(format!(
610                    "outbox index contains untracked target {target}"
611                ))
612                .into());
613            }
614        }
615        let outboxes = self.chain.load_outboxes(&targets).await?;
616        for (target, outbox) in targets.into_iter().zip(outboxes) {
617            let heights = outbox.queue.elements().await?;
618            heights_by_recipient.insert(target, heights);
619        }
620        let cross_chain_requests = self
621            .create_cross_chain_requests(heights_by_recipient)
622            .await?;
623        let mut notifications = Vec::new();
624        if let Some(old_round) = old_round {
625            let round = self.chain.manager.current_round();
626            if round > old_round {
627                let height = self.chain.tip_state.get().next_block_height;
628                notifications.push(Notification {
629                    chain_id: self.chain_id(),
630                    reason: Reason::NewRound { height, round },
631                });
632            }
633        }
634        Ok(NetworkActions {
635            cross_chain_requests,
636            notifications,
637        })
638    }
639
640    /// Returns confirmed blocks by hash, checking the cache first and batch-loading the rest
641    /// from storage. The order of the returned blocks matches the order of the input hashes.
642    async fn read_confirmed_blocks(
643        &self,
644        hashes: &[CryptoHash],
645    ) -> Result<Vec<Option<CacheArc<ConfirmedBlock>>>, WorkerError> {
646        let mut blocks = Vec::with_capacity(hashes.len());
647        let mut uncached_indices = Vec::new();
648        let mut uncached_hashes = Vec::new();
649
650        for (i, hash) in hashes.iter().enumerate() {
651            if let Some(block) = self.block_values.get(hash) {
652                blocks.push(Some(block));
653            } else {
654                blocks.push(None);
655                uncached_indices.push(i);
656                uncached_hashes.push(*hash);
657            }
658        }
659
660        if !uncached_hashes.is_empty() {
661            let from_storage = self.storage.read_confirmed_blocks(uncached_hashes).await?;
662            for (i, maybe_block) in uncached_indices.into_iter().zip(from_storage) {
663                blocks[i] = maybe_block;
664            }
665        }
666
667        Ok(blocks)
668    }
669
670    #[instrument(skip_all, fields(
671        chain_id = %self.chain_id(),
672        num_recipients = %heights_by_recipient.len()
673    ))]
674    async fn create_cross_chain_requests(
675        &self,
676        heights_by_recipient: BTreeMap<ChainId, Vec<BlockHeight>>,
677    ) -> Result<Vec<CrossChainRequest>, WorkerError> {
678        // Load all the certificates we will need, regardless of the medium.
679        let heights = heights_by_recipient
680            .values()
681            .flatten()
682            .copied()
683            .collect::<BTreeSet<_>>();
684        let hashes = self
685            .chain
686            .block_hashes_for_heights(heights.iter().copied())
687            .await?;
688
689        let blocks = self.read_confirmed_blocks(&hashes).await?;
690
691        let mut height_to_blocks = HashMap::new();
692        for (block, hash) in blocks.into_iter().zip(hashes) {
693            let block = block.ok_or_else(|| WorkerError::ReadCertificatesError(vec![hash]))?;
694            height_to_blocks.insert(block.height(), block);
695        }
696
697        let sender = self.chain.chain_id();
698        let mut cross_chain_requests = Vec::new();
699        for (recipient, heights) in heights_by_recipient {
700            // Extract the predecessor height for this recipient from the first
701            // block's `previous_message_blocks`. This lets the recipient detect
702            // gaps even before it consumes the missing message.
703            let previous_height = heights.first().and_then(|first_height| {
704                let block = height_to_blocks.get(first_height)?;
705                let (_, prev_height) =
706                    block.block().body.previous_message_blocks.get(&recipient)?;
707                Some(*prev_height)
708            });
709            let mut bundles = Vec::new();
710            let mut bundles_size = 0;
711            for height in heights {
712                let Some(confirmed_block) = height_to_blocks.get(&height) else {
713                    tracing::warn!(
714                        %height,
715                        %recipient,
716                        "spurious entry in outbox; skipping this and higher sender blocks"
717                    );
718                    break;
719                };
720                let new_bundles = confirmed_block
721                    .block()
722                    .message_bundles_for(recipient, confirmed_block.inner().hash())
723                    .collect::<Vec<_>>();
724                let new_size = new_bundles
725                    .iter()
726                    .map(|(_epoch, bundle)| bundle.estimated_size())
727                    .sum::<usize>();
728                // If adding this block's bundles would exceed the chunk limit,
729                // stop here. Always include at least one block's bundles.
730                if bundles_size + new_size > self.config.cross_chain_message_chunk_limit {
731                    if bundles.is_empty() {
732                        warn!(
733                            "Single block at height {height} produces an UpdateRecipient \
734                            of ~{new_size} bytes, exceeding the chunk limit of {}",
735                            self.config.cross_chain_message_chunk_limit
736                        );
737                    } else {
738                        debug!(
739                            "Stopping cross-chain batch for {recipient} at height {height}: \
740                            adding ~{new_size} bytes would exceed chunk limit of {} \
741                            (current batch ~{bundles_size} bytes)",
742                            self.config.cross_chain_message_chunk_limit
743                        );
744                        break;
745                    }
746                }
747                bundles.extend(new_bundles);
748                bundles_size += new_size;
749            }
750            if !bundles.is_empty() {
751                cross_chain_requests.push(CrossChainRequest::UpdateRecipient {
752                    sender,
753                    recipient,
754                    bundles,
755                    previous_height,
756                });
757            }
758        }
759        Ok(cross_chain_requests)
760    }
761
762    /// Processes a leader timeout issued for this multi-owner chain.
763    #[instrument(skip_all, fields(
764        chain_id = %self.chain_id(),
765        height = %certificate.inner().height()
766    ))]
767    pub(crate) async fn process_timeout(
768        &mut self,
769        certificate: TimeoutCertificate,
770    ) -> Result<(ChainInfoResponse, NetworkActions), WorkerError> {
771        // Check that the chain is active and ready for this timeout.
772        // Verify the certificate. Returns a catch-all error to make client code more robust.
773        self.initialize_and_save_if_needed().await?;
774        let (chain_epoch, committee) = self.chain.current_committee().await?;
775        certificate.check(&committee)?;
776        if self
777            .chain
778            .tip_state
779            .get()
780            .already_validated_block(certificate.inner().height())?
781        {
782            return Ok((self.chain_info_response().await?, NetworkActions::default()));
783        }
784        ensure!(
785            certificate.inner().epoch() == chain_epoch,
786            WorkerError::InvalidEpoch {
787                chain_id: certificate.inner().chain_id(),
788                chain_epoch,
789                epoch: certificate.inner().epoch()
790            }
791        );
792        let old_round = self.chain.manager.current_round();
793        self.chain
794            .manager
795            .handle_timeout_certificate(certificate, self.storage.clock().current_time());
796        self.save().await?;
797        let actions = self.create_network_actions(Some(old_round)).await?;
798        Ok((self.chain_info_response().await?, actions))
799    }
800
801    /// Tries to load all blobs published in this proposal.
802    ///
803    /// If they cannot be found, it creates an entry in `pending_proposed_blobs` so they can be
804    /// submitted one by one.
805    #[instrument(skip_all, fields(
806        chain_id = %self.chain_id(),
807        block_height = %proposal.content.block.height
808    ))]
809    async fn load_proposal_blobs(
810        &mut self,
811        proposal: &BlockProposal,
812    ) -> Result<Vec<Blob>, WorkerError> {
813        let owner = proposal.owner();
814        let BlockProposal {
815            content:
816                ProposalContent {
817                    block,
818                    round,
819                    outcome: _,
820                },
821            original_proposal,
822            signature: _,
823        } = proposal;
824
825        let mut maybe_blobs = self
826            .maybe_get_required_blobs(proposal.required_blob_ids(), None)
827            .await?;
828        let missing_blob_ids = missing_blob_ids(&maybe_blobs);
829        if !missing_blob_ids.is_empty() {
830            let chain = &mut self.chain;
831            if chain.ownership().await?.open_multi_leader_rounds {
832                // TODO(#3203): Allow multiple pending proposals on permissionless chains.
833                chain.pending_proposed_blobs.clear();
834            }
835            let validated = matches!(original_proposal, Some(OriginalProposal::Regular { .. }));
836            chain
837                .pending_proposed_blobs
838                .try_load_entry_mut(&owner)
839                .await?
840                .update(*round, validated, maybe_blobs)?;
841            self.save().await?;
842            return Err(WorkerError::BlobsNotFound(missing_blob_ids));
843        }
844        let published_blobs = block
845            .published_blob_ids()
846            .iter()
847            .filter_map(|blob_id| maybe_blobs.remove(blob_id).flatten())
848            .collect::<Vec<_>>();
849        Ok(published_blobs)
850    }
851
852    /// Processes a validated block issued for this multi-owner chain.
853    #[instrument(skip_all, fields(
854        chain_id = %self.chain_id(),
855        block_height = %certificate.block().header.height
856    ))]
857    pub(crate) async fn process_validated_block(
858        &mut self,
859        certificate: ValidatedBlockCertificate,
860    ) -> Result<(ChainInfoResponse, NetworkActions, BlockOutcome), WorkerError> {
861        let block = certificate.block();
862
863        let header = &block.header;
864        let height = header.height;
865        // Check that the chain is active and ready for this validated block.
866        // Verify the certificate. Returns a catch-all error to make client code more robust.
867        self.initialize_and_save_if_needed().await?;
868        let tip_state = self.chain.tip_state.get();
869        ensure!(
870            header.height == tip_state.next_block_height,
871            ChainError::UnexpectedBlockHeight {
872                expected_block_height: tip_state.next_block_height,
873                found_block_height: header.height,
874            }
875        );
876        let (epoch, committee) = self.chain.current_committee().await?;
877        check_block_epoch(epoch, header.chain_id, header.epoch)?;
878        certificate.check(&committee)?;
879        let already_committed_block = self.chain.tip_state.get().already_validated_block(height)?;
880        let should_skip_validated_block = || {
881            self.chain
882                .manager
883                .check_validated_block(&certificate)
884                .map(|outcome| outcome == manager::Outcome::Skip)
885        };
886        if already_committed_block || should_skip_validated_block()? {
887            // If we just processed the same pending block, return the chain info unchanged.
888            return Ok((
889                self.chain_info_response().await?,
890                NetworkActions::default(),
891                BlockOutcome::Skipped,
892            ));
893        }
894
895        self.block_values
896            .insert_hashed(Cow::Borrowed(certificate.inner().inner()));
897        let required_blob_ids = block.required_blob_ids();
898        let maybe_blobs = self
899            .maybe_get_required_blobs(required_blob_ids, Some(block.created_blobs()))
900            .await?;
901        let missing_blob_ids = missing_blob_ids(&maybe_blobs);
902        if !missing_blob_ids.is_empty() {
903            self.chain
904                .pending_validated_blobs
905                .update(certificate.round, true, maybe_blobs)?;
906            self.save().await?;
907            return Err(WorkerError::BlobsNotFound(missing_blob_ids));
908        }
909        let blobs = maybe_blobs
910            .into_iter()
911            .filter_map(|(blob_id, maybe_blob)| Some((blob_id, maybe_blob?)))
912            .collect();
913        let old_round = self.chain.manager.current_round();
914        self.chain.manager.create_final_vote(
915            certificate,
916            self.config.key_pair(),
917            self.storage.clock().current_time(),
918            blobs,
919        )?;
920        self.save().await?;
921        let actions = self.create_network_actions(Some(old_round)).await?;
922        Ok((
923            self.chain_info_response().await?,
924            actions,
925            BlockOutcome::Processed,
926        ))
927    }
928
929    /// Processes a confirmed block (aka a commit).
930    #[instrument(skip_all, fields(
931        chain_id = %certificate.block().header.chain_id,
932        height = %certificate.block().header.height,
933        block_hash = %certificate.hash(),
934    ))]
935    pub(crate) async fn process_confirmed_block(
936        &mut self,
937        certificate: ConfirmedBlockCertificate,
938        mode: ProcessConfirmedBlockMode,
939        notify_when_messages_are_delivered: Option<oneshot::Sender<()>>,
940    ) -> Result<(ChainInfoResponse, NetworkActions, BlockOutcome), WorkerError> {
941        let block = certificate.block();
942        let block_hash = certificate.hash();
943        let height = block.header.height;
944        let chain_id = block.header.chain_id;
945
946        // Trust-mark accept path: an earlier checkpoint cert recorded this block's
947        // hash in `pre_checkpoint_block_trust`. Removing the trust mark here is
948        // only an in-memory change; if anything below fails before `save`, the
949        // mark survives on the next reload. The dispatch's `gap` definition
950        // (`!=` rather than `<`) routes both above-tip and below-tip trust
951        // uploads to `preprocess_certified_block` so the chain can write the
952        // cert without advancing the tip.
953        let in_trust_set = self
954            .chain
955            .pre_checkpoint_block_trust
956            .contains(&block_hash)
957            .await?;
958        if in_trust_set {
959            self.chain.pre_checkpoint_block_trust.remove(&block_hash)?;
960        }
961
962        // Check if we already processed this block.
963        let tip = self.chain.tip_state.get().clone();
964        if !in_trust_set && tip.next_block_height > height {
965            let actions = self.create_network_actions(None).await?;
966            self.register_delivery_notifier(height, &actions, notify_when_messages_are_delivered)
967                .await;
968            return Ok((
969                self.chain_info_response().await?,
970                actions,
971                BlockOutcome::Skipped,
972            ));
973        }
974
975        // We haven't processed the block - verify the certificate first.
976        let committee = self.committee_for_epoch(block.header.epoch).await?;
977        certificate.check(&committee)?;
978
979        // Certificate check passed - which means the blobs the block requires are legitimate and
980        // we can take note of it, so that if any are missing, we will accept them when the client
981        // sends them.
982        let required_blob_ids = block.required_blob_ids();
983        let blobs_result = self
984            .get_required_blobs(required_blob_ids.iter().copied(), block.created_blobs())
985            .await
986            .map(|blobs| blobs.into_values().collect::<Vec<_>>());
987
988        if let Ok(blobs) = &blobs_result {
989            self.storage
990                .write_blobs_and_certificate(blobs, &certificate)
991                .await?;
992            let events = block
993                .body
994                .events
995                .iter()
996                .flatten()
997                .map(|event| (event.id(chain_id), event.value.clone()));
998            self.storage.write_events(events).await?;
999        }
1000
1001        // Update the blob state with last used certificate hash.
1002        let blob_state = certificate.value().to_blob_state(blobs_result.is_ok());
1003        let blob_ids = required_blob_ids.into_iter().collect::<Vec<_>>();
1004        self.storage
1005            .maybe_write_blob_states(&blob_ids, blob_state)
1006            .await?;
1007
1008        let blobs = blobs_result?
1009            .into_iter()
1010            .map(|blob| (blob.id(), blob))
1011            .collect::<BTreeMap<_, _>>();
1012
1013        // Dispatch on the actual outcome (preprocess / checkpoint-restore-then-execute
1014        // / contiguous execute):
1015        //  - `Preprocess` mode, or `Auto` with an unbridgeable gap: preprocess.
1016        //  - `Execute` mode with an unbridgeable gap: error.
1017        //  - `Auto`/`Execute` mode + gap + checkpoint block: install the snapshot,
1018        //    then execute.
1019        //  - `Auto`/`Execute` mode + contiguous: execute directly.
1020        use ProcessConfirmedBlockMode::{Auto, Execute, Preprocess};
1021        let gap = tip.next_block_height != height;
1022        let starts_with_checkpoint = block.starts_with_checkpoint();
1023        match (mode, gap, starts_with_checkpoint) {
1024            (Preprocess, _, _) | (Auto, true, false) => {
1025                self.preprocess_certified_block(certificate, notify_when_messages_are_delivered)
1026                    .await
1027            }
1028            (Execute, true, false) => Err(WorkerError::InvalidBlockChaining),
1029            (Auto | Execute, true, true) => {
1030                self.execute_block_with_checkpoint_restore(
1031                    certificate,
1032                    blobs,
1033                    notify_when_messages_are_delivered,
1034                )
1035                .await
1036            }
1037            (Auto | Execute, false, _) => {
1038                self.execute_contiguous_block(
1039                    certificate,
1040                    blobs,
1041                    tip,
1042                    notify_when_messages_are_delivered,
1043                )
1044                .await
1045            }
1046        }
1047    }
1048
1049    /// Preprocesses a confirmed block: updates outboxes and event streams without
1050    /// executing it, and does not advance the chain tip.
1051    async fn preprocess_certified_block(
1052        &mut self,
1053        certificate: ConfirmedBlockCertificate,
1054        notify_when_messages_are_delivered: Option<oneshot::Sender<()>>,
1055    ) -> Result<(ChainInfoResponse, NetworkActions, BlockOutcome), WorkerError> {
1056        let block_hash = certificate.hash();
1057        let block = certificate.block();
1058        let chain_id = block.header.chain_id;
1059        let height = block.header.height;
1060
1061        let tracked = self.reconcile_tracked_outboxes().await?;
1062        let updated_event_streams = self
1063            .chain
1064            .preprocess_block(certificate.value(), tracked.as_deref().map(|h| h.inner()))
1065            .await?;
1066        self.save().await?;
1067        let mut actions = self.create_network_actions(None).await?;
1068        if !updated_event_streams.is_empty() {
1069            actions.notifications.push(Notification {
1070                chain_id,
1071                reason: Reason::NewEvents {
1072                    height,
1073                    block_hash,
1074                    event_streams: updated_event_streams,
1075                },
1076            });
1077        }
1078        trace!("Preprocessed confirmed block {height}");
1079        self.register_delivery_notifier(height, &actions, notify_when_messages_are_delivered)
1080            .await;
1081        Ok((
1082            self.chain_info_response().await?,
1083            actions,
1084            BlockOutcome::Preprocessed,
1085        ))
1086    }
1087
1088    /// Restores the chain's execution state from the checkpoint blob embedded in
1089    /// the block's first oracle response, fast-forwards the tip to the block's
1090    /// height, then executes the block as if it were contiguous. Re-running the
1091    /// block applies its fees and re-derives any post-checkpoint state.
1092    async fn execute_block_with_checkpoint_restore(
1093        &mut self,
1094        certificate: ConfirmedBlockCertificate,
1095        blobs: BTreeMap<BlobId, Blob>,
1096        notify_when_messages_are_delivered: Option<oneshot::Sender<()>>,
1097    ) -> Result<(ChainInfoResponse, NetworkActions, BlockOutcome), WorkerError> {
1098        let (bytes, chain_id, height, previous_block_hash, outbox_block_hashes, inbox_cursors) = {
1099            let block = certificate.block();
1100            let Some(OracleResponse::Checkpoint {
1101                execution_state_blobs,
1102                outbox_block_hashes,
1103                inbox_cursors,
1104                ..
1105            }) = block.body.oracle_responses.first().and_then(|r| r.first())
1106            else {
1107                return Err(ChainError::InternalError(
1108                    "Checkpoint block missing OracleResponse::Checkpoint".into(),
1109                )
1110                .into());
1111            };
1112            let mut bytes = Vec::new();
1113            let mut missing = Vec::new();
1114            for hash in execution_state_blobs {
1115                let blob_id = BlobId::new(*hash, BlobType::CheckpointExecutionState);
1116                match blobs.get(&blob_id) {
1117                    Some(blob) => bytes.extend_from_slice(blob.bytes()),
1118                    None => missing.push(blob_id),
1119                }
1120            }
1121            ensure!(missing.is_empty(), WorkerError::BlobsNotFound(missing));
1122            (
1123                bytes,
1124                block.header.chain_id,
1125                block.header.height,
1126                block.header.previous_block_hash,
1127                outbox_block_hashes.clone(),
1128                inbox_cursors.clone(),
1129            )
1130        };
1131        // Every pre-checkpoint sender block the oracle response names must already
1132        // be in storage before we touch any chain state. If some aren't, record
1133        // their hashes as trusted-by-this-checkpoint and error with `BlocksNotFound`
1134        // — the client uploads each missing cert (the trust-mark path at the top
1135        // of `process_confirmed_block` accepts them regardless of their possibly
1136        // revoked epoch), then retries the checkpoint. This way the worker never
1137        // exposes a half-restored chain whose outboxes reference unknown blocks.
1138        let mut missing_blocks = Vec::new();
1139        for hash in &outbox_block_hashes {
1140            if !self.storage.contains_certificate(*hash).await? {
1141                missing_blocks.push(*hash);
1142            }
1143        }
1144        if !missing_blocks.is_empty() {
1145            for hash in &missing_blocks {
1146                self.chain.pre_checkpoint_block_trust.insert(hash)?;
1147            }
1148            self.save().await?;
1149            return Err(WorkerError::BlocksNotFound(missing_blocks));
1150        }
1151        self.chain
1152            .execution_state
1153            .restore_from_content(&bytes)
1154            .await?;
1155        // `restore_from_content` writes directly to storage and leaves the
1156        // in-memory view in an undefined state — reload from storage.
1157        self.chain = self.storage.load_chain(chain_id).await?;
1158        // Re-populate `block_hashes` for every pre-checkpoint sender block the
1159        // chain still needs. The heights live in the just-restored execution
1160        // state (`unfinalized_message_blocks`); the matching hashes are the
1161        // ones the producer recorded in the oracle response, certified by the
1162        // checkpoint cert we already trust. Without this, the next step
1163        // (re-executing the checkpoint to verify its outcome) would fail
1164        // because `collect_unfinalized_block_hashes` looks these up.
1165        let heights = self.chain.collect_unfinalized_heights().await?;
1166        ensure!(
1167            heights.len() == outbox_block_hashes.len(),
1168            ChainError::InternalError(format!(
1169                "checkpoint oracle response has {} outbox block hashes but the \
1170                 restored state references {} distinct heights",
1171                outbox_block_hashes.len(),
1172                heights.len(),
1173            ))
1174        );
1175        for (height, hash) in heights.into_iter().zip(outbox_block_hashes) {
1176            self.chain.block_hashes.insert(&height, hash)?;
1177        }
1178        // Rebuild the off-chain outbox state (queues, counters,
1179        // nonempty_outboxes) from the on-chain unfinalized map so that this
1180        // node can resume pushing pre-checkpoint messages forward. The outbox
1181        // isn't part of the certified checkpoint blob, so without this a
1182        // bootstrapped validator would silently stop delivering pending
1183        // messages.
1184        let tracked = self.tracked_full_chains();
1185        self.chain
1186            .restore_outboxes_from_unfinalized(tracked.as_deref())
1187            .await?;
1188        for (origin, cursor) in inbox_cursors {
1189            let mut inbox = self.chain.inboxes.try_load_entry_mut(&origin).await?;
1190            inbox.restore_from_checkpoint(cursor).await?;
1191        }
1192        // Seed `next_expected_events` from the restored per-stream event counts. Re-executing
1193        // the checkpoint re-emits each summarized stream's summary event; without this seed
1194        // the summary's index would look like a gap on the freshly-restored node, so the
1195        // tracker would never advance and future events on that stream would never be
1196        // delivered to subscribers. The counts are contiguous, so this matches the producer's
1197        // tracker as of just before the checkpoint block.
1198        for (stream_id, count) in self
1199            .chain
1200            .execution_state
1201            .system
1202            .stream_event_counts
1203            .index_values()
1204            .await?
1205        {
1206            // Just after restoring, every event predates the checkpoint, so the readable floor
1207            // is the count itself.
1208            self.chain.next_expected_events.insert(
1209                &stream_id,
1210                StreamCounts {
1211                    first_index: count,
1212                    next_index: count,
1213                },
1214            )?;
1215        }
1216        // We reset `execution_state` (via restore), `tip_state`, `block_hashes`
1217        // (for outbox-referenced pre-checkpoint heights), and the outbox views.
1218        // The other `ChainStateView` fields are either (a) already default for
1219        // a fresh bootstrap node (`inboxes`, `received_log`, …), (b) about to
1220        // be overwritten by `apply_confirmed_block` when the cert is applied
1221        // (`manager`, `block_hashes` for height `height`), or (c) outside the
1222        // protocol state hash so divergence from the producer is fine
1223        // (inboxes; subsequent blocks reconcile by anticipation if needed).
1224        let new_tip = ChainTipState {
1225            block_hash: previous_block_hash,
1226            next_block_height: height,
1227        };
1228        self.chain.tip_state.set(new_tip.clone());
1229        // Installing a snapshot establishes the chain's state from scratch (block 0 is never
1230        // executed here), so record it as the initialization time for the reset cooldown.
1231        self.chain
1232            .chain_initialized_at
1233            .set(self.storage.clock().current_time());
1234        self.save().await?;
1235        self.execute_contiguous_block(
1236            certificate,
1237            blobs,
1238            new_tip,
1239            notify_when_messages_are_delivered,
1240        )
1241        .await
1242    }
1243
1244    /// Executes a confirmed block whose height equals `tip.next_block_height`,
1245    /// updating inboxes, applying the block, and persisting the chain.
1246    async fn execute_contiguous_block(
1247        &mut self,
1248        certificate: ConfirmedBlockCertificate,
1249        mut blobs: BTreeMap<BlobId, Blob>,
1250        tip: ChainTipState,
1251        notify_when_messages_are_delivered: Option<oneshot::Sender<()>>,
1252    ) -> Result<(ChainInfoResponse, NetworkActions, BlockOutcome), WorkerError> {
1253        let block_hash = certificate.hash();
1254        let block = certificate.block();
1255        let chain_id = block.header.chain_id;
1256        let height = block.header.height;
1257
1258        // This should always be true for valid certificates.
1259        ensure!(
1260            tip.block_hash == block.header.previous_block_hash,
1261            WorkerError::InvalidBlockChaining
1262        );
1263
1264        // Verify that the chain is active and that the epoch we used for verifying
1265        // the certificate is actually the active one on the chain.
1266        self.initialize_and_save_if_needed().await?;
1267        let (epoch, _) = self.chain.current_committee().await?;
1268        check_block_epoch(epoch, chain_id, block.header.epoch)?;
1269
1270        // The chain is initialized and this block has not executed yet, so the current ownership
1271        // is the configuration the block was proposed under — even for the chain's first block,
1272        // whose ownership comes from the just-applied chain description. This is the point where
1273        // the first-round attestation can be checked against the actual first round; blocks that
1274        // are only preprocessed skip it and rely on the nodes that execute the chain in order.
1275        if certificate.first_round() {
1276            ensure!(
1277                certificate.round() == self.chain.ownership().await?.first_round(),
1278                ChainError::FalseFirstRoundAttestation
1279            );
1280        }
1281
1282        let published_blobs = block
1283            .published_blob_ids()
1284            .iter()
1285            .filter_map(|blob_id| blobs.remove(blob_id))
1286            .collect::<Vec<_>>();
1287
1288        let local_time = self.storage.clock().current_time();
1289        if block.header.timestamp.duration_since(local_time) > self.config.block_time_grace_period {
1290            warn!(
1291                block_timestamp = %block.header.timestamp,
1292                %local_time,
1293                "Confirmed block has a timestamp in the future beyond the block time grace period"
1294            );
1295        }
1296        let tracked = self.reconcile_tracked_outboxes().await?;
1297        let chain = &mut self.chain;
1298        chain
1299            .remove_bundles_from_inboxes(
1300                block.header.timestamp,
1301                false,
1302                block.body.incoming_bundles(),
1303            )
1304            .await?;
1305        let confirmed_block = if let Some(mut execution_state) = self
1306            .execution_state_cache
1307            .as_ref()
1308            .and_then(|cache| cache.remove(&block_hash))
1309        {
1310            chain.execution_state = execution_state
1311                .with_context(|ctx| {
1312                    chain
1313                        .execution_state
1314                        .context()
1315                        .clone_with_base_key(ctx.base_key().bytes.clone())
1316                })
1317                .await;
1318            certificate.into_value()
1319        } else {
1320            let (proposed_block, outcome) = certificate.into_value().into_block().into_proposal();
1321            let oracle_responses = Some(outcome.oracle_responses.clone());
1322            let (proposed_block, verified, _resource_tracker, _) = chain
1323                .execute_block(
1324                    proposed_block,
1325                    local_time,
1326                    None,
1327                    &published_blobs,
1328                    oracle_responses,
1329                    BundleExecutionPolicy::committed(),
1330                    BlockExecutionPhase::HandleConfirmed,
1331                )
1332                .await?;
1333            // We should always agree on the messages and state hash.
1334            if outcome != verified {
1335                return Err(ChainError::CorruptedChainState(format!(
1336                    "computed block outcome differs from the certificate.\n\
1337                    Computed: {verified:#?}\n\
1338                    Submitted: {outcome:#?}"
1339                ))
1340                .into());
1341            }
1342            ConfirmedBlock::new(Block::new(proposed_block, verified))
1343        };
1344
1345        let updated_streams = chain
1346            .apply_confirmed_block(
1347                &confirmed_block,
1348                local_time,
1349                tracked.as_deref().map(|h| h.inner()),
1350            )
1351            .await?;
1352        let mut actions = self.create_network_actions(None).await?;
1353        trace!("Processed confirmed block {height}");
1354        actions.notifications.push(Notification {
1355            chain_id,
1356            reason: Reason::NewBlock {
1357                height,
1358                hash: block_hash,
1359            },
1360        });
1361        if !updated_streams.is_empty() {
1362            actions.notifications.push(Notification {
1363                chain_id,
1364                reason: Reason::NewEvents {
1365                    height,
1366                    block_hash,
1367                    event_streams: updated_streams,
1368                },
1369            });
1370        }
1371        self.save().await?;
1372
1373        self.block_values
1374            .insert_hashed(Cow::Owned(confirmed_block.into_inner()));
1375
1376        self.register_delivery_notifier(height, &actions, notify_when_messages_are_delivered)
1377            .await;
1378
1379        Ok((
1380            self.chain_info_response().await?,
1381            actions,
1382            BlockOutcome::Processed,
1383        ))
1384    }
1385
1386    /// Schedules a notification for when cross-chain messages are delivered up to the given
1387    /// `height`.
1388    #[instrument(level = "trace", skip(self, notify_when_messages_are_delivered))]
1389    async fn register_delivery_notifier(
1390        &self,
1391        height: BlockHeight,
1392        actions: &NetworkActions,
1393        notify_when_messages_are_delivered: Option<oneshot::Sender<()>>,
1394    ) {
1395        if let Some(notifier) = notify_when_messages_are_delivered {
1396            if actions
1397                .cross_chain_requests
1398                .iter()
1399                .any(|request| request.has_messages_lower_or_equal_than(height))
1400            {
1401                self.delivery_notifier.register(height, notifier);
1402            } else {
1403                // No need to wait. Also, cross-chain requests may not trigger the
1404                // notifier later, even if we register it.
1405                if let Err(()) = notifier.send(()) {
1406                    debug!("Failed to notify message delivery to caller (early case)");
1407                }
1408            }
1409        }
1410    }
1411
1412    /// Updates the chain's inboxes, receiving messages from a cross-chain update.
1413    #[instrument(level = "debug", skip(self, bundles), fields(chain_id = %self.chain_id()))]
1414    pub(crate) async fn process_cross_chain_update(
1415        &mut self,
1416        origin: ChainId,
1417        bundles: Vec<(Epoch, MessageBundle)>,
1418        sender_previous_height: Option<BlockHeight>,
1419    ) -> Result<CrossChainUpdateResult, WorkerError> {
1420        // Only process certificates with relevant heights and epochs.
1421        let mut inbox = self.chain.inboxes.try_load_entry_mut(&origin).await?;
1422        let next_height_to_receive = inbox.next_block_height_to_receive()?;
1423        let last_anticipated_block_height = inbox
1424            .removed_bundles
1425            .back()
1426            .await?
1427            .map(|bundle| bundle.height);
1428
1429        // Proactive gap detection: if the sender declares a predecessor height that
1430        // we haven't received yet, the inbox has a gap.
1431        if let Some(prev) = sender_previous_height {
1432            if prev >= next_height_to_receive {
1433                let chain_id = self.chain_id();
1434                if self.config.allow_revert_confirm && self.config.recovery_allowed_for(&chain_id) {
1435                    warn!(
1436                        %chain_id,
1437                        "Inbox gap detected from {origin}: \
1438                        sender declares previous height {prev} but we only have up to \
1439                        {next_height_to_receive}; requesting resend",
1440                    );
1441                    return Ok(CrossChainUpdateResult::GapDetected {
1442                        origin,
1443                        retransmit_from: next_height_to_receive,
1444                    });
1445                }
1446                return Err(ChainError::InboxGapDetected {
1447                    chain_id,
1448                    origin,
1449                    expected_height: prev,
1450                    actual_height: bundles.first().map(|(_, b)| b.height).unwrap_or_default(),
1451                }
1452                .into());
1453            }
1454        }
1455
1456        let bundles = self
1457            .select_message_bundles(
1458                &origin,
1459                next_height_to_receive,
1460                last_anticipated_block_height,
1461                bundles,
1462            )
1463            .await?;
1464        let Some(last_updated_height) = bundles.last().map(|bundle| bundle.height) else {
1465            return Ok(CrossChainUpdateResult::NothingToDo);
1466        };
1467        // Process the received messages in certificates.
1468        let local_time = self.storage.clock().current_time();
1469        let mut previous_height = None;
1470        for bundle in bundles {
1471            let add_to_received_log = previous_height != Some(bundle.height);
1472            previous_height = Some(bundle.height);
1473            // Update the staged chain state with the received block.
1474            self.chain
1475                .receive_message_bundle_with_inbox(
1476                    &mut inbox,
1477                    &origin,
1478                    bundle,
1479                    local_time,
1480                    add_to_received_log,
1481                )
1482                .await?;
1483        }
1484        inbox.observe_size_metric();
1485        drop(inbox);
1486        if !self.config.allow_inactive_chains && !self.chain.is_active().await? {
1487            // Refuse to create a chain state if the chain is still inactive by
1488            // now. Accordingly, do not send a confirmation, so that the
1489            // cross-chain update is retried later.
1490            warn!(
1491                chain_id = %self.chain_id(),
1492                "Refusing to deliver messages from {origin} \
1493                at height {last_updated_height} because the recipient is still inactive",
1494            );
1495            return Ok(CrossChainUpdateResult::NothingToDo);
1496        }
1497        Ok(CrossChainUpdateResult::Updated(last_updated_height))
1498    }
1499
1500    /// Handles the cross-chain request confirming that the recipient was updated.
1501    #[instrument(skip_all, fields(
1502        chain_id = %self.chain_id(),
1503        %recipient,
1504        %latest_height
1505    ))]
1506    pub(crate) async fn confirm_updated_recipient(
1507        &mut self,
1508        recipient: ChainId,
1509        latest_height: BlockHeight,
1510    ) -> Result<bool, WorkerError> {
1511        // Reconcile the outbox indices with the *current* tracked set before draining the counter
1512        // and checking delivery.
1513        let tracked = self.reconcile_tracked_outboxes().await?;
1514        // The indices are now reconciled to the tracked set, so `all_messages_delivered_up_to`
1515        // (the `outbox_counters` fast path) is already the complete answer for tracked chains.
1516        Ok(self
1517            .chain
1518            .mark_messages_as_received(
1519                &recipient,
1520                latest_height,
1521                tracked.as_deref().map(|h| h.inner()),
1522            )
1523            .await?
1524            && self.chain.all_messages_delivered_up_to(latest_height))
1525    }
1526
1527    /// Notifies delivery waiters that all messages up to `height` have been delivered.
1528    pub(crate) fn notify_delivery(&self, height: BlockHeight) {
1529        self.delivery_notifier.notify(height);
1530    }
1531
1532    /// Processes a batch of cross-chain requests, performing at most one `save()`.
1533    ///
1534    /// Both update and confirmation requests are handled together so that a
1535    /// single write-lock acquisition covers all pending work for the chain.
1536    pub(crate) async fn process_batch(
1537        &mut self,
1538        requests: Vec<BatchRequest>,
1539    ) -> Result<(), WorkerError> {
1540        let mut update_results = Vec::new();
1541        let mut confirm_results = Vec::new();
1542        let mut need_save = false;
1543        let mut need_rollback = false;
1544        let mut recovery_error = None;
1545        let mut max_delivered_height: Option<BlockHeight> = None;
1546
1547        for request in requests {
1548            match request {
1549                BatchRequest::Update {
1550                    origin,
1551                    bundles,
1552                    previous_height,
1553                    result_sender,
1554                } => {
1555                    if need_rollback {
1556                        send_result(result_sender, Err(WorkerError::BatchRolledBack));
1557                        continue;
1558                    }
1559                    let result = self
1560                        .process_cross_chain_update(origin, bundles, previous_height)
1561                        .await;
1562                    let update_result = match result {
1563                        Ok(update_result) => update_result,
1564                        Err(error) => {
1565                            need_rollback = true;
1566                            let (recovery, to_send) = classify_processing_error(error);
1567                            recovery_error = recovery_error.or(recovery);
1568                            send_result(result_sender, Err(to_send));
1569                            continue;
1570                        }
1571                    };
1572                    match &update_result {
1573                        CrossChainUpdateResult::Updated(_) => need_save = true,
1574                        CrossChainUpdateResult::GapDetected { .. }
1575                        | CrossChainUpdateResult::NothingToDo => {}
1576                    }
1577                    update_results.push((result_sender, update_result));
1578                }
1579                BatchRequest::Confirm {
1580                    recipient,
1581                    latest_height,
1582                    result_sender,
1583                } => {
1584                    if need_rollback {
1585                        send_result(result_sender, Err(WorkerError::BatchRolledBack));
1586                        continue;
1587                    }
1588                    match self
1589                        .confirm_updated_recipient(recipient, latest_height)
1590                        .await
1591                    {
1592                        Ok(fully_delivered) => {
1593                            need_save = true;
1594                            if fully_delivered {
1595                                max_delivered_height = Some(
1596                                    max_delivered_height
1597                                        .map_or(latest_height, |h| h.max(latest_height)),
1598                                );
1599                            }
1600                            confirm_results.push((result_sender, recipient));
1601                        }
1602                        Err(error) => {
1603                            need_rollback = true;
1604                            let (recovery, to_send) = classify_processing_error(error);
1605                            recovery_error = recovery_error.or(recovery);
1606                            send_result(result_sender, Err(to_send));
1607                        }
1608                    }
1609                }
1610            }
1611        }
1612        let mut save_error = None;
1613        if !need_rollback && need_save {
1614            if let Err(error) = self.save().await {
1615                tracing::error!(%error, "failed to save batch; rolling back");
1616                need_rollback = true;
1617                save_error = Some(error);
1618            }
1619        }
1620        if need_rollback {
1621            for (result_sender, _) in update_results {
1622                send_result(result_sender, Err(WorkerError::BatchRolledBack));
1623            }
1624            for (result_sender, _) in confirm_results {
1625                send_result(result_sender, Err(WorkerError::BatchRolledBack));
1626            }
1627            // Surface an error that left the worker poisoned or the chain state
1628            // corrupted so `chain_write` can evict or reset it. A `must_reload_view`
1629            // error only reaches us from a failed `save`, since resolving the journal
1630            // (the operation that raises it) happens only in `write_batch`. A
1631            // processing step instead can raise `CorruptedChainState` while reconciling
1632            // outboxes or message counters (see `confirm_updated_recipient`), which
1633            // calls for a reset rather than eviction. Ordinary processing errors (bad
1634            // height, validation failures) leave the worker healthy after rollback, so
1635            // they return `Ok` and were already reported to their individual senders.
1636            return match save_error.or(recovery_error) {
1637                Some(error) => Err(error),
1638                None => Ok(()),
1639            };
1640        }
1641
1642        if let Some(height) = max_delivered_height {
1643            self.notify_delivery(height);
1644        }
1645
1646        for (result_sender, update_result) in update_results {
1647            send_result(result_sender, Ok(update_result));
1648        }
1649        for (result_sender, recipient) in confirm_results {
1650            let result = self
1651                .create_cross_chain_actions_for_recipient(recipient)
1652                .await;
1653            send_result(result_sender, result);
1654        }
1655        Ok(())
1656    }
1657
1658    /// Handles a `RevertConfirm` request: walks backward through
1659    /// `previous_message_blocks` to find all block heights that sent messages to
1660    /// `recipient` starting from the latest down to `retransmit_from`, re-adds them
1661    /// to the outbox, and creates cross-chain update actions to resend the bundles.
1662    #[instrument(skip_all, fields(
1663        chain_id = %self.chain_id(),
1664        %recipient,
1665        %retransmit_from,
1666    ))]
1667    pub(crate) async fn handle_revert_confirm(
1668        &mut self,
1669        recipient: ChainId,
1670        retransmit_from: BlockHeight,
1671    ) -> Result<NetworkActions, WorkerError> {
1672        self.reconcile_tracked_outboxes().await?;
1673        // 1. Walk backward through previous_message_blocks to collect all heights
1674        //    that sent messages to this recipient, from the latest down to retransmit_from.
1675        let Some(latest_height) = self
1676            .chain
1677            .execution_state
1678            .previous_message_blocks
1679            .get(&recipient)
1680            .await?
1681        else {
1682            warn!("RevertConfirm: no record of sending to {recipient}");
1683            return Ok(NetworkActions::default());
1684        };
1685
1686        let mut heights_to_re_add = Vec::new();
1687        let mut current_height = latest_height;
1688        while current_height >= retransmit_from {
1689            // We arrived at current_height via previous_message_blocks links, starting from the
1690            // chain state and following the links downwards. So these blocks should all be in
1691            // `block_hashes` already.
1692            heights_to_re_add.push(current_height);
1693            // Load the block at current_height to find the previous message block
1694            let hash = match &*self
1695                .chain
1696                .block_hashes_for_heights([current_height])
1697                .await?
1698            {
1699                [hash] => *hash,
1700                _ => {
1701                    return Err(WorkerError::BlockHashNotFound {
1702                        height: current_height,
1703                        chain_id: self.chain_id(),
1704                    })
1705                }
1706            };
1707            let block = self
1708                .read_confirmed_blocks(&[hash])
1709                .await?
1710                .pop()
1711                .flatten()
1712                .ok_or_else(|| WorkerError::LocalBlockNotFound {
1713                    height: current_height,
1714                    chain_id: self.chain_id(),
1715                })?;
1716            match block.block().body.previous_message_blocks.get(&recipient) {
1717                Some((_, prev_height)) if *prev_height >= retransmit_from => {
1718                    current_height = *prev_height;
1719                }
1720                _ => break,
1721            }
1722        }
1723
1724        // 2. Re-add the heights to the outbox.
1725        let new_heights = self
1726            .chain
1727            .outboxes
1728            .try_load_entry_mut(&recipient)
1729            .await?
1730            .revert(&heights_to_re_add)
1731            .await?;
1732
1733        if new_heights.is_empty() {
1734            debug!("RevertConfirm: all heights already in outbox for {recipient}");
1735            return Ok(NetworkActions::default());
1736        }
1737
1738        // 3. Update the indices only for tracked recipients (mirroring `process_outgoing_messages`):
1739        //    an untracked recipient keeps its re-added outbox queue but is not counted or indexed.
1740        let new_heights_len = new_heights.len();
1741        if self.is_tracked(&recipient) {
1742            for h in new_heights {
1743                *self.chain.outbox_counters.get_mut().entry(h).or_default() += 1;
1744            }
1745            self.chain.nonempty_outboxes.get_mut().insert(recipient);
1746        }
1747
1748        // 4. Create cross-chain requests for this recipient.
1749        let actions = self
1750            .create_cross_chain_actions_for_recipient(recipient)
1751            .await?;
1752
1753        // 5. Save chain state.
1754        self.save().await?;
1755
1756        warn!(
1757            "RevertConfirm: re-added {new_heights_len} heights to outbox for {recipient}, \
1758            starting from height {retransmit_from}"
1759        );
1760
1761        Ok(actions)
1762    }
1763
1764    /// If the config enables corruption recovery and the min-duration guard is
1765    /// satisfied, resets the chain state and re-executes all confirmed blocks.
1766    /// Returns `RevertConfirm` requests to dispatch, or `None` if no reset happened.
1767    pub(crate) async fn maybe_reset_corrupted_chain_state(
1768        &mut self,
1769    ) -> Result<Option<Vec<CrossChainRequest>>, WorkerError> {
1770        let Some(min_duration) = self.config.reset_on_corrupted_chain_state else {
1771            return Ok(None);
1772        };
1773        let chain_id = self.chain_id();
1774        if !self.config.recovery_allowed_for(&chain_id) {
1775            return Ok(None);
1776        }
1777        let local_time = self.storage.clock().current_time();
1778        let initialized_time = *self.chain.chain_initialized_at.get();
1779        let elapsed = local_time.duration_since(initialized_time);
1780        if elapsed < min_duration {
1781            warn!(
1782                %chain_id, ?elapsed, ?min_duration,
1783                "Not resetting corrupted chain state; not enough time elapsed \
1784                since the chain was last initialized"
1785            );
1786            return Ok(None);
1787        }
1788        warn!(%chain_id, "Corrupted chain state detected; resetting and re-executing");
1789        Ok(Some(self.reset_and_reexecute_chain().await?))
1790    }
1791
1792    /// Resets the chain state completely and re-executes all confirmed blocks from storage.
1793    /// Returns a `RevertConfirm` request for every known sender so they resend cross-chain
1794    /// messages that may have been lost during the reset.
1795    #[instrument(skip_all, fields(
1796        chain_id = %self.chain_id(),
1797    ))]
1798    pub(crate) async fn reset_and_reexecute_chain(
1799        &mut self,
1800    ) -> Result<Vec<CrossChainRequest>, WorkerError> {
1801        let chain_id = self.chain_id();
1802        let tip_height = self.chain.tip_state.get().next_block_height;
1803
1804        // 1. Collect all sender chain IDs and block hashes before clearing.
1805        let sender_ids = self.chain.inboxes.indices().await?;
1806        let block_hashes = self.chain.block_hashes.index_values().await?;
1807        // Re-execution starts from the latest checkpoint, if any: replaying the checkpoint
1808        // block onto the wiped (height-0) chain installs its snapshot, so the blocks below it
1809        // never need to be replayed. Without a checkpoint, this is `0` and the whole chain is
1810        // replayed.
1811        let restore_from =
1812            (*self.chain.latest_checkpoint_height.get()).unwrap_or(BlockHeight::ZERO);
1813
1814        // 2. Snapshot safety-critical manager state so that we cannot be tricked
1815        //    into double-signing if the reset wipes votes we already cast.
1816        let manager_snapshot = ManagerSafetySnapshot::capture(&self.chain.manager).await?;
1817
1818        // 3. Wipe every key under this chain's storage root and reload a fresh
1819        //    `ChainStateView`. `ChainStateView::clear` + `save` would only reset
1820        //    the in-memory view; `HistoricallyHashableView` deliberately keeps
1821        //    its `stored_hash` across clears, so the historical hash would carry
1822        //    over from the pre-reset state and the replayed block outcomes would
1823        //    never match the original certificates' `state_hash`. Deleting the
1824        //    storage prefix and reloading is equivalent to creating the chain
1825        //    from scratch, which is what replay expects.
1826        self.wipe_and_reload_chain().await?;
1827        self.knows_chain_is_active = false;
1828        warn!(
1829            %chain_id,
1830            "Cleared chain state up to height {tip_height}; \
1831            re-executing blocks from height {restore_from}"
1832        );
1833
1834        // 4. Re-load and re-process the certificates from the latest checkpoint onward. The
1835        //    first one lands on the wiped, height-0 chain with a tip gap: if it is a checkpoint
1836        //    `process_confirmed_block` installs its snapshot before executing, and otherwise
1837        //    (height 0) it executes directly. The remaining blocks are contiguous.
1838        let total = block_hashes
1839            .iter()
1840            .filter(|(height, _)| *height >= restore_from)
1841            .count();
1842        let mut replayed = 0;
1843        for (height, hash) in block_hashes {
1844            if height < restore_from {
1845                continue;
1846            }
1847            if replayed % 1000 == 0 {
1848                info!(
1849                    %chain_id, replayed, total,
1850                    "Re-executing confirmed blocks after reset"
1851                );
1852            }
1853            replayed += 1;
1854            let cert = self
1855                .storage
1856                .read_certificate(hash)
1857                .await?
1858                .map(CacheArc::unwrap_or_clone)
1859                .ok_or_else(|| WorkerError::LocalBlockNotFound { height, chain_id })?;
1860            Box::pin(self.process_confirmed_block(cert, ProcessConfirmedBlockMode::Execute, None))
1861                .await?;
1862        }
1863
1864        // 5. Restore any previously cast votes and locking block so we cannot be
1865        //    asked to sign a conflicting statement at the same height/round. Votes
1866        //    in the manager always belong to the pending height (one past the tip),
1867        //    so restoring is only meaningful if re-execution landed at the same
1868        //    tip. Otherwise the restored state would refer to a stale pending
1869        //    height and could only break the manager's invariants without any
1870        //    safety benefit — so we drop the snapshot in that case.
1871        let new_tip_height = self.chain.tip_state.get().next_block_height;
1872        if new_tip_height == tip_height {
1873            manager_snapshot.restore(&mut self.chain.manager)?;
1874            self.save().await?;
1875        } else {
1876            warn!(
1877                %tip_height, %new_tip_height,
1878                "Dropping manager snapshot: pre-reset tip differs from post-reset tip"
1879            );
1880        }
1881
1882        // 6. Build RevertConfirm requests so each sender resends messages we may
1883        //    have lost during the reset.
1884        let revert_requests = sender_ids
1885            .into_iter()
1886            .map(|sender| CrossChainRequest::RevertConfirm {
1887                sender,
1888                recipient: chain_id,
1889                retransmit_from: BlockHeight::ZERO,
1890            })
1891            .collect::<Vec<_>>();
1892
1893        warn!(
1894            tip_height = %self.chain.tip_state.get().next_block_height,
1895            num_revert_confirms = revert_requests.len(),
1896            "Chain reset and re-executed; sending RevertConfirm to senders"
1897        );
1898
1899        Ok(revert_requests)
1900    }
1901
1902    #[instrument(skip_all, fields(
1903        chain_id = %self.chain_id(),
1904        num_trackers = %new_trackers.len()
1905    ))]
1906    pub(crate) async fn update_received_certificate_trackers(
1907        &mut self,
1908        new_trackers: BTreeMap<ValidatorPublicKey, u64>,
1909    ) -> Result<(), WorkerError> {
1910        self.chain
1911            .update_received_certificate_trackers(new_trackers);
1912        self.save().await?;
1913        Ok(())
1914    }
1915
1916    /// Returns the preprocessed block hashes in the given height range.
1917    #[instrument(skip_all, fields(
1918        chain_id = %self.chain_id(),
1919        start = %start,
1920        end = %end
1921    ))]
1922    pub(crate) async fn get_preprocessed_block_hashes(
1923        &self,
1924        start: BlockHeight,
1925        end: BlockHeight,
1926    ) -> Result<Vec<CryptoHash>, WorkerError> {
1927        let mut hashes = Vec::new();
1928        let mut height = start;
1929        while height < end {
1930            match self.chain.block_hashes.get(&height).await? {
1931                Some(hash) => hashes.push(hash),
1932                None => break,
1933            }
1934            height = height.try_add_one()?;
1935        }
1936        Ok(hashes)
1937    }
1938
1939    /// Returns the next block height to receive from an inbox.
1940    #[instrument(skip_all, fields(
1941        chain_id = %self.chain_id(),
1942        origin = %origin
1943    ))]
1944    pub(crate) async fn get_inbox_next_height(
1945        &self,
1946        origin: ChainId,
1947    ) -> Result<BlockHeight, WorkerError> {
1948        Ok(match self.chain.inboxes.try_load_entry(&origin).await? {
1949            Some(inbox) => inbox.next_block_height_to_receive()?,
1950            None => BlockHeight::ZERO,
1951        })
1952    }
1953
1954    /// Returns the locking blobs for the given blob IDs.
1955    /// Returns `Ok(None)` if any of the blobs is not found.
1956    #[instrument(skip_all, fields(
1957        chain_id = %self.chain_id(),
1958        num_blob_ids = %blob_ids.len()
1959    ))]
1960    pub(crate) async fn get_locking_blobs(
1961        &self,
1962        blob_ids: Vec<BlobId>,
1963    ) -> Result<Option<Vec<Blob>>, WorkerError> {
1964        let results = self
1965            .chain
1966            .manager
1967            .locking_blobs
1968            .multi_get(&blob_ids)
1969            .await?;
1970        Ok(results.into_iter().collect())
1971    }
1972
1973    /// Gets block hashes for specified heights.
1974    pub(crate) async fn get_block_hashes(
1975        &self,
1976        heights: Vec<BlockHeight>,
1977    ) -> Result<Vec<CryptoHash>, WorkerError> {
1978        Ok(self.chain.block_hashes_for_heights(heights).await?)
1979    }
1980
1981    /// Gets proposed blobs from the manager for specified blob IDs.
1982    pub(crate) async fn get_proposed_blobs(
1983        &self,
1984        blob_ids: Vec<BlobId>,
1985    ) -> Result<Vec<Blob>, WorkerError> {
1986        let results = self
1987            .chain
1988            .manager
1989            .proposed_blobs
1990            .multi_get(&blob_ids)
1991            .await?;
1992        let mut blobs = Vec::with_capacity(blob_ids.len());
1993        let mut missing = Vec::new();
1994        for (blob_id, maybe_blob) in blob_ids.into_iter().zip(results) {
1995            match maybe_blob {
1996                Some(blob) => blobs.push(blob),
1997                None => missing.push(blob_id),
1998            }
1999        }
2000        if !missing.is_empty() {
2001            return Err(WorkerError::BlobsNotFound(missing));
2002        }
2003        Ok(blobs)
2004    }
2005
2006    /// Gets event subscriptions.
2007    pub(crate) async fn get_event_subscriptions(
2008        &self,
2009    ) -> Result<EventSubscriptionsResult, WorkerError> {
2010        Ok(self
2011            .chain
2012            .execution_state
2013            .system
2014            .event_subscriptions
2015            .index_values()
2016            .await?)
2017    }
2018
2019    /// Gets a stream's [`StreamCounts`]: the next expected event index and the lowest readable
2020    /// index (the first event published since the most recent checkpoint). Both default to 0 for
2021    /// a stream with no events yet. They come from the same `next_expected_events` entry, so they
2022    /// are mutually consistent and both reflect every block this node has processed — including
2023    /// ones it only preprocessed.
2024    pub(crate) async fn get_stream_indices(
2025        &self,
2026        stream_id: StreamId,
2027    ) -> Result<StreamCounts, WorkerError> {
2028        Ok(self
2029            .chain
2030            .next_expected_events
2031            .get(&stream_id)
2032            .await?
2033            .unwrap_or_default())
2034    }
2035
2036    /// Gets the `next_expected_events` indices for the given streams.
2037    pub(crate) async fn get_next_expected_events(
2038        &self,
2039        stream_ids: Vec<StreamId>,
2040    ) -> Result<BTreeMap<StreamId, u32>, WorkerError> {
2041        let values = self
2042            .chain
2043            .next_expected_events
2044            .multi_get(&stream_ids)
2045            .await?;
2046        Ok(stream_ids
2047            .into_iter()
2048            .zip(values)
2049            .filter_map(|(id, val)| Some((id, val?.next_index)))
2050            .collect())
2051    }
2052
2053    /// Gets received certificate trackers.
2054    pub(crate) async fn get_received_certificate_trackers(
2055        &self,
2056    ) -> Result<HashMap<ValidatorPublicKey, u64>, WorkerError> {
2057        Ok(self.chain.received_certificate_trackers.get().clone())
2058    }
2059
2060    /// Gets tip state and outbox info for next_outbox_heights calculation.
2061    pub(crate) async fn get_tip_state_and_outbox_info(
2062        &self,
2063        receiver_id: ChainId,
2064    ) -> Result<(BlockHeight, Option<BlockHeight>), WorkerError> {
2065        let next_block_height = self.chain.tip_state.get().next_block_height;
2066        let next_height_to_schedule = self
2067            .chain
2068            .outboxes
2069            .try_load_entry(&receiver_id)
2070            .await?
2071            .map(|outbox| *outbox.next_height_to_schedule.get());
2072        Ok((next_block_height, next_height_to_schedule))
2073    }
2074
2075    /// Gets the next height to preprocess.
2076    pub(crate) fn get_next_height_to_preprocess(&self) -> BlockHeight {
2077        *self.chain.next_height_to_preprocess.get()
2078    }
2079
2080    /// Attempts to vote for a leader timeout, if possible.
2081    #[instrument(skip_all, fields(
2082        chain_id = %self.chain_id(),
2083        height = %height,
2084        round = %round
2085    ))]
2086    async fn vote_for_leader_timeout(
2087        &mut self,
2088        height: BlockHeight,
2089        round: Round,
2090    ) -> Result<(), WorkerError> {
2091        let chain = &mut self.chain;
2092        ensure!(
2093            height == chain.tip_state.get().next_block_height,
2094            WorkerError::UnexpectedBlockHeight {
2095                expected_block_height: chain.tip_state.get().next_block_height,
2096                found_block_height: height
2097            }
2098        );
2099        let epoch = chain.execution_state.system.epoch.get();
2100        let chain_id = chain.chain_id();
2101        let key_pair = self.config.key_pair();
2102        let local_time = self.storage.clock().current_time();
2103        if chain
2104            .manager
2105            .create_timeout_vote(chain_id, height, round, *epoch, key_pair, local_time)?
2106        {
2107            self.save().await?;
2108        }
2109        Ok(())
2110    }
2111
2112    /// Votes for falling back to a public chain.
2113    ///
2114    /// Fallback is triggered when the chain is in epoch `e` and epoch `e+1` has been created
2115    /// on the admin chain longer than the configured `fallback_duration` ago.
2116    #[instrument(skip_all, fields(
2117        chain_id = %self.chain_id()
2118    ))]
2119    async fn vote_for_fallback(&mut self) -> Result<(), WorkerError> {
2120        let chain = &mut self.chain;
2121        let epoch = *chain.execution_state.system.epoch.get();
2122        let Some(admin_chain_id) = chain.execution_state.system.admin_chain_id.get() else {
2123            return Ok(());
2124        };
2125
2126        // Check if epoch e+1 exists on the admin chain and when it was created.
2127        let next_epoch_index = epoch.0.saturating_add(1);
2128        let event_id = EventId {
2129            chain_id: *admin_chain_id,
2130            stream_id: StreamId::system(EPOCH_STREAM_NAME),
2131            index: next_epoch_index,
2132        };
2133
2134        let Some(event_bytes) = self.storage.read_event(event_id).await? else {
2135            return Ok(()); // Next epoch doesn't exist yet.
2136        };
2137
2138        let event_data: EpochEventData = bcs::from_bytes(&event_bytes)?;
2139        let elapsed = self
2140            .storage
2141            .clock()
2142            .current_time()
2143            .delta_since(event_data.timestamp);
2144        if elapsed >= chain.ownership().await?.timeout_config.fallback_duration {
2145            let chain_id = chain.chain_id();
2146            let height = chain.tip_state.get().next_block_height;
2147            let key_pair = self.config.key_pair();
2148            if chain
2149                .manager
2150                .vote_fallback(chain_id, height, epoch, key_pair)
2151            {
2152                self.save().await?;
2153            }
2154        }
2155        Ok(())
2156    }
2157
2158    #[instrument(skip_all, fields(
2159        chain_id = %self.chain_id(),
2160        blob_id = %blob.id()
2161    ))]
2162    pub(crate) async fn handle_pending_blob(
2163        &mut self,
2164        blob: Blob,
2165    ) -> Result<ChainInfoResponse, WorkerError> {
2166        let mut was_expected = self
2167            .chain
2168            .pending_validated_blobs
2169            .maybe_insert(&blob)
2170            .await?;
2171        for (_, mut pending_blobs) in self
2172            .chain
2173            .pending_proposed_blobs
2174            .try_load_all_entries_mut()
2175            .await?
2176        {
2177            if !pending_blobs.validated.get() {
2178                let (_, committee) = self.chain.current_committee().await?;
2179                let policy = committee.policy();
2180                policy
2181                    .check_blob_size(blob.content())
2182                    .with_execution_context(ChainExecutionContext::Block)?;
2183                ensure!(
2184                    u64::try_from(pending_blobs.pending_blobs.iterative_count().await?)
2185                        .is_ok_and(|count| count < policy.maximum_published_blobs),
2186                    WorkerError::TooManyPublishedBlobs(policy.maximum_published_blobs)
2187                );
2188            }
2189            was_expected = was_expected || pending_blobs.maybe_insert(&blob).await?;
2190        }
2191        ensure!(was_expected, WorkerError::UnexpectedBlob);
2192        self.save().await?;
2193        self.chain_info_response().await
2194    }
2195
2196    /// Returns a stored [`Certificate`] for the chain's block at the requested [`BlockHeight`].
2197    ///
2198    /// Does not need `&mut self` because the chain is eagerly initialized when the
2199    /// chain handle is created.
2200    #[cfg(with_testing)]
2201    #[instrument(skip_all, fields(
2202        chain_id = %self.chain_id(),
2203        height = %height
2204    ))]
2205    pub(crate) async fn read_certificate(
2206        &self,
2207        height: BlockHeight,
2208    ) -> Result<Option<CacheArc<ConfirmedBlockCertificate>>, WorkerError> {
2209        let certificate_hash = match self.chain.block_hashes.get(&height).await? {
2210            Some(hash) => hash,
2211            None => return Ok(None),
2212        };
2213        let certificate = self
2214            .storage
2215            .read_certificate(certificate_hash)
2216            .await?
2217            .ok_or(WorkerError::BlocksNotFound(vec![certificate_hash]))?;
2218        Ok(Some(certificate))
2219    }
2220
2221    /// Queries an application's state on the chain.
2222    #[instrument(skip_all, fields(
2223        chain_id = %self.chain_id(),
2224        query_application_id = %query.application_id()
2225    ))]
2226    pub(crate) async fn query_application(
2227        &mut self,
2228        query: Query,
2229        block_hash: Option<CryptoHash>,
2230    ) -> Result<(QueryOutcome, BlockHeight), WorkerError> {
2231        self.initialize_and_save_if_needed().await?;
2232        let next_block_height = self.chain.tip_state.get().next_block_height;
2233        let local_time = self.storage.clock().current_time();
2234        // Try to use a cached execution state for the requested block.
2235        // We want to pretend that this block is committed, so we set the next block height.
2236        let cached_state = block_hash
2237            .zip(self.execution_state_cache.as_ref())
2238            .and_then(|(h, cache)| Some(h).zip(cache.remove(&h)));
2239        if let Some((requested_block, mut state)) = cached_state {
2240            let next_block_height = next_block_height
2241                .try_add_one()
2242                .expect("block height to not overflow");
2243            let context = QueryContext {
2244                chain_id: self.chain_id(),
2245                next_block_height,
2246                local_time,
2247            };
2248            let outcome = state
2249                .with_context(|ctx| {
2250                    self.chain
2251                        .execution_state
2252                        .context()
2253                        .clone_with_base_key(ctx.base_key().bytes.clone())
2254                })
2255                .await
2256                .query_application(context, query, self.service_runtime_endpoint.as_mut())
2257                .await
2258                .with_execution_context(ChainExecutionContext::Query)?;
2259            if let Some(cache) = &self.execution_state_cache {
2260                cache.insert(&requested_block, state);
2261            }
2262            Ok((outcome, next_block_height))
2263        } else {
2264            if block_hash.is_some() {
2265                tracing::debug!(
2266                    "requested block hash not found in cache, querying committed state"
2267                );
2268            }
2269            let outcome = self
2270                .chain
2271                .query_application(local_time, query, self.service_runtime_endpoint.as_mut())
2272                .await?;
2273            Ok((outcome, next_block_height))
2274        }
2275    }
2276
2277    /// Returns an application's description by reading the blob directly from storage.
2278    ///
2279    /// Does not track blob usage (which requires `&mut self`), making it safe for
2280    /// concurrent reads. Blob tracking is only relevant during block execution and is
2281    /// always rolled back for read-only queries.
2282    #[instrument(skip_all, fields(
2283        chain_id = %self.chain_id(),
2284        application_id = %application_id
2285    ))]
2286    pub(crate) async fn describe_application_readonly(
2287        &self,
2288        application_id: ApplicationId,
2289    ) -> Result<ApplicationDescription, WorkerError> {
2290        let blob_id = application_id.description_blob_id();
2291        let blob = self
2292            .storage
2293            .read_blob(blob_id)
2294            .await?
2295            .ok_or(WorkerError::BlobsNotFound(vec![blob_id]))?;
2296        Ok(bcs::from_bytes(blob.bytes())?)
2297    }
2298
2299    /// Executes a block without persisting any changes to the state, with a specified
2300    /// policy for handling bundle failures.
2301    ///
2302    /// The block may be modified to reflect the actual executed transactions
2303    /// (bundles may be rejected or removed based on the policy).
2304    #[instrument(skip_all, fields(
2305        chain_id = %self.chain_id(),
2306        block_height = %block.height
2307    ))]
2308    pub(crate) async fn stage_block_execution(
2309        &mut self,
2310        block: ProposedBlock,
2311        round: Option<u32>,
2312        published_blobs: &[Blob],
2313        policy: BundleExecutionPolicy,
2314    ) -> Result<
2315        (
2316            ProposedBlock,
2317            Block,
2318            ChainInfoResponse,
2319            ResourceTracker,
2320            HashSet<ChainId>,
2321        ),
2322        WorkerError,
2323    > {
2324        self.initialize_and_save_if_needed().await?;
2325        let local_time = self.storage.clock().current_time();
2326        let (_, committee) = self.chain.current_committee().await?;
2327        block.check_proposal_size(committee.policy().maximum_block_proposal_size)?;
2328
2329        self.chain
2330            .remove_bundles_from_inboxes(block.timestamp, true, block.incoming_bundles())
2331            .await?;
2332        let (executed_block, resource_tracker, never_reject_origins) =
2333            Box::pin(self.execute_block(
2334                block,
2335                local_time,
2336                round,
2337                published_blobs,
2338                policy,
2339                BlockExecutionPhase::StageProposal,
2340            ))
2341            .await?;
2342
2343        // No need to sign: only used internally.
2344        let info = ChainInfo::from_chain_view(&mut self.chain).await?;
2345        let mut response = ChainInfoResponse::new(info, None);
2346        if let Some(owner) = executed_block.header.authenticated_owner {
2347            response.info.requested_owner_balance = self
2348                .chain
2349                .execution_state
2350                .system
2351                .balances
2352                .get(&owner)
2353                .await?;
2354        }
2355
2356        let (proposed_block, _) = executed_block.clone().into_proposal();
2357        Ok((
2358            proposed_block,
2359            executed_block,
2360            response,
2361            resource_tracker,
2362            never_reject_origins,
2363        ))
2364    }
2365
2366    /// Validates and executes a block proposed to extend this chain.
2367    ///
2368    /// Returns network actions alongside the result so the caller can dispatch them
2369    /// even when the proposal is rejected: a `HasIncompatibleConfirmedVote` rejection
2370    /// can still advance `current_round` via `update_signed_proposal`, and subscribers
2371    /// need the resulting `NewRound` notification.
2372    #[instrument(skip_all, fields(
2373        chain_id = %self.chain_id(),
2374        block_height = %proposal.content.block.height
2375    ))]
2376    pub(crate) async fn handle_block_proposal(
2377        &mut self,
2378        proposal: BlockProposal,
2379    ) -> (Result<ChainInfoResponse, WorkerError>, NetworkActions) {
2380        #[cfg(with_metrics)]
2381        metrics::BLOCK_PROPOSALS_RECEIVED_TOTAL.inc();
2382        let chain_id = proposal.content.block.chain_id;
2383        let height = proposal.content.block.height;
2384        let old_round = self.chain.manager.current_round();
2385        match self.try_handle_block_proposal(proposal).await {
2386            Ok((response, actions)) => (Ok(response), actions),
2387            Err(err) => {
2388                let error_type = err.error_type();
2389                #[cfg(with_metrics)]
2390                metrics::BLOCK_PROPOSALS_REJECTED_TOTAL
2391                    .with_label_values(&[error_type.as_str()])
2392                    .inc();
2393                debug!(%chain_id, %height, %error_type, "Block proposal rejected");
2394                // Even on error, the manager's `current_round` may have advanced
2395                // (the `HasIncompatibleConfirmedVote` recovery path calls
2396                // `update_signed_proposal`). Surface the resulting `NewRound`
2397                // notification so subscribers can react.
2398                let actions = if self.chain.manager.current_round() != old_round {
2399                    self.create_network_actions(Some(old_round))
2400                        .await
2401                        .unwrap_or_default()
2402                } else {
2403                    NetworkActions::default()
2404                };
2405                (Err(err), actions)
2406            }
2407        }
2408    }
2409
2410    async fn try_handle_block_proposal(
2411        &mut self,
2412        proposal: BlockProposal,
2413    ) -> Result<(ChainInfoResponse, NetworkActions), WorkerError> {
2414        self.initialize_and_save_if_needed().await?;
2415        proposal
2416            .check_invariants()
2417            .map_err(|msg| WorkerError::InvalidBlockProposal(msg.to_string()))?;
2418        proposal.check_signature()?;
2419        let owner = proposal.owner();
2420        let BlockProposal {
2421            content,
2422            original_proposal,
2423            signature: _,
2424        } = &proposal;
2425        let block = &content.block;
2426        let chain = &self.chain;
2427        // Check if the chain is ready for this new block proposal.
2428        chain.tip_state.get().verify_block_chaining(block)?;
2429        // Check the epoch.
2430        let (epoch, committee) = chain.current_committee().await?;
2431        check_block_epoch(epoch, block.chain_id, block.epoch)?;
2432        let policy = committee.policy().clone();
2433        block.check_proposal_size(policy.maximum_block_proposal_size)?;
2434        // Check the authentication of the block.
2435        ensure!(
2436            chain.manager.can_propose(&owner, proposal.content.round),
2437            WorkerError::InvalidOwner
2438        );
2439        let old_round = self.chain.manager.current_round();
2440        match original_proposal {
2441            None => {
2442                if let Some(signer) = block.authenticated_owner {
2443                    // Check the authentication of the operations in the new block.
2444                    ensure!(signer == owner, WorkerError::InvalidSigner(owner));
2445                }
2446            }
2447            Some(OriginalProposal::Regular { certificate }) => {
2448                // Verify that this block has been validated by a quorum before.
2449                certificate.check(&committee)?;
2450            }
2451            Some(OriginalProposal::Fast(signature)) => {
2452                let original_proposal = BlockProposal {
2453                    content: ProposalContent {
2454                        block: content.block.clone(),
2455                        round: Round::Fast,
2456                        outcome: None,
2457                    },
2458                    signature: *signature,
2459                    original_proposal: None,
2460                };
2461                let super_owner = original_proposal.owner();
2462                ensure!(
2463                    chain
2464                        .manager
2465                        .ownership
2466                        .get()
2467                        .super_owners
2468                        .contains(&super_owner),
2469                    WorkerError::InvalidOwner
2470                );
2471                if let Some(signer) = block.authenticated_owner {
2472                    // Check the authentication of the operations in the new block.
2473                    ensure!(signer == super_owner, WorkerError::InvalidSigner(signer));
2474                }
2475                original_proposal.check_signature()?;
2476            }
2477        }
2478        let local_time = self.storage.clock().current_time();
2479        match chain.manager.check_proposed_block(&proposal) {
2480            Ok(manager::Outcome::Skip) => {
2481                // We already voted for this block.
2482                return Ok((self.chain_info_response().await?, NetworkActions::default()));
2483            }
2484            Ok(manager::Outcome::Accept) => {}
2485            Err(err) => {
2486                // A `HasIncompatibleConfirmedVote` rejection means the proposer is at a
2487                // round we'd otherwise be happy to sign at; only our prior confirmed vote
2488                // prevents us from voting. Record the proposal so `current_round` still
2489                // tracks the round the proposer is in — without it, the chain can wedge.
2490                // Other rejections (e.g. `WrongRound`, `InsufficientRound`) mean the
2491                // proposal is not actually valid for the chain's current state, so we
2492                // shouldn't let it advance our round.
2493                if matches!(err, ChainError::HasIncompatibleConfirmedVote(_, _))
2494                    && self
2495                        .chain
2496                        .manager
2497                        .update_signed_proposal(&proposal, local_time)
2498                {
2499                    self.save().await?;
2500                }
2501                return Err(err.into());
2502            }
2503        }
2504
2505        // Make sure we remember that a proposal was signed, to determine the correct round to
2506        // propose in.
2507        if self
2508            .chain
2509            .manager
2510            .update_signed_proposal(&proposal, local_time)
2511        {
2512            self.save().await?;
2513        }
2514
2515        let published_blobs = self.load_proposal_blobs(&proposal).await?;
2516        let ProposalContent {
2517            block,
2518            round,
2519            outcome,
2520        } = content;
2521
2522        if self.config.key_pair().is_some()
2523            && block.timestamp.duration_since(local_time) > self.config.block_time_grace_period
2524        {
2525            return Err(WorkerError::InvalidTimestamp {
2526                local_time,
2527                block_timestamp: block.timestamp,
2528                block_time_grace_period: self.config.block_time_grace_period,
2529            });
2530        }
2531        // Note: WorkerState::handle_block_proposal delays processing proposals with future
2532        // timestamps (within the grace period) before acquiring the chain lock. By the time
2533        // we reach here, the block timestamp should be in the past or very close to current time.
2534
2535        self.chain
2536            .remove_bundles_from_inboxes(block.timestamp, true, block.incoming_bundles())
2537            .await?;
2538        let block = if let Some(outcome) = outcome {
2539            outcome.clone().with(proposal.content.block.clone())
2540        } else {
2541            let (executed_block, _resource_tracker, _) = Box::pin(self.execute_block(
2542                block.clone(),
2543                local_time,
2544                round.multi_leader(),
2545                &published_blobs,
2546                BundleExecutionPolicy::committed(),
2547                BlockExecutionPhase::HandleProposal,
2548            ))
2549            .await?;
2550            executed_block
2551        };
2552
2553        ensure!(
2554            !round.is_fast() || !block.has_oracle_responses(),
2555            WorkerError::FastBlockUsingOracles
2556        );
2557        let chain = &mut self.chain;
2558        // Don't save the changes since the block is not confirmed yet.
2559        chain.rollback();
2560
2561        // Create the vote and store it in the chain state.
2562        let blobs = self
2563            .get_required_blobs(proposal.expected_blob_ids(), block.created_blobs())
2564            .await?;
2565        let key_pair = self.config.key_pair();
2566        let manager = &mut self.chain.manager;
2567        match manager.create_vote(&proposal, block, key_pair, local_time, blobs)? {
2568            // Cache the value we voted on, so the client doesn't have to send it again.
2569            Some(Either::Left(vote)) => {
2570                self.block_values
2571                    .insert_hashed(Cow::Borrowed(vote.value.inner()));
2572            }
2573            Some(Either::Right(vote)) => {
2574                self.block_values
2575                    .insert_hashed(Cow::Borrowed(vote.value.inner()));
2576            }
2577            None => (),
2578        }
2579        self.save().await?;
2580        let actions = self.create_network_actions(Some(old_round)).await?;
2581        Ok((self.chain_info_response().await?, actions))
2582    }
2583
2584    /// Prepares a [`ChainInfoResponse`] for a [`ChainInfoQuery`].
2585    #[instrument(skip_all, fields(
2586        chain_id = %self.chain_id()
2587    ))]
2588    async fn prepare_chain_info_response(
2589        &mut self,
2590        query: ChainInfoQuery,
2591    ) -> Result<ChainInfoResponse, WorkerError> {
2592        self.initialize_and_save_if_needed().await?;
2593        let mut info = ChainInfo::from_chain_view(&mut self.chain).await?;
2594        let chain = &self.chain;
2595        if query.request_owner_balance == AccountOwner::CHAIN {
2596            info.requested_owner_balance = Some(*chain.execution_state.system.balance.get());
2597        } else {
2598            info.requested_owner_balance = chain
2599                .execution_state
2600                .system
2601                .balances
2602                .get(&query.request_owner_balance)
2603                .await?;
2604        }
2605        if let Some(next_block_height) = query.test_next_block_height {
2606            // If not, send the same error as if a block with next_block_height was proposed.
2607            ensure!(
2608                chain.tip_state.get().next_block_height == next_block_height,
2609                WorkerError::UnexpectedBlockHeight {
2610                    expected_block_height: chain.tip_state.get().next_block_height,
2611                    found_block_height: next_block_height,
2612                }
2613            );
2614        }
2615        if query.request_pending_message_bundles {
2616            let mut bundles = Vec::new();
2617            let nonempty_origins: Vec<ChainId> =
2618                chain.nonempty_inboxes.get().iter().copied().collect();
2619            #[cfg(with_metrics)]
2620            metrics::NUM_INBOXES
2621                .with_label_values(&[])
2622                .observe(nonempty_origins.len() as f64);
2623            let is_closed = *chain.execution_state.system.closed.get();
2624            let action = if is_closed {
2625                MessageAction::Reject
2626            } else {
2627                MessageAction::Accept
2628            };
2629            let inboxes = chain.inboxes.try_load_entries(&nonempty_origins).await?;
2630            for (origin, inbox) in nonempty_origins.into_iter().zip(inboxes) {
2631                let inbox = inbox.ok_or_else(|| {
2632                    ChainError::InternalError(format!("Missing inbox for origin {origin}"))
2633                })?;
2634                for bundle in inbox.added_bundles.elements().await? {
2635                    bundles.push(IncomingBundle {
2636                        origin,
2637                        bundle,
2638                        action,
2639                    });
2640                }
2641            }
2642            if is_closed && !bundles.is_empty() {
2643                info!(
2644                    chain_id = %chain.chain_id(),
2645                    count = bundles.len(),
2646                    "Auto-rejecting all incoming message bundles because the chain is closed"
2647                );
2648            }
2649            info.requested_pending_message_bundles = bundles;
2650        }
2651        let hashes = chain
2652            .block_hashes_for_heights(query.request_sent_certificate_hashes_by_heights)
2653            .await?;
2654        info.requested_sent_certificate_hashes = hashes;
2655        if let Some(start) = query.request_received_log_excluding_first_n {
2656            let start = usize::try_from(start).map_err(|_| ArithmeticError::Overflow)?;
2657            let max_received_log_entries = self.config.chain_info_max_received_log_entries;
2658            let end = start
2659                .saturating_add(max_received_log_entries)
2660                .min(chain.received_log.count());
2661            info.requested_received_log = chain.received_log.read(start..end).await?;
2662        }
2663        if query.request_manager_values {
2664            info.manager.add_values(&chain.manager);
2665        }
2666        if !query.request_previous_event_blocks.is_empty() {
2667            let stream_ids = query.request_previous_event_blocks;
2668            let heights = chain
2669                .execution_state
2670                .previous_event_blocks
2671                .multi_get(&stream_ids)
2672                .await?;
2673            let mut streams_with_heights = Vec::new();
2674            for (stream_id, height) in stream_ids.into_iter().zip(heights) {
2675                if let Some(height) = height {
2676                    streams_with_heights.push((stream_id, height));
2677                }
2678            }
2679            let hashes = chain
2680                .block_hashes
2681                .multi_get(streams_with_heights.iter().map(|(_, height)| height))
2682                .await?;
2683            for (maybe_hash, (stream_id, height)) in hashes.into_iter().zip(streams_with_heights) {
2684                let hash = maybe_hash.ok_or_else(|| WorkerError::BlockHashNotFound {
2685                    height,
2686                    chain_id: info.chain_id,
2687                })?;
2688                info.requested_previous_event_blocks
2689                    .insert(stream_id, (height, hash));
2690            }
2691        }
2692        if query.request_latest_checkpoint_height {
2693            info.requested_latest_checkpoint_height = *self.chain.latest_checkpoint_height.get();
2694        }
2695        Ok(ChainInfoResponse::new(info, self.config.key_pair()))
2696    }
2697
2698    /// Executes a block with a specified policy for handling bundle failures.
2699    ///
2700    /// The block may be modified to reflect the actual executed transactions.
2701    #[instrument(skip_all, fields(
2702        chain_id = %self.chain_id(),
2703        block_height = %block.height
2704    ))]
2705    async fn execute_block(
2706        &mut self,
2707        block: ProposedBlock,
2708        local_time: Timestamp,
2709        round: Option<u32>,
2710        published_blobs: &[Blob],
2711        policy: BundleExecutionPolicy,
2712        phase: BlockExecutionPhase,
2713    ) -> Result<(Block, ResourceTracker, HashSet<ChainId>), WorkerError> {
2714        let (proposed_block, outcome, resource_tracker, never_reject_origins) =
2715            Box::pin(self.chain.execute_block(
2716                block,
2717                local_time,
2718                round,
2719                published_blobs,
2720                None,
2721                policy,
2722                phase,
2723            ))
2724            .await?;
2725        let executed_block = Block::new(proposed_block, outcome);
2726        let block_hash = executed_block.hash();
2727        if let Some(cache) = &self.execution_state_cache {
2728            cache.insert(
2729                &block_hash,
2730                Box::pin(
2731                    self.chain
2732                        .execution_state
2733                        .with_context(|ctx| InactiveContext(ctx.base_key().clone())),
2734                )
2735                .await,
2736            );
2737        }
2738        Ok((executed_block, resource_tracker, never_reject_origins))
2739    }
2740
2741    /// Initializes and saves the current chain if it is not active yet.
2742    #[instrument(skip_all, fields(
2743        chain_id = %self.chain_id()
2744    ))]
2745    pub(crate) async fn initialize_and_save_if_needed(&mut self) -> Result<(), WorkerError> {
2746        if !self.knows_chain_is_active {
2747            let local_time = self.storage.clock().current_time();
2748            self.chain.initialize_if_needed(local_time).await?;
2749            self.save().await?;
2750            self.knows_chain_is_active = true;
2751        }
2752        Ok(())
2753    }
2754
2755    pub(crate) async fn chain_info_response(&mut self) -> Result<ChainInfoResponse, WorkerError> {
2756        let info = ChainInfo::from_chain_view(&mut self.chain).await?;
2757        Ok(ChainInfoResponse::new(info, self.config.key_pair()))
2758    }
2759
2760    /// Stores the chain state in persistent storage.
2761    ///
2762    /// If the save fails, the worker is marked as poisoned and must be reloaded.
2763    #[instrument(skip_all, fields(
2764        chain_id = %self.chain_id()
2765    ))]
2766    pub(crate) async fn save(&mut self) -> Result<(), WorkerError> {
2767        if let Err(error) = self.chain.save().await {
2768            if error.must_reload_view() {
2769                tracing::error!(
2770                    ?error,
2771                    chain_id = %self.chain_id(),
2772                    "Chain save failed with a nonrecoverable error; marking worker as poisoned"
2773                );
2774                self.poisoned = true;
2775            }
2776            return Err(WorkerError::ViewError(error));
2777        }
2778        Ok(())
2779    }
2780
2781    /// Deletes every key under this chain's storage root and replaces `self.chain`
2782    /// with a freshly loaded (empty) view. If either the write or the reload fails,
2783    /// the worker is marked as poisoned: the partial deletion may have left
2784    /// storage inconsistent with the in-memory view, so it cannot be reused.
2785    #[instrument(skip_all, fields(
2786        chain_id = %self.chain_id()
2787    ))]
2788    async fn wipe_and_reload_chain(&mut self) -> Result<(), WorkerError> {
2789        let context = self.chain.context().clone();
2790        let mut batch = Batch::new();
2791        batch.delete_key_prefix(Vec::new());
2792        if let Err(error) = context.store().write_batch(batch).await {
2793            tracing::error!(
2794                ?error,
2795                chain_id = %self.chain_id(),
2796                "Wiping chain storage failed; marking worker as poisoned"
2797            );
2798            self.poisoned = true;
2799            return Err(WorkerError::PoisonedWorker);
2800        }
2801        match ChainStateView::load(context).await {
2802            Ok(chain) => {
2803                self.chain = chain;
2804                Ok(())
2805            }
2806            Err(error) => {
2807                tracing::error!(
2808                    ?error,
2809                    chain_id = %self.chain_id(),
2810                    "Reloading chain after wipe failed; marking worker as poisoned"
2811                );
2812                self.poisoned = true;
2813                Err(WorkerError::PoisonedWorker)
2814            }
2815        }
2816    }
2817}
2818
2819/// Classifies an error returned while processing a single cross-chain batch request.
2820///
2821/// If the error indicates a poisoned worker or corrupted chain state, it is returned
2822/// as the first element so `process_batch` can hand it to `chain_write` for recovery
2823/// (eviction or reset), and the originating caller is told `BatchRolledBack` so it
2824/// retries against a freshly loaded worker. Any other error leaves the worker healthy
2825/// after rollback and is reported to the caller unchanged.
2826fn classify_processing_error(error: WorkerError) -> (Option<WorkerError>, WorkerError) {
2827    if error.must_reload_view() || error.indicates_corrupted_chain_state() {
2828        (Some(error), WorkerError::BatchRolledBack)
2829    } else {
2830        (None, error)
2831    }
2832}
2833
2834/// Sends a result through a oneshot channel, logging at `debug` level if the
2835/// receiver has been dropped.
2836pub(crate) fn send_result<T>(sender: oneshot::Sender<T>, value: T) {
2837    if sender.send(value).is_err() {
2838        tracing::debug!("cannot send cross-chain result; receiver dropped");
2839    }
2840}
2841
2842/// Returns the missing indices and corresponding blob_ids.
2843fn missing_indices_blob_ids(maybe_blobs: &[(BlobId, Option<Blob>)]) -> (Vec<usize>, Vec<BlobId>) {
2844    let mut missing_indices = Vec::new();
2845    let mut missing_blob_ids = Vec::new();
2846    for (index, (blob_id, blob)) in maybe_blobs.iter().enumerate() {
2847        if blob.is_none() {
2848            missing_indices.push(index);
2849            missing_blob_ids.push(*blob_id);
2850        }
2851    }
2852    (missing_indices, missing_blob_ids)
2853}
2854
2855/// Returns the blob IDs whose corresponding value is `None`.
2856fn missing_blob_ids<'a>(
2857    maybe_blobs: impl IntoIterator<Item = (&'a BlobId, &'a Option<Blob>)>,
2858) -> Vec<BlobId> {
2859    maybe_blobs
2860        .into_iter()
2861        .filter(|(_, maybe_blob)| maybe_blob.is_none())
2862        .map(|(blob_id, _)| *blob_id)
2863        .collect()
2864}
2865
2866/// Returns an error if the block is not at the expected epoch.
2867fn check_block_epoch(
2868    chain_epoch: Epoch,
2869    block_chain: ChainId,
2870    block_epoch: Epoch,
2871) -> Result<(), WorkerError> {
2872    ensure!(
2873        block_epoch == chain_epoch,
2874        WorkerError::InvalidEpoch {
2875            chain_id: block_chain,
2876            epoch: block_epoch,
2877            chain_epoch
2878        }
2879    );
2880    Ok(())
2881}