Skip to main content

miden_client/note/
note_update_tracker.rs

1use alloc::collections::BTreeMap;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::block::{BlockHeader, BlockNumber};
5use miden_protocol::note::{
6    Note,
7    NoteAttachments,
8    NoteDetailsCommitment,
9    NoteHeader,
10    NoteId,
11    NoteInclusionProof,
12    NoteMetadata,
13    Nullifier,
14};
15use miden_standards::note::NetworkAccountTarget;
16use miden_tx::utils::serde::{
17    ByteReader,
18    ByteWriter,
19    Deserializable,
20    DeserializationError,
21    Serializable,
22};
23
24use crate::ClientError;
25use crate::rpc::domain::note::CommittedNote;
26use crate::store::{InputNoteRecord, OutputNoteRecord};
27use crate::transaction::{TransactionRecord, TransactionStatus};
28
29// NOTE CONSUMPTION
30// ================================================================================================
31
32/// A note consumption event observed on chain.
33pub struct NoteConsumption {
34    /// The nullifier of the consumed note.
35    pub nullifier: Nullifier,
36    /// The block number at which the note consumption was registered on chain.
37    pub block_num: BlockNumber,
38    /// The account ID of the consumer of the note. Will be set if the note was consumed by a
39    /// transaction submitted outside this client by an account that is tracked locally.
40    /// Otherwise, it will be `None`.
41    pub external_consumer: Option<AccountId>,
42}
43
44// NOTE UPDATE
45// ================================================================================================
46
47/// Represents the possible types of updates that can be applied to a note in a
48/// [`NoteUpdateTracker`].
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50#[repr(u8)]
51pub enum NoteUpdateType {
52    /// Indicates that the note was already tracked but it was not updated.
53    None = 0,
54    /// Indicates that the note is new and should be inserted in the store.
55    Insert = 1,
56    /// Indicates that the note was already tracked and should be updated.
57    Update = 2,
58    /// Indicates that a previously-tracked metadata-less (`Expected`) note has just been committed.
59    /// It must be persisted as a full-row insert (like [`Self::Insert`]) so its now-known `note_id`
60    /// and `nullifier` columns are written, but for reporting it is a *committed* tracked note —
61    /// not a newly-discovered one — so it is summarized under committed notes, not new notes.
62    InsertCommitted = 3,
63}
64
65impl NoteUpdateType {
66    /// Whether this update carries a pending store write, as opposed to a note that was merely
67    /// loaded as already-tracked context ([`Self::None`]). True for [`Self::Insert`],
68    /// [`Self::Update`], and [`Self::InsertCommitted`].
69    pub fn is_modified(self) -> bool {
70        matches!(self, Self::Insert | Self::Update | Self::InsertCommitted)
71    }
72}
73
74impl TryFrom<u8> for NoteUpdateType {
75    type Error = u8;
76
77    fn try_from(value: u8) -> Result<Self, Self::Error> {
78        match value {
79            0 => Ok(NoteUpdateType::None),
80            1 => Ok(NoteUpdateType::Insert),
81            2 => Ok(NoteUpdateType::Update),
82            3 => Ok(NoteUpdateType::InsertCommitted),
83            other => Err(other),
84        }
85    }
86}
87
88/// Represents the possible states of an input note record in a [`NoteUpdateTracker`].
89#[derive(Clone, Debug, PartialEq)]
90pub struct InputNoteUpdate {
91    /// Input note being updated.
92    note: InputNoteRecord,
93    /// Type of the note update.
94    update_type: NoteUpdateType,
95}
96
97impl InputNoteUpdate {
98    /// Creates a new [`InputNoteUpdate`] with the provided note with a `None` update type.
99    fn new_none(note: InputNoteRecord) -> Self {
100        Self { note, update_type: NoteUpdateType::None }
101    }
102
103    /// Creates a new [`InputNoteUpdate`] with the provided note with an `Insert` update type.
104    fn new_insert(note: InputNoteRecord) -> Self {
105        Self {
106            note,
107            update_type: NoteUpdateType::Insert,
108        }
109    }
110
111    /// Creates a new [`InputNoteUpdate`] with the provided note with an `Update` update type.
112    fn new_update(note: InputNoteRecord) -> Self {
113        Self {
114            note,
115            update_type: NoteUpdateType::Update,
116        }
117    }
118
119    /// Creates a new [`InputNoteUpdate`] for a previously-tracked expected note that has just been
120    /// committed (see [`NoteUpdateType::InsertCommitted`]).
121    fn new_insert_committed(note: InputNoteRecord) -> Self {
122        Self {
123            note,
124            update_type: NoteUpdateType::InsertCommitted,
125        }
126    }
127
128    /// Returns a reference the inner note record.
129    pub fn inner(&self) -> &InputNoteRecord {
130        &self.note
131    }
132
133    /// Returns a mutable reference to the inner note record. If the update type is `None` or
134    /// `Update`, it will be set to `Update`; insert-typed updates keep their type.
135    fn inner_mut(&mut self) -> &mut InputNoteRecord {
136        self.update_type = match self.update_type {
137            NoteUpdateType::None | NoteUpdateType::Update => NoteUpdateType::Update,
138            NoteUpdateType::Insert => NoteUpdateType::Insert,
139            NoteUpdateType::InsertCommitted => NoteUpdateType::InsertCommitted,
140        };
141
142        &mut self.note
143    }
144
145    /// Returns the type of the note update.
146    pub fn update_type(&self) -> &NoteUpdateType {
147        &self.update_type
148    }
149
150    /// Returns the identifier of the inner note. Returns `None` when the underlying
151    /// [`InputNoteRecord`] has no metadata (see [`InputNoteRecord::id`]).
152    pub fn id(&self) -> Option<NoteId> {
153        self.note.id()
154    }
155
156    /// Returns the per-account position of the consuming transaction within the account's
157    /// execution chain for the block. `None` for non-consumed notes or when the order has not
158    /// been determined yet.
159    pub fn consumed_tx_order(&self) -> Option<u32> {
160        self.note.state().consumed_tx_order()
161    }
162}
163
164/// Represents the possible states of an output note record in a [`NoteUpdateTracker`].
165#[derive(Clone, Debug, PartialEq)]
166pub struct OutputNoteUpdate {
167    /// Output note being updated.
168    note: OutputNoteRecord,
169    /// Type of the note update.
170    update_type: NoteUpdateType,
171}
172
173impl OutputNoteUpdate {
174    /// Creates a new [`OutputNoteUpdate`] with the provided note with a `None` update type.
175    fn new_none(note: OutputNoteRecord) -> Self {
176        Self { note, update_type: NoteUpdateType::None }
177    }
178
179    /// Creates a new [`OutputNoteUpdate`] with the provided note with an `Insert` update type.
180    fn new_insert(note: OutputNoteRecord) -> Self {
181        Self {
182            note,
183            update_type: NoteUpdateType::Insert,
184        }
185    }
186
187    /// Creates a new [`OutputNoteUpdate`] with the provided note with an `Update` update type.
188    fn new_update(note: OutputNoteRecord) -> Self {
189        Self {
190            note,
191            update_type: NoteUpdateType::Update,
192        }
193    }
194
195    /// Returns a reference the inner note record.
196    pub fn inner(&self) -> &OutputNoteRecord {
197        &self.note
198    }
199
200    /// Returns a mutable reference to the inner note record. If the update type is `None` or
201    /// `Update`, it will be set to `Update`.
202    fn inner_mut(&mut self) -> &mut OutputNoteRecord {
203        self.update_type = match self.update_type {
204            NoteUpdateType::None | NoteUpdateType::Update => NoteUpdateType::Update,
205            // Output notes are never assigned `InsertCommitted` (it is input-note specific), but
206            // the match must be exhaustive; treat it as an insert.
207            NoteUpdateType::Insert | NoteUpdateType::InsertCommitted => NoteUpdateType::Insert,
208        };
209
210        &mut self.note
211    }
212
213    /// Returns the type of the note update.
214    pub fn update_type(&self) -> &NoteUpdateType {
215        &self.update_type
216    }
217
218    /// Returns the identifier of the inner note.
219    pub fn id(&self) -> NoteId {
220        self.note.id()
221    }
222}
223
224// NOTE UPDATE TRACKER
225// ================================================================================================
226
227/// Contains note changes to apply to the store.
228///
229/// This includes new notes that have been created and existing notes that have been updated. The
230/// tracker also lets state changes be applied to the contained notes, this allows for already
231/// updated notes to be further updated as new information is received.
232#[derive(Clone, Debug, Default, PartialEq)]
233pub struct NoteUpdateTracker {
234    /// All new and updated input note records to be upserted in the store, keyed by their details
235    /// commitment. The details commitment is metadata-independent and therefore always available,
236    /// including for metadata-less notes (e.g. expected notes imported from bare details, or
237    /// future notes created by a transaction) that do not yet have a `NoteId`.
238    input_notes: BTreeMap<NoteDetailsCommitment, InputNoteUpdate>,
239    /// A map of updated output note records to be upserted in the store.
240    output_notes: BTreeMap<NoteId, OutputNoteUpdate>,
241    /// Lookup index from nullifier to the details commitment of the input note. Only populated for
242    /// metadata-bearing notes, as a metadata-less note has no nullifier.
243    input_notes_by_nullifier: BTreeMap<Nullifier, NoteDetailsCommitment>,
244    /// Lookup index from `NoteId` to the details commitment of the input note. Only populated for
245    /// metadata-bearing notes, since a metadata-less note has no `NoteId`. Lets a note be found by
246    /// its id even though `input_notes` is keyed by details commitment.
247    input_notes_by_id: BTreeMap<NoteId, NoteDetailsCommitment>,
248    /// Fast lookup map from nullifier to output note id.
249    output_notes_by_nullifier: BTreeMap<Nullifier, NoteId>,
250    /// Map from nullifier to its per-account position in the consuming transaction order.
251    /// Nullifiers from the same account are in execution order; ordering across different
252    /// accounts is not guaranteed.
253    nullifier_order: BTreeMap<Nullifier, u32>,
254}
255
256impl NoteUpdateTracker {
257    /// Creates a [`NoteUpdateTracker`] with already-tracked notes.
258    pub fn new(
259        input_notes: impl IntoIterator<Item = InputNoteRecord>,
260        output_notes: impl IntoIterator<Item = OutputNoteRecord>,
261    ) -> Self {
262        let mut tracker = Self::default();
263        for note in input_notes {
264            tracker.insert_input_note(note, NoteUpdateType::None);
265        }
266        for note in output_notes {
267            tracker.insert_output_note(note, NoteUpdateType::None);
268        }
269
270        tracker
271    }
272
273    /// Creates a [`NoteUpdateTracker`] for updates related to transactions.
274    ///
275    /// A transaction can:
276    ///
277    /// - Create input notes
278    /// - Update existing input notes (by consuming them)
279    /// - Create output notes
280    pub fn for_transaction_updates(
281        new_input_notes: impl IntoIterator<Item = InputNoteRecord>,
282        updated_input_notes: impl IntoIterator<Item = InputNoteRecord>,
283        new_output_notes: impl IntoIterator<Item = OutputNoteRecord>,
284    ) -> Self {
285        let mut tracker = Self::default();
286
287        for note in new_input_notes {
288            tracker.insert_input_note(note, NoteUpdateType::Insert);
289        }
290
291        for note in updated_input_notes {
292            tracker.insert_input_note(note, NoteUpdateType::Update);
293        }
294
295        for note in new_output_notes {
296            tracker.insert_output_note(note, NoteUpdateType::Insert);
297        }
298
299        tracker
300    }
301
302    // GETTERS
303    // --------------------------------------------------------------------------------------------
304
305    /// Returns all input note records that have been updated.
306    ///
307    /// This may include:
308    /// - New notes that have been created that should be inserted.
309    /// - Existing tracked notes that should be updated.
310    ///
311    /// Metadata-less expected notes (e.g. future notes created by a transaction, such as swap
312    /// payback notes) are included as well: they have no `NoteId` yet but must still be persisted
313    /// and have their tags registered. The `update_type` filter ensures notes merely loaded as
314    /// already-tracked context (`NoteUpdateType::None`) are not re-emitted.
315    pub fn updated_input_notes(&self) -> impl Iterator<Item = &InputNoteUpdate> {
316        self.input_notes.values().filter(|note| note.update_type.is_modified())
317    }
318
319    /// Returns the ids of updated input notes that are now consumed. `input_notes` is keyed by
320    /// details commitment, so the `input_notes_by_id` index provides each note's `NoteId`.
321    pub fn consumed_input_note_ids(&self) -> impl Iterator<Item = NoteId> + '_ {
322        self.input_notes_by_id.iter().filter_map(|(note_id, commitment)| {
323            let update = self.input_notes.get(commitment)?;
324            (update.update_type.is_modified() && update.inner().is_consumed()).then_some(*note_id)
325        })
326    }
327
328    /// `NoteId`s of every input + output note that transitioned to a consumed state this sync.
329    /// These are confirmed consumptions reflected in the tracker, not raw nullifier-prefix hits.
330    pub fn consumed_note_ids(&self) -> impl Iterator<Item = NoteId> + '_ {
331        let output = self.output_notes.iter().filter_map(|(note_id, update)| {
332            (update.update_type.is_modified() && update.inner().is_consumed()).then_some(*note_id)
333        });
334        self.consumed_input_note_ids().chain(output)
335    }
336
337    /// Returns all output note records that have been updated.
338    ///
339    /// This may include:
340    /// - New notes that have been created that should be inserted.
341    /// - Existing tracked notes that should be updated.
342    pub fn updated_output_notes(&self) -> impl Iterator<Item = &OutputNoteUpdate> {
343        self.output_notes.values().filter(|note| note.update_type.is_modified())
344    }
345
346    /// Returns whether no new note-related information has been retrieved.
347    pub fn is_empty(&self) -> bool {
348        self.input_notes.is_empty() && self.output_notes.is_empty()
349    }
350
351    /// Returns input and output note unspent nullifiers.
352    pub fn unspent_nullifiers(&self) -> impl Iterator<Item = Nullifier> {
353        let input_note_unspent_nullifiers = self
354            .input_notes
355            .values()
356            .filter(|note| !note.inner().is_consumed())
357            .filter_map(|note| note.inner().nullifier());
358
359        let output_note_unspent_nullifiers = self
360            .output_notes
361            .values()
362            .filter(|note| !note.inner().is_consumed())
363            .filter_map(|note| note.inner().nullifier());
364
365        input_note_unspent_nullifiers.chain(output_note_unspent_nullifiers)
366    }
367
368    /// Returns the block numbers containing input notes that remain unspent after this update.
369    pub(crate) fn unspent_input_note_block_numbers(
370        &self,
371    ) -> impl Iterator<Item = BlockNumber> + '_ {
372        self.input_notes
373            .values()
374            .filter(|update| !update.inner().is_consumed())
375            .filter_map(|update| {
376                update.inner().inclusion_proof().map(|proof| proof.location().block_num())
377            })
378    }
379
380    /// Appends nullifiers to the per-account ordered nullifier list.
381    ///
382    /// Nullifiers from the same account must be in execution order; ordering across different
383    /// accounts is not guaranteed.
384    pub fn extend_nullifiers(&mut self, nullifiers: impl IntoIterator<Item = Nullifier>) {
385        for nullifier in nullifiers {
386            let next_pos =
387                u32::try_from(self.nullifier_order.len()).expect("nullifier count exceeds u32");
388            self.nullifier_order.entry(nullifier).or_insert(next_pos);
389        }
390    }
391
392    // UPDATE METHODS
393    // --------------------------------------------------------------------------------------------
394
395    /// Inserts the new public note data into the tracker. This method doesn't check the relevance
396    /// of the note, so it should only be used for notes that are guaranteed to be relevant to the
397    /// client.
398    pub(crate) fn apply_new_public_note(
399        &mut self,
400        mut public_note_data: InputNoteRecord,
401        block_header: &BlockHeader,
402    ) -> Result<(), ClientError> {
403        public_note_data.block_header_received(block_header)?;
404        self.insert_input_note(public_note_data, NoteUpdateType::Insert);
405
406        Ok(())
407    }
408
409    /// Applies the necessary state transitions to the [`NoteUpdateTracker`] when a note is
410    /// committed in a block and returns whether the committed note is tracked as input note.
411    pub(crate) fn apply_committed_note_state_transitions(
412        &mut self,
413        committed_note: &CommittedNote,
414        block_header: &BlockHeader,
415        attachments: &NoteAttachments,
416    ) -> Result<bool, ClientError> {
417        let inclusion_proof = committed_note.inclusion_proof().clone();
418        let metadata = *committed_note.metadata();
419        let note_id = *committed_note.note_id();
420        let attachments = (!attachments.is_empty()).then_some(attachments);
421
422        let is_tracked_as_input_note = if let Some(input_note_record) =
423            self.get_input_note_by_id(note_id)
424        {
425            input_note_record.inclusion_proof_received(inclusion_proof.clone(), metadata)?;
426            input_note_record.block_header_received(block_header)?;
427            if let Some(attachments) = attachments {
428                input_note_record.attachments_received(attachments.clone());
429            }
430
431            true
432        } else if let Some(commitment) = self.expected_note_matching(note_id, &metadata) {
433            // A metadata-less note whose id, with the committed metadata, equals this note id:
434            // evolve it into a full record in place (its details commitment key is unchanged).
435            let nullifier = {
436                let update = self
437                    .input_notes
438                    .get_mut(&commitment)
439                    .expect("commitment was just matched against the tracked notes");
440                let record = &mut update.note;
441                record.inclusion_proof_received(inclusion_proof.clone(), metadata)?;
442                record.block_header_received(block_header)?;
443                if let Some(attachments) = attachments {
444                    record.attachments_received(attachments.clone());
445                }
446
447                // `InsertCommitted` so the now-known `note_id`/`nullifier` columns are persisted
448                // (a full-row insert), while still being reported as a committed tracked note
449                // rather than a newly-discovered one.
450                update.update_type = NoteUpdateType::InsertCommitted;
451                record.nullifier().expect("note with an id has metadata")
452            };
453
454            // The note now has metadata, so register it in the id and nullifier indices.
455            self.input_notes_by_nullifier.insert(nullifier, commitment);
456            self.input_notes_by_id.insert(note_id, commitment);
457
458            true
459        } else {
460            false
461        };
462
463        self.try_commit_output_note(note_id, inclusion_proof)?;
464
465        Ok(is_tracked_as_input_note)
466    }
467
468    /// Applies inclusion proofs from the transaction sync response to tracked output notes.
469    ///
470    /// This transitions output notes from `Expected` to `Committed` state using the
471    /// inclusion proofs returned by `SyncTransactions`.
472    pub(crate) fn apply_output_note_inclusion_proofs(
473        &mut self,
474        committed_notes: &[CommittedNote],
475    ) -> Result<(), ClientError> {
476        for committed_note in committed_notes {
477            self.try_commit_output_note(
478                *committed_note.note_id(),
479                committed_note.inclusion_proof().clone(),
480            )?;
481        }
482        Ok(())
483    }
484
485    /// Marks an erased note as consumed.
486    ///
487    /// This handles notes that were erased due to same-batch note erasure: the note was
488    /// created and consumed within the same batch, so it never appeared in the block body.
489    /// The `block_num` is the block in which the creating transaction was committed.
490    ///
491    /// The consumer account id is derived from the tracked input record's attachments (a
492    /// [`NetworkAccountTarget`], when present), not from the erased-note RPC stream, which delivers
493    /// only a [`NoteHeader`]. When no such attachment is present the consumer is left unknown.
494    pub(crate) fn mark_erased_note_as_consumed(
495        &mut self,
496        note_header: &NoteHeader,
497        block_num: BlockNumber,
498    ) -> Result<(), ClientError> {
499        let note_id = note_header.id();
500
501        if let Some(output_note) = self.get_output_note_by_id(note_id)
502            && output_note.is_inclusion_pending()
503            && let Some(nullifier) = output_note.nullifier()
504        {
505            output_note.nullifier_received(nullifier, block_num)?;
506        }
507
508        if let Some(commitment) = self.input_notes_by_id.get(&note_id).copied()
509            && let Some(input_note_update) = self.input_notes.get_mut(&commitment)
510            && !input_note_update.inner().is_consumed()
511            && let Some(nullifier) = input_note_update.inner().nullifier()
512        {
513            let consumer_account =
514                NetworkAccountTarget::try_from(input_note_update.inner().attachments())
515                    .ok()
516                    .map(|target| target.target_id());
517            input_note_update.inner_mut().consumed_externally(
518                nullifier,
519                block_num,
520                consumer_account,
521            )?;
522            input_note_update.inner_mut().set_consumed_tx_order(Some(0));
523        }
524
525        Ok(())
526    }
527
528    /// Returns whether the note is already tracked as an input or output record.
529    pub(crate) fn tracks_note(&self, note_id: NoteId) -> bool {
530        self.input_notes_by_id.contains_key(&note_id) || self.output_notes.contains_key(&note_id)
531    }
532
533    /// Records `note` as consumed by `consumer`, as a
534    /// [`ConsumedExternal`](crate::store::InputNoteState::ConsumedExternal) input-note record.
535    ///
536    /// No-op when the note is already tracked: recovery runs after transaction and nullifier
537    /// processing, so a tracked record's consumption has already been applied through
538    /// [`Self::apply_note_consumption`].
539    pub(crate) fn insert_consumed_public_note(
540        &mut self,
541        note: Note,
542        consumer: AccountId,
543        block_num: BlockNumber,
544    ) -> Result<(), ClientError> {
545        let note_id = note.id();
546        if self.tracks_note(note_id) {
547            return Ok(());
548        }
549        let nullifier = note.nullifier();
550        // The consuming transaction belongs to this sync, so its nullifier must have a position
551        // in the execution order; storing the record without one would break the ordering
552        // guarantees of `InputNoteReader`.
553        let order = self
554            .get_nullifier_order(nullifier)
555            .ok_or(ClientError::MissingConsumedNoteOrder(note_id))?;
556        let mut record = InputNoteRecord::from(note);
557        record.consumed_externally(nullifier, block_num, Some(consumer))?;
558        record.set_consumed_tx_order(Some(order));
559        self.insert_input_note(record, NoteUpdateType::Insert);
560        Ok(())
561    }
562
563    /// Builds a consumed input note record from a tracked output note and inserts it.
564    ///
565    /// Used when an output note is consumed externally and the client should also surface
566    /// it as a consumed input — for example, when the same client tracks both the sender
567    /// and the consumer of the note. No-op if the input is already tracked, the output is
568    /// not tracked, or the output cannot be converted to a [`Note`].
569    fn try_insert_consumed_input_from_output(
570        &mut self,
571        note_id: NoteId,
572        consumer: AccountId,
573        block_num: BlockNumber,
574        consumed_tx_order: Option<u32>,
575    ) -> Result<(), ClientError> {
576        if self.input_notes_by_id.contains_key(&note_id) {
577            return Ok(());
578        }
579        let Some(output_note) = self.output_notes.get(&note_id) else {
580            return Ok(());
581        };
582        let Ok(note) = Note::try_from(output_note.inner().clone()) else {
583            return Ok(());
584        };
585
586        let mut input_record = InputNoteRecord::from(note);
587        let nullifier =
588            input_record.nullifier().expect("record built from a full note has metadata");
589        input_record.consumed_externally(nullifier, block_num, Some(consumer))?;
590        input_record.set_consumed_tx_order(consumed_tx_order);
591        self.insert_input_note(input_record, NoteUpdateType::Insert);
592        Ok(())
593    }
594
595    /// If the note is tracked as an output note, transitions it to `Committed` with the
596    /// given inclusion proof. No-op if the note is not tracked.
597    fn try_commit_output_note(
598        &mut self,
599        note_id: NoteId,
600        inclusion_proof: NoteInclusionProof,
601    ) -> Result<(), ClientError> {
602        if let Some(output_note) = self.get_output_note_by_id(note_id) {
603            output_note.inclusion_proof_received(inclusion_proof)?;
604        }
605        Ok(())
606    }
607
608    /// Applies the necessary state transitions to the [`NoteUpdateTracker`] when a note is
609    /// nullified in a block.
610    ///
611    /// For input note records two possible scenarios are considered:
612    /// 1. The note was being processed by a local transaction that just got committed.
613    /// 2. The note was consumed by a transaction not submitted by this client. This includes
614    ///    consumption by untracked accounts as well as consumption by tracked accounts whose
615    ///    transactions were submitted by other client instances. If a local transaction was
616    ///    processing the note and it didn't get committed, the transaction should be discarded.
617    ///
618    /// If the note is tracked as an output but not as an input (e.g. the client tracks both the
619    /// sender and the consumer), a new input record is created from the output details so the
620    /// consumption surfaces through `InputNoteReader`.
621    pub(crate) fn apply_note_consumption<'a>(
622        &mut self,
623        consumption: &NoteConsumption,
624        mut committed_transactions: impl Iterator<Item = &'a TransactionRecord>,
625    ) -> Result<(), ClientError> {
626        let nullifier = consumption.nullifier;
627        let block_num = consumption.block_num;
628        let external_consumer = consumption.external_consumer;
629        let order = self.get_nullifier_order(nullifier);
630        let input_present = self.input_notes_by_nullifier.contains_key(&nullifier);
631
632        if let Some(input_note_update) = self.get_input_note_update_by_nullifier(nullifier) {
633            if let Some(consumer_transaction) = committed_transactions
634                .find(|t| input_note_update.inner().consumer_transaction_id() == Some(&t.id))
635            {
636                // The note was being processed by a local transaction that just got committed
637                if let TransactionStatus::Committed { block_number, .. } =
638                    consumer_transaction.status
639                {
640                    input_note_update
641                        .inner_mut()
642                        .transaction_committed(consumer_transaction.id, block_number)?;
643                }
644            } else {
645                // The note was consumed by a transaction not submitted by this client.
646                // If the consuming account is tracked, external_consumer will be Some.
647                input_note_update.inner_mut().consumed_externally(
648                    nullifier,
649                    block_num,
650                    external_consumer,
651                )?;
652            }
653            input_note_update.inner_mut().set_consumed_tx_order(order);
654        }
655
656        if let Some(output_note_record) = self.get_output_note_by_nullifier(nullifier) {
657            output_note_record.nullifier_received(nullifier, block_num)?;
658        }
659
660        if !input_present
661            && let Some(consumer) = external_consumer
662            && let Some(note_id) = self.output_notes_by_nullifier.get(&nullifier).copied()
663        {
664            self.try_insert_consumed_input_from_output(note_id, consumer, block_num, order)?;
665        }
666
667        Ok(())
668    }
669
670    // PRIVATE HELPERS
671    // --------------------------------------------------------------------------------------------
672
673    /// Returns the position of the given nullifier in the consuming transaction order, or `None`
674    /// if it is not present.
675    fn get_nullifier_order(&self, nullifier: Nullifier) -> Option<u32> {
676        self.nullifier_order.get(&nullifier).copied()
677    }
678
679    /// Returns a mutable reference to the input note record with the provided ID if it exists.
680    fn get_input_note_by_id(&mut self, note_id: NoteId) -> Option<&mut InputNoteRecord> {
681        let commitment = self.input_notes_by_id.get(&note_id).copied()?;
682        self.input_notes.get_mut(&commitment).map(InputNoteUpdate::inner_mut)
683    }
684
685    /// Returns the details commitment of a tracked metadata-less note whose id, combined with
686    /// `metadata`, equals `note_id`, i.e. the committed note is that imported note.
687    fn expected_note_matching(
688        &self,
689        note_id: NoteId,
690        metadata: &NoteMetadata,
691    ) -> Option<NoteDetailsCommitment> {
692        self.input_notes
693            .iter()
694            .filter(|(_, update)| update.inner().metadata().is_none())
695            .map(|(commitment, _)| *commitment)
696            .find(|commitment| NoteId::new(*commitment, metadata) == note_id)
697    }
698
699    /// Returns a mutable reference to the output note record with the provided ID if it exists.
700    fn get_output_note_by_id(&mut self, note_id: NoteId) -> Option<&mut OutputNoteRecord> {
701        self.output_notes.get_mut(&note_id).map(OutputNoteUpdate::inner_mut)
702    }
703
704    /// Returns a mutable reference to the input note update with the provided nullifier if it
705    /// exists.
706    fn get_input_note_update_by_nullifier(
707        &mut self,
708        nullifier: Nullifier,
709    ) -> Option<&mut InputNoteUpdate> {
710        let commitment = self.input_notes_by_nullifier.get(&nullifier).copied()?;
711        self.input_notes.get_mut(&commitment)
712    }
713
714    /// Returns a mutable reference to the output note record with the provided nullifier if it
715    /// exists.
716    fn get_output_note_by_nullifier(
717        &mut self,
718        nullifier: Nullifier,
719    ) -> Option<&mut OutputNoteRecord> {
720        let note_id = self.output_notes_by_nullifier.get(&nullifier).copied()?;
721        self.output_notes.get_mut(&note_id).map(OutputNoteUpdate::inner_mut)
722    }
723
724    /// Insert an input note update
725    fn insert_input_note(&mut self, note: InputNoteRecord, update_type: NoteUpdateType) {
726        let update = match update_type {
727            NoteUpdateType::None => InputNoteUpdate::new_none(note),
728            NoteUpdateType::Insert => InputNoteUpdate::new_insert(note),
729            NoteUpdateType::Update => InputNoteUpdate::new_update(note),
730            NoteUpdateType::InsertCommitted => InputNoteUpdate::new_insert_committed(note),
731        };
732
733        let commitment = update.inner().details_commitment();
734        if let Some(note_id) = update.inner().id() {
735            // A note with metadata supersedes any metadata-less record for the same commitment.
736            let nullifier = update.inner().nullifier().expect("note with an id has metadata");
737            self.input_notes_by_nullifier.insert(nullifier, commitment);
738            self.input_notes_by_id.insert(note_id, commitment);
739            self.input_notes.insert(commitment, update);
740        } else if self.input_notes.get(&commitment).is_none_or(|u| u.inner().id().is_none()) {
741            // No metadata yet means no `NoteId` and no computable nullifier. Track by details
742            // commitment until a committed note supplies the metadata to evolve it, but do not
743            // overwrite a metadata-bearing record that already supersedes it.
744            self.input_notes.insert(commitment, update);
745        }
746    }
747
748    /// Insert an output note update
749    fn insert_output_note(&mut self, note: OutputNoteRecord, update_type: NoteUpdateType) {
750        let note_id = note.id();
751        if let Some(nullifier) = note.nullifier() {
752            self.output_notes_by_nullifier.insert(nullifier, note_id);
753        }
754        let update = match update_type {
755            NoteUpdateType::None => OutputNoteUpdate::new_none(note),
756            NoteUpdateType::Update => OutputNoteUpdate::new_update(note),
757            // Output notes are never assigned `InsertCommitted`; treat it as an insert for
758            // exhaustiveness.
759            NoteUpdateType::Insert | NoteUpdateType::InsertCommitted => {
760                OutputNoteUpdate::new_insert(note)
761            },
762        };
763        self.output_notes.insert(note_id, update);
764    }
765}
766
767// SERIALIZATION
768// ================================================================================================
769
770impl Serializable for NoteUpdateType {
771    fn write_into<W: ByteWriter>(&self, target: &mut W) {
772        target.write_u8(*self as u8);
773    }
774}
775
776impl Deserializable for NoteUpdateType {
777    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
778        NoteUpdateType::try_from(source.read_u8()?).map_err(|val| {
779            DeserializationError::InvalidValue(format!("invalid note update type: {val}"))
780        })
781    }
782}
783
784impl Serializable for InputNoteUpdate {
785    fn write_into<W: ByteWriter>(&self, target: &mut W) {
786        self.note.write_into(target);
787        self.update_type.write_into(target);
788    }
789}
790
791impl Deserializable for InputNoteUpdate {
792    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
793        let note = InputNoteRecord::read_from(source)?;
794        let update_type = NoteUpdateType::read_from(source)?;
795        Ok(Self { note, update_type })
796    }
797}
798
799impl Serializable for OutputNoteUpdate {
800    fn write_into<W: ByteWriter>(&self, target: &mut W) {
801        self.note.write_into(target);
802        self.update_type.write_into(target);
803    }
804}
805
806impl Deserializable for OutputNoteUpdate {
807    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
808        let note = OutputNoteRecord::read_from(source)?;
809        let update_type = NoteUpdateType::read_from(source)?;
810        Ok(Self { note, update_type })
811    }
812}
813
814impl Serializable for NoteUpdateTracker {
815    fn write_into<W: ByteWriter>(&self, target: &mut W) {
816        // The lookup indices are serialized alongside the records so the tracker round-trips to an
817        // identical state.
818        self.input_notes.write_into(target);
819        self.output_notes.write_into(target);
820        self.nullifier_order.write_into(target);
821        self.input_notes_by_id.write_into(target);
822        self.input_notes_by_nullifier.write_into(target);
823    }
824}
825
826impl Deserializable for NoteUpdateTracker {
827    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
828        let input_notes = BTreeMap::<NoteDetailsCommitment, InputNoteUpdate>::read_from(source)?;
829        let output_notes = BTreeMap::<NoteId, OutputNoteUpdate>::read_from(source)?;
830        let nullifier_order = BTreeMap::<Nullifier, u32>::read_from(source)?;
831        let input_notes_by_id = BTreeMap::<NoteId, NoteDetailsCommitment>::read_from(source)?;
832        let input_notes_by_nullifier =
833            BTreeMap::<Nullifier, NoteDetailsCommitment>::read_from(source)?;
834
835        // Output notes always carry metadata, so this index can be safely derived from the records.
836        let output_notes_by_nullifier = output_notes
837            .iter()
838            .filter_map(|(note_id, update)| {
839                update.inner().nullifier().map(|nullifier| (nullifier, *note_id))
840            })
841            .collect();
842
843        Ok(Self {
844            input_notes,
845            output_notes,
846            input_notes_by_nullifier,
847            input_notes_by_id,
848            output_notes_by_nullifier,
849            nullifier_order,
850        })
851    }
852}
853
854// TESTS
855// ================================================================================================
856
857#[cfg(test)]
858mod tests {
859    use alloc::vec;
860
861    use miden_protocol::account::AccountId;
862    use miden_protocol::block::BlockNumber;
863    use miden_protocol::note::{
864        NoteAssets,
865        NoteAttachments,
866        NoteDetails,
867        NoteId,
868        NoteMetadata,
869        NoteRecipient,
870        NoteStorage,
871        NoteType,
872        PartialNoteMetadata,
873    };
874    use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER;
875    use miden_protocol::transaction::TransactionId;
876    use miden_protocol::utils::serde::{Deserializable, Serializable};
877    use miden_protocol::{Felt, Word, ZERO};
878    use miden_standards::note::StandardNote;
879
880    use super::{NoteConsumption, NoteUpdateTracker};
881    use crate::store::InputNoteRecord;
882    use crate::store::input_note_states::{
883        ConsumedExternalNoteState,
884        ConsumedUnauthenticatedLocalNoteState,
885        ExpectedNoteState,
886        NoteSubmissionData,
887        ProcessingUnauthenticatedNoteState,
888    };
889    use crate::transaction::TransactionRecord;
890
891    // HELPERS
892    // --------------------------------------------------------------------------------------------
893
894    fn note_details(seed: u64) -> NoteDetails {
895        let serial_number: Word = [Felt::new_unchecked(seed), ZERO, ZERO, ZERO].into();
896        let recipient = NoteRecipient::new(
897            serial_number,
898            StandardNote::SWAP.script(),
899            NoteStorage::new(vec![]).unwrap(),
900        );
901        NoteDetails::new(NoteAssets::new(vec![]).unwrap(), recipient)
902    }
903
904    fn note_metadata(sender: AccountId) -> NoteMetadata {
905        NoteMetadata::new(
906            PartialNoteMetadata::new(sender, NoteType::Public),
907            &NoteAttachments::empty(),
908        )
909    }
910
911    /// A metadata-less expected note. It has no `NoteId` and is tracked by its details commitment.
912    fn expected_note(seed: u64) -> InputNoteRecord {
913        let state = ExpectedNoteState {
914            metadata: None,
915            after_block_num: BlockNumber::from(0u32),
916            tag: None,
917        };
918        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
919    }
920
921    /// A metadata-bearing, not-yet-consumed note that can be externally consumed.
922    fn processing_note(seed: u64, sender: AccountId) -> InputNoteRecord {
923        let state = ProcessingUnauthenticatedNoteState {
924            metadata: note_metadata(sender),
925            after_block_num: BlockNumber::from(0u32),
926            submission_data: NoteSubmissionData {
927                submitted_at: Some(0),
928                consumer_account: sender,
929                consumer_transaction: TransactionId::from_raw(Word::default()),
930            },
931        };
932        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
933    }
934
935    /// A metadata-bearing note that is already consumed by a local transaction.
936    fn consumed_local_note(seed: u64, sender: AccountId) -> InputNoteRecord {
937        let state = ConsumedUnauthenticatedLocalNoteState {
938            metadata: note_metadata(sender),
939            nullifier_block_height: BlockNumber::from(1u32),
940            submission_data: NoteSubmissionData {
941                submitted_at: Some(0),
942                consumer_account: sender,
943                consumer_transaction: TransactionId::from_raw(Word::default()),
944            },
945            consumed_tx_order: Some(0),
946        };
947        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
948    }
949
950    /// A metadata-less note that is already externally consumed. It never carried a `NoteId`.
951    fn consumed_external_note(seed: u64) -> InputNoteRecord {
952        let state = ConsumedExternalNoteState {
953            nullifier_block_height: BlockNumber::from(1u32),
954            consumer_account: None,
955            consumed_tx_order: None,
956            metadata: None,
957        };
958        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
959    }
960
961    // TESTS
962    // --------------------------------------------------------------------------------------------
963
964    #[test]
965    fn consumed_input_note_ids_reports_metadata_bearing_consumed_note() {
966        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
967        let note = consumed_local_note(1, sender);
968        let id = note.id().expect("consumed-local note has metadata");
969
970        let tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
971
972        let consumed: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
973        assert_eq!(consumed, vec![id]);
974    }
975
976    #[test]
977    fn consumed_input_note_ids_omits_note_that_never_had_an_id() {
978        // A note inserted already in the externally-consumed (metadata-less) state never had an id
979        // in the tracker, so it is persisted but is not reported by id.
980        let note = consumed_external_note(2);
981        assert!(note.id().is_none());
982
983        let tracker = NoteUpdateTracker::for_transaction_updates(vec![note], vec![], vec![]);
984
985        assert_eq!(tracker.consumed_input_note_ids().count(), 0);
986        assert_eq!(tracker.updated_input_notes().count(), 1);
987    }
988
989    #[test]
990    fn external_consumption_retains_note_id() {
991        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
992        let note = processing_note(3, sender);
993        let id = note.id().expect("processing note has metadata");
994        let nullifier = note.nullifier().expect("processing note has metadata");
995
996        let mut tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
997        assert_eq!(tracker.consumed_input_note_ids().count(), 0);
998
999        // After external consumption the note must still be reported as consumed by its id.
1000        tracker
1001            .apply_note_consumption(
1002                &NoteConsumption {
1003                    nullifier,
1004                    block_num: BlockNumber::from(5u32),
1005                    external_consumer: None,
1006                },
1007                core::iter::empty::<&TransactionRecord>(),
1008            )
1009            .expect("external consumption should apply");
1010
1011        let consumed: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
1012        assert_eq!(
1013            consumed,
1014            vec![id],
1015            "an externally consumed note must still be reported by its id"
1016        );
1017    }
1018
1019    #[test]
1020    fn externally_consumed_note_id_survives_round_trip() {
1021        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1022        let note = processing_note(12, sender);
1023        let id = note.id().expect("processing note has metadata");
1024        let nullifier = note.nullifier().expect("processing note has metadata");
1025
1026        let mut tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
1027
1028        // After external consumption the note must still be reported as consumed by its id via
1029        // `input_notes_by_id`, both in memory and after a serialization round trip.
1030        tracker
1031            .apply_note_consumption(
1032                &NoteConsumption {
1033                    nullifier,
1034                    block_num: BlockNumber::from(5u32),
1035                    external_consumer: None,
1036                },
1037                core::iter::empty::<&TransactionRecord>(),
1038            )
1039            .expect("external consumption should apply");
1040
1041        // In memory the id is reported correctly.
1042        let before: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
1043        assert_eq!(before, vec![id]);
1044
1045        // The retained id must survive a serialize/deserialize round trip.
1046        let bytes = tracker.to_bytes();
1047        let restored = NoteUpdateTracker::read_from_bytes(&bytes).expect("round-trip should work");
1048        let after: alloc::vec::Vec<NoteId> = restored.consumed_input_note_ids().collect();
1049        assert_eq!(
1050            after,
1051            vec![id],
1052            "the retained id of an externally consumed note must survive serialization"
1053        );
1054    }
1055
1056    #[test]
1057    fn serialize_round_trip_preserves_lookup_indices() {
1058        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1059        let expected = expected_note(10);
1060        let processing = processing_note(11, sender);
1061        let processing_id = processing.id().expect("processing note has metadata");
1062        let processing_commitment = processing.details_commitment();
1063        let processing_nullifier = processing.nullifier().expect("processing note has metadata");
1064
1065        let tracker =
1066            NoteUpdateTracker::for_transaction_updates(vec![expected], vec![processing], vec![]);
1067
1068        let bytes = tracker.to_bytes();
1069        let restored = NoteUpdateTracker::read_from_bytes(&bytes).expect("round-trip should work");
1070
1071        // The records and lookup indices round-trip unchanged, including the metadata-less note
1072        // that is keyed only by its details commitment.
1073        assert_eq!(tracker, restored);
1074        assert_eq!(restored.updated_input_notes().count(), 2);
1075        assert_eq!(
1076            restored.input_notes_by_id.get(&processing_id).copied(),
1077            Some(processing_commitment)
1078        );
1079        assert_eq!(
1080            restored.input_notes_by_nullifier.get(&processing_nullifier).copied(),
1081            Some(processing_commitment)
1082        );
1083    }
1084}