Skip to main content

miden_client/note/
note_screener.rs

1use alloc::boxed::Box;
2use alloc::collections::BTreeMap;
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5
6use async_trait::async_trait;
7use miden_protocol::account::{AccountCode, AccountId};
8use miden_protocol::note::{Note, NoteId};
9use miden_standards::note::NoteConsumptionStatus;
10use miden_tx::{
11    NoteCheckerError,
12    NoteConsumptionChecker,
13    NoteConsumptionInfo,
14    TransactionExecutor,
15};
16use thiserror::Error;
17
18use crate::ClientError;
19use crate::rpc::NodeRpcClient;
20use crate::rpc::domain::note::CommittedNote;
21use crate::store::data_store::ClientDataStore;
22use crate::store::{InputNoteRecord, NoteFilter, Store, StoreError};
23use crate::sync::{NoteUpdateAction, OnNoteReceived};
24use crate::transaction::{AdviceMap, InputNote, TransactionArgs, TransactionRequestError};
25
26/// Represents the consumability of a note by a specific account.
27///
28/// The tuple contains the account ID that may consume the note and the moment it will become
29/// relevant.
30pub type NoteConsumability = (AccountId, NoteConsumptionStatus);
31
32/// Returns `true` if the consumption status indicates that the note may be consumable by the
33/// account. A note is considered relevant unless it is permanently unconsumable (either due to
34/// a fundamental incompatibility or unconsumable conditions).
35fn is_relevant(consumption_status: &NoteConsumptionStatus) -> bool {
36    !matches!(
37        consumption_status,
38        NoteConsumptionStatus::NeverConsumable(_) | NoteConsumptionStatus::UnconsumableConditions
39    )
40}
41
42/// Provides functionality for testing whether a note is relevant to the client or not.
43///
44/// Here, relevance is based on whether the note is able to be consumed by an account that is
45/// tracked in the provided `store`. This can be derived in a number of ways, such as looking
46/// at the combination of script root and note inputs. For example, a P2ID note is relevant
47/// for a specific account ID if this ID is its first note input.
48#[derive(Clone)]
49pub struct NoteScreener {
50    /// A reference to the client's store, used to fetch necessary data to check consumability.
51    store: Arc<dyn Store>,
52    /// Optional transaction arguments to use when checking consumability.
53    tx_args: Option<TransactionArgs>,
54    /// RPC client used for lazy-loading foreign account data during note screening.
55    rpc_api: Arc<dyn NodeRpcClient>,
56}
57
58impl NoteScreener {
59    pub fn new(store: Arc<dyn Store>, rpc_api: Arc<dyn NodeRpcClient>) -> Self {
60        Self { store, tx_args: None, rpc_api }
61    }
62
63    /// Sets the transaction arguments to use when checking note consumability.
64    /// If not set, a default `TransactionArgs` with an empty advice map is used.
65    #[must_use]
66    pub fn with_transaction_args(mut self, tx_args: TransactionArgs) -> Self {
67        self.tx_args = Some(tx_args);
68        self
69    }
70
71    fn tx_args(&self) -> TransactionArgs {
72        self.tx_args
73            .clone()
74            .unwrap_or_else(|| TransactionArgs::new(AdviceMap::default()))
75    }
76
77    /// Checks whether the provided note could be consumed by any of the accounts tracked by
78    /// this screener. Convenience wrapper around [`Self::get_batch_consumability`] for a single
79    /// note.
80    ///
81    /// Returns the [`NoteConsumptionStatus`] for each account that could consume the note.
82    pub async fn get_consumability(
83        &self,
84        note: &Note,
85    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
86        Ok(self
87            .get_batch_consumability(core::slice::from_ref(note))
88            .await?
89            .remove(&note.id())
90            .unwrap_or_default())
91    }
92
93    /// Checks whether the provided notes could be consumed by any of the accounts tracked by
94    /// this screener, by executing a transaction for each note-account pair.
95    ///
96    /// Returns a map from [`NoteId`] to a list of `(AccountId, NoteConsumptionStatus)` pairs.
97    /// Notes that are permanently unconsumable by all accounts are not included in the result.
98    pub async fn get_batch_consumability(
99        &self,
100        notes: &[Note],
101    ) -> Result<BTreeMap<NoteId, Vec<NoteConsumability>>, NoteScreenerError> {
102        let account_ids = self.store.get_account_ids().await?;
103        self.screen_notes(notes, account_ids).await
104    }
105
106    /// Checks whether the provided notes could be consumed by `account_id`, by executing a
107    /// transaction for each note. Unlike [`Self::get_batch_consumability`], only `account_id` is
108    /// screened instead of every account tracked by this screener.
109    ///
110    /// Returns a map from [`NoteId`] to a single-element list holding `account_id` and its
111    /// [`NoteConsumptionStatus`]. Notes that `account_id` cannot consume are not included in the
112    /// result.
113    pub async fn get_batch_consumability_for_account(
114        &self,
115        account_id: AccountId,
116        notes: &[Note],
117    ) -> Result<BTreeMap<NoteId, Vec<NoteConsumability>>, NoteScreenerError> {
118        self.screen_notes(notes, vec![account_id]).await
119    }
120
121    /// Screens `notes` against `account_ids`, executing a transaction for each note-account pair
122    /// and collecting the accounts that could consume each note.
123    async fn screen_notes(
124        &self,
125        notes: &[Note],
126        account_ids: Vec<AccountId>,
127    ) -> Result<BTreeMap<NoteId, Vec<NoteConsumability>>, NoteScreenerError> {
128        if notes.is_empty() || account_ids.is_empty() {
129            return Ok(BTreeMap::new());
130        }
131
132        let block_ref = self.store.get_sync_height().await?;
133        let mut relevant_notes: BTreeMap<NoteId, Vec<NoteConsumability>> = BTreeMap::new();
134        let tx_args = self.tx_args();
135
136        let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone())
137            .with_execution_input_cache();
138        // Don't attach the real authenticator for consumability checks. The
139        // NoteConsumptionChecker gracefully handles a missing authenticator by
140        // returning `ConsumableWithAuthorization` instead of calling
141        // `get_signature()`. Attaching the real authenticator here causes the
142        // external signer (e.g. wallet extension) to be invoked during
143        // sync_state, producing unwanted confirmation popups on every sync.
144        let transaction_executor: TransactionExecutor<'_, '_, _, ()> =
145            TransactionExecutor::new(&data_store);
146        let consumption_checker = NoteConsumptionChecker::new(&transaction_executor);
147
148        for account_id in account_ids {
149            let account_code = self.get_account_code(account_id).await?;
150            data_store.mast_store().load_account_code(&account_code);
151
152            for note in notes {
153                let consumption_status = consumption_checker
154                    .can_consume(
155                        account_id,
156                        block_ref,
157                        InputNote::unauthenticated(note.clone()),
158                        tx_args.clone(),
159                    )
160                    .await?;
161
162                if is_relevant(&consumption_status) {
163                    relevant_notes
164                        .entry(note.id())
165                        .or_default()
166                        .push((account_id, consumption_status));
167                }
168            }
169        }
170
171        Ok(relevant_notes)
172    }
173
174    /// Checks whether the provided notes could be consumed by a specific account by attempting
175    /// to execute them together in a transaction. Notes that fail are progressively removed
176    /// until a maximal set of successfully consumable notes is found.
177    ///
178    /// Returns a [`NoteConsumptionInfo`] splitting notes into those that succeeded and those
179    /// that failed.
180    pub async fn check_notes_consumability(
181        &self,
182        account_id: AccountId,
183        notes: Vec<Note>,
184    ) -> Result<NoteConsumptionInfo, NoteScreenerError> {
185        let block_ref = self.store.get_sync_height().await?;
186        let tx_args = self.tx_args();
187        let account_code = self.get_account_code(account_id).await?;
188
189        let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone())
190            .with_execution_input_cache();
191        let transaction_executor: TransactionExecutor<'_, '_, _, ()> =
192            TransactionExecutor::new(&data_store);
193
194        let consumption_checker = NoteConsumptionChecker::new(&transaction_executor);
195
196        data_store.mast_store().load_account_code(&account_code);
197        let note_consumption_info = consumption_checker
198            .check_notes_consumability(account_id, block_ref, notes, tx_args)
199            .await?;
200
201        Ok(note_consumption_info)
202    }
203
204    async fn get_account_code(
205        &self,
206        account_id: AccountId,
207    ) -> Result<AccountCode, NoteScreenerError> {
208        self.store
209            .get_account_code(account_id)
210            .await?
211            .ok_or(NoteScreenerError::AccountDataNotFound(account_id))
212    }
213}
214
215// DEFAULT CALLBACK IMPLEMENTATIONS
216// ================================================================================================
217
218#[async_trait(?Send)]
219impl OnNoteReceived for NoteScreener {
220    /// Default implementation of the [`OnNoteReceived`] callback. It queries the store for the
221    /// committed note to check if it's relevant. If the note wasn't being tracked but it came in
222    /// the sync response it may be a new public note, in that case we use the [`NoteScreener`]
223    /// to check its relevance.
224    async fn on_note_received(
225        &self,
226        committed_note: CommittedNote,
227        public_note: Option<InputNoteRecord>,
228    ) -> Result<NoteUpdateAction, ClientError> {
229        let note_id = *committed_note.note_id();
230
231        let mut input_note_present =
232            !self.store.get_input_notes(NoteFilter::Unique(note_id)).await?.is_empty();
233
234        // Notes imported without metadata (e.g. via `NoteFile::NoteDetails`) have a NULL `note_id`
235        // and so can't be matched by id. Recognize them by reconstructing their id from the
236        // committed metadata: `NoteId::new(details_commitment, metadata)`.
237        // TODO: revisit
238        if !input_note_present {
239            input_note_present = self
240                .store
241                .get_input_notes(NoteFilter::Expected)
242                .await?
243                .iter()
244                .filter(|note| note.metadata().is_none())
245                .any(|note| {
246                    NoteId::new(note.details_commitment(), committed_note.metadata()) == note_id
247                });
248        }
249
250        let output_note_present =
251            !self.store.get_output_notes(NoteFilter::Unique(note_id)).await?.is_empty();
252
253        if input_note_present || output_note_present {
254            // The note is being tracked by the client so it is relevant
255            return Ok(NoteUpdateAction::Commit(committed_note));
256        }
257
258        match public_note {
259            Some(public_note) => {
260                // If tracked by the user, keep note regardless of inputs and extra checks
261                if let Some(metadata) = public_note.metadata()
262                    && self.store.get_unique_note_tags().await?.contains(&metadata.tag())
263                {
264                    return Ok(NoteUpdateAction::Insert(public_note));
265                }
266
267                // The note is not being tracked by the client and is public so we can screen it
268                let new_note_relevance = self
269                    .get_consumability(
270                        &public_note
271                            .clone()
272                            .try_into()
273                            .map_err(ClientError::NoteRecordConversionError)?,
274                    )
275                    .await?;
276                let is_relevant = !new_note_relevance.is_empty();
277                if is_relevant {
278                    Ok(NoteUpdateAction::Insert(public_note))
279                } else {
280                    Ok(NoteUpdateAction::Discard)
281                }
282            },
283            None => {
284                // The note is not being tracked by the client and is private so we can't determine
285                // if it is relevant
286                Ok(NoteUpdateAction::Discard)
287            },
288        }
289    }
290}
291
292// NOTE SCREENER ERRORS
293// ================================================================================================
294
295/// Error when screening notes to check relevance to a client.
296#[derive(Debug, Error)]
297pub enum NoteScreenerError {
298    #[error("account {0} data not found in the store")]
299    AccountDataNotFound(AccountId),
300    #[error("failed to fetch data from the store")]
301    StoreError(#[from] StoreError),
302    #[error("note consumption check failed")]
303    NoteCheckerError(#[from] NoteCheckerError),
304    #[error("failed to build transaction request")]
305    TransactionRequestError(#[from] TransactionRequestError),
306}