Skip to main content

miden_client/sync/
mod.rs

1//! Provides the client APIs for synchronizing the client's local state with the Miden network. It
2//! ensures that the client maintains a valid, up-to-date view of the chain.
3//!
4//! ## Overview
5//!
6//! This module handles the synchronization process between the local client and the Miden network.
7//! The sync operation involves:
8//!
9//! - Querying the Miden node for state updates using tracked account IDs, note tags, and nullifier
10//!   prefixes.
11//! - Processing the received data to update note inclusion proofs, reconcile note state (new,
12//!   committed, or consumed), and update account states.
13//! - Incorporating new block headers and updating the local Merkle Mountain Range (MMR) with new
14//!   peaks and authentication nodes.
15//! - Aggregating transaction updates to determine which transactions have been committed or
16//!   discarded.
17//!
18//! The result of the synchronization process is captured in a [`SyncSummary`], which provides a
19//! summary of the new block number along with lists of received, committed, and consumed note IDs,
20//! updated account IDs, locked accounts, and committed transaction IDs.
21//!
22//! Once the data is requested and retrieved, updates are persisted in the client's store.
23//!
24//! ## Examples
25//!
26//! The following example shows how to initiate a state sync and handle the resulting summary:
27//!
28//! ```rust
29//! # use miden_client::auth::TransactionAuthenticator;
30//! # use miden_client::sync::SyncSummary;
31//! # use miden_client::{Client, ClientError};
32//! # use miden_protocol::{block::BlockHeader, Felt, Word};
33//! # use miden_protocol::crypto::rand::FeltRng;
34//! # async fn run_sync<AUTH: TransactionAuthenticator + Sync + 'static>(client: &mut Client<AUTH>) -> Result<(), ClientError> {
35//! // Attempt to synchronize the client's state with the Miden network.
36//! // The requested data is based on the client's state: it gets updates for accounts, relevant
37//! // notes, etc. For more information on the data that gets requested, see the doc comments for
38//! // `sync_state()`.
39//! let sync_summary: SyncSummary = client.sync_state().await?;
40//!
41//! println!("Synced up to block number: {}", sync_summary.block_num);
42//! println!("New private notes: {}", sync_summary.new_private_notes.len());
43//! println!("Committed notes: {}", sync_summary.committed_notes.len());
44//! println!("Consumed notes: {}", sync_summary.consumed_notes.len());
45//! println!("Updated accounts: {}", sync_summary.updated_accounts.len());
46//! println!("Locked accounts: {}", sync_summary.locked_accounts.len());
47//! println!("Committed transactions: {}", sync_summary.committed_transactions.len());
48//!
49//! Ok(())
50//! # }
51//! ```
52//!
53//! The `sync_state` method loops internally until the client is fully synced to the network tip.
54//!
55//! For more advanced usage, refer to the individual functions (such as `committed_note_updates` and
56//! `consumed_note_updates`) to understand how the sync data is processed and applied to the local
57//! store.
58
59use alloc::collections::BTreeSet;
60use alloc::format;
61use alloc::sync::Arc;
62use alloc::vec::Vec;
63use core::cmp::max;
64
65use miden_protocol::account::AccountId;
66use miden_protocol::block::BlockNumber;
67use miden_protocol::crypto::merkle::mmr::{InOrderIndex, PartialMmr};
68use miden_protocol::note::NoteId;
69use miden_protocol::transaction::TransactionId;
70use miden_tx::auth::TransactionAuthenticator;
71use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
72use tracing::{debug, info, warn};
73
74use crate::pswap::PswapChainObserver;
75use crate::store::{NoteFilter, TransactionFilter};
76use crate::{Client, ClientError};
77mod block_header;
78
79mod tag;
80pub use tag::{NoteTagRecord, NoteTagSource};
81
82mod note_observer;
83pub use note_observer::NoteObserver;
84
85mod state_sync;
86pub(crate) use state_sync::block_num_from_forest;
87pub use state_sync::{ChainSyncData, NoteUpdateAction, OnNoteReceived, StateSync, StateSyncInput};
88
89mod state_sync_update;
90pub use state_sync_update::{
91    AccountUpdates,
92    PartialBlockchainUpdates,
93    PublicAccountUpdate,
94    StateSyncUpdate,
95    TransactionUpdateTracker,
96};
97
98/// Untracks the given block leaves from `partial_mmr`, returning the authentication-node indices
99/// that are no longer needed by any remaining tracked leaf.
100///
101/// Untracking a leaf frees an inner node only once no other tracked leaf still needs it, so the
102/// returned indices are exactly the nodes that became removable.
103fn untrack_blocks(
104    partial_mmr: &mut PartialMmr,
105    block_positions: impl IntoIterator<Item = usize>,
106) -> Vec<InOrderIndex> {
107    block_positions
108        .into_iter()
109        .flat_map(|block_pos| partial_mmr.untrack(block_pos))
110        .map(|(index, _)| index)
111        .collect()
112}
113
114/// Client synchronization methods.
115impl<AUTH> Client<AUTH>
116where
117    AUTH: TransactionAuthenticator + Sync + 'static,
118{
119    // SYNC STATE
120    // --------------------------------------------------------------------------------------------
121
122    /// Returns the block number of the last state sync block.
123    pub async fn get_sync_height(&self) -> Result<BlockNumber, ClientError> {
124        self.store.get_sync_height().await.map_err(Into::into)
125    }
126
127    /// Syncs the client's on-chain state with the current state of the Miden network and returns a
128    /// [`SyncSummary`] corresponding to the local state update.
129    ///
130    /// Does **not** fetch private notes from the Note Transport Layer. Use [`Client::sync_state`]
131    /// for the combined sync, or call [`Client::sync_note_transport`] separately.
132    ///
133    /// Fetches everything from the node first ([`Client::fetch_chain_updates`] and
134    /// [`StateSync::fetch_nullifiers`]), then applies the result with
135    /// [`Client::apply_chain_updates`], which also caches the partial MMR and prunes irrelevant
136    /// blocks according to the configured cadence.
137    pub async fn sync_chain(&mut self) -> Result<SyncSummary, ClientError> {
138        self.ensure_genesis_in_place().await?;
139        self.ensure_rpc_limits_in_place().await?;
140
141        let state_sync = self.state_sync().await?;
142        let mut chain_sync_data = self.fetch_chain_updates(&state_sync).await?;
143        state_sync.derive_state_updates(&mut chain_sync_data).await?;
144        state_sync.fetch_nullifiers(&mut chain_sync_data).await?;
145
146        self.apply_chain_updates(&state_sync, chain_sync_data).await
147    }
148
149    /// Fetches the node's view of everything that changed since the client's chain tip, without
150    /// storing anything or modifying the partial MMR.
151    ///
152    /// Builds the default sync input and runs [`StateSync::fetch_state`]. The state updates must be
153    /// derived with [`StateSync::derive_state_updates`]. The nullifier check is not part of this:
154    /// run [`StateSync::fetch_nullifiers`] on the result before applying it, so it can also cover
155    /// transport-delivered notes another sync path fetched in the same call.
156    pub async fn fetch_chain_updates(
157        &self,
158        state_sync: &StateSync,
159    ) -> Result<ChainSyncData, ClientError> {
160        let input = self.build_sync_input().await?;
161        let block_from = block_num_from_forest(&self.get_current_partial_mmr().await?)?;
162
163        state_sync.fetch_state(block_from, input).await
164    }
165
166    /// Builds the [`StateSync`] driving one chain sync.
167    ///
168    /// Each `NoteObserver` owns its own per-sync state, so this must be called once per sync rather
169    /// than shared; `with_note_observer` just attaches it.
170    async fn state_sync(&self) -> Result<StateSync, ClientError> {
171        let validator_config = self.get_validator_config().await?;
172
173        Ok(StateSync::new(
174            self.rpc_api.clone(),
175            Arc::new(self.note_screener()),
176            self.tx_discard_delta,
177            validator_config,
178        )
179        .with_note_observer(Arc::new(PswapChainObserver::new(self.store.clone()))))
180    }
181
182    /// Verifies fetched chain data against the client's partial MMR and saves the resulting update
183    /// to the store.
184    ///
185    /// [`StateSync::derive_state_updates`] and [`StateSync::fetch_nullifiers`] must have run on the
186    /// data first. Also caches the partial MMR and prunes irrelevant blocks.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the client no longer starts where the data was fetched from, which means
191    /// another sync advanced the store in between and the data is stale.
192    pub async fn apply_chain_updates(
193        &mut self,
194        state_sync: &StateSync,
195        chain_sync_data: ChainSyncData,
196    ) -> Result<SyncSummary, ClientError> {
197        let mut partial_mmr = self.get_current_partial_mmr().await?;
198
199        let block_from = block_num_from_forest(&partial_mmr)?;
200        if block_from != chain_sync_data.block_from {
201            return Err(ClientError::ChainValidationError(format!(
202                "chain sync chain_sync_data starts at block {} but the client is at block {block_from}",
203                chain_sync_data.block_from
204            )));
205        }
206
207        let state_sync_update = StateSync::build_update(chain_sync_data, &mut partial_mmr)?;
208
209        let sync_summary: SyncSummary = (&state_sync_update).into();
210        debug!(sync_summary = ?sync_summary, "Sync summary computed");
211
212        // Post-sync observer hooks; run before persisting. Per-observer errors are logged, not
213        // propagated.
214        state_sync.run_apply_hooks(&state_sync_update).await?;
215
216        info!("Applying changes to the store.");
217
218        // Apply received and computed updates to the store
219        self.store
220            .apply_state_sync(state_sync_update)
221            .await
222            .map_err(ClientError::StoreError)?;
223
224        // Cache MMR so pruning can reuse in-memory MMR.
225        self.cache_partial_mmr(partial_mmr).await?;
226
227        self.maybe_untrack_and_prune_irrelevant_blocks().await?;
228
229        Ok(sync_summary)
230    }
231
232    /// Fetches private notes from the Note Transport Layer for the tracked note tags.
233    ///
234    /// Returns the IDs of notes imported in this call. No-op (returns an empty vec) if note
235    /// transport is disabled.
236    pub async fn sync_note_transport(&mut self) -> Result<Vec<NoteId>, ClientError> {
237        if !self.is_note_transport_enabled() {
238            return Ok(Vec::new());
239        }
240        self.ensure_genesis_in_place().await?;
241
242        let note_transport_update = self.fetch_note_transport_updates().await?;
243        let (imported_ids, _) = self.apply_note_transport_update(note_transport_update).await?;
244        Ok(imported_ids)
245    }
246
247    /// Runs the full client sync: private notes from the Note Transport Layer and the client's
248    /// on-chain state with the Miden node.
249    ///
250    /// The NTL and the node are fetched concurrently, and everything that writes runs sequentially
251    /// afterwards:
252    ///
253    /// 1. Concurrently: the note transport fetch and [`Client::fetch_chain_updates`]. Only node and
254    ///    NTL calls happen here, which is all that benefits from overlapping.
255    /// 2. The transport writes, when its fetch succeeded, whose records are then tracked in the
256    ///    chain sync's note updates.
257    /// 3. [`StateSync::derive_state_updates`], which screens the node's notes against the store —
258    ///    hence after step 2, so a transport-delivered note is recognised rather than discarded —
259    ///    and applies a commitment reported this sync to those records.
260    /// 4. [`StateSync::fetch_nullifiers`], covering the tracked notes *and* the transport-delivered
261    ///    ones, so a note delivered and consumed in the same window is reported as consumed by this
262    ///    call.
263    /// 5. The chain update, written last: a nullified transport-delivered note is saved as an
264    ///    update to the row step 2 inserts.
265    ///
266    /// A transport failure is logged and the chain sync continues without it, leaving the transport
267    /// cursor for the next call to retry. Before step 2 but the relay outbox, which
268    /// [`Client::flush_relay_outbox`] persists during the fetch and the next sync retries.
269    pub async fn sync_state(&mut self) -> Result<SyncSummary, ClientError> {
270        // Both fetch phases need genesis in place, and connecting here means the two concurrent
271        // futures never race on the RPC client's lazy connect.
272        self.ensure_genesis_in_place().await?;
273        self.ensure_rpc_limits_in_place().await?;
274
275        let state_sync = self.state_sync().await?;
276        let (note_transport_update, chain_sync_data) = futures::join!(
277            self.fetch_note_transport_updates(),
278            self.fetch_chain_updates(&state_sync),
279        );
280
281        // An NTL failure does not end the sync
282        let (new_private_notes, imported_notes) = match note_transport_update {
283            Ok(note_transport_update) => {
284                self.apply_note_transport_update(note_transport_update).await?
285            },
286            Err(err) => {
287                warn!(?err, "note transport fetch failed; syncing the chain without it");
288                (Vec::new(), Vec::new())
289            },
290        };
291
292        let mut chain_sync_data = chain_sync_data?;
293
294        // The chain sync built its note updates from a store snapshot taken before the import, so
295        // the imported records are added here. Without them this sync has no record to apply its
296        // verdicts to, and a note committed within this sync's own block range stays expected.
297        let imported_notes =
298            self.get_input_notes(NoteFilter::DetailsCommitments(imported_notes)).await?;
299        chain_sync_data.note_updates.track_existing_input_notes(imported_notes);
300
301        state_sync.derive_state_updates(&mut chain_sync_data).await?;
302        state_sync.fetch_nullifiers(&mut chain_sync_data).await?;
303
304        let mut summary = self.apply_chain_updates(&state_sync, chain_sync_data).await?;
305        summary.new_private_notes = new_private_notes;
306        Ok(summary)
307    }
308
309    /// Builds a default [`StateSyncInput`] from the current client state.
310    ///
311    /// This includes all tracked account headers, all unique note tags, all unspent input and
312    /// output notes, and all uncommitted transactions.
313    pub async fn build_sync_input(&self) -> Result<StateSyncInput, ClientError> {
314        let accounts = self
315            .store
316            .get_account_headers()
317            .await?
318            .into_iter()
319            .map(|(header, _status)| header)
320            .collect();
321
322        let note_tags = self.store.get_unique_note_tags().await?;
323
324        let input_notes = self.store.get_input_notes(NoteFilter::Unspent).await?;
325        let output_notes = self.store.get_output_notes(NoteFilter::Unspent).await?;
326
327        let uncommitted_transactions =
328            self.store.get_transactions(TransactionFilter::Uncommitted).await?;
329
330        Ok(StateSyncInput {
331            accounts,
332            note_tags,
333            input_notes,
334            output_notes,
335            uncommitted_transactions,
336        })
337    }
338
339    /// Applies the state sync update to the store and prunes irrelevant blocks according to the
340    /// configured cadence.
341    ///
342    /// See [`crate::Store::apply_state_sync()`] for what the update implies.
343    pub async fn apply_state_sync(&mut self, update: StateSyncUpdate) -> Result<(), ClientError> {
344        self.store.apply_state_sync(update).await?;
345
346        self.maybe_untrack_and_prune_irrelevant_blocks().await?;
347
348        Ok(())
349    }
350
351    /// Prunes irrelevant blocks and their MMR authentication nodes according to the configured
352    /// cadence.
353    async fn maybe_untrack_and_prune_irrelevant_blocks(&mut self) -> Result<(), ClientError> {
354        let Some(interval) = self.irrelevant_block_prune_interval else {
355            return Ok(());
356        };
357
358        let sync_height = self.store.get_sync_height().await?;
359
360        if let Some(last_prune_height) = self.last_irrelevant_block_prune_sync_height
361            && sync_height < last_prune_height + interval
362        {
363            return Ok(());
364        }
365
366        self.untrack_and_prune_irrelevant_blocks().await?;
367        self.last_irrelevant_block_prune_sync_height = Some(sync_height);
368
369        Ok(())
370    }
371
372    /// Prunes irrelevant block data from the store.
373    ///
374    /// Identifies tracked blocks whose input notes have all been consumed, untracks them from the
375    /// `PartialMmr` to determine which authentication nodes are no longer needed, then delegates to
376    /// [`Store::untrack_and_prune_irrelevant_blocks`] to atomically remove the stale nodes, mark
377    /// the blocks as irrelevant, and delete irrelevant block headers. Any caller of this function
378    /// should've cached the `PartialMmr` beforehand.
379    async fn untrack_and_prune_irrelevant_blocks(&mut self) -> Result<(), ClientError> {
380        let tracked_blocks = self.store.get_tracked_block_header_numbers().await?;
381        let to_untrack: Vec<usize> = if tracked_blocks.is_empty() {
382            // Do not early-return: even without blocks to untrack, old irrelevant tip headers may
383            // need pruning.
384            Vec::new()
385        } else {
386            // Blocks that still have at least one unspent note need to stay tracked.
387            let unspent_notes = self.store.get_input_notes(NoteFilter::Unspent).await?;
388            let live_blocks: BTreeSet<usize> = unspent_notes
389                .iter()
390                .filter_map(|n| n.inclusion_proof().map(|p| p.location().block_num().as_usize()))
391                .collect();
392
393            tracked_blocks.difference(&live_blocks).copied().collect()
394        };
395
396        let mut blocks_to_untrack = Vec::new();
397        let mut nodes_to_remove = Vec::new();
398        let mut updated_partial_mmr = None;
399
400        if !to_untrack.is_empty() {
401            // Rebuild the PartialMmr and untrack each block to collect the authentication node
402            // indices that are no longer needed by any remaining tracked leaf.
403            let mut partial_mmr = self.get_current_partial_mmr().await?;
404            nodes_to_remove = untrack_blocks(&mut partial_mmr, to_untrack.iter().copied());
405
406            blocks_to_untrack = to_untrack
407                .iter()
408                .map(|&b| BlockNumber::from(u32::try_from(b).expect("block number fits in u32")))
409                .collect();
410            updated_partial_mmr = Some(partial_mmr);
411        }
412
413        // Store deletes stale auth nodes, marks blocks as irrelevant, and removes irrelevant block
414        // headers. Old irrelevant tip headers may still need pruning.
415        self.store
416            .untrack_and_prune_irrelevant_blocks(&blocks_to_untrack, &nodes_to_remove)
417            .await?;
418
419        if let Some(partial_mmr) = updated_partial_mmr {
420            self.cache_partial_mmr(partial_mmr).await?;
421        }
422
423        Ok(())
424    }
425
426    /// Ensures that the RPC limits are set in the RPC client. If not already cached, fetches them
427    /// from the node and persists them in the store.
428    pub async fn ensure_rpc_limits_in_place(&mut self) -> Result<(), ClientError> {
429        if self.rpc_api.has_rpc_limits().is_some() {
430            return Ok(());
431        }
432
433        let limits = self.rpc_api.get_rpc_limits().await?;
434        self.store.set_rpc_limits(limits).await?;
435        Ok(())
436    }
437}
438
439// SYNC SUMMARY
440// ================================================================================================
441
442/// Contains stats about the sync operation.
443#[derive(Debug, PartialEq)]
444pub struct SyncSummary {
445    /// Block number up to which the client has been synced.
446    pub block_num: BlockNumber,
447    /// IDs of new public notes that the client has received.
448    pub new_public_notes: Vec<NoteId>,
449    /// IDs of private notes imported from the Note Transport Layer in this sync. They are still
450    /// `Expected` until observed on-chain.
451    ///
452    /// Only populated by [`Client::sync_state`]; [`Client::sync_chain`] always leaves this empty
453    /// because it does not touch the Note Transport Layer.
454    pub new_private_notes: Vec<NoteId>,
455    /// IDs of tracked notes that have been committed.
456    pub committed_notes: Vec<NoteId>,
457    /// IDs of notes that have been consumed.
458    pub consumed_notes: Vec<NoteId>,
459    /// IDs of on-chain accounts that have been updated.
460    pub updated_accounts: Vec<AccountId>,
461    /// IDs of private accounts that have been locked.
462    pub locked_accounts: Vec<AccountId>,
463    /// IDs of committed transactions.
464    pub committed_transactions: Vec<TransactionId>,
465}
466
467impl SyncSummary {
468    pub fn new(
469        block_num: BlockNumber,
470        new_public_notes: Vec<NoteId>,
471        new_private_notes: Vec<NoteId>,
472        committed_notes: Vec<NoteId>,
473        consumed_notes: Vec<NoteId>,
474        updated_accounts: Vec<AccountId>,
475        locked_accounts: Vec<AccountId>,
476        committed_transactions: Vec<TransactionId>,
477    ) -> Self {
478        Self {
479            block_num,
480            new_public_notes,
481            new_private_notes,
482            committed_notes,
483            consumed_notes,
484            updated_accounts,
485            locked_accounts,
486            committed_transactions,
487        }
488    }
489
490    pub fn new_empty(block_num: BlockNumber) -> Self {
491        Self {
492            block_num,
493            new_public_notes: vec![],
494            new_private_notes: vec![],
495            committed_notes: vec![],
496            consumed_notes: vec![],
497            updated_accounts: vec![],
498            locked_accounts: vec![],
499            committed_transactions: vec![],
500        }
501    }
502
503    pub fn is_empty(&self) -> bool {
504        self.new_public_notes.is_empty()
505            && self.new_private_notes.is_empty()
506            && self.committed_notes.is_empty()
507            && self.consumed_notes.is_empty()
508            && self.updated_accounts.is_empty()
509            && self.locked_accounts.is_empty()
510            && self.committed_transactions.is_empty()
511    }
512
513    pub fn combine_with(&mut self, mut other: Self) {
514        self.block_num = max(self.block_num, other.block_num);
515        self.new_public_notes.append(&mut other.new_public_notes);
516        self.new_private_notes.append(&mut other.new_private_notes);
517        self.committed_notes.append(&mut other.committed_notes);
518        self.consumed_notes.append(&mut other.consumed_notes);
519        self.updated_accounts.append(&mut other.updated_accounts);
520        self.locked_accounts.append(&mut other.locked_accounts);
521        self.committed_transactions.append(&mut other.committed_transactions);
522    }
523}
524
525impl Serializable for SyncSummary {
526    fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
527        self.block_num.write_into(target);
528        self.new_public_notes.write_into(target);
529        self.new_private_notes.write_into(target);
530        self.committed_notes.write_into(target);
531        self.consumed_notes.write_into(target);
532        self.updated_accounts.write_into(target);
533        self.locked_accounts.write_into(target);
534        self.committed_transactions.write_into(target);
535    }
536}
537
538impl Deserializable for SyncSummary {
539    fn read_from<R: miden_tx::utils::serde::ByteReader>(
540        source: &mut R,
541    ) -> Result<Self, DeserializationError> {
542        let block_num = BlockNumber::read_from(source)?;
543        let new_public_notes = Vec::<NoteId>::read_from(source)?;
544        let new_private_notes = Vec::<NoteId>::read_from(source)?;
545        let committed_notes = Vec::<NoteId>::read_from(source)?;
546        let consumed_notes = Vec::<NoteId>::read_from(source)?;
547        let updated_accounts = Vec::<AccountId>::read_from(source)?;
548        let locked_accounts = Vec::<AccountId>::read_from(source)?;
549        let committed_transactions = Vec::<TransactionId>::read_from(source)?;
550
551        Ok(Self {
552            block_num,
553            new_public_notes,
554            new_private_notes,
555            committed_notes,
556            consumed_notes,
557            updated_accounts,
558            locked_accounts,
559            committed_transactions,
560        })
561    }
562}