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