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