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