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.
4//! Depending on the variant of [`NoteFile`] provided, the client will either fetch note details
5//! from the network or create a new note record from supplied data. If a note already exists in
6//! the store, it is updated with the new information. Additionally, the appropriate note tag
7//! is tracked based on the imported 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    NoteMetadata,
24    NoteTag,
25};
26use miden_standards::note::NoteFile;
27use miden_tx::auth::TransactionAuthenticator;
28
29use crate::rpc::RpcError;
30use crate::rpc::domain::note::FetchedNote;
31use crate::store::input_note_states::ExpectedNoteState;
32use crate::store::{InputNoteRecord, InputNoteState, NoteFilter};
33use crate::sync::NoteTagRecord;
34use crate::{Client, ClientError};
35
36/// Note importing methods.
37impl<AUTH> Client<AUTH>
38where
39    AUTH: TransactionAuthenticator + Sync + 'static,
40{
41    // INPUT NOTE CREATION
42    // --------------------------------------------------------------------------------------------
43
44    /// Imports a batch of new input notes into the client's store. The information stored depends
45    /// on the type of note files provided. If the notes existed previously, it will be updated
46    /// with the new information. The tags specified by the `NoteFile`s will start being
47    /// tracked. Returns the details commitments of notes that were successfully imported or
48    /// updated. The details commitment is used (rather than the note ID) because notes imported
49    /// without metadata — e.g. from [`NoteFile::ExpectedNote`] in an `Expected` state — have no
50    /// note ID yet, whereas the details commitment is always available.
51    ///
52    /// - If the note files are [`NoteFile::NoteId`], the notes are fetched from the node and stored
53    ///   in the client's store. If the note is private or doesn't exist, an error is returned.
54    /// - If the note files are [`NoteFile::ExpectedNote`], new notes are created with the provided
55    ///   details and tags.
56    /// - If the note files are [`NoteFile::Committed`], the notes are stored with the provided
57    ///   inclusion proof and metadata. The block header data is only fetched from the node if the
58    ///   note is committed in the past relative to the client.
59    ///
60    /// # Errors
61    ///
62    /// - If an attempt is made to overwrite a note that is currently processing.
63    ///
64    /// Note: This operation is atomic. If any note file is invalid or any existing note is in the
65    /// processing state, the entire operation fails and no notes are imported.
66    pub async fn import_notes(
67        &mut self,
68        note_files: &[NoteFile],
69    ) -> Result<Vec<NoteDetailsCommitment>, ClientError> {
70        // Deduplicate the incoming files, keeping note IDs and details commitments in separate
71        // collections. `NoteFile::NoteId` entries are keyed by their note ID; detail-carrying
72        // entries (`ExpectedNote`/`Committed`) are keyed by their details commitment, since
73        // they may have no note ID of their own.
74        let mut ids = BTreeSet::new();
75        let mut files_by_commitment = BTreeMap::new();
76        for note_file in note_files {
77            match note_file {
78                NoteFile::NoteId(id) => {
79                    ids.insert(*id);
80                },
81                NoteFile::ExpectedNote { details, .. } => {
82                    files_by_commitment.insert(details.commitment(), note_file.clone());
83                },
84                NoteFile::Committed { note, .. } => {
85                    files_by_commitment.insert(note.details_commitment(), note_file.clone());
86                },
87            }
88        }
89
90        // Resolve previously stored versions: by id for `NoteFile::NoteId`, by details commitment
91        // otherwise (which also matches metadata-less records, whose `note_id` is NULL).
92        let previous_by_id: BTreeMap<NoteId, InputNoteRecord> = self
93            .get_input_notes(NoteFilter::List(ids.iter().copied().collect()))
94            .await?
95            .into_iter()
96            .filter_map(|note| note.id().map(|id| (id, note)))
97            .collect();
98        let previous_by_commitment: BTreeMap<NoteDetailsCommitment, InputNoteRecord> = self
99            .get_input_notes(NoteFilter::DetailsCommitments(
100                files_by_commitment.keys().copied().collect(),
101            ))
102            .await?
103            .into_iter()
104            .map(|note| (note.details_commitment(), note))
105            .collect();
106
107        // Pair each deduplicated file with its previously stored version (if any), bucketed by
108        // variant. A note that is currently being processed can't be overwritten.
109        let mut requests_by_id = BTreeMap::new();
110        let mut requests_by_details = vec![];
111        let mut requests_by_proof = vec![];
112
113        for id in ids {
114            let previous_note = previous_by_id.get(&id).cloned();
115            ensure_not_processing(previous_note.as_ref())?;
116            requests_by_id.insert(id, previous_note);
117        }
118
119        for (commitment, note_file) in files_by_commitment {
120            let previous_note = previous_by_commitment.get(&commitment).cloned();
121            ensure_not_processing(previous_note.as_ref())?;
122            match note_file {
123                NoteFile::ExpectedNote { details, sync_hint } => {
124                    requests_by_details.push((
125                        previous_note,
126                        details,
127                        sync_hint.after_block_num(),
128                        Some(sync_hint.tag()),
129                    ));
130                },
131                NoteFile::Committed { note, proof } => {
132                    requests_by_proof.push((previous_note, note, proof));
133                },
134                NoteFile::NoteId(_) => {
135                    unreachable!("files_by_commitment only holds detail-carrying note files")
136                },
137            }
138        }
139
140        let mut imported_notes = vec![];
141        if !requests_by_id.is_empty() {
142            let notes_by_id = self.import_note_records_by_id(requests_by_id).await?;
143            imported_notes.extend(notes_by_id);
144        }
145
146        if !requests_by_details.is_empty() {
147            let notes_by_details = self.import_note_records_by_details(requests_by_details).await?;
148            imported_notes.extend(notes_by_details);
149        }
150
151        if !requests_by_proof.is_empty() {
152            let notes_by_proof = self.import_note_records_by_proof(requests_by_proof).await?;
153            imported_notes.extend(notes_by_proof);
154        }
155
156        let mut imported_commitments = Vec::with_capacity(imported_notes.len());
157        for note in imported_notes.into_iter().flatten() {
158            let details_commitment = note.details_commitment();
159            if let InputNoteState::Expected(ExpectedNoteState { tag: Some(tag), .. }) = note.state()
160            {
161                self.store
162                    .add_note_tag(NoteTagRecord::with_note_source(*tag, details_commitment))
163                    .await?;
164            }
165            self.store.upsert_input_notes(&[note]).await?;
166            imported_commitments.push(details_commitment);
167        }
168
169        Ok(imported_commitments)
170    }
171
172    // HELPERS
173    // ================================================================================================
174
175    /// Builds note records from the note IDs. If a note with the same ID was already stored it
176    /// is passed via `previous_note` so it can be updated. The note information is fetched from
177    /// the node and stored in the client's store.
178    ///
179    /// The returned records are positional rather than keyed by [`NoteId`]; a `None` entry marks a
180    /// note that needs no update.
181    ///
182    /// # Errors:
183    /// - If a note doesn't exist on the node.
184    /// - If a note exists but is private.
185    async fn import_note_records_by_id(
186        &mut self,
187        notes: BTreeMap<NoteId, Option<InputNoteRecord>>,
188    ) -> Result<Vec<Option<InputNoteRecord>>, ClientError> {
189        let note_ids = notes.keys().copied().collect::<Vec<_>>();
190
191        let fetched_notes =
192            self.rpc_api.get_notes_by_id(&note_ids).await.map_err(|err| match err {
193                RpcError::NoteNotFound(note_id) => ClientError::NoteNotFoundOnChain(note_id),
194                err => ClientError::RpcError(err),
195            })?;
196
197        if fetched_notes.is_empty() {
198            return Err(ClientError::NoteImportError("No notes fetched from node".to_string()));
199        }
200
201        let mut note_records = Vec::new();
202        let mut notes_to_request = vec![];
203        for fetched_note in fetched_notes {
204            let note_id = fetched_note.id();
205            let inclusion_proof = fetched_note.inclusion_proof().clone();
206
207            let previous_note =
208                notes.get(&note_id).cloned().ok_or(ClientError::NoteImportError(format!(
209                    "Failed to retrieve note with id {note_id} from node"
210                )))?;
211            if let Some(mut previous_note) = previous_note {
212                if previous_note
213                    .inclusion_proof_received(inclusion_proof, *fetched_note.metadata())?
214                {
215                    self.store.remove_note_tag((&previous_note).try_into()?).await?;
216
217                    note_records.push(Some(previous_note));
218                } else {
219                    note_records.push(None);
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
245    /// nullifier is used to determine if the note has been consumed in the node and gives it
246    /// the correct state.
247    ///
248    /// If the note isn't consumed and it was committed in the past relative to the client, then
249    /// the MMR for the relevant block is fetched from the node and stored.
250    pub(crate) async fn import_note_records_by_proof(
251        &mut self,
252        requested_notes: Vec<(Option<InputNoteRecord>, Note, NoteInclusionProof)>,
253    ) -> Result<Vec<Option<InputNoteRecord>>, ClientError> {
254        // TODO: iterating twice over requested notes
255        let mut note_records = vec![];
256
257        let mut nullifier_requests = BTreeSet::new();
258        let mut lowest_block_height: BlockNumber = u32::MAX.into();
259        for (previous_note, note, inclusion_proof) in &requested_notes {
260            let nullifier = match previous_note {
261                Some(previous_note) => previous_note.nullifier(),
262                None => Some(note.nullifier()),
263            };
264            if let Some(nullifier) = nullifier {
265                nullifier_requests.insert(nullifier);
266            }
267            if inclusion_proof.location().block_num() < lowest_block_height {
268                lowest_block_height = inclusion_proof.location().block_num();
269            }
270        }
271
272        let nullifier_commit_heights = self
273            .rpc_api
274            .get_nullifier_commit_heights(nullifier_requests, lowest_block_height)
275            .await?;
276
277        for (previous_note, note, inclusion_proof) in requested_notes {
278            let metadata = *note.metadata();
279            let attachments = note.attachments().clone();
280            let mut note_record = previous_note.unwrap_or(InputNoteRecord::new(
281                note.into(),
282                attachments,
283                self.store.get_current_timestamp(),
284                ExpectedNoteState {
285                    metadata: Some(metadata),
286                    after_block_num: inclusion_proof.location().block_num(),
287                    tag: Some(metadata.tag()),
288                }
289                .into(),
290            ));
291
292            if let Some(nullifier) = note_record.nullifier()
293                && let Some(Some(block_height)) = nullifier_commit_heights.get(&nullifier)
294            {
295                if note_record.consumed_externally(nullifier, *block_height, None)? {
296                    note_records.push(Some(note_record));
297                }
298
299                note_records.push(None);
300            } else {
301                let block_height = inclusion_proof.location().block_num();
302                let current_block_num = self.get_sync_height().await?;
303
304                let tag = metadata.tag();
305                let mut note_changed =
306                    note_record.inclusion_proof_received(inclusion_proof, metadata)?;
307
308                if block_height <= current_block_num {
309                    // If the note is committed in the past we need to manually fetch the block
310                    // header and MMR proof to verify the inclusion proof.
311                    //
312                    // Building the MMR outside the loop would fail with BlockHeaderNotFound(0)
313                    // because store will be fresh, which can't happen here.
314                    let mut partial_mmr = self.get_current_partial_mmr().await?;
315                    let block_header = self
316                        .get_and_store_authenticated_block(block_height, &mut partial_mmr)
317                        .await?;
318                    self.cache_partial_mmr(partial_mmr).await?;
319
320                    note_changed |= note_record.block_header_received(&block_header)?;
321                } else {
322                    // If the note is in the future we import it as unverified. We add the note tag
323                    // so that the note is verified naturally in the next sync.
324                    self.store
325                        .add_note_tag(NoteTagRecord::with_note_source(
326                            tag,
327                            note_record.details_commitment(),
328                        ))
329                        .await?;
330                }
331
332                if note_changed {
333                    note_records.push(Some(note_record));
334                } else {
335                    note_records.push(None);
336                }
337            }
338        }
339
340        Ok(note_records)
341    }
342
343    /// Builds a note record list from note details. If a note with the same ID was already stored
344    /// it is passed via `previous_note` so it can be updated.
345    async fn import_note_records_by_details(
346        &mut self,
347        requested_notes: Vec<(Option<InputNoteRecord>, NoteDetails, BlockNumber, Option<NoteTag>)>,
348    ) -> Result<Vec<Option<InputNoteRecord>>, ClientError> {
349        let mut lowest_request_block: BlockNumber = u32::MAX.into();
350        let mut note_requests = vec![];
351        for (_, details, after_block_num, tag) in &requested_notes {
352            if let Some(tag) = tag {
353                note_requests.push((details.commitment(), tag));
354                if after_block_num < &lowest_request_block {
355                    lowest_request_block = *after_block_num;
356                }
357            }
358        }
359        let mut committed_notes_data =
360            self.check_expected_notes(lowest_request_block, note_requests).await?;
361
362        let mut note_records = vec![];
363        for (previous_note, details, after_block_num, tag) in requested_notes {
364            let note_record = previous_note.unwrap_or({
365                InputNoteRecord::new(
366                    details,
367                    NoteAttachments::empty(),
368                    self.store.get_current_timestamp(),
369                    ExpectedNoteState { metadata: None, after_block_num, tag }.into(),
370                )
371            });
372
373            match committed_notes_data.remove(&note_record.details_commitment()) {
374                Some((metadata, inclusion_proof)) => {
375                    // Building the MMR outside the loop would fail with BlockHeaderNotFound(0)
376                    // because store will be fresh, which can't happen here.
377                    let mut partial_mmr = self.get_current_partial_mmr().await?;
378                    let block_header = self
379                        .get_and_store_authenticated_block(
380                            inclusion_proof.location().block_num(),
381                            &mut partial_mmr,
382                        )
383                        .await?;
384
385                    self.cache_partial_mmr(partial_mmr).await?;
386
387                    let tag = metadata.tag();
388                    let mut note_record = note_record;
389                    let note_changed =
390                        note_record.inclusion_proof_received(inclusion_proof, metadata)?;
391
392                    if note_record.block_header_received(&block_header)? | note_changed {
393                        self.store
394                            .remove_note_tag(NoteTagRecord::with_note_source(
395                                tag,
396                                note_record.details_commitment(),
397                            ))
398                            .await?;
399
400                        note_records.push(Some(note_record));
401                    } else {
402                        note_records.push(None);
403                    }
404                },
405                None => {
406                    note_records.push(Some(note_record));
407                },
408            }
409        }
410
411        Ok(note_records)
412    }
413
414    /// Checks if notes with the given details commitments and tags are present in the chain between
415    /// `request_block_num` and the current block, returning their metadata and inclusion proof
416    /// keyed by details commitment.
417    ///
418    /// Expected notes have no metadata and thus no `NoteId`, so each committed note is matched by
419    /// reconstructing the id from the committed metadata: `NoteId::new(details_commitment,
420    /// metadata)`.
421    async fn check_expected_notes(
422        &mut self,
423        request_block_num: BlockNumber,
424        // Expected notes' details commitments with their tags
425        expected_notes: Vec<(NoteDetailsCommitment, &NoteTag)>,
426    ) -> Result<BTreeMap<NoteDetailsCommitment, (NoteMetadata, NoteInclusionProof)>, ClientError>
427    {
428        let tracked_tags: BTreeSet<NoteTag> = expected_notes.iter().map(|(_, tag)| **tag).collect();
429        let mut retrieved_proofs = BTreeMap::new();
430        let current_block_num = self.get_sync_height().await?;
431
432        if request_block_num > current_block_num {
433            return Ok(retrieved_proofs);
434        }
435
436        let blocks = self
437            .rpc_api
438            .sync_notes(request_block_num, current_block_num, &tracked_tags)
439            .await
440            .map_err(ClientError::RpcError)?;
441
442        for block in &blocks {
443            if block.block_header.block_num() > current_block_num {
444                break;
445            }
446
447            for sync_note in block.notes.values() {
448                let Some((commitment, _)) = expected_notes.iter().find(|(commitment, _)| {
449                    NoteId::new(*commitment, sync_note.metadata()) == *sync_note.note_id()
450                }) else {
451                    continue;
452                };
453
454                retrieved_proofs.insert(
455                    *commitment,
456                    (*sync_note.metadata(), sync_note.inclusion_proof().clone()),
457                );
458            }
459        }
460
461        Ok(retrieved_proofs)
462    }
463}
464
465// HELPERS
466// ================================================================================================
467
468/// Returns an error if the already-stored note is currently being processed by a local
469/// transaction, since an in-flight note can't be overwritten by an import.
470fn ensure_not_processing(previous_note: Option<&InputNoteRecord>) -> Result<(), ClientError> {
471    if let Some(note) = previous_note
472        && note.is_processing()
473    {
474        return Err(ClientError::NoteImportError(format!(
475            "Can't overwrite note with details commitment {} as it's currently being processed",
476            note.details_commitment().to_hex(),
477        )));
478    }
479    Ok(())
480}