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