miden_client/note/
note_screener.rs

1use alloc::boxed::Box;
2use alloc::sync::Arc;
3use alloc::vec::Vec;
4use core::fmt;
5
6use miden_lib::account::interface::AccountInterface;
7use miden_lib::note::well_known_note::WellKnownNote;
8use miden_objects::account::{Account, AccountId};
9use miden_objects::note::{Note, NoteId};
10use miden_objects::transaction::{InputNote, InputNotes};
11use miden_objects::{AccountError, AssetError};
12use miden_tx::auth::TransactionAuthenticator;
13use miden_tx::{NoteCheckerError, NoteConsumptionChecker, TransactionExecutor};
14use thiserror::Error;
15use tonic::async_trait;
16
17use crate::ClientError;
18use crate::rpc::domain::note::CommittedNote;
19use crate::store::data_store::ClientDataStore;
20use crate::store::{InputNoteRecord, NoteFilter, Store, StoreError};
21use crate::sync::{NoteUpdateAction, OnNoteReceived};
22use crate::transaction::{TransactionRequestBuilder, TransactionRequestError};
23
24/// Describes the relevance of a note based on the screening.
25#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
26pub enum NoteRelevance {
27    /// The note can be consumed in the client's current block.
28    Now,
29    /// The note can be consumed after the block with the specified number.
30    After(u32),
31}
32
33/// Represents the consumability of a note by a specific account.
34///
35/// The tuple contains the account ID that may consume the note and the moment it will become
36/// relevant.
37pub type NoteConsumability = (AccountId, NoteRelevance);
38
39impl fmt::Display for NoteRelevance {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            NoteRelevance::Now => write!(f, "Now"),
43            NoteRelevance::After(height) => write!(f, "After block {height}"),
44        }
45    }
46}
47
48/// Provides functionality for testing whether a note is relevant to the client or not.
49///
50/// Here, relevance is based on whether the note is able to be consumed by an account that is
51/// tracked in the provided `store`. This can be derived in a number of ways, such as looking
52/// at the combination of script root and note inputs. For example, a P2ID note is relevant
53/// for a specific account ID if this ID is its first note input.
54pub struct NoteScreener<AUTH> {
55    /// A reference to the client's store, used to fetch necessary data to check consumability.
56    store: Arc<dyn Store>,
57    /// A reference to the transaction authenticator
58    authenticator: Option<Arc<AUTH>>,
59}
60
61impl<AUTH> NoteScreener<AUTH>
62where
63    AUTH: TransactionAuthenticator + Sync,
64{
65    pub fn new(store: Arc<dyn Store>, authenticator: Option<Arc<AUTH>>) -> Self {
66        Self { store, authenticator }
67    }
68
69    /// Returns a vector of tuples describing the relevance of the provided note to the
70    /// accounts monitored by this screener.
71    ///
72    /// Does a fast check for known scripts (P2ID, P2IDE, SWAP). We're currently
73    /// unable to execute notes that aren't committed so a slow check for other scripts is
74    /// currently not available.
75    ///
76    /// If relevance can't be determined, the screener defaults to setting the note as consumable.
77    pub async fn check_relevance(
78        &self,
79        note: &Note,
80    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
81        let mut note_relevances = vec![];
82        for id in self.store.get_account_ids().await? {
83            let account_record = self
84                .store
85                .get_account(id)
86                .await?
87                .ok_or(NoteScreenerError::AccountDataNotFound(id))?;
88
89            match self.check_standard_consumability(account_record.account(), note).await {
90                Ok(Some(relevance)) => {
91                    note_relevances.push((id, relevance));
92                },
93                Ok(None) => {
94                    // The note might be consumable after a certain block height if the note is
95                    // p2ide
96                    let script_root = note.script().root();
97
98                    if script_root == WellKnownNote::P2IDE.script_root()
99                        && let Some(relevance) = Self::check_p2ide_recall_consumability(note, &id)?
100                    {
101                        note_relevances.push((id, relevance));
102                    }
103                },
104                // If an error occurs while checking consumability, we count it as not relevant for
105                // that account
106                Err(_) => {},
107            }
108        }
109
110        Ok(note_relevances)
111    }
112
113    /// Tries to execute a standard consume transaction to check if the note is consumable by the
114    /// account.
115    async fn check_standard_consumability(
116        &self,
117        account: &Account,
118        note: &Note,
119    ) -> Result<Option<NoteRelevance>, NoteScreenerError> {
120        let transaction_request =
121            TransactionRequestBuilder::new().build_consume_notes(vec![note.id()])?;
122
123        let tx_script = transaction_request.build_transaction_script(
124            &AccountInterface::from(account),
125            crate::DebugMode::Enabled,
126        )?;
127
128        let tx_args = transaction_request.clone().into_transaction_args(tx_script, vec![]);
129        let input_notes = InputNotes::new(vec![InputNote::unauthenticated(note.clone())])
130            .expect("Single note should be valid");
131
132        let data_store = ClientDataStore::new(self.store.clone());
133        let mut transaction_executor = TransactionExecutor::new(&data_store);
134        if let Some(authenticator) = &self.authenticator {
135            transaction_executor = transaction_executor.with_authenticator(authenticator.as_ref());
136        }
137
138        let consumption_checker = NoteConsumptionChecker::new(&transaction_executor);
139
140        data_store.mast_store().load_account_code(account.code());
141        let note_execution_check = consumption_checker
142            .check_notes_consumability(
143                account.id(),
144                self.store.get_sync_height().await?,
145                input_notes,
146                tx_args,
147            )
148            .await?;
149        if !note_execution_check.successful.is_empty() {
150            return Ok(Some(NoteRelevance::Now));
151        }
152
153        Ok(None)
154    }
155
156    /// Special relevance check for P2IDE notes. It checks if the sender account can consume and
157    /// recall the note.
158    fn check_p2ide_recall_consumability(
159        note: &Note,
160        account_id: &AccountId,
161    ) -> Result<Option<NoteRelevance>, NoteScreenerError> {
162        let note_inputs = note.inputs().values();
163        if note_inputs.len() != 4 {
164            return Err(InvalidNoteInputsError::WrongNumInputs(note.id(), 4).into());
165        }
166
167        let recall_height_felt = note_inputs[2];
168
169        let sender = note.metadata().sender();
170        let recall_height: u32 = recall_height_felt.as_int().try_into().map_err(|_err| {
171            InvalidNoteInputsError::BlockNumberError(note.id(), recall_height_felt.as_int())
172        })?;
173
174        if sender == *account_id {
175            Ok(Some(NoteRelevance::After(recall_height)))
176        } else {
177            Ok(None)
178        }
179    }
180}
181
182// DEFAULT CALLBACK IMPLEMENTATIONS
183// ================================================================================================
184
185#[async_trait(?Send)]
186impl<AUTH> OnNoteReceived for NoteScreener<AUTH>
187where
188    AUTH: TransactionAuthenticator + Sync,
189{
190    /// Default implementation of the [`OnNoteReceived`] callback. It queries the store for the
191    /// committed note to check if it's relevant. If the note wasn't being tracked but it came in
192    /// the sync response it may be a new public note, in that case we use the [`NoteScreener`]
193    /// to check its relevance.
194    async fn on_note_received(
195        &self,
196        committed_note: CommittedNote,
197        public_note: Option<InputNoteRecord>,
198    ) -> Result<NoteUpdateAction, ClientError> {
199        let note_id = *committed_note.note_id();
200
201        let input_note_present =
202            !self.store.get_input_notes(NoteFilter::Unique(note_id)).await?.is_empty();
203        let output_note_present =
204            !self.store.get_output_notes(NoteFilter::Unique(note_id)).await?.is_empty();
205
206        if input_note_present || output_note_present {
207            // The note is being tracked by the client so it is relevant
208            return Ok(NoteUpdateAction::Commit(committed_note));
209        }
210
211        match public_note {
212            Some(public_note) => {
213                // If tracked by the user, keep note regardless of inputs and extra checks
214                if let Some(metadata) = public_note.metadata()
215                    && self.store.get_unique_note_tags().await?.contains(&metadata.tag())
216                {
217                    return Ok(NoteUpdateAction::Insert(public_note));
218                }
219
220                // The note is not being tracked by the client and is public so we can screen it
221                let new_note_relevance = self
222                    .check_relevance(
223                        &public_note
224                            .clone()
225                            .try_into()
226                            .map_err(ClientError::NoteRecordConversionError)?,
227                    )
228                    .await?;
229                let is_relevant = !new_note_relevance.is_empty();
230                if is_relevant {
231                    Ok(NoteUpdateAction::Insert(public_note))
232                } else {
233                    Ok(NoteUpdateAction::Discard)
234                }
235            },
236            None => {
237                // The note is not being tracked by the client and is private so we can't determine
238                // if it is relevant
239                Ok(NoteUpdateAction::Discard)
240            },
241        }
242    }
243}
244
245// NOTE SCREENER ERRORS
246// ================================================================================================
247
248/// Error when screening notes to check relevance to a client.
249#[derive(Debug, Error)]
250pub enum NoteScreenerError {
251    #[error("error while processing note inputs")]
252    InvalidNoteInputsError(#[from] InvalidNoteInputsError),
253    #[error("account data wasn't found for account id {0}")]
254    AccountDataNotFound(AccountId),
255    #[error("error while fetching data from the store")]
256    StoreError(#[from] StoreError),
257    #[error("error while checking note")]
258    NoteCheckerError(#[from] NoteCheckerError),
259    #[error("error while building transaction request")]
260    TransactionRequestError(#[from] TransactionRequestError),
261}
262
263#[derive(Debug, Error)]
264pub enum InvalidNoteInputsError {
265    #[error("account error for note with id {0}: {1}")]
266    AccountError(NoteId, AccountError),
267    #[error("asset error for note with id {0}: {1}")]
268    AssetError(NoteId, AssetError),
269    #[error("expected {1} note inputs for note with id {0}")]
270    WrongNumInputs(NoteId, usize),
271    #[error("note input representing block with value {1} for note with id {0}")]
272    BlockNumberError(NoteId, u64),
273}