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