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: Option<&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
421        let is_tracked_as_input_note = if let Some(input_note_record) =
422            self.get_input_note_by_id(note_id)
423        {
424            input_note_record.inclusion_proof_received(inclusion_proof.clone(), metadata)?;
425            input_note_record.block_header_received(block_header)?;
426            if let Some(attachments) = attachments {
427                input_note_record.attachments_received(attachments.clone());
428            }
429
430            true
431        } else if let Some(commitment) = self.expected_note_matching(note_id, &metadata) {
432            // A metadata-less note whose id, with the committed metadata, equals this note id:
433            // evolve it into a full record in place (its details commitment key is unchanged).
434            let nullifier = {
435                let update = self
436                    .input_notes
437                    .get_mut(&commitment)
438                    .expect("commitment was just matched against the tracked notes");
439                let record = &mut update.note;
440                record.inclusion_proof_received(inclusion_proof.clone(), metadata)?;
441                record.block_header_received(block_header)?;
442                if let Some(attachments) = attachments {
443                    record.attachments_received(attachments.clone());
444                }
445
446                // `InsertCommitted` so the now-known `note_id`/`nullifier` columns are persisted
447                // (a full-row insert), while still being reported as a committed tracked note
448                // rather than a newly-discovered one.
449                update.update_type = NoteUpdateType::InsertCommitted;
450                record.nullifier().expect("note with an id has metadata")
451            };
452
453            // The note now has metadata, so register it in the id and nullifier indices.
454            self.input_notes_by_nullifier.insert(nullifier, commitment);
455            self.input_notes_by_id.insert(note_id, commitment);
456
457            true
458        } else {
459            false
460        };
461
462        self.try_commit_output_note(note_id, inclusion_proof)?;
463
464        Ok(is_tracked_as_input_note)
465    }
466
467    /// Applies inclusion proofs from the transaction sync response to tracked output notes.
468    ///
469    /// This transitions output notes from `Expected` to `Committed` state using the
470    /// inclusion proofs returned by `SyncTransactions`.
471    pub(crate) fn apply_output_note_inclusion_proofs(
472        &mut self,
473        committed_notes: &[CommittedNote],
474    ) -> Result<(), ClientError> {
475        for committed_note in committed_notes {
476            self.try_commit_output_note(
477                *committed_note.note_id(),
478                committed_note.inclusion_proof().clone(),
479            )?;
480        }
481        Ok(())
482    }
483
484    /// Marks an erased note as consumed.
485    ///
486    /// This handles notes that were erased due to same-batch note erasure: the note was
487    /// created and consumed within the same batch, so it never appeared in the block body.
488    /// The `block_num` is the block in which the creating transaction was committed.
489    ///
490    /// The consumer account id is derived from the tracked input record's attachments (a
491    /// [`NetworkAccountTarget`], when present), not from the erased-note RPC stream, which delivers
492    /// only a [`NoteHeader`]. When no such attachment is present the consumer is left unknown.
493    pub(crate) fn mark_erased_note_as_consumed(
494        &mut self,
495        note_header: &NoteHeader,
496        block_num: BlockNumber,
497    ) -> Result<(), ClientError> {
498        let note_id = note_header.id();
499
500        if let Some(output_note) = self.get_output_note_by_id(note_id)
501            && output_note.is_inclusion_pending()
502            && let Some(nullifier) = output_note.nullifier()
503        {
504            output_note.nullifier_received(nullifier, block_num)?;
505        }
506
507        if let Some(commitment) = self.input_notes_by_id.get(&note_id).copied()
508            && let Some(input_note_update) = self.input_notes.get_mut(&commitment)
509            && !input_note_update.inner().is_consumed()
510            && let Some(nullifier) = input_note_update.inner().nullifier()
511        {
512            let consumer_account =
513                NetworkAccountTarget::try_from(input_note_update.inner().attachments())
514                    .ok()
515                    .map(|target| target.target_id());
516            input_note_update.inner_mut().consumed_externally(
517                nullifier,
518                block_num,
519                consumer_account,
520            )?;
521            input_note_update.inner_mut().set_consumed_tx_order(Some(0));
522        }
523
524        Ok(())
525    }
526
527    /// Returns whether the note is already tracked as an input or output record.
528    pub(crate) fn tracks_note(&self, note_id: NoteId) -> bool {
529        self.input_notes_by_id.contains_key(&note_id) || self.output_notes.contains_key(&note_id)
530    }
531
532    /// Records `note` as consumed by `consumer`, as a
533    /// [`ConsumedExternal`](crate::store::InputNoteState::ConsumedExternal) input-note record.
534    ///
535    /// No-op when the note is already tracked: recovery runs after transaction and nullifier
536    /// processing, so a tracked record's consumption has already been applied through
537    /// [`Self::apply_note_consumption`].
538    pub(crate) fn insert_consumed_public_note(
539        &mut self,
540        note: Note,
541        consumer: AccountId,
542        block_num: BlockNumber,
543    ) -> Result<(), ClientError> {
544        let note_id = note.id();
545        if self.tracks_note(note_id) {
546            return Ok(());
547        }
548        let nullifier = note.nullifier();
549        // The consuming transaction belongs to this sync, so its nullifier must have a position
550        // in the execution order; storing the record without one would break the ordering
551        // guarantees of `InputNoteReader`.
552        let order = self
553            .get_nullifier_order(nullifier)
554            .ok_or(ClientError::MissingConsumedNoteOrder(note_id))?;
555        let mut record = InputNoteRecord::from(note);
556        record.consumed_externally(nullifier, block_num, Some(consumer))?;
557        record.set_consumed_tx_order(Some(order));
558        self.insert_input_note(record, NoteUpdateType::Insert);
559        Ok(())
560    }
561
562    /// Builds a consumed input note record from a tracked output note and inserts it.
563    ///
564    /// Used when an output note is consumed externally and the client should also surface
565    /// it as a consumed input — for example, when the same client tracks both the sender
566    /// and the consumer of the note. No-op if the input is already tracked, the output is
567    /// not tracked, or the output cannot be converted to a [`Note`].
568    fn try_insert_consumed_input_from_output(
569        &mut self,
570        note_id: NoteId,
571        consumer: AccountId,
572        block_num: BlockNumber,
573        consumed_tx_order: Option<u32>,
574    ) -> Result<(), ClientError> {
575        if self.input_notes_by_id.contains_key(&note_id) {
576            return Ok(());
577        }
578        let Some(output_note) = self.output_notes.get(&note_id) else {
579            return Ok(());
580        };
581        let Ok(note) = Note::try_from(output_note.inner().clone()) else {
582            return Ok(());
583        };
584
585        let mut input_record = InputNoteRecord::from(note);
586        let nullifier =
587            input_record.nullifier().expect("record built from a full note has metadata");
588        input_record.consumed_externally(nullifier, block_num, Some(consumer))?;
589        input_record.set_consumed_tx_order(consumed_tx_order);
590        self.insert_input_note(input_record, NoteUpdateType::Insert);
591        Ok(())
592    }
593
594    /// If the note is tracked as an output note, transitions it to `Committed` with the
595    /// given inclusion proof. No-op if the note is not tracked.
596    fn try_commit_output_note(
597        &mut self,
598        note_id: NoteId,
599        inclusion_proof: NoteInclusionProof,
600    ) -> Result<(), ClientError> {
601        if let Some(output_note) = self.get_output_note_by_id(note_id) {
602            output_note.inclusion_proof_received(inclusion_proof)?;
603        }
604        Ok(())
605    }
606
607    /// Applies the necessary state transitions to the [`NoteUpdateTracker`] when a note is
608    /// nullified in a block.
609    ///
610    /// For input note records two possible scenarios are considered:
611    /// 1. The note was being processed by a local transaction that just got committed.
612    /// 2. The note was consumed by a transaction not submitted by this client. This includes
613    ///    consumption by untracked accounts as well as consumption by tracked accounts whose
614    ///    transactions were submitted by other client instances. If a local transaction was
615    ///    processing the note and it didn't get committed, the transaction should be discarded.
616    ///
617    /// If the note is tracked as an output but not as an input (e.g. the client tracks both the
618    /// sender and the consumer), a new input record is created from the output details so the
619    /// consumption surfaces through `InputNoteReader`.
620    pub(crate) fn apply_note_consumption<'a>(
621        &mut self,
622        consumption: &NoteConsumption,
623        mut committed_transactions: impl Iterator<Item = &'a TransactionRecord>,
624    ) -> Result<(), ClientError> {
625        let nullifier = consumption.nullifier;
626        let block_num = consumption.block_num;
627        let external_consumer = consumption.external_consumer;
628        let order = self.get_nullifier_order(nullifier);
629        let input_present = self.input_notes_by_nullifier.contains_key(&nullifier);
630
631        if let Some(input_note_update) = self.get_input_note_update_by_nullifier(nullifier) {
632            if let Some(consumer_transaction) = committed_transactions
633                .find(|t| input_note_update.inner().consumer_transaction_id() == Some(&t.id))
634            {
635                // The note was being processed by a local transaction that just got committed
636                if let TransactionStatus::Committed { block_number, .. } =
637                    consumer_transaction.status
638                {
639                    input_note_update
640                        .inner_mut()
641                        .transaction_committed(consumer_transaction.id, block_number)?;
642                }
643            } else {
644                // The note was consumed by a transaction not submitted by this client.
645                // If the consuming account is tracked, external_consumer will be Some.
646                input_note_update.inner_mut().consumed_externally(
647                    nullifier,
648                    block_num,
649                    external_consumer,
650                )?;
651            }
652            input_note_update.inner_mut().set_consumed_tx_order(order);
653        }
654
655        if let Some(output_note_record) = self.get_output_note_by_nullifier(nullifier) {
656            output_note_record.nullifier_received(nullifier, block_num)?;
657        }
658
659        if !input_present
660            && let Some(consumer) = external_consumer
661            && let Some(note_id) = self.output_notes_by_nullifier.get(&nullifier).copied()
662        {
663            self.try_insert_consumed_input_from_output(note_id, consumer, block_num, order)?;
664        }
665
666        Ok(())
667    }
668
669    // PRIVATE HELPERS
670    // --------------------------------------------------------------------------------------------
671
672    /// Returns the position of the given nullifier in the consuming transaction order, or `None`
673    /// if it is not present.
674    fn get_nullifier_order(&self, nullifier: Nullifier) -> Option<u32> {
675        self.nullifier_order.get(&nullifier).copied()
676    }
677
678    /// Returns a mutable reference to the input note record with the provided ID if it exists.
679    fn get_input_note_by_id(&mut self, note_id: NoteId) -> Option<&mut InputNoteRecord> {
680        let commitment = self.input_notes_by_id.get(&note_id).copied()?;
681        self.input_notes.get_mut(&commitment).map(InputNoteUpdate::inner_mut)
682    }
683
684    /// Returns the details commitment of a tracked metadata-less note whose id, combined with
685    /// `metadata`, equals `note_id`, i.e. the committed note is that imported note.
686    fn expected_note_matching(
687        &self,
688        note_id: NoteId,
689        metadata: &NoteMetadata,
690    ) -> Option<NoteDetailsCommitment> {
691        self.input_notes
692            .iter()
693            .filter(|(_, update)| update.inner().metadata().is_none())
694            .map(|(commitment, _)| *commitment)
695            .find(|commitment| NoteId::new(*commitment, metadata) == note_id)
696    }
697
698    /// Returns a mutable reference to the output note record with the provided ID if it exists.
699    fn get_output_note_by_id(&mut self, note_id: NoteId) -> Option<&mut OutputNoteRecord> {
700        self.output_notes.get_mut(&note_id).map(OutputNoteUpdate::inner_mut)
701    }
702
703    /// Returns a mutable reference to the input note update with the provided nullifier if it
704    /// exists.
705    fn get_input_note_update_by_nullifier(
706        &mut self,
707        nullifier: Nullifier,
708    ) -> Option<&mut InputNoteUpdate> {
709        let commitment = self.input_notes_by_nullifier.get(&nullifier).copied()?;
710        self.input_notes.get_mut(&commitment)
711    }
712
713    /// Returns a mutable reference to the output note record with the provided nullifier if it
714    /// exists.
715    fn get_output_note_by_nullifier(
716        &mut self,
717        nullifier: Nullifier,
718    ) -> Option<&mut OutputNoteRecord> {
719        let note_id = self.output_notes_by_nullifier.get(&nullifier).copied()?;
720        self.output_notes.get_mut(&note_id).map(OutputNoteUpdate::inner_mut)
721    }
722
723    /// Insert an input note update
724    fn insert_input_note(&mut self, note: InputNoteRecord, update_type: NoteUpdateType) {
725        let update = match update_type {
726            NoteUpdateType::None => InputNoteUpdate::new_none(note),
727            NoteUpdateType::Insert => InputNoteUpdate::new_insert(note),
728            NoteUpdateType::Update => InputNoteUpdate::new_update(note),
729            NoteUpdateType::InsertCommitted => InputNoteUpdate::new_insert_committed(note),
730        };
731
732        let commitment = update.inner().details_commitment();
733        if let Some(note_id) = update.inner().id() {
734            // A note with metadata supersedes any metadata-less record for the same commitment.
735            let nullifier = update.inner().nullifier().expect("note with an id has metadata");
736            self.input_notes_by_nullifier.insert(nullifier, commitment);
737            self.input_notes_by_id.insert(note_id, commitment);
738            self.input_notes.insert(commitment, update);
739        } else if self.input_notes.get(&commitment).is_none_or(|u| u.inner().id().is_none()) {
740            // No metadata yet means no `NoteId` and no computable nullifier. Track by details
741            // commitment until a committed note supplies the metadata to evolve it, but do not
742            // overwrite a metadata-bearing record that already supersedes it.
743            self.input_notes.insert(commitment, update);
744        }
745    }
746
747    /// Insert an output note update
748    fn insert_output_note(&mut self, note: OutputNoteRecord, update_type: NoteUpdateType) {
749        let note_id = note.id();
750        if let Some(nullifier) = note.nullifier() {
751            self.output_notes_by_nullifier.insert(nullifier, note_id);
752        }
753        let update = match update_type {
754            NoteUpdateType::None => OutputNoteUpdate::new_none(note),
755            NoteUpdateType::Update => OutputNoteUpdate::new_update(note),
756            // Output notes are never assigned `InsertCommitted`; treat it as an insert for
757            // exhaustiveness.
758            NoteUpdateType::Insert | NoteUpdateType::InsertCommitted => {
759                OutputNoteUpdate::new_insert(note)
760            },
761        };
762        self.output_notes.insert(note_id, update);
763    }
764}
765
766// SERIALIZATION
767// ================================================================================================
768
769impl Serializable for NoteUpdateType {
770    fn write_into<W: ByteWriter>(&self, target: &mut W) {
771        target.write_u8(*self as u8);
772    }
773}
774
775impl Deserializable for NoteUpdateType {
776    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
777        NoteUpdateType::try_from(source.read_u8()?).map_err(|val| {
778            DeserializationError::InvalidValue(format!("invalid note update type: {val}"))
779        })
780    }
781}
782
783impl Serializable for InputNoteUpdate {
784    fn write_into<W: ByteWriter>(&self, target: &mut W) {
785        self.note.write_into(target);
786        self.update_type.write_into(target);
787    }
788}
789
790impl Deserializable for InputNoteUpdate {
791    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
792        let note = InputNoteRecord::read_from(source)?;
793        let update_type = NoteUpdateType::read_from(source)?;
794        Ok(Self { note, update_type })
795    }
796}
797
798impl Serializable for OutputNoteUpdate {
799    fn write_into<W: ByteWriter>(&self, target: &mut W) {
800        self.note.write_into(target);
801        self.update_type.write_into(target);
802    }
803}
804
805impl Deserializable for OutputNoteUpdate {
806    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
807        let note = OutputNoteRecord::read_from(source)?;
808        let update_type = NoteUpdateType::read_from(source)?;
809        Ok(Self { note, update_type })
810    }
811}
812
813impl Serializable for NoteUpdateTracker {
814    fn write_into<W: ByteWriter>(&self, target: &mut W) {
815        // The lookup indices are serialized alongside the records so the tracker round-trips to an
816        // identical state.
817        self.input_notes.write_into(target);
818        self.output_notes.write_into(target);
819        self.nullifier_order.write_into(target);
820        self.input_notes_by_id.write_into(target);
821        self.input_notes_by_nullifier.write_into(target);
822    }
823}
824
825impl Deserializable for NoteUpdateTracker {
826    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
827        let input_notes = BTreeMap::<NoteDetailsCommitment, InputNoteUpdate>::read_from(source)?;
828        let output_notes = BTreeMap::<NoteId, OutputNoteUpdate>::read_from(source)?;
829        let nullifier_order = BTreeMap::<Nullifier, u32>::read_from(source)?;
830        let input_notes_by_id = BTreeMap::<NoteId, NoteDetailsCommitment>::read_from(source)?;
831        let input_notes_by_nullifier =
832            BTreeMap::<Nullifier, NoteDetailsCommitment>::read_from(source)?;
833
834        // Output notes always carry metadata, so this index can be safely derived from the records.
835        let output_notes_by_nullifier = output_notes
836            .iter()
837            .filter_map(|(note_id, update)| {
838                update.inner().nullifier().map(|nullifier| (nullifier, *note_id))
839            })
840            .collect();
841
842        Ok(Self {
843            input_notes,
844            output_notes,
845            input_notes_by_nullifier,
846            input_notes_by_id,
847            output_notes_by_nullifier,
848            nullifier_order,
849        })
850    }
851}
852
853// TESTS
854// ================================================================================================
855
856#[cfg(test)]
857mod tests {
858    use alloc::vec;
859
860    use miden_protocol::account::AccountId;
861    use miden_protocol::block::BlockNumber;
862    use miden_protocol::note::{
863        NoteAssets,
864        NoteAttachments,
865        NoteDetails,
866        NoteId,
867        NoteMetadata,
868        NoteRecipient,
869        NoteStorage,
870        NoteType,
871        PartialNoteMetadata,
872    };
873    use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER;
874    use miden_protocol::transaction::TransactionId;
875    use miden_protocol::utils::serde::{Deserializable, Serializable};
876    use miden_protocol::{Felt, Word, ZERO};
877    use miden_standards::note::StandardNote;
878
879    use super::{NoteConsumption, NoteUpdateTracker};
880    use crate::store::InputNoteRecord;
881    use crate::store::input_note_states::{
882        ConsumedExternalNoteState,
883        ConsumedUnauthenticatedLocalNoteState,
884        ExpectedNoteState,
885        NoteSubmissionData,
886        ProcessingUnauthenticatedNoteState,
887    };
888    use crate::transaction::TransactionRecord;
889
890    // HELPERS
891    // --------------------------------------------------------------------------------------------
892
893    fn note_details(seed: u64) -> NoteDetails {
894        let serial_number: Word = [Felt::new_unchecked(seed), ZERO, ZERO, ZERO].into();
895        let recipient = NoteRecipient::new(
896            serial_number,
897            StandardNote::SWAP.script(),
898            NoteStorage::new(vec![]).unwrap(),
899        );
900        NoteDetails::new(NoteAssets::new(vec![]).unwrap(), recipient)
901    }
902
903    fn note_metadata(sender: AccountId) -> NoteMetadata {
904        NoteMetadata::new(
905            PartialNoteMetadata::new(sender, NoteType::Public),
906            &NoteAttachments::empty(),
907        )
908    }
909
910    /// A metadata-less expected note. It has no `NoteId` and is tracked by its details commitment.
911    fn expected_note(seed: u64) -> InputNoteRecord {
912        let state = ExpectedNoteState {
913            metadata: None,
914            after_block_num: BlockNumber::from(0u32),
915            tag: None,
916        };
917        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
918    }
919
920    /// A metadata-bearing, not-yet-consumed note that can be externally consumed.
921    fn processing_note(seed: u64, sender: AccountId) -> InputNoteRecord {
922        let state = ProcessingUnauthenticatedNoteState {
923            metadata: note_metadata(sender),
924            after_block_num: BlockNumber::from(0u32),
925            submission_data: NoteSubmissionData {
926                submitted_at: Some(0),
927                consumer_account: sender,
928                consumer_transaction: TransactionId::from_raw(Word::default()),
929            },
930        };
931        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
932    }
933
934    /// A metadata-bearing note that is already consumed by a local transaction.
935    fn consumed_local_note(seed: u64, sender: AccountId) -> InputNoteRecord {
936        let state = ConsumedUnauthenticatedLocalNoteState {
937            metadata: note_metadata(sender),
938            nullifier_block_height: BlockNumber::from(1u32),
939            submission_data: NoteSubmissionData {
940                submitted_at: Some(0),
941                consumer_account: sender,
942                consumer_transaction: TransactionId::from_raw(Word::default()),
943            },
944            consumed_tx_order: Some(0),
945        };
946        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
947    }
948
949    /// A metadata-less note that is already externally consumed. It never carried a `NoteId`.
950    fn consumed_external_note(seed: u64) -> InputNoteRecord {
951        let state = ConsumedExternalNoteState {
952            nullifier_block_height: BlockNumber::from(1u32),
953            consumer_account: None,
954            consumed_tx_order: None,
955            metadata: None,
956        };
957        InputNoteRecord::new(note_details(seed), NoteAttachments::empty(), Some(0), state.into())
958    }
959
960    // TESTS
961    // --------------------------------------------------------------------------------------------
962
963    #[test]
964    fn consumed_input_note_ids_reports_metadata_bearing_consumed_note() {
965        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
966        let note = consumed_local_note(1, sender);
967        let id = note.id().expect("consumed-local note has metadata");
968
969        let tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
970
971        let consumed: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
972        assert_eq!(consumed, vec![id]);
973    }
974
975    #[test]
976    fn consumed_input_note_ids_omits_note_that_never_had_an_id() {
977        // A note inserted already in the externally-consumed (metadata-less) state never had an id
978        // in the tracker, so it is persisted but is not reported by id.
979        let note = consumed_external_note(2);
980        assert!(note.id().is_none());
981
982        let tracker = NoteUpdateTracker::for_transaction_updates(vec![note], vec![], vec![]);
983
984        assert_eq!(tracker.consumed_input_note_ids().count(), 0);
985        assert_eq!(tracker.updated_input_notes().count(), 1);
986    }
987
988    #[test]
989    fn external_consumption_retains_note_id() {
990        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
991        let note = processing_note(3, sender);
992        let id = note.id().expect("processing note has metadata");
993        let nullifier = note.nullifier().expect("processing note has metadata");
994
995        let mut tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
996        assert_eq!(tracker.consumed_input_note_ids().count(), 0);
997
998        // After external consumption the note must still be reported as consumed by its id.
999        tracker
1000            .apply_note_consumption(
1001                &NoteConsumption {
1002                    nullifier,
1003                    block_num: BlockNumber::from(5u32),
1004                    external_consumer: None,
1005                },
1006                core::iter::empty::<&TransactionRecord>(),
1007            )
1008            .expect("external consumption should apply");
1009
1010        let consumed: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
1011        assert_eq!(
1012            consumed,
1013            vec![id],
1014            "an externally consumed note must still be reported by its id"
1015        );
1016    }
1017
1018    #[test]
1019    fn externally_consumed_note_id_survives_round_trip() {
1020        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1021        let note = processing_note(12, sender);
1022        let id = note.id().expect("processing note has metadata");
1023        let nullifier = note.nullifier().expect("processing note has metadata");
1024
1025        let mut tracker = NoteUpdateTracker::for_transaction_updates(vec![], vec![note], vec![]);
1026
1027        // After external consumption the note must still be reported as consumed by its id via
1028        // `input_notes_by_id`, both in memory and after a serialization round trip.
1029        tracker
1030            .apply_note_consumption(
1031                &NoteConsumption {
1032                    nullifier,
1033                    block_num: BlockNumber::from(5u32),
1034                    external_consumer: None,
1035                },
1036                core::iter::empty::<&TransactionRecord>(),
1037            )
1038            .expect("external consumption should apply");
1039
1040        // In memory the id is reported correctly.
1041        let before: alloc::vec::Vec<NoteId> = tracker.consumed_input_note_ids().collect();
1042        assert_eq!(before, vec![id]);
1043
1044        // The retained id must survive a serialize/deserialize round trip.
1045        let bytes = tracker.to_bytes();
1046        let restored = NoteUpdateTracker::read_from_bytes(&bytes).expect("round-trip should work");
1047        let after: alloc::vec::Vec<NoteId> = restored.consumed_input_note_ids().collect();
1048        assert_eq!(
1049            after,
1050            vec![id],
1051            "the retained id of an externally consumed note must survive serialization"
1052        );
1053    }
1054
1055    #[test]
1056    fn serialize_round_trip_preserves_lookup_indices() {
1057        let sender: AccountId = ACCOUNT_ID_SENDER.try_into().unwrap();
1058        let expected = expected_note(10);
1059        let processing = processing_note(11, sender);
1060        let processing_id = processing.id().expect("processing note has metadata");
1061        let processing_commitment = processing.details_commitment();
1062        let processing_nullifier = processing.nullifier().expect("processing note has metadata");
1063
1064        let tracker =
1065            NoteUpdateTracker::for_transaction_updates(vec![expected], vec![processing], vec![]);
1066
1067        let bytes = tracker.to_bytes();
1068        let restored = NoteUpdateTracker::read_from_bytes(&bytes).expect("round-trip should work");
1069
1070        // The records and lookup indices round-trip unchanged, including the metadata-less note
1071        // that is keyed only by its details commitment.
1072        assert_eq!(tracker, restored);
1073        assert_eq!(restored.updated_input_notes().count(), 2);
1074        assert_eq!(
1075            restored.input_notes_by_id.get(&processing_id).copied(),
1076            Some(processing_commitment)
1077        );
1078        assert_eq!(
1079            restored.input_notes_by_nullifier.get(&processing_nullifier).copied(),
1080            Some(processing_commitment)
1081        );
1082    }
1083}