Skip to main content

miden_client/note/
import.rs

1//! Provides note importing methods.
2//!
3//! This module allows users to import notes into the client's store. Depending on the variant of
4//! [`NoteFile`] provided, the client will either fetch note details from the network or create a
5//! new note record from supplied data. If a note already exists in the store, it is updated with
6//! the new information. Additionally, the appropriate note tag is tracked based on the imported
7//! note's metadata.
8//!
9//! For more specific information on how the process is performed, refer to the docs for
10//! [`Client::import_note()`].
11use alloc::collections::{BTreeMap, BTreeSet};
12use alloc::string::ToString;
13use alloc::vec::Vec;
14
15use miden_protocol::block::BlockNumber;
16use miden_protocol::note::{
17    Note,
18    NoteAttachments,
19    NoteDetails,
20    NoteDetailsCommitment,
21    NoteId,
22    NoteInclusionProof,
23    NoteTag,
24};
25use miden_standards::note::NoteFile;
26use miden_tx::auth::TransactionAuthenticator;
27
28use crate::rpc::domain::note::{FetchedNote, ResolvedSyncNotesBlock};
29use crate::rpc::{NoteContentFetch, RpcError};
30use crate::store::input_note_states::ExpectedNoteState;
31use crate::store::{InputNoteRecord, InputNoteState, NoteFilter};
32use crate::sync::NoteTagRecord;
33use crate::{Client, ClientError};
34
35/// Note importing methods.
36impl<AUTH> Client<AUTH>
37where
38    AUTH: TransactionAuthenticator + Sync + 'static,
39{
40    // INPUT NOTE CREATION
41    // --------------------------------------------------------------------------------------------
42
43    /// Imports a batch of new input notes into the client's store. The information stored depends
44    /// on the type of note files provided. If the notes existed previously, it will be updated with
45    /// the new information. The tags specified by the `NoteFile`s will start being tracked. Returns
46    /// the details commitments of notes that were successfully imported or updated. The details
47    /// commitment is used (rather than the note ID) because notes imported without metadata — e.g.
48    /// from [`NoteFile::ExpectedNote`] in an `Expected` state — have no note ID yet, whereas the
49    /// details commitment is always available.
50    ///
51    /// - If the note files are [`NoteFile::NoteId`], the notes are fetched from the node and stored
52    ///   in the client's store. If the note is private or doesn't exist, an error is returned.
53    /// - If the note files are [`NoteFile::ExpectedNote`], new notes are created with the provided
54    ///   details and tags.
55    /// - If the note files are [`NoteFile::Committed`], the notes are stored with the provided
56    ///   inclusion proof and metadata. The block header data is only fetched from the node if the
57    ///   note is committed in the past relative to the client.
58    ///
59    /// # Errors
60    ///
61    /// - If an attempt is made to overwrite a note that is currently processing.
62    ///
63    /// Note: This operation is atomic. If any note file is invalid or any existing note is in the
64    /// processing state, the entire operation fails and no notes are imported.
65    // TODO: Validations need to be added to the import workflows. For example, when adding a block
66    // header for a note we need to check the chain root validity, etc.
67    pub async fn import_notes(
68        &mut self,
69        note_files: &[NoteFile],
70    ) -> Result<Vec<NoteDetailsCommitment>, ClientError> {
71        self.ensure_genesis_in_place().await?;
72
73        // Deduplicate the incoming files, keeping note IDs and details commitments in separate
74        // collections. `NoteFile::NoteId` entries are keyed by their note ID; detail-carrying
75        // entries (`ExpectedNote`/`Committed`) are keyed by their details commitment, since they
76        // may have no note ID of their own.
77        let mut ids = BTreeSet::new();
78        let mut files_by_commitment = BTreeMap::new();
79        for note_file in note_files {
80            match note_file {
81                NoteFile::NoteId(id) => {
82                    ids.insert(*id);
83                },
84                NoteFile::ExpectedNote { details, .. } => {
85                    files_by_commitment.insert(details.commitment(), note_file.clone());
86                },
87                NoteFile::Committed { note, .. } => {
88                    files_by_commitment.insert(note.details_commitment(), note_file.clone());
89                },
90            }
91        }
92
93        // Resolve previously stored versions: by id for `NoteFile::NoteId`, by details commitment
94        // otherwise (which also matches metadata-less records, whose `note_id` is NULL).
95        let previous_by_id: BTreeMap<NoteId, InputNoteRecord> = self
96            .get_input_notes(NoteFilter::List(ids.iter().copied().collect()))
97            .await?
98            .into_iter()
99            .filter_map(|note| note.id().map(|id| (id, note)))
100            .collect();
101        let previous_by_commitment: BTreeMap<NoteDetailsCommitment, InputNoteRecord> = self
102            .get_input_notes(NoteFilter::DetailsCommitments(
103                files_by_commitment.keys().copied().collect(),
104            ))
105            .await?
106            .into_iter()
107            .map(|note| (note.details_commitment(), note))
108            .collect();
109
110        // Pair each deduplicated file with its previously stored version (if any), bucketed by
111        // variant. A note that is currently being processed can't be overwritten.
112        let mut requests_by_id = BTreeMap::new();
113        let mut requests_by_details = vec![];
114        let mut requests_by_proof = vec![];
115
116        for id in ids {
117            let previous_note = previous_by_id.get(&id).cloned();
118            ensure_not_processing(previous_note.as_ref())?;
119            requests_by_id.insert(id, previous_note);
120        }
121
122        for (commitment, note_file) in files_by_commitment {
123            let previous_note = previous_by_commitment.get(&commitment).cloned();
124            ensure_not_processing(previous_note.as_ref())?;
125            match note_file {
126                NoteFile::ExpectedNote { details, sync_hint } => {
127                    requests_by_details.push((
128                        previous_note,
129                        details,
130                        sync_hint.after_block_num(),
131                        sync_hint.tag(),
132                    ));
133                },
134                NoteFile::Committed { note, proof } => {
135                    requests_by_proof.push((previous_note, note, proof));
136                },
137                NoteFile::NoteId(_) => {
138                    unreachable!("files_by_commitment only holds detail-carrying note files")
139                },
140            }
141        }
142
143        let mut imported_notes = vec![];
144        if !requests_by_id.is_empty() {
145            let notes_by_id = self.import_note_records_by_id(requests_by_id).await?;
146            imported_notes.extend(notes_by_id);
147        }
148
149        if !requests_by_details.is_empty() {
150            let notes_by_details = self.import_note_records_by_details(requests_by_details).await?;
151            imported_notes.extend(notes_by_details);
152        }
153
154        if !requests_by_proof.is_empty() {
155            let notes_by_proof = self.import_note_records_by_proof(requests_by_proof).await?;
156            imported_notes.extend(notes_by_proof);
157        }
158
159        let mut imported_commitments = Vec::with_capacity(imported_notes.len());
160        for note in imported_notes {
161            let details_commitment = note.details_commitment();
162            if let InputNoteState::Expected(ExpectedNoteState { tag: Some(tag), .. }) = note.state()
163            {
164                self.store
165                    .add_note_tag(NoteTagRecord::with_note_source(*tag, details_commitment))
166                    .await?;
167            }
168            self.store.upsert_input_notes(&[note]).await?;
169            imported_commitments.push(details_commitment);
170        }
171
172        Ok(imported_commitments)
173    }
174
175    // HELPERS
176    // ================================================================================================
177
178    /// Builds note records from the note IDs. If a note with the same ID was already stored it is
179    /// passed via `previous_note` so it can be updated. The note information is fetched from the
180    /// node and stored in the client's store.
181    ///
182    /// Only records that changed as a result of the import are returned.
183    ///
184    /// # Errors:
185    /// - If a note doesn't exist on the node.
186    /// - If a note exists but is private.
187    async fn import_note_records_by_id(
188        &mut self,
189        notes: BTreeMap<NoteId, Option<InputNoteRecord>>,
190    ) -> Result<Vec<InputNoteRecord>, ClientError> {
191        let note_ids = notes.keys().copied().collect::<Vec<_>>();
192
193        let fetched_notes =
194            self.rpc_api.get_notes_by_id(&note_ids).await.map_err(|err| match err {
195                RpcError::NoteNotFound(note_id) => ClientError::NoteNotFoundOnChain(note_id),
196                err => ClientError::RpcError(err),
197            })?;
198
199        if fetched_notes.is_empty() {
200            return Err(ClientError::NoteImportError("No notes fetched from node".to_string()));
201        }
202
203        let mut note_records = Vec::new();
204        let mut notes_to_request = vec![];
205        for fetched_note in fetched_notes {
206            let note_id = fetched_note.id();
207            let inclusion_proof = fetched_note.inclusion_proof().clone();
208
209            let previous_note =
210                notes.get(&note_id).cloned().ok_or(ClientError::NoteImportError(format!(
211                    "Failed to retrieve note with id {note_id} from node"
212                )))?;
213            if let Some(mut previous_note) = previous_note {
214                if previous_note
215                    .inclusion_proof_received(inclusion_proof, *fetched_note.metadata())?
216                {
217                    self.store.remove_note_tag((&previous_note).try_into()?).await?;
218
219                    note_records.push(previous_note);
220                }
221            } else {
222                let fetched_note = match fetched_note {
223                    FetchedNote::Public(note, _) => note,
224                    FetchedNote::Private(..) => {
225                        return Err(ClientError::NoteImportError(
226                            "Incomplete imported note is private".to_string(),
227                        ));
228                    },
229                };
230
231                let note_request = (previous_note, fetched_note, inclusion_proof);
232                notes_to_request.push(note_request);
233            }
234        }
235
236        if !notes_to_request.is_empty() {
237            let note_records_by_proof = self.import_note_records_by_proof(notes_to_request).await?;
238            note_records.extend(note_records_by_proof);
239        }
240        Ok(note_records)
241    }
242
243    /// Builds a note record list from notes and inclusion proofs. If a note with the same ID was
244    /// already stored it is passed via `previous_note` so it can be updated. The note's nullifier
245    /// is used to determine if the note has been consumed in the node and gives it the correct
246    /// state.
247    ///
248    /// If the note isn't consumed and it was committed in the past relative to the client, then the
249    /// MMR for the relevant block is fetched from the node and stored.
250    ///
251    /// Only records that changed as a result of the import are returned.
252    pub(crate) async fn import_note_records_by_proof(
253        &mut self,
254        requested_notes: Vec<(Option<InputNoteRecord>, Note, NoteInclusionProof)>,
255    ) -> Result<Vec<InputNoteRecord>, ClientError> {
256        // TODO: iterating twice over requested notes
257        let mut note_records = vec![];
258
259        let mut nullifier_requests = BTreeSet::new();
260        let mut lowest_block_height: BlockNumber = u32::MAX.into();
261        for (previous_note, note, inclusion_proof) in &requested_notes {
262            let nullifier = match previous_note {
263                Some(previous_note) => previous_note.nullifier(),
264                None => Some(note.nullifier()),
265            };
266            if let Some(nullifier) = nullifier {
267                nullifier_requests.insert(nullifier);
268            }
269            if inclusion_proof.location().block_num() < lowest_block_height {
270                lowest_block_height = inclusion_proof.location().block_num();
271            }
272        }
273
274        let nullifier_commit_heights = self
275            .rpc_api
276            .get_nullifier_commit_heights(nullifier_requests, lowest_block_height)
277            .await?;
278        let mut partial_mmr = self.get_current_partial_mmr().await?;
279
280        for (previous_note, note, inclusion_proof) in requested_notes {
281            let metadata = *note.metadata();
282            let attachments = note.attachments().clone();
283            let mut note_record = previous_note.unwrap_or(InputNoteRecord::new(
284                note.into(),
285                attachments,
286                self.store.get_current_timestamp(),
287                ExpectedNoteState {
288                    metadata: Some(metadata),
289                    after_block_num: inclusion_proof.location().block_num(),
290                    tag: Some(metadata.tag()),
291                }
292                .into(),
293            ));
294
295            if let Some(nullifier) = note_record.nullifier()
296                && let Some(Some(block_height)) = nullifier_commit_heights.get(&nullifier)
297            {
298                if note_record.consumed_externally(nullifier, *block_height, None)? {
299                    note_records.push(note_record);
300                }
301            } else {
302                let block_height = inclusion_proof.location().block_num();
303                let current_block_num = self.get_sync_height().await?;
304
305                let tag = metadata.tag();
306                let mut note_changed =
307                    note_record.inclusion_proof_received(inclusion_proof, metadata)?;
308
309                if block_height <= current_block_num {
310                    // A note committed in the past needs its block header fetched and authenticated
311                    // to verify the inclusion proof.
312                    let block_header = self
313                        .get_and_store_authenticated_block(block_height, &mut partial_mmr)
314                        .await?;
315                    note_changed |= note_record.block_header_received(&block_header)?;
316                } else {
317                    // If the note is in the future we import it as unverified. We add the note tag
318                    // so that the note is verified naturally in the next sync.
319                    self.store
320                        .add_note_tag(NoteTagRecord::with_note_source(
321                            tag,
322                            note_record.details_commitment(),
323                        ))
324                        .await?;
325                }
326
327                if note_changed {
328                    note_records.push(note_record);
329                }
330            }
331        }
332        self.cache_partial_mmr(partial_mmr).await?;
333
334        Ok(note_records)
335    }
336
337    /// Builds a note record list from note details. If a note with the same ID was already stored
338    /// it is passed via `previous_note` so it can be updated.
339    ///
340    /// Only records that need to be stored are returned: notes the node has not reported as
341    /// committed keep (or get) their expected record, while committed notes are returned only if
342    /// the new information changed them.
343    async fn import_note_records_by_details(
344        &mut self,
345        requested_notes: Vec<NoteImportByDetailsRequest>,
346    ) -> Result<Vec<InputNoteRecord>, ClientError> {
347        let mut lowest_request_block: BlockNumber = u32::MAX.into();
348        let mut note_requests = vec![];
349        for (_, details, after_block_num, tag) in &requested_notes {
350            note_requests.push((details.commitment(), *tag));
351            lowest_request_block = lowest_request_block.min(*after_block_num);
352        }
353        let blocks = self.sync_expected_notes(lowest_request_block, &note_requests).await?;
354
355        // The blocks arrive with the notes, so a committed note needs no further block lookup. They
356        // are stored first, so a record is never persisted as committed before the header that
357        // proves its inclusion is tracked and stored.
358        let mut partial_mmr = self.get_current_partial_mmr().await?;
359        self.insert_note_blocks(&blocks, &mut partial_mmr).await?;
360        self.cache_partial_mmr(partial_mmr).await?;
361
362        let mut note_records = vec![];
363        for (previous_note, details, after_block_num, tag) in requested_notes {
364            let mut note_record = previous_note.unwrap_or_else(|| {
365                InputNoteRecord::new(
366                    details,
367                    NoteAttachments::empty(),
368                    self.store.get_current_timestamp(),
369                    ExpectedNoteState {
370                        metadata: None,
371                        after_block_num,
372                        tag: Some(tag),
373                    }
374                    .into(),
375                )
376            });
377
378            // Notes the node has not reported as committed keep their expected record untouched.
379            let commitment = note_record.details_commitment();
380            let Some((sync_note, block_header)) = blocks.iter().find_map(|block| {
381                let sync_note = block.notes.values().find(|sync_note| {
382                    NoteId::new(commitment, &sync_note.metadata) == sync_note.note_id
383                })?;
384                Some((sync_note, &block.block_header))
385            }) else {
386                note_records.push(note_record);
387                continue;
388            };
389
390            // A note that carries no attachments has nothing to apply to the record.
391            let attachments =
392                (!sync_note.attachments.is_empty()).then(|| sync_note.attachments.clone());
393
394            let metadata = sync_note.metadata;
395            let mut note_changed = note_record
396                .inclusion_proof_received(sync_note.inclusion_proof.clone(), metadata)?;
397
398            if let Some(attachments) = attachments {
399                note_changed |= note_record.attachments_received(attachments);
400            }
401
402            // `block_header_received` transitions the record's state, so it must always run.
403            note_changed |= note_record.block_header_received(block_header)?;
404
405            // Once committed, the note no longer needs its expected-note tag.
406            if note_changed {
407                self.store
408                    .remove_note_tag(NoteTagRecord::with_note_source(
409                        metadata.tag(),
410                        note_record.details_commitment(),
411                    ))
412                    .await?;
413            }
414
415            if note_changed {
416                note_records.push(note_record);
417            }
418        }
419
420        self.mark_externally_consumed(&mut note_records).await?;
421
422        Ok(note_records)
423    }
424
425    /// Marks a record whose nullifier is already on chain as consumed, when the nullifier commit
426    /// height is at or below the client's sync height.
427    ///
428    /// Only a note the node reported as committed carries the metadata a nullifier is derived from,
429    /// so the rest are skipped.
430    async fn mark_externally_consumed(
431        &self,
432        note_records: &mut [InputNoteRecord],
433    ) -> Result<(), ClientError> {
434        let mut nullifiers = BTreeSet::new();
435        let mut lowest_commitment_block: BlockNumber = u32::MAX.into();
436        for note_record in note_records.iter() {
437            let (Some(nullifier), Some(inclusion_proof)) =
438                (note_record.nullifier(), note_record.inclusion_proof())
439            else {
440                continue;
441            };
442            nullifiers.insert(nullifier);
443            lowest_commitment_block =
444                lowest_commitment_block.min(inclusion_proof.location().block_num());
445        }
446
447        if nullifiers.is_empty() {
448            return Ok(());
449        }
450
451        let spent_heights = self
452            .rpc_api
453            .get_nullifier_commit_heights(nullifiers, lowest_commitment_block)
454            .await?;
455
456        let sync_height = self.get_sync_height().await?;
457        for note_record in note_records.iter_mut() {
458            let Some(nullifier) = note_record.nullifier() else {
459                continue;
460            };
461            if let Some(Some(spent_at)) = spent_heights.get(&nullifier)
462                && *spent_at <= sync_height
463            {
464                note_record.consumed_externally(nullifier, *spent_at, None)?;
465            }
466        }
467
468        Ok(())
469    }
470
471    /// Fetches every block between `request_block_num` and the client's sync height that holds a
472    /// note under one of `sync_tags`.
473    ///
474    /// Each block carries its header and the MMR path proving its inclusion at the sync height,
475    /// which is the forest the client's partial MMR is at, so a note found here needs no further
476    /// block lookup. Deciding which of the returned notes answer a request is the caller's.
477    async fn sync_expected_notes(
478        &self,
479        request_block_num: BlockNumber,
480        // Expected notes' details commitments with their tags.
481        expected_notes: &[(NoteDetailsCommitment, NoteTag)],
482    ) -> Result<Vec<ResolvedSyncNotesBlock>, ClientError> {
483        let sync_tags: BTreeSet<NoteTag> = expected_notes.iter().map(|(_, tag)| *tag).collect();
484        let current_block_num = self.get_sync_height().await?;
485
486        // Notes expected only after a block we have not reached can't be committed within our
487        // synced view yet: skip the lookup and let them stay expected until a future sync.
488        if request_block_num > current_block_num {
489            return Ok(Vec::new());
490        }
491
492        let blocks = self
493            .rpc_api
494            .sync_notes_with_content(
495                request_block_num,
496                current_block_num,
497                &sync_tags,
498                NoteContentFetch::AttachmentsOnly,
499            )
500            .await
501            .map_err(ClientError::RpcError)?;
502
503        let mut matched_blocks = vec![];
504        for block in blocks {
505            let mut block_matches = false;
506            if block.block_header.block_num() > current_block_num {
507                break;
508            }
509
510            for sync_note in block.notes.values() {
511                // The note carries its own commit height in its inclusion proof, which is a
512                // separate field from the block header checked above. Authenticating the note later
513                // looks that height up in the partial MMR, so a height beyond our synced view has
514                // to be dropped here rather than trusted.
515                if sync_note.block_num() > current_block_num {
516                    continue;
517                }
518
519                let Some((..)) = expected_notes.iter().find(|(commitment, _)| {
520                    NoteId::new(*commitment, &sync_note.metadata) == sync_note.note_id
521                }) else {
522                    continue;
523                };
524
525                block_matches = true;
526            }
527
528            if block_matches {
529                matched_blocks.push(block);
530            }
531        }
532
533        Ok(matched_blocks)
534    }
535}
536
537/// A note to import by details: the stored record it updates when there is one, its details, the
538/// block from which to look for its commitment, and the tag to track it under.
539pub(crate) type NoteImportByDetailsRequest =
540    (Option<InputNoteRecord>, NoteDetails, BlockNumber, NoteTag);
541
542// HELPERS
543// ================================================================================================
544
545/// Returns an error if the already-stored note is currently being processed by a local transaction,
546/// since an in-flight note can't be overwritten by an import.
547pub fn ensure_not_processing(previous_note: Option<&InputNoteRecord>) -> Result<(), ClientError> {
548    if let Some(note) = previous_note
549        && note.is_processing()
550    {
551        return Err(ClientError::NoteImportError(format!(
552            "Can't overwrite note with details commitment {} as it's currently being processed",
553            note.details_commitment().to_hex(),
554        )));
555    }
556    Ok(())
557}