Skip to main content

espresso_node/
follower.rs

1//! A query node that follows the chain through the light client instead of taking part in
2//! consensus. Verified blocks reach the query API through the same sink a validator hands its
3//! decides to, so every API module serves them unchanged.
4//!
5//! The light client's root of trust is its genesis stake table. Unless
6//! [`FollowerOptions::light_client_genesis`] pins one, it is derived from the network config, and
7//! a follower starting on an empty database fetches that config from its config peers, which
8//! default to the very upstreams it verifies. Pin the genesis wherever the upstreams are not
9//! trusted on first start.
10
11use std::{cmp::max, collections::HashMap, ops::Range, sync::Arc, time::Duration};
12
13use ::light_client::{
14    LightClient,
15    client::{FallbackClient, QueryServiceClient},
16    state::{Genesis as LightClientGenesis, LightClientOptions},
17    storage::LightClientDbOptions,
18};
19use alloy::primitives::U256;
20use anyhow::{Context, anyhow, bail, ensure};
21use async_lock::RwLock;
22use async_trait::async_trait;
23use committable::{Commitment, Committable};
24use derivative::Derivative;
25use espresso_api::error::SubmitError;
26use espresso_types::{
27    Certificate2, ChainConfig, Header, Leaf2, NodeState, PubKey, SeqTypes, Transaction,
28    ValidatedState,
29    traits::MembershipPersistence,
30    v0::traits::{SequencerPersistence, StateCatchup, StateCertPersistence},
31    v0_1::ChainId,
32};
33use futures::{FutureExt, future::BoxFuture};
34use hotshot_query_service::{
35    availability::{BlockInfo, BlockQueryData, LeafQueryData, VidCommonQueryData},
36    types::HeightIndexed,
37};
38use hotshot_types::{
39    ValidatorConfig,
40    data::{EpochNumber, VidShare, ViewNumber},
41    epoch_membership::EpochMembershipCoordinator,
42    message::UpgradeLock,
43    network::NetworkConfig,
44    traits::{
45        ValidatedState as _,
46        election::{Membership, MembershipSnapshot},
47        metrics::Metrics,
48        storage::Storage,
49    },
50    utils::{
51        StateAndDelta, epoch_from_block_number, is_epoch_root, is_transition_block,
52        root_block_in_epoch,
53    },
54};
55use http_client::{Client, StatusCode, error::ClientErr};
56use parking_lot::Mutex;
57use tokio::{sync::watch, time::sleep};
58use url::Url;
59use versions::NEW_PROTOCOL_VERSION;
60
61use crate::{
62    CatchupParams, Genesis, L1Params, LoadedNetworkConfig, NodeStateParts, SequencerApiVersion,
63    api::{
64        DecideSink,
65        context::{ApiContext, ConsensusSource, Delta, NodeLightClient},
66        light_client_genesis,
67    },
68    apply_genesis_overrides,
69    context::TaskList,
70    init_node_state, load_or_fetch_network_config,
71    startup_catchup::bootstrap_epoch_window,
72    state_signature::StateSignatureMemStorage,
73};
74
75#[derive(Clone, Debug)]
76pub struct FollowerOptions {
77    pub poll_interval: Duration,
78    /// Older heights are left to the query service's backfill.
79    pub max_blocks_per_poll: u64,
80    pub light_client: LightClientOptions,
81    pub light_client_db: LightClientDbOptions,
82    /// Derived from the network config when unset.
83    pub light_client_genesis: Option<LightClientGenesis>,
84}
85
86impl FollowerOptions {
87    fn validate(&self) -> anyhow::Result<()> {
88        ensure!(
89            self.max_blocks_per_poll > 0,
90            "max_blocks_per_poll must be at least 1: a follower ingesting no blocks per poll \
91             never follows"
92        );
93        ensure!(
94            !self.poll_interval.is_zero(),
95            "poll_interval must be positive: a follower polling without pause spins on its \
96             upstreams"
97        );
98        Ok(())
99    }
100}
101
102#[derive(Clone, Debug)]
103pub struct FollowerParams {
104    pub upstreams: Vec<Url>,
105    /// The upstreams when unset.
106    pub config_peers: Option<Vec<Url>>,
107    pub catchup: CatchupParams,
108    pub bootstrap_epoch_catchup_timeout: Duration,
109    pub options: FollowerOptions,
110}
111
112/// What the API holds. Clones share the follower's state and own nothing that stops it; a
113/// [`FollowerContext`] does that.
114#[derive(Derivative, Clone)]
115#[derivative(Debug(bound = ""))]
116pub struct FollowerHandle<P: SequencerPersistence> {
117    #[derivative(Debug = "ignore")]
118    consensus: Arc<FollowerConsensus>,
119    #[derivative(Debug = "ignore")]
120    light_client: Option<Arc<NodeLightClient>>,
121    node_state: NodeState,
122    #[derivative(Debug = "ignore")]
123    persistence: Arc<P>,
124    network_config: NetworkConfig<SeqTypes>,
125    /// The follow loop, and the API tasks [`Options::serve`](crate::api::Options::serve)
126    /// attaches, until a [`FollowerContext`] takes them over. Shared, because dropping a
127    /// [`TaskList`] aborts its tasks and dropping a clone of the handle must not.
128    #[derivative(Debug = "ignore")]
129    tasks: Arc<Mutex<TaskList>>,
130}
131
132/// Owns the follower's tasks: dropping it stops following unless it was
133/// [detached](Self::detach). Not `Clone`, so nothing else can stop the follower; the API holds a
134/// [`FollowerHandle`].
135#[derive(Derivative)]
136#[derivative(Debug(bound = ""))]
137pub struct FollowerContext<P: SequencerPersistence> {
138    handle: FollowerHandle<P>,
139    tasks: TaskList,
140    detached: bool,
141}
142
143/// The follow loop is running when this returns; [`FollowerContext::new`] takes over stopping it.
144pub async fn init_follower_node<P>(
145    genesis: Genesis,
146    params: FollowerParams,
147    metrics: Box<dyn Metrics>,
148    persistence: P,
149    l1_params: L1Params,
150    sink: impl DecideSink + 'static,
151) -> anyhow::Result<FollowerHandle<P>>
152where
153    P: SequencerPersistence + MembershipPersistence,
154    Arc<P>: Storage<SeqTypes>,
155{
156    init_with_source(genesis, params, metrics, persistence, l1_params, sink, None).await
157}
158
159/// For tests that replay a recorded chain; the network config must already be persisted, as there
160/// are no peers to fetch it from.
161#[cfg(any(test, feature = "testing"))]
162pub(crate) async fn init_replay_node<P>(
163    genesis: Genesis,
164    params: FollowerParams,
165    metrics: Box<dyn Metrics>,
166    persistence: P,
167    l1_params: L1Params,
168    sink: impl DecideSink + 'static,
169    source: Arc<dyn ChainSource>,
170) -> anyhow::Result<FollowerHandle<P>>
171where
172    P: SequencerPersistence + MembershipPersistence,
173    Arc<P>: Storage<SeqTypes>,
174{
175    init_with_source(
176        genesis,
177        params,
178        metrics,
179        persistence,
180        l1_params,
181        sink,
182        Some(source),
183    )
184    .await
185}
186
187async fn init_with_source<P>(
188    genesis: Genesis,
189    params: FollowerParams,
190    metrics: Box<dyn Metrics>,
191    persistence: P,
192    l1_params: L1Params,
193    sink: impl DecideSink + 'static,
194    replay: Option<Arc<dyn ChainSource>>,
195) -> anyhow::Result<FollowerHandle<P>>
196where
197    P: SequencerPersistence + MembershipPersistence,
198    Arc<P>: Storage<SeqTypes>,
199{
200    params.options.validate()?;
201
202    // A follower has no stake, so any key serves as its identity in the network config.
203    let validator_config = ValidatorConfig::<SeqTypes>::generated_from_seed_indexed(
204        rand::random(),
205        0,
206        U256::ONE,
207        false,
208    );
209    let config_peers = params
210        .config_peers
211        .clone()
212        .unwrap_or_else(|| params.upstreams.clone());
213    let (mut network_config, fetched) = match load_or_fetch_network_config(
214        &persistence,
215        Some(config_peers),
216        &validator_config,
217        params.catchup.backoff,
218        params.catchup.base_timeout,
219    )
220    .await?
221    {
222        Some(LoadedNetworkConfig::Persisted(config)) => (config, false),
223        Some(LoadedNetworkConfig::Fetched(config)) => (config, true),
224        None => bail!("a follower needs peers to fetch the network config from"),
225    };
226    apply_genesis_overrides(&mut network_config, &genesis);
227    if fetched {
228        persistence.save_config(&network_config).await?;
229    }
230    // From genesis rather than the network config, as `init_node_state` reads it: the light
231    // client verifies epochs, so a follower cannot run without them.
232    let epoch_height = genesis.epoch_height.unwrap_or_default();
233    ensure!(
234        epoch_height > 0,
235        "a follower needs a positive epoch_height in genesis"
236    );
237    let upgrade = versions::Upgrade::new(genesis.base_version, genesis.upgrade_version);
238
239    let NodeStateParts {
240        node_state,
241        state_catchup: _,
242        persistence,
243    } = init_node_state(
244        &genesis,
245        &network_config,
246        l1_params,
247        params.catchup.clone(),
248        persistence,
249        &*metrics,
250    )
251    .await?;
252    let coordinator = node_state.coordinator.clone();
253
254    let current_epoch = bootstrap_epoch_window(
255        &coordinator,
256        epoch_height,
257        params.bootstrap_epoch_catchup_timeout,
258    )
259    .await
260    .context("startup stake-table catchup failed")?;
261    tracing::info!(%current_epoch, "startup catchup complete");
262
263    let (source, light_client): (Arc<dyn ChainSource>, _) = match replay {
264        Some(source) => (source, None),
265        None => {
266            let light_client = Arc::new(
267                connect_light_client(
268                    &params.options,
269                    &params.upstreams,
270                    &network_config,
271                    genesis.chain_config.chain_id,
272                    fetched,
273                )
274                .await?,
275            );
276            (light_client.clone(), Some(light_client))
277        },
278    };
279
280    // Seeded like a validator's lock, so the API validates state certificates against the
281    // version the network has upgraded to rather than the base version forever.
282    let upgrade_lock =
283        UpgradeLock::from_certificate(upgrade, &persistence.load_upgrade_certificate().await?);
284
285    let (decided, decided_rx) = watch::channel(None);
286    let chain_config = Arc::new(RwLock::new(genesis.chain_config));
287    let consensus = FollowerConsensus {
288        decided: decided_rx,
289        chain_config: chain_config.clone(),
290        coordinator: coordinator.clone(),
291        upgrade_lock: upgrade_lock.clone(),
292        upstreams: params.upstreams,
293        epoch_height,
294    };
295
296    let next = sink
297        .block_height()
298        .await
299        .context("reading the height the query database has reached")?;
300    let follower = Follower {
301        source,
302        sink: Arc::new(sink),
303        persistence: persistence.clone(),
304        coordinator,
305        state_catchup: node_state.state_catchup.clone(),
306        decided,
307        chain_config,
308        upgrade_lock,
309        epoch_height,
310        options: params.options,
311        bootstrap_epoch_catchup_timeout: params.bootstrap_epoch_catchup_timeout,
312        next,
313    };
314    let mut tasks = TaskList::default();
315    tasks.spawn("light client follower", follower.run());
316
317    Ok(FollowerHandle {
318        consensus: Arc::new(consensus),
319        light_client,
320        node_state,
321        persistence,
322        network_config,
323        tasks: Arc::new(Mutex::new(tasks)),
324    })
325}
326
327impl<P: SequencerPersistence> FollowerContext<P> {
328    /// Takes the tasks out of `handle` and its clones, so from here only this context stops the
329    /// follower.
330    pub fn new(handle: FollowerHandle<P>) -> Self {
331        let tasks = std::mem::take(&mut *handle.tasks.lock());
332        Self {
333            handle,
334            tasks,
335            detached: false,
336        }
337    }
338
339    pub fn handle(&self) -> FollowerHandle<P> {
340        self.handle.clone()
341    }
342
343    /// Waits for the first decided leaf.
344    pub async fn decided_leaf(&self) -> Leaf2 {
345        self.handle.consensus.decided_leaf().await
346    }
347
348    pub fn node_state(&self) -> NodeState {
349        self.handle.node_state.clone()
350    }
351
352    /// `None` when replaying a recorded chain.
353    pub fn light_client(&self) -> Option<Arc<NodeLightClient>> {
354        self.handle.light_client.clone()
355    }
356
357    pub async fn shut_down(&mut self) {
358        tracing::info!("shutting down FollowerContext");
359        self.tasks.shut_down();
360        self.handle.node_state.l1_client.shut_down_tasks().await;
361        self.detached = true;
362    }
363
364    pub async fn join(&mut self) {
365        self.tasks.join().await;
366    }
367
368    /// Keep following even after this context is dropped.
369    pub fn detach(&mut self) {
370        self.detached = true;
371    }
372}
373
374impl<P: SequencerPersistence> Drop for FollowerContext<P> {
375    fn drop(&mut self) {
376        if !self.detached {
377            let tasks = self.tasks.clone();
378            let l1_client = self.handle.node_state.l1_client.clone();
379            tokio::spawn(async move {
380                tracing::info!("shutting down FollowerContext");
381                tasks.shut_down();
382                l1_client.shut_down_tasks().await;
383            });
384            self.detached = true;
385        }
386    }
387}
388
389impl<P: SequencerPersistence> ApiContext for FollowerHandle<P> {
390    fn consensus(&self) -> Arc<dyn ConsensusSource> {
391        self.consensus.clone()
392    }
393
394    fn membership(&self) -> Arc<dyn MembershipPersistence> {
395        self.persistence.clone()
396    }
397
398    fn state_certs(&self) -> Arc<dyn StateCertPersistence> {
399        self.persistence.clone()
400    }
401
402    fn node_state(&self) -> NodeState {
403        self.node_state.clone()
404    }
405
406    fn network_config(&self) -> NetworkConfig<SeqTypes> {
407        self.network_config.clone()
408    }
409
410    fn validator_config(&self) -> Option<&ValidatorConfig<SeqTypes>> {
411        None
412    }
413
414    fn state_signatures(&self) -> Option<Arc<RwLock<StateSignatureMemStorage>>> {
415        None
416    }
417
418    fn light_client(&self) -> Option<Arc<NodeLightClient>> {
419        self.light_client.clone()
420    }
421
422    fn request_vid_shares(
423        &self,
424        _block_number: u64,
425        _vid_common: VidCommonQueryData<SeqTypes>,
426        _timeout: Duration,
427    ) -> BoxFuture<'static, anyhow::Result<Vec<VidShare>>> {
428        futures::future::ready(Err(anyhow!(
429            "a follower has no request-response protocol to fetch VID shares with"
430        )))
431        .boxed()
432    }
433
434    fn with_task_list(self, tasks: TaskList) -> Self {
435        self.tasks.lock().extend(tasks);
436        self
437    }
438}
439
440/// Behind an `Arc` in [`FollowerHandle`], as the API's `ConsensusSource`.
441struct FollowerConsensus {
442    decided: watch::Receiver<Option<LeafQueryData<SeqTypes>>>,
443    chain_config: Arc<RwLock<ChainConfig>>,
444    coordinator: EpochMembershipCoordinator<SeqTypes>,
445    upgrade_lock: UpgradeLock<SeqTypes>,
446    upstreams: Vec<Url>,
447    epoch_height: u64,
448}
449
450#[async_trait]
451impl ConsensusSource for FollowerConsensus {
452    async fn decided_leaf(&self) -> Leaf2 {
453        let mut decided = self.decided.clone();
454        let leaf = decided
455            .wait_for(Option::is_some)
456            .await
457            .expect("the follower context holds the sender");
458        leaf.as_ref()
459            .expect("waited for a decided leaf")
460            .leaf()
461            .clone()
462    }
463
464    async fn decided_state(&self) -> Option<Arc<ValidatedState>> {
465        let leaf = self.decided.borrow().clone()?;
466        let mut state = ValidatedState::from_header(leaf.header());
467        state.chain_config = (*self.chain_config.read().await).into();
468        Some(Arc::new(state))
469    }
470
471    async fn state(&self, _view: ViewNumber) -> Option<Arc<ValidatedState>> {
472        None
473    }
474
475    async fn state_and_delta(&self, _view: ViewNumber) -> StateAndDelta<SeqTypes> {
476        (None, None)
477    }
478
479    async fn undecided_leaves(&self) -> Vec<Leaf2> {
480        Vec::new()
481    }
482
483    async fn current_epoch(&self) -> Option<EpochNumber> {
484        let decided = self.decided.borrow();
485        decided
486            .as_ref()
487            .and_then(|leaf| leaf.leaf().epoch(self.epoch_height))
488    }
489
490    async fn membership_coordinator(&self) -> EpochMembershipCoordinator<SeqTypes> {
491        self.coordinator.clone()
492    }
493
494    async fn upgrade_lock(&self) -> UpgradeLock<SeqTypes> {
495        self.upgrade_lock.clone()
496    }
497
498    async fn submit_transaction(&self, tx: Transaction) -> anyhow::Result<Commitment<Transaction>> {
499        let mut last_err = anyhow!("no upstream query nodes to forward the transaction to");
500        for upstream in &self.upstreams {
501            let client = Client::<ClientErr, SequencerApiVersion>::new(upstream.clone());
502            let result = client
503                .post::<Commitment<Transaction>>("submit/submit")
504                .body_binary(&tx)?
505                .send()
506                .await;
507            match result {
508                Ok(commitment) => return Ok(commitment),
509                Err(err) => {
510                    tracing::warn!(%upstream, %err, "forwarding transaction failed");
511                    match err.status {
512                        // Every upstream would refuse it.
513                        StatusCode::BAD_REQUEST => {
514                            return Err(SubmitError::Invalid(err.message).into());
515                        },
516                        StatusCode::SERVICE_UNAVAILABLE => {
517                            last_err = SubmitError::Overloaded(err.message).into();
518                        },
519                        _ => {
520                            last_err = anyhow::Error::from(err)
521                                .context(format!("forwarding to {upstream}"));
522                        },
523                    }
524                },
525            }
526        }
527        Err(last_err)
528    }
529
530    async fn update_leaf(
531        &self,
532        _leaf: Leaf2,
533        _state: Arc<ValidatedState>,
534        _delta: Option<Arc<Delta>>,
535    ) -> anyhow::Result<()> {
536        // The follower keeps no in-memory state per view; catchup serves from storage.
537        Ok(())
538    }
539
540    async fn current_proposal_participation(&self) -> HashMap<PubKey, f64> {
541        HashMap::new()
542    }
543
544    async fn proposal_participation(&self, _epoch: EpochNumber) -> HashMap<PubKey, f64> {
545        HashMap::new()
546    }
547
548    async fn current_vote_participation(&self) -> HashMap<PubKey, f64> {
549        HashMap::new()
550    }
551
552    async fn vote_participation(&self, _epoch: EpochNumber) -> HashMap<PubKey, f64> {
553        HashMap::new()
554    }
555}
556
557struct Follower<P> {
558    source: Arc<dyn ChainSource>,
559    sink: Arc<dyn DecideSink>,
560    persistence: Arc<P>,
561    coordinator: EpochMembershipCoordinator<SeqTypes>,
562    state_catchup: Arc<dyn StateCatchup>,
563    decided: watch::Sender<Option<LeafQueryData<SeqTypes>>>,
564    chain_config: Arc<RwLock<ChainConfig>>,
565    upgrade_lock: UpgradeLock<SeqTypes>,
566    epoch_height: u64,
567    options: FollowerOptions,
568    bootstrap_epoch_catchup_timeout: Duration,
569    next: u64,
570}
571
572/// Where a follower reads the chain from.
573#[async_trait]
574pub(crate) trait ChainSource: Send + Sync {
575    async fn block_height(&self) -> anyhow::Result<u64>;
576
577    /// The blocks at heights `from..to`, in order.
578    async fn fetch_range(&self, from: u64, to: u64) -> anyhow::Result<Vec<VerifiedBlock>>;
579
580    /// The cert2 that finalized `header` directly, if any.
581    async fn fetch_cert2(&self, header: &Header) -> anyhow::Result<Option<Certificate2<SeqTypes>>>;
582}
583
584pub(crate) struct VerifiedBlock {
585    pub(crate) leaf: LeafQueryData<SeqTypes>,
586    pub(crate) block: BlockQueryData<SeqTypes>,
587    pub(crate) vid_common: VidCommonQueryData<SeqTypes>,
588}
589
590impl<P: SequencerPersistence> Follower<P> {
591    async fn run(mut self) {
592        tracing::info!(from = self.next, "following the chain");
593        loop {
594            if let Err(err) = self.poll().await {
595                tracing::warn!(
596                    next = self.next,
597                    err = %format!("{err:#}"),
598                    "following the chain failed"
599                );
600            }
601            sleep(self.options.poll_interval).await;
602        }
603    }
604
605    async fn poll(&mut self) -> anyhow::Result<()> {
606        let tip = self
607            .source
608            .block_height()
609            .await
610            .context("fetching the chain height")?;
611        let from = max(
612            self.next,
613            tip.saturating_sub(self.options.max_blocks_per_poll),
614        );
615        if from >= tip {
616            return Ok(());
617        }
618        if from > self.next {
619            tracing::info!(
620                next = self.next,
621                from,
622                tip,
623                "skipping ahead; the query service backfills the gap above its pruned height"
624            );
625            self.catch_up_skipped_epochs(self.next..from).await?;
626            self.next = from;
627        }
628        for block in self.source.fetch_range(from, tip).await? {
629            let height = block.leaf.height();
630            ensure!(
631                height == self.next,
632                "the light client returned block {height} where {} was expected",
633                self.next
634            );
635            self.follow(block)
636                .await
637                .with_context(|| format!("following block {height}"))?;
638            self.next = height + 1;
639        }
640        Ok(())
641    }
642
643    async fn follow(&self, block: VerifiedBlock) -> anyhow::Result<()> {
644        let VerifiedBlock {
645            leaf,
646            block,
647            vid_common,
648        } = block;
649        let mut info = BlockInfo::new(leaf.clone(), Some(block), Some(vid_common), None);
650        // Upstream stores a cert2 only at the heights it finalized directly, so most answers
651        // are `None`; asking at every height is what keeps the follower serving the same cert2s.
652        if leaf.header().version() >= NEW_PROTOCOL_VERSION
653            && let Some(cert2) = self
654                .source
655                .fetch_cert2(leaf.header())
656                .await
657                .context("fetching cert2")?
658        {
659            info = info.with_cert2(cert2);
660        }
661        self.sink.append(info).await.context("storing block")?;
662
663        self.track_upgrade(leaf.leaf()).await;
664        self.track_epoch(leaf.leaf()).await?;
665        self.track_chain_config(leaf.header()).await;
666        self.decided.send_replace(Some(leaf));
667        Ok(())
668    }
669
670    /// As a validator does on decide, so the API validates state certificates against the version
671    /// the network runs.
672    async fn track_upgrade(&self, leaf: &Leaf2) {
673        let Some(cert) = leaf.upgrade_certificate() else {
674            return;
675        };
676        if self.upgrade_lock.decided_upgrade_cert().as_ref() == Some(cert) {
677            return;
678        }
679        tracing::warn!(
680            height = leaf.height(),
681            version = ?cert.data.new_version,
682            "the network decided an upgrade"
683        );
684        self.upgrade_lock.set_decided_upgrade_cert(cert.clone());
685        if let Err(err) = self
686            .persistence
687            .store_upgrade_certificate(Some(cert.clone()))
688            .await
689        {
690            tracing::warn!(
691                height = leaf.height(),
692                "cannot persist the upgrade certificate: {err:#}"
693            );
694        }
695    }
696
697    /// Awaited, so the transition block finds the stake table it supplies a DRB for, and a
698    /// failed derivation fails the block and is retried with it. Nothing is redone when the
699    /// membership already knows the result, as after a restart or a catchup walk.
700    async fn track_epoch(&self, leaf: &Leaf2) -> anyhow::Result<()> {
701        let height = leaf.height();
702        let Some(epoch) = leaf.epoch(self.epoch_height) else {
703            return Ok(());
704        };
705        if is_epoch_root(height, self.epoch_height) && !self.has_stake_table(epoch + 2) {
706            self.coordinator
707                .add_epoch_root(leaf.block_header().clone())
708                .await
709                .map_err(|err| anyhow!("deriving the stake table of epoch {}: {err}", epoch + 2))?;
710        }
711        if is_transition_block(height, self.epoch_height)
712            && let Some(drb) = leaf.next_drb_result
713            && !self.has_drb(epoch + 1)
714        {
715            self.coordinator.supply_drb(epoch + 1, drb);
716        }
717        Ok(())
718    }
719
720    /// Skipping an epoch root skips the stake table it derives, which later roots build on. DRBs
721    /// the skipped transition blocks carried are left to the coordinator, which fetches them on
722    /// demand.
723    async fn catch_up_skipped_epochs(&self, skipped: Range<u64>) -> anyhow::Result<()> {
724        if skipped.is_empty() {
725            return Ok(());
726        }
727        let first_epoch = self.coordinator.membership().first_epoch();
728        let first = max(
729            epoch_from_block_number(skipped.start, self.epoch_height),
730            first_epoch.u64(),
731        );
732        let last = epoch_from_block_number(skipped.end - 1, self.epoch_height);
733        let missing = (first..=last).any(|epoch| {
734            skipped.contains(&root_block_in_epoch(epoch, self.epoch_height))
735                && !self.has_stake_table(EpochNumber::new(epoch + 2))
736        });
737        if !missing {
738            return Ok(());
739        }
740        tracing::warn!(
741            ?skipped,
742            "skipped epoch roots whose stake tables are missing; catching up from peers"
743        );
744        let current_epoch = bootstrap_epoch_window(
745            &self.coordinator,
746            self.epoch_height,
747            self.bootstrap_epoch_catchup_timeout,
748        )
749        .await
750        .context("stake-table catchup after skipping ahead")?;
751        tracing::info!(%current_epoch, "stake-table catchup complete");
752        Ok(())
753    }
754
755    fn has_stake_table(&self, epoch: EpochNumber) -> bool {
756        self.coordinator.membership().snapshot(epoch).is_some()
757    }
758
759    fn has_drb(&self, epoch: EpochNumber) -> bool {
760        self.coordinator
761            .membership()
762            .snapshot(epoch)
763            .is_some_and(|snapshot| snapshot.has_drb())
764    }
765
766    /// Keep the resolved chain config current, so `submit` enforces the right block size.
767    async fn track_chain_config(&self, header: &Header) {
768        let resolvable = header.chain_config();
769        if resolvable.commit() == self.chain_config.read().await.commit() {
770            return;
771        }
772        let resolved = match resolvable.resolve() {
773            Some(chain_config) => Ok(chain_config),
774            None => {
775                self.state_catchup
776                    .fetch_chain_config(resolvable.commit())
777                    .await
778            },
779        };
780        match resolved {
781            Ok(chain_config) => *self.chain_config.write().await = chain_config,
782            Err(err) => tracing::warn!(
783                height = header.height(),
784                "cannot resolve the chain config: {err:#}"
785            ),
786        }
787    }
788}
789
790#[async_trait]
791impl ChainSource for NodeLightClient {
792    async fn block_height(&self) -> anyhow::Result<u64> {
793        Ok(NodeLightClient::block_height(self).await?)
794    }
795
796    /// One finality proof and one payload proof request cover the whole range.
797    async fn fetch_range(&self, from: u64, to: u64) -> anyhow::Result<Vec<VerifiedBlock>> {
798        let (from, to) = (from as usize, to as usize);
799        let leaves = self
800            .fetch_leaves_in_range(from, to)
801            .await
802            .context("fetching leaves")?;
803        let blocks = self
804            .fetch_blocks_and_vid_common_in_range(from, to)
805            .await
806            .context("fetching payloads")?;
807        ensure!(
808            leaves.len() == to - from && blocks.len() == to - from,
809            "the light client returned {} leaves and {} payloads for {} blocks",
810            leaves.len(),
811            blocks.len(),
812            to - from
813        );
814        Ok(leaves
815            .into_iter()
816            .zip(blocks)
817            .map(|(leaf, (block, vid_common))| VerifiedBlock {
818                leaf,
819                block,
820                vid_common,
821            })
822            .collect())
823    }
824
825    async fn fetch_cert2(&self, header: &Header) -> anyhow::Result<Option<Certificate2<SeqTypes>>> {
826        Ok(self.fetch_certificate2_for_header(header).await?)
827    }
828}
829
830async fn connect_light_client(
831    options: &FollowerOptions,
832    upstreams: &[Url],
833    network_config: &NetworkConfig<SeqTypes>,
834    chain_id: ChainId,
835    config_fetched: bool,
836) -> anyhow::Result<NodeLightClient> {
837    let db = options
838        .light_client_db
839        .clone()
840        .connect()
841        .await
842        .context("opening the light client database")?;
843    let client = FallbackClient::new(
844        upstreams
845            .iter()
846            .cloned()
847            .map(QueryServiceClient::new)
848            .collect(),
849    )?;
850    let genesis = match options.light_client_genesis.clone() {
851        Some(genesis) => genesis,
852        None => {
853            if config_fetched {
854                tracing::warn!(
855                    "deriving the light client's root of trust from a network config fetched from \
856                     peers; pin a light client genesis unless those peers are trusted"
857                );
858            }
859            light_client_genesis(network_config, chain_id)
860        },
861    };
862    Ok(LightClient::from_genesis_with_options(
863        db,
864        client,
865        genesis,
866        options.light_client.clone(),
867    ))
868}