miden_client/sync/mod.rs
1//! Provides the client APIs for synchronizing the client's local state with the Miden
2//! network. It 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
19//! a summary of the new block number along with lists of received, committed, and consumed note
20//! IDs, 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
56//! `committed_note_updates` and `consumed_note_updates`) to understand how the sync data is
57//! processed and applied to the local store.
58
59use alloc::collections::BTreeSet;
60use alloc::sync::Arc;
61use alloc::vec::Vec;
62use core::cmp::max;
63
64use miden_protocol::account::AccountId;
65use miden_protocol::block::BlockNumber;
66use miden_protocol::crypto::merkle::mmr::{InOrderIndex, PartialMmr};
67use miden_protocol::note::NoteId;
68use miden_protocol::transaction::TransactionId;
69use miden_tx::auth::TransactionAuthenticator;
70use miden_tx::utils::serde::{Deserializable, DeserializationError, Serializable};
71use tracing::{debug, info};
72
73use crate::pswap::PswapChainObserver;
74use crate::store::{NoteFilter, TransactionFilter};
75use crate::{Client, ClientError};
76mod block_header;
77
78mod tag;
79pub use tag::{NoteTagRecord, NoteTagSource};
80
81mod note_observer;
82pub use note_observer::NoteObserver;
83
84mod state_sync;
85pub use state_sync::{NoteUpdateAction, OnNoteReceived, StateSync, StateSyncInput};
86
87mod state_sync_update;
88pub use state_sync_update::{
89 AccountUpdates,
90 PartialBlockchainUpdates,
91 PublicAccountUpdate,
92 StateSyncUpdate,
93 TransactionUpdateTracker,
94};
95
96/// Untracks the given block leaves from `partial_mmr`, returning the authentication-node indices
97/// that are no longer needed by any remaining tracked leaf.
98///
99/// Untracking a leaf frees an inner node only once no other tracked leaf still needs it, so the
100/// returned indices are exactly the nodes that became removable.
101fn untrack_blocks(
102 partial_mmr: &mut PartialMmr,
103 block_positions: impl IntoIterator<Item = usize>,
104) -> Vec<InOrderIndex> {
105 block_positions
106 .into_iter()
107 .flat_map(|block_pos| partial_mmr.untrack(block_pos))
108 .map(|(index, _)| index)
109 .collect()
110}
111
112/// Client synchronization methods.
113impl<AUTH> Client<AUTH>
114where
115 AUTH: TransactionAuthenticator + Sync + 'static,
116{
117 // SYNC STATE
118 // --------------------------------------------------------------------------------------------
119
120 /// Returns the block number of the last state sync block.
121 pub async fn get_sync_height(&self) -> Result<BlockNumber, ClientError> {
122 self.store.get_sync_height().await.map_err(Into::into)
123 }
124
125 /// Syncs the client's on-chain state with the current state of the Miden network and returns
126 /// a [`SyncSummary`] corresponding to the local state update.
127 ///
128 /// Does **not** fetch private notes from the Note Transport Layer. Use
129 /// [`Client::sync_state`] for the combined sync, or call [`Client::sync_note_transport`]
130 /// separately.
131 ///
132 /// Builds the default sync input, runs [`StateSync::sync_state`] (see that method for the
133 /// detailed pipeline), applies the resulting update to the store, caches the partial MMR, and
134 /// prunes irrelevant blocks according to the configured cadence.
135 pub async fn sync_chain(&mut self) -> Result<SyncSummary, ClientError> {
136 self.ensure_genesis_in_place().await?;
137 self.ensure_rpc_limits_in_place().await?;
138
139 // Each `NoteObserver` owns its own per-sync state; `with_note_observer` just attaches.
140 let note_screener = self.note_screener();
141 let state_sync =
142 StateSync::new(self.rpc_api.clone(), Arc::new(note_screener), self.tx_discard_delta)
143 .with_note_observer(Arc::new(PswapChainObserver::new(self.store.clone())));
144 let input = self.build_sync_input().await?;
145
146 let mut partial_mmr = self.get_current_partial_mmr().await?;
147
148 // Get the sync update from the network
149 let state_sync_update = state_sync.sync_state(&mut partial_mmr, input).await?;
150
151 let sync_summary: SyncSummary = (&state_sync_update).into();
152 debug!(sync_summary = ?sync_summary, "Sync summary computed");
153
154 // Post-sync observer hooks; run before persisting. Per-observer errors are logged, not
155 // propagated.
156 state_sync.run_apply_hooks(&state_sync_update).await?;
157
158 info!("Applying changes to the store.");
159
160 // Apply received and computed updates to the store
161 self.store
162 .apply_state_sync(state_sync_update)
163 .await
164 .map_err(ClientError::StoreError)?;
165
166 // Cache MMR so pruning can reuse in-memory MMR.
167 self.cache_partial_mmr(partial_mmr).await?;
168
169 self.maybe_untrack_and_prune_irrelevant_blocks().await?;
170
171 Ok(sync_summary)
172 }
173
174 /// Fetches private notes from the Note Transport Layer for the tracked note tags.
175 ///
176 /// Returns the IDs of notes imported in this call. No-op (returns an empty vec) if note
177 /// transport is disabled.
178 pub async fn sync_note_transport(&mut self) -> Result<Vec<NoteId>, ClientError> {
179 if !self.is_note_transport_enabled() {
180 return Ok(Vec::new());
181 }
182
183 // Drain any private notes whose previous relay attempt failed. A flush
184 // error is logged, not propagated: a failing relay must not block the
185 // sync, and the entries stay durable for the next attempt.
186 if let Err(err) = self.flush_relay_outbox().await {
187 tracing::warn!(?err, "relay outbox flush failed during sync; entries retained");
188 }
189
190 // Recover historical private notes for any tag added after the global cursor advanced.
191 // This drains each newly tracked tag from the start, fetching only that tag's own history.
192 let mut imported_ids = self.backfill_new_tags().await?;
193
194 let cursor = self.store.get_note_transport_cursor().await?;
195 let note_tags: Vec<_> = self.store.get_unique_note_tags().await?.into_iter().collect();
196 let (ids, new_cursor) = self.fetch_transport_notes(cursor, ¬e_tags).await?;
197 self.store.update_note_transport_cursor(new_cursor).await?;
198 imported_ids.extend(ids);
199
200 imported_ids.sort_unstable();
201 imported_ids.dedup();
202
203 Ok(imported_ids)
204 }
205
206 /// Runs the full client sync.
207 ///
208 /// First fetches private notes from the Note Transport Layer (see
209 /// [`Client::sync_note_transport`]), then syncs the client's on-chain state with the Miden
210 /// node (see [`Client::sync_chain`]). If note transport is disabled, this is equivalent to
211 /// [`Client::sync_chain`].
212 ///
213 /// Fails fast on the first error. Private notes delivered via NTL are imported before the
214 /// chain sync reads its input set, so their nullifiers are checked in the same call.
215 pub async fn sync_state(&mut self) -> Result<SyncSummary, ClientError> {
216 let new_private_notes = self.sync_note_transport().await?;
217 let mut summary = self.sync_chain().await?;
218 summary.new_private_notes = new_private_notes;
219 Ok(summary)
220 }
221
222 /// Builds a default [`StateSyncInput`] from the current client state.
223 ///
224 /// This includes all tracked account headers, all unique note tags, all unspent input and
225 /// output notes, and all uncommitted transactions.
226 pub async fn build_sync_input(&self) -> Result<StateSyncInput, ClientError> {
227 let accounts = self
228 .store
229 .get_account_headers()
230 .await?
231 .into_iter()
232 .map(|(header, _status)| header)
233 .collect();
234
235 let note_tags = self.store.get_unique_note_tags().await?;
236
237 let input_notes = self.store.get_input_notes(NoteFilter::Unspent).await?;
238 let output_notes = self.store.get_output_notes(NoteFilter::Unspent).await?;
239
240 let uncommitted_transactions =
241 self.store.get_transactions(TransactionFilter::Uncommitted).await?;
242
243 Ok(StateSyncInput {
244 accounts,
245 note_tags,
246 input_notes,
247 output_notes,
248 uncommitted_transactions,
249 })
250 }
251
252 /// Applies the state sync update to the store and prunes irrelevant blocks according to the
253 /// configured cadence.
254 ///
255 /// See [`crate::Store::apply_state_sync()`] for what the update implies.
256 pub async fn apply_state_sync(&mut self, update: StateSyncUpdate) -> Result<(), ClientError> {
257 self.store.apply_state_sync(update).await?;
258
259 self.maybe_untrack_and_prune_irrelevant_blocks().await?;
260
261 Ok(())
262 }
263
264 /// Prunes irrelevant blocks and their MMR authentication nodes according to the configured
265 /// cadence.
266 async fn maybe_untrack_and_prune_irrelevant_blocks(&mut self) -> Result<(), ClientError> {
267 let Some(interval) = self.irrelevant_block_prune_interval else {
268 return Ok(());
269 };
270
271 let sync_height = self.store.get_sync_height().await?;
272
273 if let Some(last_prune_height) = self.last_irrelevant_block_prune_sync_height
274 && sync_height < last_prune_height + interval
275 {
276 return Ok(());
277 }
278
279 self.untrack_and_prune_irrelevant_blocks().await?;
280 self.last_irrelevant_block_prune_sync_height = Some(sync_height);
281
282 Ok(())
283 }
284
285 /// Prunes irrelevant block data from the store.
286 ///
287 /// Identifies tracked blocks whose input notes have all been consumed, untracks them from the
288 /// `PartialMmr` to determine which authentication nodes are no longer needed, then delegates
289 /// to [`Store::untrack_and_prune_irrelevant_blocks`] to atomically remove the stale nodes,
290 /// mark the blocks as irrelevant, and delete irrelevant block headers.
291 /// Any caller of this function should've cached the `PartialMmr` beforehand.
292 async fn untrack_and_prune_irrelevant_blocks(&mut self) -> Result<(), ClientError> {
293 let tracked_blocks = self.store.get_tracked_block_header_numbers().await?;
294 let to_untrack: Vec<usize> = if tracked_blocks.is_empty() {
295 // Do not early-return: even without blocks to untrack, old irrelevant tip headers may
296 // need pruning.
297 Vec::new()
298 } else {
299 // Blocks that still have at least one unspent note need to stay tracked.
300 let unspent_notes = self.store.get_input_notes(NoteFilter::Unspent).await?;
301 let live_blocks: BTreeSet<usize> = unspent_notes
302 .iter()
303 .filter_map(|n| n.inclusion_proof().map(|p| p.location().block_num().as_usize()))
304 .collect();
305
306 tracked_blocks.difference(&live_blocks).copied().collect()
307 };
308
309 let mut blocks_to_untrack = Vec::new();
310 let mut nodes_to_remove = Vec::new();
311 let mut updated_partial_mmr = None;
312
313 if !to_untrack.is_empty() {
314 // Rebuild the PartialMmr and untrack each block to collect the authentication node
315 // indices that are no longer needed by any remaining tracked leaf.
316 let mut partial_mmr = self.get_current_partial_mmr().await?;
317 nodes_to_remove = untrack_blocks(&mut partial_mmr, to_untrack.iter().copied());
318
319 blocks_to_untrack = to_untrack
320 .iter()
321 .map(|&b| BlockNumber::from(u32::try_from(b).expect("block number fits in u32")))
322 .collect();
323 updated_partial_mmr = Some(partial_mmr);
324 }
325
326 // Store deletes stale auth nodes, marks blocks as irrelevant, and removes irrelevant
327 // block headers. Old irrelevant tip headers may still need pruning.
328 self.store
329 .untrack_and_prune_irrelevant_blocks(&blocks_to_untrack, &nodes_to_remove)
330 .await?;
331
332 if let Some(partial_mmr) = updated_partial_mmr {
333 self.cache_partial_mmr(partial_mmr).await?;
334 }
335
336 Ok(())
337 }
338
339 /// Ensures that the RPC limits are set in the RPC client. If not already cached,
340 /// fetches them from the node and persists them in the store.
341 pub async fn ensure_rpc_limits_in_place(&mut self) -> Result<(), ClientError> {
342 if self.rpc_api.has_rpc_limits().is_some() {
343 return Ok(());
344 }
345
346 let limits = self.rpc_api.get_rpc_limits().await?;
347 self.store.set_rpc_limits(limits).await?;
348 Ok(())
349 }
350}
351
352// SYNC SUMMARY
353// ================================================================================================
354
355/// Contains stats about the sync operation.
356#[derive(Debug, PartialEq)]
357pub struct SyncSummary {
358 /// Block number up to which the client has been synced.
359 pub block_num: BlockNumber,
360 /// IDs of new public notes that the client has received.
361 pub new_public_notes: Vec<NoteId>,
362 /// IDs of private notes imported from the Note Transport Layer in this sync. They are still
363 /// `Expected` until observed on-chain.
364 ///
365 /// Only populated by [`Client::sync_state`]; [`Client::sync_chain`] always leaves this empty
366 /// because it does not touch the Note Transport Layer.
367 pub new_private_notes: Vec<NoteId>,
368 /// IDs of tracked notes that have been committed.
369 pub committed_notes: Vec<NoteId>,
370 /// IDs of notes that have been consumed.
371 pub consumed_notes: Vec<NoteId>,
372 /// IDs of on-chain accounts that have been updated.
373 pub updated_accounts: Vec<AccountId>,
374 /// IDs of private accounts that have been locked.
375 pub locked_accounts: Vec<AccountId>,
376 /// IDs of committed transactions.
377 pub committed_transactions: Vec<TransactionId>,
378}
379
380impl SyncSummary {
381 pub fn new(
382 block_num: BlockNumber,
383 new_public_notes: Vec<NoteId>,
384 new_private_notes: Vec<NoteId>,
385 committed_notes: Vec<NoteId>,
386 consumed_notes: Vec<NoteId>,
387 updated_accounts: Vec<AccountId>,
388 locked_accounts: Vec<AccountId>,
389 committed_transactions: Vec<TransactionId>,
390 ) -> Self {
391 Self {
392 block_num,
393 new_public_notes,
394 new_private_notes,
395 committed_notes,
396 consumed_notes,
397 updated_accounts,
398 locked_accounts,
399 committed_transactions,
400 }
401 }
402
403 pub fn new_empty(block_num: BlockNumber) -> Self {
404 Self {
405 block_num,
406 new_public_notes: vec![],
407 new_private_notes: vec![],
408 committed_notes: vec![],
409 consumed_notes: vec![],
410 updated_accounts: vec![],
411 locked_accounts: vec![],
412 committed_transactions: vec![],
413 }
414 }
415
416 pub fn is_empty(&self) -> bool {
417 self.new_public_notes.is_empty()
418 && self.new_private_notes.is_empty()
419 && self.committed_notes.is_empty()
420 && self.consumed_notes.is_empty()
421 && self.updated_accounts.is_empty()
422 && self.locked_accounts.is_empty()
423 && self.committed_transactions.is_empty()
424 }
425
426 pub fn combine_with(&mut self, mut other: Self) {
427 self.block_num = max(self.block_num, other.block_num);
428 self.new_public_notes.append(&mut other.new_public_notes);
429 self.new_private_notes.append(&mut other.new_private_notes);
430 self.committed_notes.append(&mut other.committed_notes);
431 self.consumed_notes.append(&mut other.consumed_notes);
432 self.updated_accounts.append(&mut other.updated_accounts);
433 self.locked_accounts.append(&mut other.locked_accounts);
434 self.committed_transactions.append(&mut other.committed_transactions);
435 }
436}
437
438impl Serializable for SyncSummary {
439 fn write_into<W: miden_tx::utils::serde::ByteWriter>(&self, target: &mut W) {
440 self.block_num.write_into(target);
441 self.new_public_notes.write_into(target);
442 self.new_private_notes.write_into(target);
443 self.committed_notes.write_into(target);
444 self.consumed_notes.write_into(target);
445 self.updated_accounts.write_into(target);
446 self.locked_accounts.write_into(target);
447 self.committed_transactions.write_into(target);
448 }
449}
450
451impl Deserializable for SyncSummary {
452 fn read_from<R: miden_tx::utils::serde::ByteReader>(
453 source: &mut R,
454 ) -> Result<Self, DeserializationError> {
455 let block_num = BlockNumber::read_from(source)?;
456 let new_public_notes = Vec::<NoteId>::read_from(source)?;
457 let new_private_notes = Vec::<NoteId>::read_from(source)?;
458 let committed_notes = Vec::<NoteId>::read_from(source)?;
459 let consumed_notes = Vec::<NoteId>::read_from(source)?;
460 let updated_accounts = Vec::<AccountId>::read_from(source)?;
461 let locked_accounts = Vec::<AccountId>::read_from(source)?;
462 let committed_transactions = Vec::<TransactionId>::read_from(source)?;
463
464 Ok(Self {
465 block_num,
466 new_public_notes,
467 new_private_notes,
468 committed_notes,
469 consumed_notes,
470 updated_accounts,
471 locked_accounts,
472 committed_transactions,
473 })
474 }
475}