Skip to main content

miden_standards/account/auth/network_account/
note_allowlist.rs

1use alloc::collections::BTreeSet;
2
3use miden_protocol::account::component::{SchemaType, StorageSlotSchema};
4use miden_protocol::account::{
5    AccountId,
6    AccountStorage,
7    StorageMap,
8    StorageMapKey,
9    StorageSlot,
10    StorageSlotContent,
11    StorageSlotName,
12};
13use miden_protocol::note::NoteScriptRoot;
14use miden_protocol::utils::sync::LazyLock;
15use miden_protocol::{Felt, Word};
16
17// CONSTANTS
18// ================================================================================================
19
20static SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
21    StorageSlotName::new("miden::standards::auth::network_account::allowed_note_scripts")
22        .expect("storage slot name should be valid")
23});
24
25// A flag value used as the storage map entry for each allowed script root. Its only job is to be
26// distinguishable from the storage map's default empty word, letting the MASM allowlist check
27// detect "this key is present" without caring about its contents. Any non-empty word would serve;
28// we pick `[1, 0, 0, 0]` for readability when inspecting storage.
29const ALLOWED_FLAG: Word = Word::new([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]);
30
31// NETWORK ACCOUNT NOTE ALLOWLIST
32// ================================================================================================
33
34/// A standardized storage slot holding the allowlist of input-note script roots that a network
35/// account is willing to consume.
36///
37/// The presence of this slot is what defines an account as a "network account": it is the
38/// abstraction shared by every network-account component, so off-chain services (like the network
39/// transaction builder) can identify a network account and filter notes by inspecting account
40/// storage for this slot, independent of which component the account uses.
41///
42/// The slot is a [`StorageMap`] keyed by note script root; any non-empty value marks a root as
43/// allowed.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct NetworkAccountNoteAllowlist {
46    allowed_script_roots: BTreeSet<NoteScriptRoot>,
47}
48
49impl NetworkAccountNoteAllowlist {
50    /// Creates a new allowlist from the provided list of allowed input-note script roots.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if `allowed_script_roots` is empty since the account could not consume any
55    /// notes.
56    pub fn new(
57        allowed_script_roots: BTreeSet<NoteScriptRoot>,
58    ) -> Result<Self, NetworkAccountNoteAllowlistError> {
59        if allowed_script_roots.is_empty() {
60            return Err(NetworkAccountNoteAllowlistError::EmptyAllowlist);
61        }
62
63        Ok(Self { allowed_script_roots })
64    }
65
66    /// Returns the [`StorageSlotName`] of the standardized allowlist slot.
67    pub fn slot_name() -> &'static StorageSlotName {
68        &SLOT_NAME
69    }
70
71    /// Returns the allowed input-note script roots in this allowlist.
72    pub fn allowed_script_roots(&self) -> &BTreeSet<NoteScriptRoot> {
73        &self.allowed_script_roots
74    }
75
76    /// Returns the allowed input-note script roots in this allowlist.
77    pub fn into_allowed_script_roots(self) -> BTreeSet<NoteScriptRoot> {
78        self.allowed_script_roots
79    }
80
81    /// Returns the schema entry for the allowlist slot.
82    pub fn slot_schema() -> (StorageSlotName, StorageSlotSchema) {
83        (
84            Self::slot_name().clone(),
85            StorageSlotSchema::map(
86                "Allowed input note script roots",
87                SchemaType::native_word(),
88                SchemaType::native_word(),
89            ),
90        )
91    }
92
93    /// Consumes this allowlist and returns the [`StorageSlot`] suitable for inclusion in an
94    /// [`AccountComponent`](miden_protocol::account::AccountComponent)'s storage layout.
95    pub fn into_storage_slot(self) -> StorageSlot {
96        let entries = self
97            .allowed_script_roots
98            .into_iter()
99            .map(|root| (StorageMapKey::new(root.as_word()), ALLOWED_FLAG));
100
101        let storage_map = StorageMap::with_entries(entries)
102            .expect("allowlist entries should produce a valid storage map");
103
104        StorageSlot::with_map(Self::slot_name().clone(), storage_map)
105    }
106}
107
108// TRAIT IMPLEMENTATIONS
109// ================================================================================================
110
111impl TryFrom<&AccountStorage> for NetworkAccountNoteAllowlist {
112    type Error = NetworkAccountNoteAllowlistError;
113
114    /// Reconstructs a [`NetworkAccountNoteAllowlist`] from account storage by reading the
115    /// allowlist slot and collecting its keys.
116    ///
117    /// # Errors
118    /// Returns an error if:
119    /// - The standardized allowlist slot is not present in storage.
120    /// - The slot is present but is not a [`StorageSlotContent::Map`].
121    fn try_from(storage: &AccountStorage) -> Result<Self, Self::Error> {
122        let slot = storage
123            .get(Self::slot_name())
124            .ok_or(NetworkAccountNoteAllowlistError::SlotNotFound)?;
125
126        let StorageSlotContent::Map(map) = slot.content() else {
127            return Err(NetworkAccountNoteAllowlistError::UnexpectedSlotType);
128        };
129
130        let allowed_script_roots = map
131            .entries()
132            .map(|(key, _value)| NoteScriptRoot::from_raw(key.as_word()))
133            .collect();
134
135        Self::new(allowed_script_roots)
136    }
137}
138
139// NETWORK ACCOUNT NOTE ALLOWLIST ERROR
140// ================================================================================================
141
142/// Errors that can occur when constructing a [`NetworkAccountNoteAllowlist`] or reconstructing one
143/// from storage.
144#[derive(Debug, thiserror::Error)]
145pub enum NetworkAccountNoteAllowlistError {
146    #[error(
147        "network account allowlist must contain at least one allowed note script root: an empty \
148         allowlist would prevent the account from consuming any notes"
149    )]
150    EmptyAllowlist,
151    #[error(
152        "network account allowlist storage slot {} not found in account storage",
153        NetworkAccountNoteAllowlist::slot_name()
154    )]
155    SlotNotFound,
156    #[error(
157        "network account allowlist storage slot {} must be a map",
158        NetworkAccountNoteAllowlist::slot_name()
159    )]
160    UnexpectedSlotType,
161    #[error("network account must have public account type, but account {0} does not")]
162    AccountNotPublic(AccountId),
163}
164
165// TESTS
166// ================================================================================================
167
168#[cfg(test)]
169mod tests {
170    use miden_protocol::account::{AccountBuilder, StorageSlotContent};
171
172    use super::*;
173    use crate::account::auth::network_account::AuthNetworkAccount;
174    use crate::account::wallets::BasicWallet;
175
176    #[test]
177    fn allowlist_storage_slot_contains_expected_entries() {
178        let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]);
179        let root_b = NoteScriptRoot::from_array([5, 6, 7, 8]);
180
181        let slot = NetworkAccountNoteAllowlist::new(BTreeSet::from_iter([root_a, root_b]))
182            .expect("non-empty allowlist should construct")
183            .into_storage_slot();
184
185        assert_eq!(slot.name(), NetworkAccountNoteAllowlist::slot_name());
186
187        let StorageSlotContent::Map(map) = slot.content() else {
188            panic!("allowlist slot must be a map");
189        };
190
191        assert_eq!(
192            map.get(&StorageMapKey::new(root_a.as_word())),
193            ALLOWED_FLAG,
194            "root_a should resolve to the flag value"
195        );
196        assert_eq!(
197            map.get(&StorageMapKey::new(root_b.as_word())),
198            ALLOWED_FLAG,
199            "root_b should resolve to the flag value"
200        );
201    }
202
203    #[test]
204    fn empty_allowlist_is_rejected() {
205        let result = NetworkAccountNoteAllowlist::new(BTreeSet::new());
206        assert!(matches!(result, Err(NetworkAccountNoteAllowlistError::EmptyAllowlist)));
207    }
208
209    #[test]
210    fn allowlist_round_trips_through_account_storage() {
211        use alloc::collections::BTreeSet;
212
213        let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]);
214        let root_b = NoteScriptRoot::from_array([5, 6, 7, 8]);
215        let root_c = NoteScriptRoot::from_array([9, 10, 11, 12]);
216        let original_roots = BTreeSet::from_iter([root_a, root_b, root_c]);
217
218        let account = AccountBuilder::new([0; 32])
219            .with_auth_component(
220                AuthNetworkAccount::with_allowed_notes(original_roots.clone())
221                    .expect("non-empty allowlist should construct"),
222            )
223            .with_component(BasicWallet)
224            .build()
225            .expect("account building with AuthNetworkAccount failed");
226
227        let allowlist = NetworkAccountNoteAllowlist::try_from(account.storage())
228            .expect("allowlist should be reconstructable from account storage");
229
230        // The map's ordering is determined by the StorageMapKey, so compare as sets.
231        let expected: BTreeSet<NoteScriptRoot> = original_roots.into_iter().collect();
232        let actual: BTreeSet<NoteScriptRoot> =
233            allowlist.allowed_script_roots().iter().copied().collect();
234
235        assert_eq!(actual, expected);
236    }
237}