miden_client/note/
note_screener.rs

1use alloc::{collections::BTreeSet, string::ToString, vec::Vec};
2use core::fmt;
3
4use miden_objects::{
5    account::{Account, AccountId},
6    asset::Asset,
7    note::{Note, NoteId},
8    AccountError, AssetError, Felt, Word,
9};
10use thiserror::Error;
11
12use super::script_roots::{P2ID, P2IDR, SWAP};
13use crate::store::{Store, StoreError};
14
15/// Describes the relevance of a note based on the screening.
16#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub enum NoteRelevance {
18    /// The note can be consumed at any time.
19    Always,
20    /// The note can be consumed after the block with the specified number.
21    After(u32),
22}
23
24/// Represents the consumability of a note by a specific account.
25///
26/// The tuple contains the account ID that may consume the note and the moment it will become
27/// relevant.
28pub type NoteConsumability = (AccountId, NoteRelevance);
29
30impl fmt::Display for NoteRelevance {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            NoteRelevance::Always => write!(f, "Always"),
34            NoteRelevance::After(height) => write!(f, "After block {}", height),
35        }
36    }
37}
38
39/// Provides functionality for testing whether a note is relevant to the client or not.
40///
41/// Here, relevance is based on whether the note is able to be consumed by an account that is
42/// tracked in the provided `store`. This can be derived in a number of ways, such as looking
43/// at the combination of script root and note inputs. For example, a P2ID note is relevant
44/// for a specific account ID if this ID is its first note input.
45pub struct NoteScreener {
46    store: alloc::sync::Arc<dyn Store>,
47}
48
49impl NoteScreener {
50    pub fn new(store: alloc::sync::Arc<dyn Store>) -> Self {
51        Self { store }
52    }
53
54    /// Returns a vector of tuples describing the relevance of the provided note to the
55    /// accounts monitored by this screener.
56    ///
57    /// Does a fast check for known scripts (P2ID, P2IDR, SWAP). We're currently
58    /// unable to execute notes that aren't committed so a slow check for other scripts is
59    /// currently not available.
60    pub async fn check_relevance(
61        &self,
62        note: &Note,
63    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
64        let account_ids = BTreeSet::from_iter(self.store.get_account_ids().await?);
65
66        let script_hash = note.script().hash().to_string();
67        let note_relevance = match script_hash.as_str() {
68            P2ID => Self::check_p2id_relevance(note, &account_ids)?,
69            P2IDR => Self::check_p2idr_relevance(note, &account_ids)?,
70            SWAP => self.check_swap_relevance(note, &account_ids).await?,
71            _ => self.check_script_relevance(note, &account_ids)?,
72        };
73
74        Ok(note_relevance)
75    }
76
77    fn check_p2id_relevance(
78        note: &Note,
79        account_ids: &BTreeSet<AccountId>,
80    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
81        let note_inputs = note.inputs().values();
82        if note_inputs.len() != 2 {
83            return Err(InvalidNoteInputsError::WrongNumInputs(note.id(), 2).into());
84        }
85
86        let account_id_felts: [Felt; 2] = note_inputs[0..2].try_into().expect(
87            "Should be able to convert the first two note inputs to an array of two Felt elements",
88        );
89
90        let account_id =
91            AccountId::try_from([account_id_felts[1], account_id_felts[0]]).map_err(|err| {
92                InvalidNoteInputsError::AccountError(
93                    note.id(),
94                    AccountError::FinalAccountHeaderIdParsingFailed(err),
95                )
96            })?;
97
98        if !account_ids.contains(&account_id) {
99            return Ok(vec![]);
100        }
101        Ok(vec![(account_id, NoteRelevance::Always)])
102    }
103
104    fn check_p2idr_relevance(
105        note: &Note,
106        account_ids: &BTreeSet<AccountId>,
107    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
108        let note_inputs = note.inputs().values();
109        if note_inputs.len() != 3 {
110            return Err(InvalidNoteInputsError::WrongNumInputs(note.id(), 3).into());
111        }
112
113        let account_id_felts: [Felt; 2] = note_inputs[0..2].try_into().expect(
114            "Should be able to convert the first two note inputs to an array of two Felt elements",
115        );
116
117        let recall_height_felt = note_inputs[2];
118
119        let sender = note.metadata().sender();
120        let recall_height: u32 = recall_height_felt.as_int().try_into().map_err(|_err| {
121            InvalidNoteInputsError::BlockNumberError(note.id(), recall_height_felt.as_int())
122        })?;
123
124        let account_id =
125            AccountId::try_from([account_id_felts[1], account_id_felts[0]]).map_err(|err| {
126                InvalidNoteInputsError::AccountError(
127                    note.id(),
128                    AccountError::FinalAccountHeaderIdParsingFailed(err),
129                )
130            })?;
131
132        Ok(vec![
133            (account_id, NoteRelevance::Always),
134            (sender, NoteRelevance::After(recall_height)),
135        ]
136        .into_iter()
137        .filter(|(account_id, _relevance)| account_ids.contains(account_id))
138        .collect())
139    }
140
141    /// Checks if a swap note can be consumed by any account whose ID is in `account_ids`.
142    ///
143    /// This implementation serves as a placeholder as we're currently not able to create, execute
144    /// and send SWAP NOTES. Hence, it's also untested. The main logic should be the same: for each
145    /// account check if it has enough of the wanted asset.
146    /// This is also very inefficient as we're loading the full accounts. We should instead just
147    /// load the account's vaults, or even have a function in the `Store` to do this.
148    // TODO: test/revisit this in the future
149    async fn check_swap_relevance(
150        &self,
151        note: &Note,
152        account_ids: &BTreeSet<AccountId>,
153    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
154        let note_inputs = note.inputs().values();
155        if note_inputs.len() != 10 {
156            return Err(InvalidNoteInputsError::WrongNumInputs(note.id(), 10).into());
157        }
158
159        let asset_felts: [Felt; 4] = note_inputs[4..8].try_into().expect(
160            "Should be able to convert the second word from note inputs to an array of four Felt elements",
161        );
162
163        // get the demanded asset from the note's inputs
164        let asset: Asset = Word::from(asset_felts)
165            .try_into()
166            .map_err(|err| InvalidNoteInputsError::AssetError(note.id(), err))?;
167
168        let mut accounts_with_relevance = Vec::new();
169
170        for account_id in account_ids {
171            let account: Account = self
172                .store
173                .get_account(*account_id)
174                .await?
175                .ok_or(NoteScreenerError::AccountDataNotFound(*account_id))?
176                .into();
177
178            // Check that the account can cover the demanded asset
179            match asset {
180                Asset::NonFungible(non_fungible_asset)
181                    if account.vault().has_non_fungible_asset(non_fungible_asset).expect(
182                        "Should be able to query has_non_fungible_asset for an Asset::NonFungible",
183                    ) =>
184                {
185                    accounts_with_relevance.push((*account_id, NoteRelevance::Always))
186                },
187                Asset::Fungible(fungible_asset) => {
188                    let asset_faucet_id = fungible_asset.faucet_id();
189                    if account
190                        .vault()
191                        .get_balance(asset_faucet_id)
192                        .expect("Should be able to query get_balance for an Asset::Fungible")
193                        >= fungible_asset.amount()
194                    {
195                        accounts_with_relevance.push((*account_id, NoteRelevance::Always))
196                    }
197                },
198                _ => {},
199            }
200        }
201
202        Ok(accounts_with_relevance)
203    }
204
205    fn check_script_relevance(
206        &self,
207        _note: &Note,
208        account_ids: &BTreeSet<AccountId>,
209    ) -> Result<Vec<NoteConsumability>, NoteScreenerError> {
210        // TODO: try to execute the note script against relevant accounts; this will
211        // require querying data from the store
212        Ok(account_ids
213            .iter()
214            .map(|account_id| (*account_id, NoteRelevance::Always))
215            .collect())
216    }
217}
218
219// NOTE SCREENER ERRORS
220// ================================================================================================
221
222/// Error when screening notes to check relevance to a client.
223#[derive(Debug, Error)]
224pub enum NoteScreenerError {
225    #[error("error while processing note inputs")]
226    InvalidNoteInputsError(#[from] InvalidNoteInputsError),
227    #[error("account data wasn't found for account id {0}")]
228    AccountDataNotFound(AccountId),
229    #[error("error while fetching data from the store")]
230    StoreError(#[from] StoreError),
231}
232
233#[derive(Debug, Error)]
234pub enum InvalidNoteInputsError {
235    #[error("account error for note with id {0}: {1}")]
236    AccountError(NoteId, AccountError),
237    #[error("asset error for note with id {0}: {1}")]
238    AssetError(NoteId, AssetError),
239    #[error("expected {1} note inputs for note with id {0}")]
240    WrongNumInputs(NoteId, usize),
241    #[error("note input representing block with value {1} for note with id {0}")]
242    BlockNumberError(NoteId, u64),
243}