diff --git a/CHANGELOG.md b/CHANGELOG.md index 2457f714b8..a2bd91d89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ - Fixed `pausable::assert_not_paused` to guard its storage read with `active_account::has_storage_slot`, making it a no-op on accounts without the `Pausable` component instead of panicking on the missing `is_paused` slot ([#3047](https://github.com/0xMiden/protocol/pull/3047)). - [BREAKING] Fixed batch ID being serialized/deserialized and potentially not matching the serialized transaction headers ([#3061](https://github.com/0xMiden/protocol/pull/3061)). +## v0.15.2 (TBD) + +### Changes + +- [BREAKING] `AuthNetworkAccount` now gates transaction scripts with a root allowlist instead of banning them outright, enabling network accounts to run approved tx scripts such as setting the expiration delta ([#3028](https://github.com/0xMiden/protocol/pull/3028)). +- [BREAKING] `TransactionScript::root()` now returns `TransactionScriptRoot` instead of `Word` ([#3028](https://github.com/0xMiden/protocol/pull/3028)). +- Renamed `AuthNetworkAccount::with_allowlist` to `with_allowed_notes` and aligned the component's internal allowlist field names, for consistency with `with_allowed_tx_scripts` ([#3049](https://github.com/0xMiden/protocol/pull/3049)). + ## v0.15.1 (TBD) ### Changes diff --git a/crates/miden-agglayer/build.rs b/crates/miden-agglayer/build.rs index 7bced82a44..64a2334c4d 100644 --- a/crates/miden-agglayer/build.rs +++ b/crates/miden-agglayer/build.rs @@ -326,7 +326,7 @@ fn generate_agglayer_constants( // The allowlist lives in storage, not code, and here we only care about the code commitment // of the accounts, so we can init the allowlists with dummy values. let placeholder_allowlist = BTreeSet::from([NoteScriptRoot::from_raw(Word::default())]); - let auth_component = AuthNetworkAccount::with_allowlist(placeholder_allowlist) + let auth_component = AuthNetworkAccount::with_allowed_notes(placeholder_allowlist) .expect("placeholder allowlist is non-empty"); let mut components: Vec = vec![AccountComponent::from(auth_component), agglayer_component]; diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 0c7ceed13d..19f35dd8ce 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -138,7 +138,7 @@ fn create_bridge_account_builder( .account_type(AccountType::Public) .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id)) .with_auth_component( - AuthNetworkAccount::with_allowlist(AggLayerBridge::allowed_notes()) + AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) .expect("bridge note allowlist is non-empty"), ) } @@ -212,7 +212,7 @@ fn create_agglayer_faucet_builder( .with_components(token_policy_manager) .with_component(BurnAllowAll) .with_auth_component( - AuthNetworkAccount::with_allowlist(AggLayerFaucet::allowed_notes()) + AuthNetworkAccount::with_allowed_notes(AggLayerFaucet::allowed_notes()) .expect("faucet note allowlist is non-empty"), ) } diff --git a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs index cd0bdaeec0..6c692e1248 100644 --- a/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs +++ b/crates/miden-protocol/src/transaction/kernel/advice_inputs.rs @@ -197,7 +197,9 @@ impl TransactionAdviceInputs { // --- number of notes, script root and args -------------------------- self.extend_stack([Felt::from(tx_inputs.input_notes().num_notes())]); let tx_args = tx_inputs.tx_args(); - self.extend_stack(tx_args.tx_script().map_or(Word::empty(), |script| script.root())); + self.extend_stack( + tx_args.tx_script().map_or(Word::empty(), |script| script.root().as_word()), + ); self.extend_stack(tx_args.tx_script_args()); // --- auth procedure args -------------------------------------------- diff --git a/crates/miden-protocol/src/transaction/mod.rs b/crates/miden-protocol/src/transaction/mod.rs index 864c90014b..8b036db3f2 100644 --- a/crates/miden-protocol/src/transaction/mod.rs +++ b/crates/miden-protocol/src/transaction/mod.rs @@ -33,7 +33,7 @@ pub use outputs::{ pub use partial_blockchain::PartialBlockchain; pub use proven_tx::{InputNoteCommitment, ProvenTransaction, TxAccountUpdate}; pub use transaction_id::TransactionId; -pub use tx_args::{TransactionArgs, TransactionScript}; +pub use tx_args::{TransactionArgs, TransactionScript, TransactionScriptRoot}; pub use tx_header::TransactionHeader; pub use tx_summary::TransactionSummary; pub use verifier::TransactionVerifier; diff --git a/crates/miden-protocol/src/transaction/tx_args.rs b/crates/miden-protocol/src/transaction/tx_args.rs index c18b2eb996..0ffc8eeec5 100644 --- a/crates/miden-protocol/src/transaction/tx_args.rs +++ b/crates/miden-protocol/src/transaction/tx_args.rs @@ -1,9 +1,12 @@ use alloc::collections::BTreeMap; +use alloc::string::String; use alloc::sync::Arc; use alloc::vec::Vec; +use core::fmt::Display; use miden_core::mast::MastNodeExt; use miden_crypto::merkle::InnerNodeInfo; +use miden_crypto_derive::WordWrapper; use miden_mast_package::Package; use super::{Felt, Hasher, Word}; @@ -276,6 +279,42 @@ impl Deserializable for TransactionArgs { } } +// TRANSACTION SCRIPT ROOT +// ================================================================================================ + +/// The MAST root of a [`TransactionScript`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, WordWrapper)] +pub struct TransactionScriptRoot(Word); + +impl From for Word { + fn from(root: TransactionScriptRoot) -> Self { + root.0 + } +} + +impl Display for TransactionScriptRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl Serializable for TransactionScriptRoot { + fn write_into(&self, target: &mut W) { + target.write(self.0); + } + + fn get_size_hint(&self) -> usize { + self.0.get_size_hint() + } +} + +impl Deserializable for TransactionScriptRoot { + fn read_from(source: &mut R) -> Result { + let word: Word = source.read()?; + Ok(Self::from_raw(word)) + } +} + // TRANSACTION SCRIPT // ================================================================================================ @@ -334,8 +373,8 @@ impl TransactionScript { } /// Returns the commitment of this transaction script (i.e., the script's MAST root). - pub fn root(&self) -> Word { - self.mast[self.entrypoint].digest() + pub fn root(&self) -> TransactionScriptRoot { + TransactionScriptRoot::from_raw(self.mast[self.entrypoint].digest()) } /// Returns a new [TransactionScript] with the provided advice map entries merged into the diff --git a/crates/miden-standards/asm/account_components/auth/network_account.masm b/crates/miden-standards/asm/account_components/auth/network_account.masm index cd6b716a7f..0237e8996e 100644 --- a/crates/miden-standards/asm/account_components/auth/network_account.masm +++ b/crates/miden-standards/asm/account_components/auth/network_account.masm @@ -6,6 +6,7 @@ use miden::protocol::active_account use miden::protocol::native_account use miden::core::word use miden::standards::auth::note_script_allowlist +use miden::standards::auth::tx_script_allowlist # CONSTANTS # ================================================================================================= @@ -14,13 +15,18 @@ use miden::standards::auth::note_script_allowlist # (defined as Word); any non-empty value marks a root as allowed. const ALLOWED_NOTE_SCRIPTS_SLOT = word("miden::standards::auth::network_account::allowed_note_scripts") +# The slot holding the map of allowed tx script roots. Keys are tx script roots (defined as Word); +# any non-empty value marks a root as allowed. +const ALLOWED_TX_SCRIPTS_SLOT = word("miden::standards::auth::network_account::allowed_tx_scripts") + # AUTH PROCEDURE # ================================================================================================= #! Authenticates a transaction against an `AuthNetworkAccount` component. #! #! Enforces two invariants: -#! 1. No transaction script was executed in this transaction. +#! 1. The transaction script root, if any, must be present in the allowlist stored at +#! `ALLOWED_TX_SCRIPTS_SLOT` (a transaction that executed no tx script is always allowed). #! 2. Every consumed input note must have a script root present in the allowlist stored at #! `ALLOWED_NOTE_SCRIPTS_SLOT`. #! @@ -36,8 +42,11 @@ pub proc auth_network_transaction(auth_args: word) dropw # => [pad(16)] - # ---- Reject transactions that executed a tx script ---- - exec.note_script_allowlist::assert_no_tx_script + # ---- Reject any tx script whose root is not allowlisted ---- + push.ALLOWED_TX_SCRIPTS_SLOT[0..2] + # => [slot_id_suffix, slot_id_prefix, pad(16)] + + exec.tx_script_allowlist::assert_tx_script_allowed # => [pad(16)] # ---- Reject any input note whose script root is not allowlisted ---- diff --git a/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm index 32371667e1..3164c43324 100644 --- a/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm +++ b/crates/miden-standards/asm/standards/auth/note_script_allowlist.masm @@ -1,11 +1,10 @@ -# Reusable note-script allowlist primitives. +# Reusable note-script allowlist primitive. # -# Provides two checks used to restrict what an account can do during a transaction: -# - `assert_no_tx_script` rejects transactions that executed a tx script. +# Provides one check used to restrict what an account can do during a transaction: # - `assert_all_input_notes_allowed` rejects transactions that consume an input note whose script # root is not present in a storage map at the given slot id. # -# These are designed to be composed into auth components. The caller owns the storage map and +# This is designed to be composed into auth components. The caller owns the storage map and # passes the slot id (suffix, prefix) so the same logic can back multiple components, each with # their own allowlist. @@ -17,29 +16,11 @@ use miden::core::word # ERRORS # ================================================================================================= -const ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED="a transaction script cannot be executed against an account guarded by a note script allowlist" const ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED="input note script root is not in the note script allowlist" # PROCEDURES # ================================================================================================= -#! Asserts that no transaction script was executed in the current transaction. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Invocation: exec -pub proc assert_no_tx_script - exec.tx::get_tx_script_root - # => [TX_SCRIPT_ROOT] - - exec.word::eqz - # => [has_no_tx_script] - - assert.err=ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED - # => [] -end - #! Asserts that every input note consumed by this transaction has a script root present in the #! storage map at the given slot id. #! diff --git a/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm b/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm new file mode 100644 index 0000000000..e829d54ac0 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/tx_script_allowlist.masm @@ -0,0 +1,66 @@ +# Reusable tx-script allowlist primitive. +# +# Provides a single check used to restrict which transaction scripts an account will execute: +# - `assert_tx_script_allowed` accepts transactions that executed no tx script, and otherwise +# rejects transactions whose tx script root is not present in a storage map at the given slot id. +# +# This is designed to be composed into auth components. The caller owns the storage map and passes +# the slot id (suffix, prefix) so the same logic can back multiple components, each with their own +# allowlist. + +use miden::protocol::active_account +use miden::protocol::tx +use miden::core::word + +# ERRORS +# ================================================================================================= + +const ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED="transaction script root is not in the tx script allowlist" + +# PROCEDURES +# ================================================================================================= + +#! Asserts that the transaction script root is present in the storage map at the given slot id. +#! +#! A transaction that executed no tx script (empty root) is always allowed. Any other tx script must +#! have its root present in the allowlist. +#! +#! Map convention: keys are tx script roots (defined as Word), and any non-empty value marks a root +#! as allowed. Empty values (the default for absent keys) cause this procedure to fail. +#! +#! Inputs: [allowlist_slot_id_suffix, allowlist_slot_id_prefix] +#! Outputs: [] +#! +#! Where: +#! - allowlist_slot_id_{suffix, prefix} are the suffix and prefix felts of the slot identifier +#! pointing at the allowlist storage map. +#! +#! Invocation: exec +pub proc assert_tx_script_allowed + # => [slot_id_suffix, slot_id_prefix] + + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT, slot_id_suffix, slot_id_prefix] + + exec.word::testz + # => [no_tx_script, TX_SCRIPT_ROOT, slot_id_suffix, slot_id_prefix] + + if.true + # No tx script was executed, which is always allowed. + dropw drop drop + # => [] + else + movup.5 movup.5 + # => [slot_id_suffix, slot_id_prefix, TX_SCRIPT_ROOT] + + exec.active_account::get_map_item + # => [VALUE] + + exec.word::eqz not + # => [is_allowed] + + assert.err=ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED + # => [] + end + # => [] +end diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index e6caf01607..6bd23d4b63 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -22,4 +22,6 @@ pub use network_account::{ NetworkAccount, NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError, + NetworkAccountTxScriptAllowlist, + NetworkAccountTxScriptAllowlistError, }; diff --git a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs index 324c4efbbf..56322f66cc 100644 --- a/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs +++ b/crates/miden-standards/src/account/auth/network_account/auth_network_account.rs @@ -9,8 +9,13 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{AccountComponent, AccountComponentName, StorageSlotName}; use miden_protocol::note::NoteScriptRoot; +use miden_protocol::transaction::TransactionScriptRoot; -use super::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; +use super::{ + NetworkAccountNoteAllowlist, + NetworkAccountNoteAllowlistError, + NetworkAccountTxScriptAllowlist, +}; use crate::account::account_component_code; account_component_code!(NETWORK_ACCOUNT_AUTH_CODE, "auth/network_account.masl"); @@ -19,24 +24,44 @@ account_component_code!(NETWORK_ACCOUNT_AUTH_CODE, "auth/network_account.masl"); // ================================================================================================ /// An [`AccountComponent`] implementing an authentication scheme that restricts what notes an -/// account can consume to a fixed allowlist of note script roots, and forbids transaction scripts -/// from running against the account. +/// account can consume to a fixed allowlist of note script roots, and what transaction scripts may +/// run against the account to a fixed allowlist of tx script roots. /// /// This is intended for network-owned accounts (e.g. the AggLayer bridge or a network faucet) -/// whose only legitimate inputs are a known, finite set of system-issued notes. +/// whose only legitimate inputs are a known, finite set of system-issued notes and scripts. /// /// The component exports a single auth procedure, `auth_network_transaction`, that rejects the /// transaction unless: -/// - no transaction script was executed, and -/// - every consumed input note has a script root present in the component's allowlist. +/// - the transaction script root, if any, is present in the component's tx-script allowlist, and +/// - every consumed input note has a script root present in the component's note-script allowlist. +/// +/// Because a network account has no signature gate by default, a transaction script is an +/// unconstrained code path that could call the account's procedures directly. The tx-script +/// allowlist constrains this to a fixed set of owner-approved scripts; an empty tx-script allowlist +/// permits no transaction scripts at all. +/// +/// IMPORTANT: an allowlisted root pins a script's *code* (its MAST root), not the inputs it runs +/// on. A tx script still receives caller-controlled `TX_SCRIPT_ARGS` and advice-provider inputs, +/// and a note script receives caller-controlled `NOTE_ARGS`; on an open network account anyone can +/// supply those. A root should therefore only be allowlisted when the script's effect is safe for +/// *every* possible input. The canonical example is a tx script that sets the transaction +/// expiration delta to a hardcoded constant: its effect is fixed regardless of caller or inputs, +/// and the kernel only ever lets a script tighten the current transaction's expiration window +/// (never extend it), so the worst a caller can do is make their own transaction expire sooner. +/// Allowlisting a script whose effect depends on its inputs re-opens the very code path the +/// allowlist exists to constrain. /// -/// The allowlist is stored in the standardized [`NetworkAccountNoteAllowlist`] slot so off-chain -/// services can identify a network account by checking for this slot. +/// The note allowlist is stored in the standardized [`NetworkAccountNoteAllowlist`] slot so +/// off-chain services can identify a network account by checking for this slot. /// -/// The allowlist is fixed at account creation; there is intentionally no procedure to mutate it -/// after deployment. +/// Both allowlists are fixed at account creation: this component intentionally exports no procedure +/// to mutate them after deployment. That is a limitation of this component rather than a safety +/// requirement, and a user who needs a mutable allowlist can write their own component today. Note +/// that the node would likely not yet respect updates made to the list after deployment, but there +/// is in principle nothing preventing us from supporting mutation in the future. pub struct AuthNetworkAccount { - allowlist: NetworkAccountNoteAllowlist, + allowed_notes: NetworkAccountNoteAllowlist, + allowed_tx_scripts: NetworkAccountTxScriptAllowlist, } impl AuthNetworkAccount { @@ -60,33 +85,64 @@ impl AuthNetworkAccount { /// /// Returns an error if `allowed_script_roots` is empty since the account could not consume any /// notes. - pub fn with_allowlist( + pub fn with_allowed_notes( allowed_script_roots: BTreeSet, ) -> Result { Ok(Self { - allowlist: NetworkAccountNoteAllowlist::new(allowed_script_roots)?, + allowed_notes: NetworkAccountNoteAllowlist::new(allowed_script_roots)?, + allowed_tx_scripts: NetworkAccountTxScriptAllowlist::default(), }) } + /// Sets the allowlist of transaction script roots this account will execute, replacing any + /// previously configured tx-script allowlist. + /// + /// An empty set (the default) means the account permits no transaction scripts. + /// + /// Only scripts whose effect is safe for every possible input should be allowlisted: a root + /// pins the script's code but not its `TX_SCRIPT_ARGS` or advice inputs, which the + /// (arbitrary) transaction submitter controls. See the [`AuthNetworkAccount`] type docs for + /// the full rationale. + pub fn with_allowed_tx_scripts( + mut self, + allowed_tx_script_roots: BTreeSet, + ) -> Self { + self.allowed_tx_scripts = NetworkAccountTxScriptAllowlist::new(allowed_tx_script_roots); + self + } + /// Returns the storage slot holding the allowlist of allowed input-note script roots. pub fn allowed_note_scripts_slot() -> &'static StorageSlotName { NetworkAccountNoteAllowlist::slot_name() } - /// Returns the storage slot schema for the allowlist slot. + /// Returns the storage slot schema for the note-script allowlist slot. pub fn allowed_note_scripts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { NetworkAccountNoteAllowlist::slot_schema() } + /// Returns the storage slot holding the allowlist of allowed transaction script roots. + pub fn allowed_tx_scripts_slot() -> &'static StorageSlotName { + NetworkAccountTxScriptAllowlist::slot_name() + } + + /// Returns the storage slot schema for the tx-script allowlist slot. + pub fn allowed_tx_scripts_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + NetworkAccountTxScriptAllowlist::slot_schema() + } + /// Returns the [`AccountComponentMetadata`] for this component. pub fn component_metadata() -> AccountComponentMetadata { - let storage_schema = StorageSchema::new(vec![NetworkAccountNoteAllowlist::slot_schema()]) - .expect("storage schema should be valid"); + let storage_schema = StorageSchema::new(vec![ + NetworkAccountNoteAllowlist::slot_schema(), + NetworkAccountTxScriptAllowlist::slot_schema(), + ]) + .expect("storage schema should be valid"); AccountComponentMetadata::new(Self::NAME) .with_description( - "Authentication component that restricts input notes to a fixed allowlist of \ - note script roots and forbids tx scripts", + "Authentication component that restricts input notes and transaction scripts to \ + fixed allowlists of script roots", ) .with_storage_schema(storage_schema) } @@ -94,7 +150,10 @@ impl AuthNetworkAccount { impl From for AccountComponent { fn from(component: AuthNetworkAccount) -> Self { - let storage_slots = vec![component.allowlist.into_storage_slot()]; + let storage_slots = vec![ + component.allowed_notes.into_storage_slot(), + component.allowed_tx_scripts.into_storage_slot(), + ]; let metadata = AuthNetworkAccount::component_metadata(); AccountComponent::new(AuthNetworkAccount::code().clone(), storage_slots, metadata).expect( @@ -121,7 +180,7 @@ mod tests { let _account = AccountBuilder::new([0; 32]) .with_auth_component( - AuthNetworkAccount::with_allowlist(BTreeSet::from_iter([root_a, root_b])) + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([root_a, root_b])) .expect("non-empty allowlist should construct"), ) .with_component(BasicWallet) @@ -131,7 +190,7 @@ mod tests { #[test] fn auth_network_account_with_empty_allowlist_is_rejected() { - let result = AuthNetworkAccount::with_allowlist(BTreeSet::new()); + let result = AuthNetworkAccount::with_allowed_notes(BTreeSet::new()); assert!(matches!(result, Err(NetworkAccountNoteAllowlistError::EmptyAllowlist))); } @@ -139,16 +198,19 @@ mod tests { fn auth_network_account_uses_standardized_allowlist_slot() { let root_a = NoteScriptRoot::from_array([1, 2, 3, 4]); let component: AccountComponent = - AuthNetworkAccount::with_allowlist(BTreeSet::from_iter([root_a])) + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([root_a])) .expect("non-empty allowlist should construct") .into(); let storage_slots = component.storage_slots(); - assert_eq!(storage_slots.len(), 1); + assert_eq!(storage_slots.len(), 2); assert_eq!(storage_slots[0].name(), NetworkAccountNoteAllowlist::slot_name()); + assert_eq!(storage_slots[1].name(), NetworkAccountTxScriptAllowlist::slot_name()); - let StorageSlotContent::Map(_) = storage_slots[0].content() else { - panic!("allowlist slot must be a map"); - }; + for slot in storage_slots { + let StorageSlotContent::Map(_) = slot.content() else { + panic!("allowlist slots must be maps"); + }; + } } } diff --git a/crates/miden-standards/src/account/auth/network_account/mod.rs b/crates/miden-standards/src/account/auth/network_account/mod.rs index c3c773ca2a..d1362af9ab 100644 --- a/crates/miden-standards/src/account/auth/network_account/mod.rs +++ b/crates/miden-standards/src/account/auth/network_account/mod.rs @@ -7,3 +7,9 @@ pub use network_account::NetworkAccount; mod note_allowlist; pub use note_allowlist::{NetworkAccountNoteAllowlist, NetworkAccountNoteAllowlistError}; + +mod tx_script_allowlist; +pub use tx_script_allowlist::{ + NetworkAccountTxScriptAllowlist, + NetworkAccountTxScriptAllowlistError, +}; diff --git a/crates/miden-standards/src/account/auth/network_account/network_account.rs b/crates/miden-standards/src/account/auth/network_account/network_account.rs index dff81eae76..9f3e9aef7b 100644 --- a/crates/miden-standards/src/account/auth/network_account/network_account.rs +++ b/crates/miden-standards/src/account/auth/network_account/network_account.rs @@ -101,7 +101,7 @@ mod tests { AccountBuilder::new([0; 32]) .account_type(account_type) .with_auth_component( - AuthNetworkAccount::with_allowlist(roots).expect("non-empty allowlist"), + AuthNetworkAccount::with_allowed_notes(roots).expect("non-empty allowlist"), ) .with_component(BasicWallet) .build() diff --git a/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs b/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs index 68bb7519a2..5d458e39ff 100644 --- a/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs +++ b/crates/miden-standards/src/account/auth/network_account/note_allowlist.rs @@ -217,7 +217,7 @@ mod tests { let account = AccountBuilder::new([0; 32]) .with_auth_component( - AuthNetworkAccount::with_allowlist(original_roots.clone()) + AuthNetworkAccount::with_allowed_notes(original_roots.clone()) .expect("non-empty allowlist should construct"), ) .with_component(BasicWallet) diff --git a/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs b/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs new file mode 100644 index 0000000000..58d78b6340 --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account/tx_script_allowlist.rs @@ -0,0 +1,253 @@ +use alloc::collections::BTreeSet; + +use miden_protocol::account::component::{SchemaType, StorageSlotSchema}; +use miden_protocol::account::{ + AccountStorage, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotContent, + StorageSlotName, +}; +use miden_protocol::transaction::TransactionScriptRoot; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +// CONSTANTS +// ================================================================================================ + +static SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::network_account::allowed_tx_scripts") + .expect("storage slot name should be valid") +}); + +// A flag value used as the storage map entry for each allowed script root. Its only job is to be +// distinguishable from the storage map's default empty word, letting the MASM allowlist check +// detect "this key is present" without caring about its contents. Any non-empty word would serve; +// we pick `[1, 0, 0, 0]` for readability when inspecting storage. +const ALLOWED_FLAG: Word = Word::new([Felt::ONE, Felt::ZERO, Felt::ZERO, Felt::ZERO]); + +// NETWORK ACCOUNT TX SCRIPT ALLOWLIST +// ================================================================================================ + +/// A standardized storage slot holding the allowlist of transaction script roots that a network +/// account is willing to execute. +/// +/// A network account has no signature gate, so any transaction script that runs against it is a +/// code path the account owner must have pre-approved. This allowlist is that approval: a +/// transaction that executes no tx script is always allowed, but any other tx script must have its +/// root present here. An empty allowlist therefore reproduces the strictest behavior of permitting +/// no transaction scripts at all. +/// +/// A root pins the script's code but not its `TX_SCRIPT_ARGS` or advice inputs, which the +/// transaction submitter controls; only scripts whose effect is safe for every possible input +/// should be allowlisted (see the [`AuthNetworkAccount`](super::AuthNetworkAccount) docs). +/// +/// The slot is a [`StorageMap`] keyed by tx script root; any non-empty value marks a root as +/// allowed. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct NetworkAccountTxScriptAllowlist { + allowed_script_roots: BTreeSet, +} + +impl NetworkAccountTxScriptAllowlist { + /// Creates a new allowlist from the provided list of allowed transaction script roots. + /// + /// An empty set is permitted and means the account allows no transaction scripts. + pub fn new(allowed_script_roots: BTreeSet) -> Self { + Self { allowed_script_roots } + } + + /// Returns the [`StorageSlotName`] of the standardized allowlist slot. + pub fn slot_name() -> &'static StorageSlotName { + &SLOT_NAME + } + + /// Returns the allowed transaction script roots in this allowlist. + pub fn allowed_script_roots(&self) -> &BTreeSet { + &self.allowed_script_roots + } + + /// Consumes this allowlist and returns the allowed transaction script roots. + pub fn into_allowed_script_roots(self) -> BTreeSet { + self.allowed_script_roots + } + + /// Returns the schema entry for the allowlist slot. + pub fn slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::slot_name().clone(), + StorageSlotSchema::map( + "Allowed transaction script roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + /// Consumes this allowlist and returns the [`StorageSlot`] suitable for inclusion in an + /// [`AccountComponent`](miden_protocol::account::AccountComponent)'s storage layout. + pub fn into_storage_slot(self) -> StorageSlot { + let entries = self + .allowed_script_roots + .into_iter() + .map(|root| (StorageMapKey::new(root.as_word()), ALLOWED_FLAG)); + + let storage_map = StorageMap::with_entries(entries) + .expect("allowlist entries should produce a valid storage map"); + + StorageSlot::with_map(Self::slot_name().clone(), storage_map) + } +} + +// TRAIT IMPLEMENTATIONS +// ================================================================================================ + +impl TryFrom<&AccountStorage> for NetworkAccountTxScriptAllowlist { + type Error = NetworkAccountTxScriptAllowlistError; + + /// Reconstructs a [`NetworkAccountTxScriptAllowlist`] from account storage by reading the + /// allowlist slot and collecting its keys. + /// + /// # Errors + /// Returns an error if: + /// - The standardized allowlist slot is not present in storage. + /// - The slot is present but is not a [`StorageSlotContent::Map`]. + fn try_from(storage: &AccountStorage) -> Result { + let slot = storage + .get(Self::slot_name()) + .ok_or(NetworkAccountTxScriptAllowlistError::SlotNotFound)?; + + let StorageSlotContent::Map(map) = slot.content() else { + return Err(NetworkAccountTxScriptAllowlistError::UnexpectedSlotType); + }; + + // Only entries with a non-empty value mark a root as allowed, matching the MASM check + // (`word::eqz`), so the reconstructed view agrees with on-chain enforcement. + let allowed_script_roots = map + .entries() + .filter(|(_key, value)| **value != Word::empty()) + .map(|(key, _value)| TransactionScriptRoot::from_raw(key.as_word())) + .collect(); + + Ok(Self::new(allowed_script_roots)) + } +} + +// NETWORK ACCOUNT TX SCRIPT ALLOWLIST ERROR +// ================================================================================================ + +/// Errors that can occur when reconstructing a [`NetworkAccountTxScriptAllowlist`] from storage. +#[derive(Debug, thiserror::Error)] +pub enum NetworkAccountTxScriptAllowlistError { + #[error( + "network account tx script allowlist storage slot {} not found in account storage", + NetworkAccountTxScriptAllowlist::slot_name() + )] + SlotNotFound, + #[error( + "network account tx script allowlist storage slot {} must be a map", + NetworkAccountTxScriptAllowlist::slot_name() + )] + UnexpectedSlotType, +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountBuilder, StorageSlotContent}; + + use super::*; + use crate::account::auth::network_account::AuthNetworkAccount; + use crate::account::wallets::BasicWallet; + + #[test] + fn allowlist_storage_slot_contains_expected_entries() { + let root_a = TransactionScriptRoot::from_raw(Word::from([1, 2, 3, 4u32])); + let root_b = TransactionScriptRoot::from_raw(Word::from([5, 6, 7, 8u32])); + + let slot = NetworkAccountTxScriptAllowlist::new(BTreeSet::from_iter([root_a, root_b])) + .into_storage_slot(); + + assert_eq!(slot.name(), NetworkAccountTxScriptAllowlist::slot_name()); + + let StorageSlotContent::Map(map) = slot.content() else { + panic!("allowlist slot must be a map"); + }; + + assert_eq!( + map.get(&StorageMapKey::new(root_a.as_word())), + ALLOWED_FLAG, + "root_a should resolve to the flag value" + ); + assert_eq!( + map.get(&StorageMapKey::new(root_b.as_word())), + ALLOWED_FLAG, + "root_b should resolve to the flag value" + ); + } + + #[test] + fn empty_allowlist_is_allowed() { + let slot = NetworkAccountTxScriptAllowlist::new(BTreeSet::new()).into_storage_slot(); + let StorageSlotContent::Map(map) = slot.content() else { + panic!("allowlist slot must be a map"); + }; + assert_eq!(map.entries().count(), 0); + } + + #[test] + fn allowlist_round_trips_through_account_storage() { + let root_a = TransactionScriptRoot::from_raw(Word::from([1, 2, 3, 4u32])); + let root_b = TransactionScriptRoot::from_raw(Word::from([5, 6, 7, 8u32])); + let original_roots = BTreeSet::from_iter([root_a, root_b]); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component( + AuthNetworkAccount::with_allowed_notes(BTreeSet::from_iter([ + miden_protocol::note::NoteScriptRoot::from_array([9, 9, 9, 9]), + ])) + .expect("non-empty note allowlist should construct") + .with_allowed_tx_scripts(original_roots.clone()), + ) + .with_component(BasicWallet) + .build() + .expect("account building with AuthNetworkAccount failed"); + + let allowlist = NetworkAccountTxScriptAllowlist::try_from(account.storage()) + .expect("allowlist should be reconstructable from account storage"); + + let actual: BTreeSet = + allowlist.allowed_script_roots().iter().copied().collect(); + + assert_eq!(actual, original_roots); + } + + #[test] + fn try_from_fails_when_slot_missing() { + // Storage that contains an unrelated slot but not the tx-script allowlist slot. + let other_slot = StorageSlot::with_value( + StorageSlotName::new("miden::standards::test::unrelated").expect("valid slot name"), + Word::empty(), + ); + let storage = AccountStorage::new(vec![other_slot]).expect("storage should be valid"); + + let result = NetworkAccountTxScriptAllowlist::try_from(&storage); + assert!(matches!(result, Err(NetworkAccountTxScriptAllowlistError::SlotNotFound))); + } + + #[test] + fn try_from_fails_when_slot_is_not_a_map() { + // The allowlist slot is present but is a value slot rather than the expected map. + let value_slot = StorageSlot::with_value( + NetworkAccountTxScriptAllowlist::slot_name().clone(), + Word::empty(), + ); + let storage = AccountStorage::new(vec![value_slot]).expect("storage should be valid"); + + let result = NetworkAccountTxScriptAllowlist::try_from(&storage); + assert!(matches!(result, Err(NetworkAccountTxScriptAllowlistError::UnexpectedSlotType))); + } +} diff --git a/crates/miden-standards/src/account/faucets/fungible/mod.rs b/crates/miden-standards/src/account/faucets/fungible/mod.rs index 9dd256bc2a..7f1fcd42c9 100644 --- a/crates/miden-standards/src/account/faucets/fungible/mod.rs +++ b/crates/miden-standards/src/account/faucets/fungible/mod.rs @@ -669,13 +669,17 @@ fn build_auth_component( // consumed against the faucet. ( AccessControl::Ownable2Step { .. } | AccessControl::Rbac { .. }, - AuthMethod::NetworkAccount { allowed_script_roots }, - ) => Ok(AuthNetworkAccount::with_allowlist(allowed_script_roots) + AuthMethod::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots, + }, + ) => Ok(AuthNetworkAccount::with_allowed_notes(allowed_script_roots) .map_err(|err| { FungibleFaucetError::UnsupportedAuthMethod(alloc::format!( "invalid network account allowlist: {err}" )) })? + .with_allowed_tx_scripts(allowed_tx_script_roots) .into()), // Ownable2Step / Rbac + NoAuth: valid; the setter gate is the in-procedure owner / diff --git a/crates/miden-standards/src/account/faucets/fungible/tests.rs b/crates/miden-standards/src/account/faucets/fungible/tests.rs index 7072e4003d..c7d1e155d6 100644 --- a/crates/miden-standards/src/account/faucets/fungible/tests.rs +++ b/crates/miden-standards/src/account/faucets/fungible/tests.rs @@ -196,7 +196,10 @@ fn auth_controlled_rejects_network_account() { [7u8; 32], sample_faucet(), AccountType::Private, - AuthMethod::NetworkAccount { allowed_script_roots }, + AuthMethod::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots: BTreeSet::new(), + }, AccessControl::AuthControlled, allow_all_policy_manager(), ) diff --git a/crates/miden-standards/src/account/interface/component.rs b/crates/miden-standards/src/account/interface/component.rs index a386fa0c0c..121e694274 100644 --- a/crates/miden-standards/src/account/interface/component.rs +++ b/crates/miden-standards/src/account/interface/component.rs @@ -14,6 +14,7 @@ use crate::account::auth::{ AuthSingleSig, AuthSingleSigAcl, NetworkAccountNoteAllowlist, + NetworkAccountTxScriptAllowlist, }; use crate::account::interface::AccountInterfaceError; @@ -411,9 +412,12 @@ fn extract_multisig_auth_method( /// Extracts authentication method from a network-account component. fn extract_network_account_auth_method(storage: &AccountStorage) -> AuthMethod { let allowlist = NetworkAccountNoteAllowlist::try_from(storage) - .expect("network account allowlist slot should be present and valid"); + .expect("network account note allowlist slot should be present and valid"); + let tx_script_allowlist = NetworkAccountTxScriptAllowlist::try_from(storage) + .expect("network account tx script allowlist slot should be present and valid"); AuthMethod::NetworkAccount { allowed_script_roots: allowlist.into_allowed_script_roots(), + allowed_tx_script_roots: tx_script_allowlist.into_allowed_script_roots(), } } diff --git a/crates/miden-standards/src/auth_method.rs b/crates/miden-standards/src/auth_method.rs index 1cb97350e7..86c0306e2a 100644 --- a/crates/miden-standards/src/auth_method.rs +++ b/crates/miden-standards/src/auth_method.rs @@ -3,6 +3,7 @@ use alloc::vec::Vec; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::note::NoteScriptRoot; +use miden_protocol::transaction::TransactionScriptRoot; /// Defines standard authentication methods supported by account auth components. #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,10 +29,12 @@ pub enum AuthMethod { /// An authentication method intended for network-owned accounts. /// /// It restricts the account to consuming only notes whose script roots are in - /// `allowed_script_roots`, and forbids transaction scripts from running against the account. - /// The allowlist must be non-empty. + /// `allowed_script_roots` (which must be non-empty), and to executing only transaction scripts + /// whose roots are in `allowed_tx_script_roots`. An empty `allowed_tx_script_roots` permits no + /// transaction scripts. NetworkAccount { allowed_script_roots: BTreeSet, + allowed_tx_script_roots: BTreeSet, }, /// A non-standard authentication method. Unknown, diff --git a/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs b/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs index 667eaea6fd..95ab4b236b 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_prologue.rs @@ -207,7 +207,7 @@ fn global_input_memory_assertions(exec_output: &ExecutionOutput, inputs: &Transa assert_eq!( exec_output.get_kernel_mem_word(TX_SCRIPT_ROOT_PTR), - inputs.tx_args().tx_script().as_ref().unwrap().root(), + inputs.tx_args().tx_script().as_ref().unwrap().root().as_word(), "The transaction script root should be stored at the TX_SCRIPT_ROOT_PTR" ); } diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 7d08b5f890..3c79e25c1d 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -8,6 +8,7 @@ use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKeyCommitme use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; use miden_protocol::note::NoteScriptRoot; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_protocol::transaction::TransactionScriptRoot; use miden_standards::account::auth::multisig_smart::ProcedurePolicy; use miden_standards::account::auth::{ AuthGuardedMultisig, @@ -83,9 +84,11 @@ pub enum Auth { Conditional, /// Network-account authentication that restricts the account to consuming only notes whose - /// script roots appear in `allowed_script_roots`. Must be non-empty. + /// script roots appear in `allowed_script_roots` (must be non-empty), and to executing only + /// transaction scripts whose roots appear in `allowed_tx_script_roots` (may be empty). NetworkAccount { allowed_script_roots: BTreeSet, + allowed_tx_script_roots: BTreeSet, }, } @@ -180,10 +183,15 @@ impl Auth { Auth::IncrNonce => (IncrNonceAuthComponent.into(), None), Auth::Noop => (NoopAuthComponent.into(), None), Auth::Conditional => (ConditionalAuthComponent.into(), None), - Auth::NetworkAccount { allowed_script_roots } => { - let component = AuthNetworkAccount::with_allowlist(allowed_script_roots.clone()) - .expect("network account allowlist must be non-empty") - .into(); + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots, + } => { + let component = + AuthNetworkAccount::with_allowed_notes(allowed_script_roots.clone()) + .expect("network account allowlist must be non-empty") + .with_allowed_tx_scripts(allowed_tx_script_roots.clone()) + .into(); (component, None) }, } diff --git a/crates/miden-testing/src/mock_chain/chain_builder.rs b/crates/miden-testing/src/mock_chain/chain_builder.rs index ff146119c6..e3202a0b02 100644 --- a/crates/miden-testing/src/mock_chain/chain_builder.rs +++ b/crates/miden-testing/src/mock_chain/chain_builder.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use anyhow::Context; @@ -459,7 +459,10 @@ impl MockChainBuilder { .collect(); self.add_existing_fungible_faucet( - Auth::NetworkAccount { allowed_script_roots }, + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots: BTreeSet::new(), + }, faucet, AccountType::Public, AccessControl::Ownable2Step { owner: owner_account_id }, @@ -492,7 +495,10 @@ impl MockChainBuilder { .collect(); self.add_existing_fungible_faucet( - Auth::NetworkAccount { allowed_script_roots }, + Auth::NetworkAccount { + allowed_script_roots, + allowed_tx_script_roots: BTreeSet::new(), + }, faucet, AccountType::Public, AccessControl::Ownable2Step { owner: owner_account_id }, diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index a3bfa23ac0..6c97851245 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -24,7 +24,7 @@ use miden_protocol::transaction::RawOutputNote; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED, - ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, + ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, }; use miden_standards::testing::note::NoteBuilder; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; @@ -38,7 +38,7 @@ end "; /// Asserts that a transaction submitting any tx script against a bridge account fails with -/// [`ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`], even when the transaction also consumes +/// [`ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`], even when the transaction also consumes /// an allowlisted input note (UPDATE_GER). This proves the tx-script check fires regardless of /// what allowlisted input notes accompany it — the two allowlist checks are independent. #[tokio::test] @@ -79,7 +79,7 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } @@ -126,7 +126,7 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { } /// Asserts that a transaction submitting any tx script against an AggLayer faucet account fails -/// with [`ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`]. Symmetric to +/// with [`ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED`]. Symmetric to /// [`bridge_rejects_tx_script`]: the faucet's [`AuthNetworkAccount`] allowlist (MINT, BURN) must /// reject every tx script, regardless of which input notes (if any) accompany it. /// @@ -162,7 +162,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } diff --git a/crates/miden-testing/tests/auth/network_account.rs b/crates/miden-testing/tests/auth/network_account.rs index e8f0fa8919..3a38009349 100644 --- a/crates/miden-testing/tests/auth/network_account.rs +++ b/crates/miden-testing/tests/auth/network_account.rs @@ -1,36 +1,48 @@ use core::slice; +use std::collections::BTreeSet; -use miden_protocol::Word; use miden_protocol::account::{Account, AccountBuilder, AccountType}; -use miden_protocol::note::NoteScriptRoot; -use miden_protocol::transaction::RawOutputNote; +use miden_protocol::note::{Note, NoteScriptRoot}; +use miden_protocol::testing::account_id::ACCOUNT_ID_SENDER; +use miden_protocol::transaction::{RawOutputNote, TransactionScript, TransactionScriptRoot}; +use miden_protocol::{Felt, Word}; use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_NOTE_SCRIPT_ALLOWLIST_NOTE_NOT_ALLOWED, - ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, + ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED, }; use miden_standards::testing::note::NoteBuilder; use miden_testing::{MockChain, assert_transaction_executor_error}; +use rstest::rstest; // HELPER FUNCTIONS // ================================================================================================ -/// A placeholder script root used when a test needs an [`AuthNetworkAccount`] account whose -/// allowlist contents are not material to the test logic (e.g. for bootstrap accounts that only -/// exist to seed a [`NoteBuilder`]). The constructor rejects empty allowlists, so tests must -/// supply at least one root. +/// A placeholder note script root used when a test needs an [`AuthNetworkAccount`] account whose +/// allowlist contents are not material to the test logic (e.g. an account that never consumes a +/// note). The constructor rejects empty allowlists, so tests must supply at least one root. fn placeholder_script_root() -> Word { NoteScriptRoot::from_array([1, 0, 0, 0]).into() } /// Builds a minimal account that uses the [`AuthNetworkAccount`] auth component with the provided -/// allowlist of input-note script roots. +/// allowlist of input-note script roots and an empty tx-script allowlist. fn build_allowlist_account(allowed_script_roots: Vec) -> anyhow::Result { - let auth_component = AuthNetworkAccount::with_allowlist( - allowed_script_roots.into_iter().map(NoteScriptRoot::from_raw).collect(), - )?; + build_account_with_allowlists(allowed_script_roots, Vec::new()) +} + +/// Builds a minimal account that uses the [`AuthNetworkAccount`] auth component with the provided +/// note-script and tx-script allowlists. +fn build_account_with_allowlists( + allowed_note_script_roots: Vec, + allowed_tx_script_roots: Vec, +) -> anyhow::Result { + let auth_component = AuthNetworkAccount::with_allowed_notes( + allowed_note_script_roots.into_iter().map(NoteScriptRoot::from_raw).collect(), + )? + .with_allowed_tx_scripts(allowed_tx_script_roots.into_iter().collect::>()); Ok(AccountBuilder::new([0; 32]) .with_auth_component(auth_component) @@ -39,14 +51,59 @@ fn build_allowlist_account(allowed_script_roots: Vec) -> anyhow::Result anyhow::Result { + Ok(NoteBuilder::new(ACCOUNT_ID_SENDER.try_into()?, &mut rand::rng()).build()?) +} + +/// Compiles a transaction script that sets the transaction expiration delta to `delta`. This is the +/// canonical kind of tx script a network account would allowlist (see protocol issue #3027). +fn expiration_tx_script(delta: u16) -> TransactionScript { + let code = format!( + " + use miden::protocol::tx + + begin + push.{delta} + exec.tx::update_expiration_block_delta + end + " + ); + + CodeBuilder::default() + .compile_tx_script(code) + .expect("expiration tx script should compile") +} + +/// Compiles a transaction script that sets the expiration delta to the value the caller supplies in +/// the first element of `TX_SCRIPT_ARGS`, rather than baking it into the script. A single +/// allowlisted root therefore accepts any caller-chosen delta. At script entry the operand stack +/// holds `[TX_SCRIPT_ARGS, ..]`, so the top element is the delta; the remaining three arg elements +/// are dropped. +fn expiration_from_args_tx_script() -> TransactionScript { + let code = " + use miden::protocol::tx + + begin + exec.tx::update_expiration_block_delta + drop drop drop + end + "; + + CodeBuilder::default() + .compile_tx_script(code) + .expect("expiration-from-args tx script should compile") +} + // TESTS // ================================================================================================ -/// A transaction that executes a tx script must be rejected by `AuthNetworkAccount`, even if the -/// allowlist and input notes are otherwise valid. +/// A transaction that executes a tx script whose root is not in the tx-script allowlist must be +/// rejected by `AuthNetworkAccount`. An empty tx-script allowlist rejects every tx script. #[tokio::test] async fn test_auth_network_account_rejects_tx_script() -> anyhow::Result<()> { - // Allowlist contents don't matter — the tx-script check rejects before any allowlist lookup. + // Empty tx-script allowlist => no tx script is permitted. let account = build_allowlist_account(vec![placeholder_script_root()])?; let mut builder = MockChain::builder(); @@ -62,42 +119,220 @@ async fn test_auth_network_account_rejects_tx_script() -> anyhow::Result<()> { .execute() .await; - assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); Ok(()) } -/// A transaction that consumes a mix of allowed and disallowed input notes must be rejected: the -/// allowlist check must fail as soon as any single consumed note is not in the allowlist, even if -/// the others are. +/// A transaction that executes a tx script whose root IS in the tx-script allowlist must succeed, +/// and the script's effect (setting the expiration delta) must be reflected in the transaction. +/// +/// The transaction also consumes an allowlisted note, both because a network transaction does so in +/// practice and because the kernel rejects a transaction that neither changes the account state nor +/// consumes a note. #[tokio::test] -async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow::Result<()> { - // Build a template note with the default code to learn the "allowed" script root. The - // bootstrap account never executes a transaction, so its allowlist contents don't matter. - let bootstrap_account = build_allowlist_account(vec![placeholder_script_root()])?; - let template_allowed = NoteBuilder::new(bootstrap_account.id(), &mut rand::rng()) - .build() - .expect("failed to build template allowed note"); - let allowed_root = template_allowed.script().root(); +async fn test_auth_network_account_accepts_allowlisted_tx_script() -> anyhow::Result<()> { + const DELTA: u16 = 10; + let tx_script = expiration_tx_script(DELTA); + + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note script root and the expiration tx script's root. + let account = + build_account_with_allowlists(vec![note.script().root().into()], vec![tx_script.root()])?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(tx_script) + .build()? + .execute() + .await?; + + // The expiration delta script set the expiration to reference_block + DELTA. + let reference_block = executed.block_header().block_num(); + assert_eq!( + executed.expiration_block_num(), + reference_block + u32::from(DELTA), + "the allowlisted expiration script should have set the expiration block number", + ); + + Ok(()) +} + +/// A transaction that runs no tx script must be allowed regardless of the tx-script allowlist +/// contents: the empty-root case short-circuits before any allowlist lookup. +#[tokio::test] +async fn test_auth_network_account_allows_no_tx_script_with_non_empty_allowlist() +-> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Non-empty tx-script allowlist, but the transaction below runs no tx script at all. + let account = build_account_with_allowlists( + vec![note.script().root().into()], + vec![expiration_tx_script(10).root()], + )?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; - // Build the real account with only that one root in the allowlist. - let account = build_allowlist_account(vec![allowed_root.into()])?; + mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .build()? + .execute() + .await?; + + Ok(()) +} + +/// A non-empty tx-script allowlist must still reject a tx script whose root is not in it. +#[tokio::test] +async fn test_auth_network_account_rejects_non_allowlisted_tx_script() -> anyhow::Result<()> { + // Allowlist the expiration script, then try to run a different (non-allowlisted) tx script. + let allowed_script = expiration_tx_script(10); + let account = build_account_with_allowlists( + vec![placeholder_script_root()], + vec![allowed_script.root()], + )?; let mut builder = MockChain::builder(); builder.add_account(account.clone())?; + let mock_chain = builder.build()?; + + let other_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + assert_ne!( + other_script.root(), + allowed_script.root(), + "the other script must differ from the allowlisted one", + ); + + let result = mock_chain + .build_tx_context(account.id(), &[], &[])? + .tx_script(other_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_TX_SCRIPT_ALLOWLIST_TX_SCRIPT_NOT_ALLOWED); + + Ok(()) +} + +/// Allowlisting several *distinct* tx-script roots must let a transaction run any one of them. Both +/// hardcoded expiration scripts (delta 10 and delta 30) are allowlisted, and running either one +/// end-to-end succeeds and produces the corresponding expiration block number. +#[rstest] +#[case(10)] +#[case(30)] +#[tokio::test] +async fn test_auth_network_account_accepts_any_of_multiple_allowlisted_roots( + #[case] delta: u16, +) -> anyhow::Result<()> { + let script_10 = expiration_tx_script(10); + let script_30 = expiration_tx_script(30); + let tx_script = expiration_tx_script(delta); + + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note root and both distinct expiration script roots. + let account = build_account_with_allowlists( + vec![note.script().root().into()], + vec![script_10.root(), script_30.root()], + )?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(tx_script) + .build()? + .execute() + .await?; - // Allowed note: uses the default note code so its script root matches `allowed_root`. - let note_allowed = NoteBuilder::new(account.id(), &mut rand::rng()) - .build() - .expect("failed to build allowed input note"); assert_eq!( - note_allowed.script().root(), - allowed_root, - "default-code NoteBuilder should reproduce the allowed script root", + executed.expiration_block_num(), + executed.block_header().block_num() + u32::from(delta), + "running one of several allowlisted scripts should set the expiration to reference + delta", ); - // Disallowed note: distinct code → distinct script root → not in the allowlist. - let note_disallowed = NoteBuilder::new(account.id(), &mut rand::rng()) + Ok(()) +} + +/// An allowlisted tx script may read caller-supplied `TX_SCRIPT_ARGS`: the allowlist pins the +/// script's *code* (its root), not its arguments. Here a single expiration-delta script that takes +/// the delta from `TX_SCRIPT_ARGS` is allowlisted, and transactions supplying different arbitrary +/// deltas all run end-to-end and produce the corresponding expiration block number. +/// +/// This is the input-dependent pattern the type docs caution against in general; it is acceptable +/// for the expiration delta specifically because the kernel only ever lets a script tighten the +/// expiration window, never extend it, so the worst an arbitrary caller can do is make their own +/// transaction expire sooner. Network accounts that want this (e.g. for the ntx-builder) can +/// allowlist such a script knowingly. +#[rstest] +#[case(10)] +#[case(30)] +#[case(u16::MAX)] +#[tokio::test] +async fn test_auth_network_account_accepts_allowlisted_tx_script_with_caller_args( + #[case] delta: u16, +) -> anyhow::Result<()> { + let tx_script = expiration_from_args_tx_script(); + + let mut builder = MockChain::builder(); + let note = build_input_note()?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + // Allowlist the note root and the single caller-parameterized expiration script. + let account = + build_account_with_allowlists(vec![note.script().root().into()], vec![tx_script.root()])?; + builder.add_account(account.clone())?; + + let mock_chain = builder.build()?; + + // The caller chooses the expiration delta via TX_SCRIPT_ARGS; the allowlist still permits it + // because the script's root is allowlisted regardless of its arguments. + let tx_script_args = Word::new([Felt::from(delta), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + + let executed = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .tx_script(tx_script) + .tx_script_args(tx_script_args) + .build()? + .execute() + .await?; + + assert_eq!( + executed.expiration_block_num(), + executed.block_header().block_num() + u32::from(delta), + "the caller-supplied expiration delta should be applied", + ); + + Ok(()) +} + +/// A transaction that consumes a mix of allowed and disallowed input notes must be rejected: the +/// allowlist check must fail as soon as any single consumed note is not in the allowlist, even if +/// the others are. +#[tokio::test] +async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // Allowed note: uses the default note code, so its script root is the one we allowlist. + let note_allowed = build_input_note()?; + let account = build_allowlist_account(vec![note_allowed.script().root().into()])?; + builder.add_account(account.clone())?; + + // Disallowed note: distinct code => distinct script root => not in the allowlist. + let note_disallowed = NoteBuilder::new(ACCOUNT_ID_SENDER.try_into()?, &mut rand::rng()) .code( "\ @note_script @@ -106,11 +341,10 @@ async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow: end ", ) - .build() - .expect("failed to build disallowed input note"); + .build()?; assert_ne!( note_disallowed.script().root(), - allowed_root, + note_allowed.script().root(), "disallowed note must have a different script root than the allowed one", ); @@ -134,31 +368,11 @@ async fn test_auth_network_account_rejects_when_any_note_disallowed() -> anyhow: /// Consuming an input note whose script root is in the allowlist must succeed. #[tokio::test] async fn test_auth_network_account_accepts_allowed_note() -> anyhow::Result<()> { - // First build a template note so we know its script root, then use that root to configure the - // account's allowlist. The bootstrap account never executes a transaction, so its allowlist - // contents don't matter. - let bootstrap_account = build_allowlist_account(vec![placeholder_script_root()])?; - let template_note = NoteBuilder::new(bootstrap_account.id(), &mut rand::rng()) - .build() - .expect("failed to build template note"); - let allowed_root = template_note.script().root(); - - // Now build the real account with the allowlist containing that root. - let account = build_allowlist_account(vec![allowed_root.into()])?; - let mut builder = MockChain::builder(); - builder.add_account(account.clone())?; - // Build a note that uses the same code but is sent from the real account so its script root - // matches `allowed_root`. - let note = NoteBuilder::new(account.id(), &mut rand::rng()) - .build() - .expect("failed to build input note"); - assert_eq!( - note.script().root(), - allowed_root, - "NoteBuilder with default code should produce a fixed script root" - ); + let note = build_input_note()?; + let account = build_allowlist_account(vec![note.script().root().into()])?; + builder.add_account(account.clone())?; builder.add_output_note(RawOutputNote::Full(note.clone())); let mock_chain = builder.build()?; @@ -167,8 +381,7 @@ async fn test_auth_network_account_accepts_allowed_note() -> anyhow::Result<()> .build_tx_context(account.id(), &[], slice::from_ref(¬e))? .build()? .execute() - .await - .expect("consuming an allowed note should succeed"); + .await?; Ok(()) } diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index 65b51b5875..06d0313ab1 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -2109,6 +2109,7 @@ fn build_network_faucet_with_blocklist_transfer( builder.add_account_from_builder( Auth::NetworkAccount { allowed_script_roots: BTreeSet::from([MintNote::script_root()]), + allowed_tx_script_roots: BTreeSet::new(), }, account_builder, AccountState::Exists,