Skip to main content

miden_client/sync/
state_sync_update.rs

1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::vec::Vec;
3
4use miden_protocol::Word;
5use miden_protocol::account::AccountId;
6use miden_protocol::block::{BlockHeader, BlockNumber};
7use miden_protocol::crypto::merkle::mmr::{InOrderIndex, MmrPeaks};
8use miden_protocol::note::{NoteId, Nullifier};
9use miden_protocol::transaction::TransactionId;
10
11use super::SyncSummary;
12use crate::account::Account;
13use crate::note::{NoteUpdateTracker, NoteUpdateType};
14use crate::rpc::domain::transaction::TransactionInclusion;
15use crate::transaction::{DiscardCause, TransactionRecord, TransactionStatus};
16
17// STATE SYNC UPDATE
18// ================================================================================================
19
20/// Contains all information needed to apply the update in the store after syncing with the node.
21#[derive(Default)]
22pub struct StateSyncUpdate {
23    /// The block number of the last block that was synced.
24    pub block_num: BlockNumber,
25    /// New blocks and authentication nodes.
26    pub block_updates: BlockUpdates,
27    /// New and updated notes to be upserted in the store.
28    pub note_updates: NoteUpdateTracker,
29    /// Committed and discarded transactions after the sync.
30    pub transaction_updates: TransactionUpdateTracker,
31    /// Public account updates and mismatched private accounts after the sync.
32    pub account_updates: AccountUpdates,
33}
34
35impl From<&StateSyncUpdate> for SyncSummary {
36    fn from(value: &StateSyncUpdate) -> Self {
37        let new_public_note_ids = value
38            .note_updates
39            .updated_input_notes()
40            .filter_map(|note_update| {
41                let note = note_update.inner();
42                if let NoteUpdateType::Insert = note_update.update_type() {
43                    Some(note.id())
44                } else {
45                    None
46                }
47            })
48            .collect();
49
50        let committed_note_ids: BTreeSet<NoteId> = value
51            .note_updates
52            .updated_input_notes()
53            .filter_map(|note_update| {
54                let note = note_update.inner();
55                if let NoteUpdateType::Update = note_update.update_type() {
56                    note.is_committed().then_some(note.id())
57                } else {
58                    None
59                }
60            })
61            .chain(value.note_updates.updated_output_notes().filter_map(|note_update| {
62                let note = note_update.inner();
63                if let NoteUpdateType::Update = note_update.update_type() {
64                    note.is_committed().then_some(note.id())
65                } else {
66                    None
67                }
68            }))
69            .collect();
70
71        let consumed_note_ids: BTreeSet<NoteId> = value
72            .note_updates
73            .updated_input_notes()
74            .filter_map(|note| note.inner().is_consumed().then_some(note.inner().id()))
75            .collect();
76
77        SyncSummary::new(
78            value.block_num,
79            new_public_note_ids,
80            committed_note_ids.into_iter().collect(),
81            consumed_note_ids.into_iter().collect(),
82            value
83                .account_updates
84                .updated_public_accounts()
85                .iter()
86                .map(Account::id)
87                .collect(),
88            value
89                .account_updates
90                .mismatched_private_accounts()
91                .iter()
92                .map(|(id, _)| *id)
93                .collect(),
94            value.transaction_updates.committed_transactions().map(|t| t.id).collect(),
95        )
96    }
97}
98
99/// Contains all the block information that needs to be added in the client's store after a sync.
100#[derive(Debug, Clone, Default)]
101pub struct BlockUpdates {
102    /// New block headers to be stored, keyed by block number. The value contains the block
103    /// header, a flag indicating whether the block contains notes relevant to the client, and
104    /// the MMR peaks for the block.
105    block_headers: BTreeMap<BlockNumber, (BlockHeader, bool, MmrPeaks)>,
106    /// New authentication nodes that are meant to be stored in order to authenticate block
107    /// headers.
108    new_authentication_nodes: Vec<(InOrderIndex, Word)>,
109}
110
111impl BlockUpdates {
112    /// Adds or updates a block header and its corresponding data in this [`BlockUpdates`].
113    ///
114    /// If the block header already exists (same block number), the `has_client_notes` flag is
115    /// OR-ed and the peaks are kept from the first insertion. Otherwise a new entry is added.
116    pub fn insert(
117        &mut self,
118        block_header: BlockHeader,
119        has_client_notes: bool,
120        peaks: MmrPeaks,
121        new_authentication_nodes: Vec<(InOrderIndex, Word)>,
122    ) {
123        self.block_headers
124            .entry(block_header.block_num())
125            .and_modify(|(_, existing_has_notes, _)| {
126                *existing_has_notes |= has_client_notes;
127            })
128            .or_insert((block_header, has_client_notes, peaks));
129
130        self.new_authentication_nodes.extend(new_authentication_nodes);
131    }
132
133    /// Returns the new block headers to be stored, along with a flag indicating whether the block
134    /// contains notes that are relevant to the client and the MMR peaks for the block.
135    pub fn block_headers(&self) -> impl Iterator<Item = &(BlockHeader, bool, MmrPeaks)> {
136        self.block_headers.values()
137    }
138
139    /// Adds authentication nodes without an associated block header.
140    ///
141    /// This is used when a synced block is not stored (no relevant notes and not the chain tip)
142    /// but the MMR authentication nodes it produced must still be persisted so that the on-disk
143    /// state stays consistent with the in-memory `PartialMmr`.
144    pub fn extend_authentication_nodes(&mut self, nodes: Vec<(InOrderIndex, Word)>) {
145        self.new_authentication_nodes.extend(nodes);
146    }
147
148    /// Returns the new authentication nodes that are meant to be stored in order to authenticate
149    /// block headers.
150    pub fn new_authentication_nodes(&self) -> &[(InOrderIndex, Word)] {
151        &self.new_authentication_nodes
152    }
153}
154
155/// Contains transaction changes to apply to the store.
156#[derive(Default)]
157pub struct TransactionUpdateTracker {
158    /// Transactions that were committed in the block.
159    transactions: BTreeMap<TransactionId, TransactionRecord>,
160    /// Nullifier-to-account mappings from external transactions by tracked accounts.
161    external_nullifier_accounts: BTreeMap<Nullifier, AccountId>,
162}
163
164impl TransactionUpdateTracker {
165    /// Creates a new [`TransactionUpdateTracker`]
166    pub fn new(transactions: Vec<TransactionRecord>) -> Self {
167        let transactions =
168            transactions.into_iter().map(|tx| (tx.id, tx)).collect::<BTreeMap<_, _>>();
169
170        Self {
171            transactions,
172            external_nullifier_accounts: BTreeMap::new(),
173        }
174    }
175
176    /// Returns a reference to committed transactions.
177    pub fn committed_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
178        self.transactions
179            .values()
180            .filter(|tx| matches!(tx.status, TransactionStatus::Committed { .. }))
181    }
182
183    /// Returns a reference to discarded transactions.
184    pub fn discarded_transactions(&self) -> impl Iterator<Item = &TransactionRecord> {
185        self.transactions
186            .values()
187            .filter(|tx| matches!(tx.status, TransactionStatus::Discarded(_)))
188    }
189
190    /// Returns a mutable reference to pending transactions in the tracker.
191    fn mutable_pending_transactions(&mut self) -> impl Iterator<Item = &mut TransactionRecord> {
192        self.transactions
193            .values_mut()
194            .filter(|tx| matches!(tx.status, TransactionStatus::Pending))
195    }
196
197    /// Returns transaction IDs of all transactions that have been updated.
198    pub fn updated_transaction_ids(&self) -> impl Iterator<Item = TransactionId> {
199        self.committed_transactions()
200            .chain(self.discarded_transactions())
201            .map(|tx| tx.id)
202    }
203
204    /// Returns the account ID that consumed the given nullifier in an external transaction, if
205    /// available.
206    pub fn external_nullifier_account(&self, nullifier: &Nullifier) -> Option<AccountId> {
207        self.external_nullifier_accounts.get(nullifier).copied()
208    }
209
210    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a
211    /// transaction is included in a block.
212    pub fn apply_transaction_inclusion(
213        &mut self,
214        transaction_inclusion: &TransactionInclusion,
215        timestamp: u64,
216    ) {
217        if let Some(transaction) = self.transactions.get_mut(&transaction_inclusion.transaction_id)
218        {
219            transaction.commit_transaction(transaction_inclusion.block_num, timestamp);
220            return;
221        }
222
223        // Fallback for transactions with unauthenticated input notes: the node
224        // authenticates these notes during processing, which changes the transaction
225        // ID. Match by account ID and pre-transaction state instead.
226        if let Some(transaction) = self.transactions.values_mut().find(|tx| {
227            tx.details.account_id == transaction_inclusion.account_id
228                && tx.details.init_account_state == transaction_inclusion.initial_state_commitment
229        }) {
230            transaction.commit_transaction(transaction_inclusion.block_num, timestamp);
231            return;
232        }
233
234        // No local transaction matched. This is an external transaction by a tracked account.
235        // Record the nullifier→account mappings so we can attribute note consumption to tracked
236        // accounts during nullifier processing.
237        for nullifier in &transaction_inclusion.nullifiers {
238            self.external_nullifier_accounts
239                .insert(*nullifier, transaction_inclusion.account_id);
240        }
241    }
242
243    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a the sync
244    /// height of the client is updated. This may result in stale or expired transactions.
245    pub fn apply_sync_height_update(
246        &mut self,
247        new_sync_height: BlockNumber,
248        tx_discard_delta: Option<u32>,
249    ) {
250        if let Some(tx_discard_delta) = tx_discard_delta {
251            self.discard_transaction_with_predicate(
252                |transaction| {
253                    transaction.details.submission_height
254                        < new_sync_height.checked_sub(tx_discard_delta).unwrap_or_default()
255                },
256                DiscardCause::Stale,
257            );
258        }
259
260        // NOTE: we check for <= new_sync height because at this point we would have committed the
261        // transaction otherwise
262        self.discard_transaction_with_predicate(
263            |transaction| transaction.details.expiration_block_num <= new_sync_height,
264            DiscardCause::Expired,
265        );
266    }
267
268    /// Applies the necessary state transitions to the [`TransactionUpdateTracker`] when a note is
269    /// nullified. this may result in transactions being discarded because they were processing the
270    /// nullified note.
271    pub fn apply_input_note_nullified(&mut self, input_note_nullifier: Nullifier) {
272        self.discard_transaction_with_predicate(
273            |transaction| {
274                // Check if the note was being processed by a local transaction that didn't end up
275                // being committed so it should be discarded
276                transaction
277                    .details
278                    .input_note_nullifiers
279                    .contains(&input_note_nullifier.as_word())
280            },
281            DiscardCause::InputConsumed,
282        );
283    }
284
285    /// Discards transactions that have the same initial account state as the provided one.
286    pub fn apply_invalid_initial_account_state(&mut self, invalid_account_state: Word) {
287        self.discard_transaction_with_predicate(
288            |transaction| transaction.details.init_account_state == invalid_account_state,
289            DiscardCause::DiscardedInitialState,
290        );
291    }
292
293    /// Discards transactions that match the predicate and also applies the new invalid account
294    /// states
295    fn discard_transaction_with_predicate<F>(&mut self, predicate: F, discard_cause: DiscardCause)
296    where
297        F: Fn(&TransactionRecord) -> bool,
298    {
299        let mut new_invalid_account_states = vec![];
300
301        for transaction in self.mutable_pending_transactions() {
302            // Discard transactions, and also push the invalid account state if the transaction
303            // got correctly discarded
304            // NOTE: previous updates in a chain of state syncs could have committed a transaction,
305            // so we need to check that `discard_transaction` returns `true` here (aka, it got
306            // discarded from a valid state)
307            if predicate(transaction) && transaction.discard_transaction(discard_cause) {
308                new_invalid_account_states.push(transaction.details.final_account_state);
309            }
310        }
311
312        for state in new_invalid_account_states {
313            self.apply_invalid_initial_account_state(state);
314        }
315    }
316}
317
318// ACCOUNT UPDATES
319// ================================================================================================
320
321/// Contains account changes to apply to the store after a sync request.
322#[derive(Debug, Clone, Default)]
323pub struct AccountUpdates {
324    /// Updated public accounts.
325    updated_public_accounts: Vec<Account>,
326    /// Account commitments received from the network that don't match the currently
327    /// locally-tracked state of the private accounts.
328    ///
329    /// These updates may represent a stale account commitment (meaning that the latest local state
330    /// hasn't been committed). If this is not the case, the account may be locked until the state
331    /// is restored manually.
332    mismatched_private_accounts: Vec<(AccountId, Word)>,
333}
334
335impl AccountUpdates {
336    /// Creates a new instance of `AccountUpdates`.
337    pub fn new(
338        updated_public_accounts: Vec<Account>,
339        mismatched_private_accounts: Vec<(AccountId, Word)>,
340    ) -> Self {
341        Self {
342            updated_public_accounts,
343            mismatched_private_accounts,
344        }
345    }
346
347    /// Returns the updated public accounts.
348    pub fn updated_public_accounts(&self) -> &[Account] {
349        &self.updated_public_accounts
350    }
351
352    /// Returns the mismatched private accounts.
353    pub fn mismatched_private_accounts(&self) -> &[(AccountId, Word)] {
354        &self.mismatched_private_accounts
355    }
356
357    pub fn extend(&mut self, other: AccountUpdates) {
358        self.updated_public_accounts.extend(other.updated_public_accounts);
359        self.mismatched_private_accounts.extend(other.mismatched_private_accounts);
360    }
361}