Skip to main content

zebra_rpc/
sync.rs

1//! Syncer task for maintaining a non-finalized state in Zebra's ReadStateService and updating `ChainTipSender` via RPCs
2
3use std::{net::SocketAddr, sync::Arc, time::Duration};
4
5use tokio::task::JoinHandle;
6use tonic::{Status, Streaming};
7use tower::BoxError;
8use zebra_chain::{
9    block::{self, Block, Height},
10    parameters::Network,
11    serialization::BytesInDisplayOrder,
12};
13use zebra_state::{
14    spawn_init_read_only, ChainTipBlock, ChainTipChange, ChainTipSender, CheckpointVerifiedBlock,
15    HashOrHeight, LatestChainTip, NonFinalizedState, ReadStateService, SemanticallyVerifiedBlock,
16    ValidateContextError, ZebraDb,
17};
18
19use zebra_chain::diagnostic::task::WaitForPanics;
20
21use crate::indexer::{
22    indexer_client::IndexerClient, BlockAndHash, BlockRequest, Empty,
23    NonFinalizedStateChangeRequest,
24};
25
26/// How long to wait between calls to `subscribe_to_non_finalized_state_change` when it returns an error.
27const POLL_DELAY: Duration = Duration::from_secs(5);
28
29/// How long to wait for a message on a gRPC subscription stream before assuming the stream is dead
30/// and re-subscribing.
31///
32/// Generous, because legitimate gaps between blocks/tip changes can be several minutes; this is a
33/// backstop against a wedged connection that the keep-alive ping below doesn't catch. Re-subscribing
34/// is cheap and resumes from the syncer's current chain tips, so an occasional false trigger during
35/// a quiet period is harmless.
36const STREAM_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
37
38/// HTTP/2 keep-alive ping interval for the indexer gRPC connection, so a half-open connection is
39/// detected promptly instead of hanging a stream read indefinitely.
40const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(60);
41
42/// How long to wait for a keep-alive ping response before treating the connection as dead.
43const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20);
44
45/// How long to wait before re-subscribing after a block fails to commit to the non-finalized state.
46///
47/// A block can persistently fail to commit (e.g. [`ValidateContextError::NotReadyToBeCommitted`])
48/// when the secondary finalized state hasn't yet caught up with the primary. Without this delay,
49/// re-subscribing immediately turns that into a full-speed busy loop that saturates the logs.
50const COMMIT_RETRY_DELAY: Duration = Duration::from_secs(1);
51
52/// How long to wait for a single `get_block` fetch while bridging the finalized gap before giving
53/// up. A single block fetch from a co-located node should return promptly, so this bounds a wedged
54/// connection without false-triggering; the bridge retries on the next subscription.
55const GET_BLOCK_TIMEOUT: Duration = Duration::from_secs(30);
56
57/// How long to wait to establish a gRPC subscription stream before assuming the request is wedged
58/// and retrying. The subscription handshake should complete promptly.
59const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(30);
60
61/// Syncs non-finalized blocks in the best chain from a trusted Zebra node's RPC methods.
62#[derive(Debug)]
63pub struct TrustedChainSync {
64    /// gRPC client for calling Zebra's indexer methods.
65    pub indexer_rpc_client: IndexerClient<tonic::transport::Channel>,
66    /// The read state service.
67    db: ZebraDb,
68    /// The non-finalized state - currently only contains the best chain.
69    non_finalized_state: NonFinalizedState,
70    /// The chain tip sender for updating [`LatestChainTip`] and [`ChainTipChange`].
71    chain_tip_sender: ChainTipSender,
72    /// The non-finalized state sender, for updating the [`ReadStateService`] when the non-finalized best chain changes.
73    non_finalized_state_sender: tokio::sync::watch::Sender<NonFinalizedState>,
74    /// Flipped to `true` once `sync()` receives its first parseable block from the
75    /// `non_finalized_state_change` stream, which stops [`update_finalized_chain_tip`] so `sync()`
76    /// becomes the sole caller of `try_catch_up_with_primary` on the shared secondary db.
77    started_sync_sender: tokio::sync::watch::Sender<bool>,
78}
79
80async fn update_finalized_chain_tip(
81    db: ZebraDb,
82    mut indexer_rpc_client: IndexerClient<tonic::transport::Channel>,
83    mut finalized_chain_tip_sender: ChainTipSender,
84    mut started_sync_receiver: tokio::sync::watch::Receiver<bool>,
85) {
86    let mut chain_tip_change_stream = None;
87
88    loop {
89        // Stop as soon as `sync()` has received its first parseable non-finalized block. From then
90        // on `sync()` is the sole caller of `try_catch_up_with_primary` on the shared secondary, so
91        // this task must not advance the secondary's view concurrently (which would let a block be
92        // finalized between `sync()`'s check and its commit, failing as a duplicate-effects error).
93        // `sync()` also owns the published chain tip from then on, via its (higher) non-finalized
94        // tip, so publishing our lagging finalized tip would drag the reported tip backwards.
95        if *started_sync_receiver.borrow() {
96            return;
97        }
98
99        let Some(ref mut chain_tip_change) = chain_tip_change_stream else {
100            chain_tip_change_stream = match tokio::time::timeout(
101                SUBSCRIBE_TIMEOUT,
102                indexer_rpc_client.chain_tip_change(Empty {}),
103            )
104            .await
105            {
106                Ok(Ok(response)) => Some(response.into_inner()),
107                Ok(Err(err)) => {
108                    tracing::warn!(?err, "failed to subscribe to chain tip changes");
109                    tokio::time::sleep(POLL_DELAY).await;
110                    None
111                }
112                Err(_) => {
113                    tracing::warn!("timed out subscribing to chain tip changes");
114                    tokio::time::sleep(POLL_DELAY).await;
115                    None
116                }
117            };
118
119            continue;
120        };
121
122        // The message is only a signal that the primary's best chain advanced. We publish our own
123        // finalized tip below, not the primary's (non-finalized) best tip, so the hash is unused.
124        //
125        // Stop immediately if `sync()` takes over while we're parked here, rather than waiting for
126        // the next chain-tip change, so we never catch up concurrently with `sync()`'s commits.
127        let message = tokio::select! {
128            biased;
129            _ = started_sync_receiver.changed() => return,
130            message = tokio::time::timeout(STREAM_MESSAGE_TIMEOUT, chain_tip_change.message()) => message,
131        };
132
133        match message {
134            Ok(Ok(Some(_block_hash_and_height))) => {}
135            Ok(Ok(None)) => {
136                tracing::warn!("chain_tip_change stream ended unexpectedly");
137                chain_tip_change_stream = None;
138                continue;
139            }
140            Ok(Err(err)) => {
141                tracing::warn!(?err, "error receiving chain tip change");
142                chain_tip_change_stream = None;
143                continue;
144            }
145            Err(_) => {
146                tracing::debug!("chain tip change stream timed out, re-subscribing");
147                chain_tip_change_stream = None;
148                continue;
149            }
150        }
151
152        // Don't advance the secondary or publish once `sync()` has taken over (it may have done so
153        // while we waited above), so we never catch up concurrently with `sync()`'s commits.
154        if *started_sync_receiver.borrow() {
155            return;
156        }
157
158        // Catch the secondary's finalized state up to the primary, then publish its finalized tip.
159        //
160        // This keeps the finalized tip current while the secondary is still catching up, before
161        // `TrustedChainSync::sync()` has any non-finalized blocks of its own to publish.
162        //
163        // # Correctness
164        //
165        // Catching up with the primary db instance concurrent with commits to the non-finalized state
166        // when the underlying Zebra node is syncing blocks rapidly could cause repeated commit failures.
167        if let Err(error) = db.spawn_try_catch_up_with_primary().await {
168            tracing::debug!(
169                ?error,
170                "failed to catch up to the primary database while updating the finalized tip"
171            );
172            continue;
173        }
174
175        if let Some(tip_block) = finalized_chain_tip_block(&db).await {
176            // Re-check immediately before publishing, after the awaits above: `sync()` can take over
177            // while we wait on `spawn_try_catch_up_with_primary` or `finalized_chain_tip_block`, so
178            // checking any earlier would leave a window where we still publish the stale tip.
179            //
180            // `sync()`'s `ChainTipSender` latches onto the non-finalized tip, but this task holds a
181            // separate `finalized_sender()` handle that doesn't, so `set_finalized_tip` would keep
182            // overwriting the non-finalized tip rather than becoming a no-op. The task therefore
183            // stops itself. The non-finalized state only grows once populated, so this handover is
184            // permanent.
185            if *started_sync_receiver.borrow() {
186                return;
187            }
188
189            finalized_chain_tip_sender.set_finalized_tip(tip_block);
190        }
191    }
192}
193
194/// Reads the finalized tip block from the secondary db instance and converts it to a
195/// [`ChainTipBlock`].
196async fn finalized_chain_tip_block(db: &ZebraDb) -> Option<ChainTipBlock> {
197    let db = db.clone();
198    tokio::task::spawn_blocking(move || {
199        let (height, hash) = db.tip()?;
200        db.block(height.into())
201            .map(|block| CheckpointVerifiedBlock::with_hash(block, hash))
202            .map(ChainTipBlock::from)
203    })
204    .wait_for_panics()
205    .await
206}
207
208impl TrustedChainSync {
209    /// Creates a new [`TrustedChainSync`] with a [`ChainTipSender`], then spawns a task to sync blocks
210    /// from the node's non-finalized best chain.
211    ///
212    /// Returns the [`LatestChainTip`], [`ChainTipChange`], and a [`JoinHandle`] for the sync task.
213    pub async fn spawn(
214        indexer_rpc_address: SocketAddr,
215        db: ZebraDb,
216        non_finalized_state_sender: tokio::sync::watch::Sender<NonFinalizedState>,
217    ) -> Result<(LatestChainTip, ChainTipChange, JoinHandle<()>), BoxError> {
218        let non_finalized_state = NonFinalizedState::new(&db.network());
219        let (chain_tip_sender, latest_chain_tip, chain_tip_change) =
220            ChainTipSender::new(None, &db.network());
221        let channel =
222            tonic::transport::Endpoint::from_shared(format!("http://{indexer_rpc_address}"))?
223                .keep_alive_while_idle(true)
224                .http2_keep_alive_interval(KEEPALIVE_INTERVAL)
225                .keep_alive_timeout(KEEPALIVE_TIMEOUT)
226                .connect()
227                .await?;
228        let indexer_rpc_client = IndexerClient::new(channel);
229        let finalized_chain_tip_sender = chain_tip_sender.finalized_sender();
230
231        // `sync()` flips this to `true` as soon as it receives its first parseable non-finalized
232        // block, which stops `update_finalized_chain_tip` so `sync()` becomes the sole caller of
233        // `try_catch_up_with_primary` on the shared secondary db.
234        let (started_sync_sender, started_sync_receiver) = tokio::sync::watch::channel(false);
235
236        let mut syncer = Self {
237            indexer_rpc_client: indexer_rpc_client.clone(),
238            db: db.clone(),
239            non_finalized_state,
240            chain_tip_sender,
241            non_finalized_state_sender,
242            started_sync_sender,
243        };
244
245        // Spawn a task to send finalized chain tip changes to the chain tip change and latest chain tip channels.
246        tokio::spawn(async move {
247            update_finalized_chain_tip(
248                db,
249                indexer_rpc_client,
250                finalized_chain_tip_sender,
251                started_sync_receiver,
252            )
253            .await
254        });
255
256        let sync_task = tokio::spawn(async move {
257            syncer.sync().await;
258        });
259
260        Ok((latest_chain_tip, chain_tip_change, sync_task))
261    }
262
263    /// Starts syncing blocks from the node's non-finalized best chain and checking for chain tip changes in the finalized state.
264    ///
265    /// When the best chain tip in Zebra is not available in the finalized state or the local non-finalized state,
266    /// gets any unavailable blocks in Zebra's best chain from the RPC server, adds them to the local non-finalized state, then
267    /// sends the updated chain tip block and non-finalized state to the [`ChainTipSender`] and non-finalized state sender.
268    #[tracing::instrument(skip_all)]
269    async fn sync(&mut self) {
270        let mut non_finalized_blocks_listener = None;
271        // The hash of the block that most recently failed to commit, used to avoid re-logging the
272        // same warning at full rate while a block persistently fails to commit.
273        let mut last_failed_commit_hash = None;
274        // Whether we've signalled `update_finalized_chain_tip` to stop, so we only send once.
275        let mut signalled_started = false;
276        self.try_catch_up_with_primary().await;
277        if let Some(finalized_tip_block) = finalized_chain_tip_block(&self.db).await {
278            self.chain_tip_sender.set_finalized_tip(finalized_tip_block);
279        }
280
281        loop {
282            let Some(ref mut non_finalized_state_change) = non_finalized_blocks_listener else {
283                non_finalized_blocks_listener = match self
284                    .subscribe_to_non_finalized_state_change()
285                    .await
286                {
287                    Ok(listener) => Some(listener),
288                    Err(err) => {
289                        tracing::warn!(?err, "failed to subscribe to non-finalized state changes");
290                        tokio::time::sleep(POLL_DELAY).await;
291                        None
292                    }
293                };
294
295                continue;
296            };
297
298            let message = match tokio::time::timeout(
299                STREAM_MESSAGE_TIMEOUT,
300                non_finalized_state_change.message(),
301            )
302            .await
303            {
304                Ok(Ok(Some(block_and_hash))) => block_and_hash,
305                Ok(Ok(None)) => {
306                    tracing::warn!("non-finalized state change stream ended unexpectedly");
307                    non_finalized_blocks_listener = None;
308                    continue;
309                }
310                Ok(Err(err)) => {
311                    tracing::warn!(?err, "error receiving non-finalized state change");
312                    non_finalized_blocks_listener = None;
313                    continue;
314                }
315                Err(_) => {
316                    tracing::debug!("non-finalized state change stream timed out, re-subscribing");
317                    non_finalized_blocks_listener = None;
318                    continue;
319                }
320            };
321
322            let Some((block, hash)) = message.decode() else {
323                tracing::warn!("received malformed non-finalized state change message");
324                non_finalized_blocks_listener = None;
325                continue;
326            };
327
328            // We have a parseable block from the stream, so take over the finalized tip: stop
329            // `update_finalized_chain_tip` so it no longer catches up the shared secondary db
330            // concurrently with our commits below.
331            if !signalled_started {
332                self.started_sync_sender.send_replace(true);
333                signalled_started = true;
334            }
335
336            if self.non_finalized_state.any_chain_contains(&hash) {
337                // Expected and harmless: on a resumed or multi-chain stream the server can re-send a
338                // block the syncer already has (e.g. a fork's shared ancestors), so this is logged
339                // at debug rather than warn to avoid noise.
340                tracing::debug!(
341                    ?hash,
342                    "non-finalized state already contains block, skipping"
343                );
344                continue;
345            }
346
347            // Skip blocks that are already finalized on the secondary. The server streams its
348            // entire non-finalized chain on initial subscription, which can start below our
349            // secondary's finalized tip when the secondary has caught up past the server's
350            // non-finalized root. Trying to commit those blocks fails with NotReadyToBeCommitted
351            // and triggers an endless re-subscribe loop.
352            if let Some(height) = block.coinbase_height() {
353                if self
354                    .db
355                    .finalized_tip_height()
356                    .is_some_and(|tip| height <= tip)
357                {
358                    tracing::debug!(?height, "skipping block already finalized on secondary");
359                    continue;
360                }
361            }
362
363            let block = SemanticallyVerifiedBlock::with_hash(Arc::new(block), hash);
364            match self.try_commit(block.clone()).await {
365                Ok(()) => {
366                    last_failed_commit_hash = None;
367                }
368                Err(error) => {
369                    // Only log on transitions to avoid saturating the logs when the same block
370                    // persistently fails to commit (e.g. when the secondary finalized state hasn't
371                    // caught up with the primary yet).
372                    if last_failed_commit_hash != Some(hash) {
373                        tracing::warn!(
374                            ?error,
375                            ?hash,
376                            "failed to commit block to non-finalized state"
377                        );
378                        last_failed_commit_hash = Some(hash);
379                    }
380
381                    non_finalized_blocks_listener = None;
382
383                    // Back off before re-subscribing so a persistently failing block doesn't turn
384                    // re-subscription into a full-speed busy loop.
385                    tokio::time::sleep(COMMIT_RETRY_DELAY).await;
386                }
387            };
388        }
389    }
390
391    async fn try_commit(
392        &mut self,
393        block: SemanticallyVerifiedBlock,
394    ) -> Result<(), ValidateContextError> {
395        self.try_catch_up_with_primary().await;
396
397        // When the non-finalized state is empty and the incoming block doesn't build directly on
398        // the secondary's finalized tip, the secondary's finalized state is lagging the primary's.
399        // The streamed non-finalized blocks start at the primary's finalized tip, which can be
400        // several blocks above ours, so the incoming block has no parent to attach to. Bridge that
401        // gap by fetching the missing (already-finalized) blocks from the primary, so the incoming
402        // block has a contiguous chain to commit onto.
403        if self.non_finalized_state.best_chain().is_none()
404            && self.db.finalized_tip_hash() != block.block.header.previous_block_hash
405        {
406            self.fill_finalized_gap(block.height).await;
407        }
408
409        self.commit(block)
410    }
411
412    /// Commits `block` to the non-finalized state, starting a new chain if it builds on the
413    /// finalized tip or extending an existing chain otherwise, prunes newly-finalized blocks, and
414    /// publishes the updated chain tip and non-finalized state.
415    ///
416    /// Updating the channels here (rather than only after `try_commit` returns) means the bridge
417    /// blocks committed by [`Self::fill_finalized_gap`] also advance the published chain tip.
418    fn commit(&mut self, block: SemanticallyVerifiedBlock) -> Result<(), ValidateContextError> {
419        if self.db.finalized_tip_hash() == block.block.header.previous_block_hash {
420            self.prune_finalized();
421            self.non_finalized_state.commit_new_chain(block, &self.db)?;
422        } else {
423            self.non_finalized_state.commit_block(block, &self.db)?;
424            self.prune_finalized();
425        }
426
427        self.update_channels();
428
429        Ok(())
430    }
431
432    /// Fetches the blocks between the secondary's finalized tip and `target_height` (exclusive)
433    /// from the primary and commits them to the non-finalized state.
434    ///
435    /// These blocks are finalized on the primary but not yet on the lagging secondary. Committing
436    /// them as non-finalized bridge blocks gives the block at `target_height` a contiguous chain to
437    /// attach to; they are dropped again by [`Self::prune_finalized`] as the secondary catches up.
438    ///
439    /// This is best-effort: if a block can't be fetched or committed, it returns early and lets the
440    /// caller's commit fail so the block is retried on the next subscription.
441    async fn fill_finalized_gap(&mut self, target_height: Height) {
442        loop {
443            // Try to advance the secondary's finalized state first: if it catches up to the gap on
444            // its own, we can stop fetching blocks. This also drops any bridge blocks the secondary
445            // has since finalized, so the next height is recomputed from where we actually are.
446            self.try_catch_up_with_primary().await;
447            self.prune_finalized();
448
449            // The next height to bridge is just above the highest block we already have: the
450            // non-finalized tip if we've committed bridge blocks, otherwise the finalized tip.
451            let Some(highest) = self
452                .non_finalized_state
453                .best_tip()
454                .map(|(height, _hash)| height)
455                .max(self.db.finalized_tip_height())
456            else {
457                return;
458            };
459
460            let Ok(next_height) = highest.next() else {
461                return;
462            };
463
464            // Stop once the chain reaches the streamed block's parent, whether by the finalized
465            // state catching up or by the bridge blocks we fetched.
466            if next_height >= target_height {
467                return;
468            }
469
470            let (block, hash) = match self.get_block(next_height.into()).await {
471                Ok(block_and_hash) => block_and_hash,
472                Err(error) => {
473                    tracing::warn!(
474                        ?error,
475                        ?next_height,
476                        "failed to fetch a block while bridging the finalized gap; \
477                         will retry on the next subscription"
478                    );
479                    return;
480                }
481            };
482
483            let block = SemanticallyVerifiedBlock::with_hash(Arc::new(block), hash);
484            if let Err(error) = self.commit(block) {
485                tracing::warn!(
486                    ?error,
487                    ?next_height,
488                    "failed to commit a block while bridging the finalized gap; \
489                     will retry on the next subscription"
490                );
491                return;
492            }
493        }
494    }
495
496    /// Fetches a single block from the primary by hash or height via the indexer gRPC.
497    async fn get_block(
498        &self,
499        hash_or_height: HashOrHeight,
500    ) -> Result<(Block, block::Hash), Status> {
501        // Encode the request as a single `hash_or_height` byte string: a 32-byte hash in display
502        // order, or a 4-byte big-endian height. The server tells them apart by length.
503        let hash_or_height = match hash_or_height {
504            HashOrHeight::Hash(hash) => hash.bytes_in_display_order().to_vec(),
505            HashOrHeight::Height(height) => height.0.to_be_bytes().to_vec(),
506        };
507        let request = BlockRequest { hash_or_height };
508
509        let response = tokio::time::timeout(
510            GET_BLOCK_TIMEOUT,
511            self.indexer_rpc_client.clone().get_block(request),
512        )
513        .await
514        .map_err(|_| Status::deadline_exceeded("get_block request timed out"))??;
515
516        response
517            .into_inner()
518            .decode()
519            .ok_or_else(|| Status::internal("failed to decode block from get_block response"))
520    }
521
522    /// Calls the `non_finalized_state_change()` method on the indexer gRPC client to subscribe
523    /// to non-finalized state changes, and returns the response stream.
524    ///
525    /// Passes the tip hashes of every chain currently in this syncer's non-finalized state so the
526    /// server only streams blocks after the tips we already have, instead of re-sending the whole
527    /// non-finalized state on every (re)subscription. When the non-finalized state is empty, the
528    /// request carries no tips and the server streams every non-finalized block.
529    async fn subscribe_to_non_finalized_state_change(
530        &mut self,
531    ) -> Result<Streaming<BlockAndHash>, Status> {
532        let request = NonFinalizedStateChangeRequest {
533            chain_tip_hashes: self
534                .non_finalized_state
535                .chain_iter()
536                .map(|c| c.non_finalized_tip_hash().bytes_in_display_order().to_vec())
537                .collect(),
538        };
539
540        tokio::time::timeout(
541            SUBSCRIBE_TIMEOUT,
542            self.indexer_rpc_client
543                .clone()
544                .non_finalized_state_change(request),
545        )
546        .await
547        .map_err(|_| {
548            Status::deadline_exceeded("non_finalized_state_change subscription timed out")
549        })?
550        .map(|a| a.into_inner())
551    }
552
553    /// Tries to catch up to the primary db instance for an up-to-date view of finalized blocks.
554    async fn try_catch_up_with_primary(&self) {
555        let _ = self.db.spawn_try_catch_up_with_primary().await;
556    }
557
558    /// Finalizes any non-finalized blocks that are at or below the finalized tip height.
559    ///
560    /// The secondary's finalized state follows the primary, so after catching up it may have
561    /// advanced past the root of the non-finalized state. This drops those now-finalized blocks so
562    /// the non-finalized state only tracks blocks above the finalized tip. Does nothing when the
563    /// non-finalized state is empty.
564    fn prune_finalized(&mut self) {
565        let finalized_tip_height = self.db.finalized_tip_height().unwrap_or(Height::MIN);
566        while self
567            .non_finalized_state
568            .root_height()
569            .is_some_and(|root_height| root_height <= finalized_tip_height)
570        {
571            tracing::trace!("finalizing block past the reorg limit");
572            self.non_finalized_state.finalize();
573        }
574    }
575
576    /// Sends the new chain tip and non-finalized state to the latest chain channels.
577    // TODO: Replace this with the `update_latest_chain_channels()` fn in `write.rs`.
578    fn update_channels(&mut self) {
579        // If the final receiver was just dropped, ignore the error.
580        let _ = self
581            .non_finalized_state_sender
582            .send(self.non_finalized_state.clone());
583
584        let best_chain = self.non_finalized_state.best_chain().expect("unexpected empty non-finalized state: must commit at least one block before updating channels");
585
586        let tip_block = best_chain
587            .tip_block()
588            .expect(
589                "unexpected empty chain: must commit at least one block before updating channels",
590            )
591            .clone();
592
593        self.chain_tip_sender
594            .set_best_non_finalized_tip(Some(tip_block.into()));
595    }
596}
597
598/// Accepts a [zebra-state configuration](zebra_state::Config), a [`Network`], and
599/// the [`SocketAddr`] of a Zebra node's RPC server.
600///
601/// Initializes a [`ReadStateService`] and a [`TrustedChainSync`] to update the
602/// non-finalized best chain and the latest chain tip.
603///
604/// Returns a [`ReadStateService`], [`LatestChainTip`], [`ChainTipChange`], and
605/// a [`JoinHandle`] for the sync task.
606pub fn init_read_state_with_syncer(
607    config: zebra_state::Config,
608    network: &Network,
609    indexer_rpc_address: SocketAddr,
610) -> tokio::task::JoinHandle<
611    Result<
612        (
613            ReadStateService,
614            LatestChainTip,
615            ChainTipChange,
616            tokio::task::JoinHandle<()>,
617        ),
618        BoxError,
619    >,
620> {
621    let network = network.clone();
622    tokio::spawn(async move {
623        if config.ephemeral {
624            return Err("standalone read state service cannot be used with ephemeral state".into());
625        }
626
627        // The outer `?` propagates a `JoinError` if the blocking task panicked or was
628        // cancelled, and the inner `?` propagates a `StateInitError` (e.g. a missing
629        // read-only database).
630        let (read_state, db, non_finalized_state_sender) =
631            spawn_init_read_only(config, &network).await??;
632        let (latest_chain_tip, chain_tip_change, sync_task) =
633            TrustedChainSync::spawn(indexer_rpc_address, db, non_finalized_state_sender).await?;
634        Ok((read_state, latest_chain_tip, chain_tip_change, sync_task))
635    })
636}