From 29e6a0a95799114d0a6ed08548f89276aa325413 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 27 Apr 2026 16:03:13 -0300 Subject: [PATCH 01/16] verify root of RPC-fetched note scripts --- crates/rust-client/src/store/data_store.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/rust-client/src/store/data_store.rs b/crates/rust-client/src/store/data_store.rs index f6c326aea5..732e0c83cb 100644 --- a/crates/rust-client/src/store/data_store.rs +++ b/crates/rust-client/src/store/data_store.rs @@ -430,6 +430,14 @@ impl DataStore for ClientDataStore { DataStoreError::other_with_source("failed to fetch note script via RPC", err) })?; + // Verify the RPC returned the script we asked for. + let fetched_root = note_script.root(); + if fetched_root != script_root { + return Err(DataStoreError::other(format!( + "RPC returned note script with root {fetched_root} for requested root {script_root}", + ))); + } + // Persist for future lookups. if let Err(err) = store.upsert_note_scripts(core::slice::from_ref(¬e_script)).await { tracing::warn!( From d7ac558cfea5af51f3e4d2d689268b6e934bac1d Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 28 Apr 2026 11:35:09 -0300 Subject: [PATCH 02/16] move note-script root verification down to the RPC client The check now lives in `GrpcClient::get_note_script_by_root` and returns `RpcError::InvalidResponse` on a root mismatch, with the invariant documented on the `NodeRpcClient` trait. Removes the duplicate check from `ClientDataStore::get_note_script` and ensures every caller of the RPC method (including `ensure_ntx_scripts_registered`) gets the verification. --- crates/rust-client/src/rpc/mod.rs | 6 ++++++ crates/rust-client/src/rpc/tonic_client/mod.rs | 7 +++++++ crates/rust-client/src/store/data_store.rs | 8 -------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index 2affd2d247..c89c651f16 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -406,8 +406,14 @@ pub trait NodeRpcClient: Send + Sync { /// Fetches the note script with the specified root. /// + /// Implementations must verify that the returned script's root matches the requested + /// `root` and return [`RpcError::InvalidResponse`] otherwise; callers may rely on this + /// invariant. + /// /// Errors: /// - [`RpcError::ExpectedDataMissing`] if the note with the specified root is not found. + /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not + /// match the requested `root`. async fn get_note_script_by_root(&self, root: Word) -> Result; /// Fetches storage map updates for specified account and storage slots within a block range, diff --git a/crates/rust-client/src/rpc/tonic_client/mod.rs b/crates/rust-client/src/rpc/tonic_client/mod.rs index dd980f67c3..df6b3cd82e 100644 --- a/crates/rust-client/src/rpc/tonic_client/mod.rs +++ b/crates/rust-client/src/rpc/tonic_client/mod.rs @@ -945,6 +945,13 @@ impl NodeRpcClient for GrpcClient { .ok_or(RpcError::ExpectedDataMissing("GetNoteScriptByRoot.script".to_string()))?, )?; + let fetched_root = note_script.root(); + if fetched_root != root { + return Err(RpcError::InvalidResponse(format!( + "node returned note script with root {fetched_root} for requested root {root}", + ))); + } + Ok(note_script) } diff --git a/crates/rust-client/src/store/data_store.rs b/crates/rust-client/src/store/data_store.rs index 732e0c83cb..f6c326aea5 100644 --- a/crates/rust-client/src/store/data_store.rs +++ b/crates/rust-client/src/store/data_store.rs @@ -430,14 +430,6 @@ impl DataStore for ClientDataStore { DataStoreError::other_with_source("failed to fetch note script via RPC", err) })?; - // Verify the RPC returned the script we asked for. - let fetched_root = note_script.root(); - if fetched_root != script_root { - return Err(DataStoreError::other(format!( - "RPC returned note script with root {fetched_root} for requested root {script_root}", - ))); - } - // Persist for future lookups. if let Err(err) = store.upsert_note_scripts(core::slice::from_ref(¬e_script)).await { tracing::warn!( From f62aaa8f0b0c5b80cb7b7536d10371b98ee6f5c2 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 28 Apr 2026 11:48:03 -0300 Subject: [PATCH 03/16] format --- crates/rust-client/src/rpc/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rust-client/src/rpc/mod.rs b/crates/rust-client/src/rpc/mod.rs index c89c651f16..f8ff2cb097 100644 --- a/crates/rust-client/src/rpc/mod.rs +++ b/crates/rust-client/src/rpc/mod.rs @@ -412,8 +412,8 @@ pub trait NodeRpcClient: Send + Sync { /// /// Errors: /// - [`RpcError::ExpectedDataMissing`] if the note with the specified root is not found. - /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not - /// match the requested `root`. + /// - [`RpcError::InvalidResponse`] if the node returns a script whose root does not match the + /// requested `root`. async fn get_note_script_by_root(&self, root: Word) -> Result; /// Fetches storage map updates for specified account and storage slots within a block range, From 07d17030de1649bfa102a2782685611d961ef5fd Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 28 Apr 2026 11:59:16 -0300 Subject: [PATCH 04/16] add per-request note-script trust policy Introduces NoteScriptTrustPolicy on TransactionRequest so the SDK no longer silently fetches and executes unknown input-note scripts. The policy is checked at the user-facing transaction boundary (execute_transaction and submit_new_transaction_with_prover) before any side-effectful work, and is also applied by the note screener so discovery does not run unverified bytecode. Defaults to StandardScriptsOnly (P2ID, P2IDE, SWAP, MINT, BURN). Custom scripts are opted in via TransactionRequestBuilder::trusted_input_note_ script_roots, or the request can opt out of root-based gating with allow_unlisted_note_scripts_after_user_approval for clients that have their own approval flow. Failures surface as ClientError::UntrustedNoteScript. The policy is part of TransactionRequest's serialized form, so a deserialized request carries its own trust decision through the same preflight as a freshly built one. --- CHANGELOG.md | 2 + crates/rust-client/src/errors.rs | 9 +- crates/rust-client/src/transaction/mod.rs | 21 +++ .../src/transaction/request/builder.rs | 29 +++ .../src/transaction/request/mod.rs | 21 ++- .../transaction/request/note_script_policy.rs | 169 ++++++++++++++++++ .../testing/miden-client-tests/src/tests.rs | 65 ++++++- 7 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 crates/rust-client/src/transaction/request/note_script_policy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d117e0594..c65a7bd382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ * [BREAKING][param][rust] `NodeRpcClient::get_block_by_number()` now takes an `include_proof: bool` parameter to control whether the block proof is included in the response. ([#1991](https://github.com/0xMiden/miden-client/pull/1991)) * [BREAKING][param][rust] `NodeRpcClient::sync_chain_mmr()` replaced `block_to: Option` with `upper_bound: SyncTarget` to match the RPC definition. Use `SyncTarget::CommittedChainTip` for previous default behavior (`None`), or `SyncTarget::BlockNumber(num)` for a specific block number. ([#1991](https://github.com/0xMiden/miden-client/pull/1991)) * [BREAKING][rust] Added `submit_proven_batch` to `NodeRpcClient` trait. ([#2075](https://github.com/0xMiden/miden-client/pull/2075)) +* [BREAKING][rust] `TransactionRequest` now defaults to `NoteScriptTrustPolicy::StandardScriptsOnly` for input-note scripts. Transactions consuming notes with non-standard scripts must explicitly opt in via `TransactionRequestBuilder::trusted_input_note_script_roots(...)` or `::allow_unlisted_note_scripts_after_user_approval()`. Previously, missing non-standard input-note scripts could be silently fetched from the node and executed. +* [BREAKING][rust] `TransactionRequest` binary serialization format changed: a new `note_script_trust_policy` field is appended. Persisted/cached requests from previous versions will fail to deserialize. ### Enhancements diff --git a/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index 9529d150cb..ffc7c9c623 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -27,7 +27,7 @@ use crate::note::NoteScreenerError; use crate::note_transport::NoteTransportError; use crate::rpc::RpcError; use crate::store::{NoteRecordError, StoreError}; -use crate::transaction::TransactionRequestError; +use crate::transaction::{NoteScriptTrustPolicy, TransactionRequestError}; // ACTIONABLE HINTS // ================================================================================================ @@ -165,6 +165,13 @@ pub enum ClientError { #[source] source: RpcError, }, + #[error( + "note script {script_root} is not allowed by this transaction's note-script trust policy" + )] + UntrustedNoteScript { + script_root: Word, + policy: NoteScriptTrustPolicy, + }, } // CONVERSIONS diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index 1721d76704..84afe0fb0b 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -114,6 +114,7 @@ mod request; pub use request::{ ForeignAccount, NoteArgs, + NoteScriptTrustPolicy, PaymentNoteDescription, SwapTransactionData, TransactionRequest, @@ -208,6 +209,9 @@ where transaction_request: TransactionRequest, tx_prover: Arc, ) -> Result { + // Check before NTX registration, which can submit a separate transaction. + check_input_note_script_trust(&transaction_request)?; + // Register any missing NTX scripts before the main transaction. // The registration path contains its own full execute -> prove -> submit pipeline. if !transaction_request.expected_ntx_scripts().is_empty() { @@ -252,6 +256,9 @@ where // Validates the transaction request before executing self.validate_request(account_id, &transaction_request).await?; + // Also check direct execution callers. + check_input_note_script_trust(&transaction_request)?; + // Retrieve all input notes from the store. let mut stored_note_records = self .store @@ -816,6 +823,20 @@ where // HELPERS // ================================================================================================ +/// Rejects the first input note whose script root is not allowed by the request policy. +fn check_input_note_script_trust( + transaction_request: &TransactionRequest, +) -> Result<(), ClientError> { + let policy = transaction_request.note_script_trust_policy(); + for note in transaction_request.input_notes() { + let script_root = note.script().root(); + if !policy.allows(script_root) { + return Err(ClientError::UntrustedNoteScript { script_root, policy: policy.clone() }); + } + } + Ok(()) +} + /// Helper to get the account outgoing assets. /// /// Any outgoing assets resulting from executing note scripts but not present in expected output diff --git a/crates/rust-client/src/transaction/request/builder.rs b/crates/rust-client/src/transaction/request/builder.rs index b1de0650c1..7c8bb80168 100644 --- a/crates/rust-client/src/transaction/request/builder.rs +++ b/crates/rust-client/src/transaction/request/builder.rs @@ -32,6 +32,7 @@ use miden_standards::note::{P2idNote, P2ideNote, P2ideNoteStorage, SwapNote}; use super::{ ForeignAccount, NoteArgs, + NoteScriptTrustPolicy, TransactionRequest, TransactionRequestError, TransactionScriptTemplate, @@ -92,6 +93,8 @@ pub struct TransactionRequestBuilder { /// /// See [`TransactionRequestBuilder::expected_ntx_scripts`] for details. expected_ntx_scripts: Vec, + /// Trust policy for input-note scripts during transaction execution. + note_script_trust_policy: NoteScriptTrustPolicy, } impl TransactionRequestBuilder { @@ -115,6 +118,7 @@ impl TransactionRequestBuilder { script_arg: None, auth_arg: None, expected_ntx_scripts: vec![], + note_script_trust_policy: NoteScriptTrustPolicy::default(), } } @@ -283,6 +287,30 @@ impl TransactionRequestBuilder { self } + /// Trusts the listed input-note script roots in addition to standard scripts. + /// + /// By default, a request rejects any non-standard input-note script. Use this to opt in + /// scripts the caller has independently verified. + #[must_use] + pub fn trusted_input_note_script_roots( + mut self, + roots: impl IntoIterator, + ) -> Self { + self.note_script_trust_policy = + NoteScriptTrustPolicy::TrustedScriptRoots(roots.into_iter().collect()); + self + } + + /// Allows this request to execute input-note scripts that are not recognized standards or + /// listed as trusted roots. + /// + /// Use this only after the caller has approved the unlisted scripts through their own flow. + #[must_use] + pub fn allow_unlisted_note_scripts_after_user_approval(mut self) -> Self { + self.note_script_trust_policy = NoteScriptTrustPolicy::AllowUnlistedAfterApproval; + self + } + // STANDARDIZED REQUESTS // -------------------------------------------------------------------------------------------- @@ -483,6 +511,7 @@ impl TransactionRequestBuilder { script_arg: self.script_arg, auth_arg: self.auth_arg, expected_ntx_scripts: self.expected_ntx_scripts, + note_script_trust_policy: self.note_script_trust_policy, }) } } diff --git a/crates/rust-client/src/transaction/request/mod.rs b/crates/rust-client/src/transaction/request/mod.rs index b78b06532a..e33ba01430 100644 --- a/crates/rust-client/src/transaction/request/mod.rs +++ b/crates/rust-client/src/transaction/request/mod.rs @@ -50,6 +50,9 @@ mod foreign; pub use foreign::ForeignAccount; pub(crate) use foreign::account_proof_into_inputs; +mod note_script_policy; +pub use note_script_policy::NoteScriptTrustPolicy; + use crate::store::InputNoteRecord; // TRANSACTION REQUEST @@ -122,6 +125,8 @@ pub struct TransactionRequest { /// /// See [`TransactionRequestBuilder::expected_ntx_scripts`] for details. expected_ntx_scripts: Vec, + /// Trust policy for input-note scripts. See [`NoteScriptTrustPolicy`]. + note_script_trust_policy: NoteScriptTrustPolicy, } impl TransactionRequest { @@ -231,6 +236,11 @@ impl TransactionRequest { &self.expected_ntx_scripts } + /// Returns the trust policy that gates which input-note scripts may be executed. + pub fn note_script_trust_policy(&self) -> &NoteScriptTrustPolicy { + &self.note_script_trust_policy + } + /// Builds the [`InputNotes`] needed for the transaction execution. /// /// Authenticated input notes are provided by the caller (typically fetched from the store). @@ -374,6 +384,7 @@ impl Serializable for TransactionRequest { self.script_arg.write_into(target); self.auth_arg.write_into(target); self.expected_ntx_scripts.write_into(target); + self.note_script_trust_policy.write_into(target); } } @@ -413,6 +424,7 @@ impl Deserializable for TransactionRequest { let script_arg = Option::::read_from(source)?; let auth_arg = Option::::read_from(source)?; let expected_ntx_scripts = Vec::::read_from(source)?; + let note_script_trust_policy = NoteScriptTrustPolicy::read_from(source)?; Ok(TransactionRequest { input_notes, @@ -428,6 +440,7 @@ impl Deserializable for TransactionRequest { script_arg, auth_arg, expected_ntx_scripts, + note_script_trust_policy, }) } } @@ -553,7 +566,7 @@ mod tests { use miden_standards::testing::account_component::MockAccountComponent; use miden_tx::utils::serde::{Deserializable, Serializable}; - use super::{TransactionRequest, TransactionRequestBuilder}; + use super::{NoteScriptTrustPolicy, TransactionRequest, TransactionRequestBuilder}; use crate::rpc::domain::account::AccountStorageRequirements; use crate::transaction::ForeignAccount; @@ -646,4 +659,10 @@ mod tests { let deserialized_tx_request = TransactionRequest::read_from_bytes(&buffer).unwrap(); assert_eq!(tx_request, deserialized_tx_request); } + + #[test] + fn builder_default_policy_is_standard_scripts_only() { + let request = TransactionRequestBuilder::new().build().unwrap(); + assert_eq!(request.note_script_trust_policy(), &NoteScriptTrustPolicy::StandardScriptsOnly); + } } diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs new file mode 100644 index 0000000000..a4259c4368 --- /dev/null +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -0,0 +1,169 @@ +//! Trust policy applied to note scripts during transaction execution. +//! +//! See [`NoteScriptTrustPolicy`] for the available variants and their semantics. + +use alloc::collections::BTreeSet; +use alloc::string::ToString; + +use miden_protocol::Word; +use miden_standards::note::StandardNote; +use miden_tx::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; + +/// Per-request trust policy controlling which note scripts the transaction executor is allowed to +/// run. +/// +/// The policy is checked at the start of every transaction execution by validating each input +/// note's script root before any execution-time work happens. Trust is therefore an *execution* +/// decision rather than a *fetch* decision: a script previously imported into the local store +/// does not bypass the policy. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum NoteScriptTrustPolicy { + /// Only allow scripts that match a known [`StandardNote`] (P2ID, P2IDE, SWAP, MINT, BURN). + /// + /// This is the default and rejects any custom or unknown script the executor encounters, + /// even if its bytes are already in the local store. + #[default] + StandardScriptsOnly, + /// Allow scripts whose root is in the provided set, in addition to standard scripts. Use this + /// to opt in to specific note scripts the caller has independently verified. + TrustedScriptRoots(BTreeSet), + /// Allow any script root. Equivalent to disabling the trust gate entirely. + /// + /// Intended for clients that surface unknown scripts to the user behind their own approval + /// flow before submitting the transaction. The SDK does not enforce or record any such + /// approval; selecting this variant simply opts the request out of root-based gating, and the + /// integrator is responsible for whatever review process they implement. + AllowUnlistedAfterApproval, +} + +impl NoteScriptTrustPolicy { + /// Returns whether the policy permits the executor to run a script with the given root. + pub fn allows(&self, root: Word) -> bool { + match self { + Self::StandardScriptsOnly => is_standard_script(root), + Self::TrustedScriptRoots(set) => is_standard_script(root) || set.contains(&root), + Self::AllowUnlistedAfterApproval => true, + } + } +} + +fn is_standard_script(root: Word) -> bool { + StandardNote::from_script_root(root).is_some() +} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for NoteScriptTrustPolicy { + fn write_into(&self, target: &mut W) { + match self { + Self::StandardScriptsOnly => target.write_u8(0), + Self::TrustedScriptRoots(set) => { + target.write_u8(1); + let roots: alloc::vec::Vec = set.iter().copied().collect(); + roots.write_into(target); + }, + Self::AllowUnlistedAfterApproval => target.write_u8(2), + } + } +} + +impl Deserializable for NoteScriptTrustPolicy { + fn read_from(source: &mut R) -> Result { + match source.read_u8()? { + 0 => Ok(Self::StandardScriptsOnly), + 1 => { + let roots = alloc::vec::Vec::::read_from(source)?; + Ok(Self::TrustedScriptRoots(roots.into_iter().collect())) + }, + 2 => Ok(Self::AllowUnlistedAfterApproval), + tag => Err(DeserializationError::InvalidValue( + ["invalid NoteScriptTrustPolicy tag: ", &tag.to_string()].concat(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::Felt; + + use super::*; + + fn standard_root() -> Word { + // Pick any standard variant; the exact one doesn't matter, only that + // `StandardNote::from_script_root` recognizes it. + StandardNote::P2ID.script_root() + } + + fn unknown_root() -> Word { + // A deterministic non-standard root. + Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]) + } + + #[test] + fn default_is_standard_scripts_only() { + assert_eq!(NoteScriptTrustPolicy::default(), NoteScriptTrustPolicy::StandardScriptsOnly); + } + + #[test] + fn standard_scripts_only_accepts_standards_and_rejects_unknown() { + let policy = NoteScriptTrustPolicy::StandardScriptsOnly; + assert!(policy.allows(standard_root())); + assert!(!policy.allows(unknown_root())); + } + + #[test] + fn trusted_script_roots_accepts_listed_root_and_standards() { + let listed = unknown_root(); + let policy = NoteScriptTrustPolicy::TrustedScriptRoots(BTreeSet::from([listed])); + assert!(policy.allows(listed)); + assert!(policy.allows(standard_root())); + + let other_unknown = Word::from([Felt::new(9), Felt::new(9), Felt::new(9), Felt::new(9)]); + assert!(!policy.allows(other_unknown)); + } + + #[test] + fn allow_unlisted_accepts_anything() { + let policy = NoteScriptTrustPolicy::AllowUnlistedAfterApproval; + assert!(policy.allows(standard_root())); + assert!(policy.allows(unknown_root())); + } + + fn roundtrip(policy: &NoteScriptTrustPolicy) { + let mut buffer = alloc::vec::Vec::new(); + policy.write_into(&mut buffer); + let decoded = NoteScriptTrustPolicy::read_from_bytes(&buffer).unwrap(); + assert_eq!(policy, &decoded); + } + + #[test] + fn serialization_roundtrip_standard_scripts_only() { + roundtrip(&NoteScriptTrustPolicy::StandardScriptsOnly); + } + + #[test] + fn serialization_roundtrip_trusted_script_roots() { + let mut roots = BTreeSet::new(); + roots.insert(unknown_root()); + roots.insert(Word::from([Felt::new(7), Felt::new(7), Felt::new(7), Felt::new(7)])); + roundtrip(&NoteScriptTrustPolicy::TrustedScriptRoots(roots)); + } + + #[test] + fn serialization_roundtrip_trusted_script_roots_empty_set() { + roundtrip(&NoteScriptTrustPolicy::TrustedScriptRoots(BTreeSet::new())); + } + + #[test] + fn serialization_roundtrip_allow_unlisted() { + roundtrip(&NoteScriptTrustPolicy::AllowUnlistedAfterApproval); + } +} diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index df0e1141a7..5a038bbc88 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -2963,8 +2963,9 @@ async fn consume_note_with_custom_script() { let stored_script = client.test_store().get_note_script(note_script.root()).await.unwrap(); assert_eq!(stored_script.root().to_hex(), note_script.root().to_hex()); - // Consume note + // Consume note (the script is custom, so the consumer must explicitly trust its root). let transaction_request = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([note_script.root()]) .build_consume_notes(vec![custom_note.clone()]) .unwrap(); @@ -2977,6 +2978,68 @@ async fn consume_note_with_custom_script() { client.sync_state().await.unwrap(); } +/// A custom note script that is already in the local store must still be rejected when the active +/// trust policy does not allow it. Importing a script locally must NOT bypass the per-request +/// trust check. +#[tokio::test] +async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { + let (mut client, mock_rpc_api, keystore) = create_test_client().await; + + let (sender_account, receiver_account, faucet_account) = setup_two_wallets_and_faucet( + &mut client, + AccountStorageMode::Private, + &keystore, + RPO_FALCON_SCHEME_ID, + ) + .await + .unwrap(); + + let sender_id = sender_account.id(); + let receiver_id = receiver_account.id(); + let faucet_id = faucet_account.id(); + + mint_and_consume(&mut client, sender_id, faucet_id, NoteType::Private).await; + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + let note_script = client.code_builder().compile_note_script("begin nop end").unwrap(); + + let note_storage = NoteStorage::new(vec![]).unwrap(); + let serial_num = client.rng().draw_word(); + let note_metadata = NoteMetadata::new(sender_id, NoteType::Private) + .with_tag(NoteTag::with_account_target(receiver_id)); + let note_assets = NoteAssets::new(vec![]).unwrap(); + let note_recipient = NoteRecipient::new(serial_num, note_script.clone(), note_storage); + let custom_note = Note::new(note_assets, note_metadata, note_recipient); + + let create_request = TransactionRequestBuilder::new() + .own_output_notes(vec![custom_note.clone()]) + .build() + .unwrap(); + let _ = Box::pin(client.submit_new_transaction(sender_id, create_request)) + .await + .unwrap(); + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + // The script is now in the local store. + let stored_script = client.test_store().get_note_script(note_script.root()).await.unwrap(); + assert_eq!(stored_script.root(), note_script.root()); + + // Consume without trusting the root: must fail with `UntrustedNoteScript`, even though the + // script is locally available. + let consume_request = TransactionRequestBuilder::new() + .build_consume_notes(vec![custom_note.clone()]) + .unwrap(); + + match Box::pin(client.submit_new_transaction(receiver_id, consume_request)).await { + Err(ClientError::UntrustedNoteScript { script_root, .. }) => { + assert_eq!(script_root, note_script.root()); + }, + other => panic!("expected UntrustedNoteScript, got {other:?}"), + } +} + #[tokio::test] async fn add_note_tag_fails_if_note_tag_limit_is_exceeded() { let (mut client, _rpc_api, _) = Box::pin(create_test_client()).await; From 584adb3854a6c66bb249cb82b739e33b6122c5dc Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 28 Apr 2026 18:35:33 -0300 Subject: [PATCH 05/16] trust custom note scripts where intentionally consumed Adds a `--allow-unlisted-note-scripts` flag to the `consume-notes` CLI and updates the integration tests that consume custom-script notes to opt in via `trusted_input_note_script_roots`, so they pass under the new default `StandardScriptsOnly` policy. --- CHANGELOG.md | 1 + .../src/tests/custom_transaction.rs | 4 +++ .../src/tests/network_transaction.rs | 1 + .../src/tests/pass_through.rs | 2 ++ .../src/commands/new_transactions.rs | 31 +++++++++++++------ bin/miden-cli/tests/cli.rs | 8 ++++- 6 files changed, 36 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c65a7bd382..c926248473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Fixed the faucet token symbol display when showing account details ([#1985](https://github.com/0xMiden/miden-client/pull/1985)). * [FEATURE][rust,cli,web] Added `get_network_note_status` to `NodeRpcClient` trait for querying the processing status of notes submitted to the network (pending, nullifier-inflight, discarded, nullifier-committed), along with attempt count and error details. Exposed as `miden-client network-note-status ` CLI command and `RpcClient.getNetworkNoteStatus()` in the web client. ([#1981](https://github.com/0xMiden/miden-client/pull/1981)) * Added `miden-cli call` command for invoking account procedures directly from the CLI. +* [cli] Added `--allow-unlisted-note-scripts` flag to `consume-notes` to consume notes whose scripts are not recognized standards. ## 0.14.4 (TBA) diff --git a/bin/integration-tests/src/tests/custom_transaction.rs b/bin/integration-tests/src/tests/custom_transaction.rs index 688a8ce9bb..f38d0e0627 100644 --- a/bin/integration-tests/src/tests/custom_transaction.rs +++ b/bin/integration-tests/src/tests/custom_transaction.rs @@ -101,6 +101,7 @@ pub async fn test_transaction_request(client_config: ClientConfig) -> Result<()> // FAILURE ATTEMPT let transaction_request = TransactionRequestBuilder::new() .input_notes(note_args_map.clone()) + .trusted_input_note_script_roots([note.script().root()]) .custom_script(tx_script.clone()) .script_arg(Word::empty()) .extend_advice_map(advice_map.clone()) @@ -117,6 +118,7 @@ pub async fn test_transaction_request(client_config: ClientConfig) -> Result<()> // SUCCESS EXECUTION let transaction_request = TransactionRequestBuilder::new() .input_notes(note_args_map) + .trusted_input_note_script_roots([note.script().root()]) .custom_script(tx_script) .script_arg([Felt::new(4), Felt::new(3), Felt::new(2), Felt::new(1)].into()) .extend_advice_map(advice_map) @@ -187,6 +189,7 @@ pub async fn test_merkle_store(client_config: ClientConfig) -> Result<()> { // these exact arguments let note_args_commitment = Poseidon2::hash_elements(&NOTE_ARGS); + let trusted_script_root = note.script().root(); let note_args_map = vec![(note, Some(note_args_commitment))]; let mut advice_map = AdviceMap::default(); advice_map.insert(note_args_commitment, NOTE_ARGS.to_vec()); @@ -231,6 +234,7 @@ pub async fn test_merkle_store(client_config: ClientConfig) -> Result<()> { let transaction_request = TransactionRequestBuilder::new() .input_notes(note_args_map) + .trusted_input_note_script_roots([trusted_script_root]) .custom_script(tx_script) .extend_advice_map(advice_map) .extend_merkle_store(merkle_store.inner_nodes()) diff --git a/bin/integration-tests/src/tests/network_transaction.rs b/bin/integration-tests/src/tests/network_transaction.rs index b33ded65e5..c9f651a2f3 100644 --- a/bin/integration-tests/src/tests/network_transaction.rs +++ b/bin/integration-tests/src/tests/network_transaction.rs @@ -239,6 +239,7 @@ pub async fn test_recall_note_before_ntx_consumes_it(client_config: ClientConfig client.apply_transaction(&bump_result, current_height).await?; let tx_request = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([network_note.script().root()]) .input_notes(vec![(network_note, None)]) .build()?; diff --git a/bin/integration-tests/src/tests/pass_through.rs b/bin/integration-tests/src/tests/pass_through.rs index aa5e06652d..0dbc37e6af 100644 --- a/bin/integration-tests/src/tests/pass_through.rs +++ b/bin/integration-tests/src/tests/pass_through.rs @@ -129,6 +129,7 @@ pub async fn test_pass_through(client_config: ClientConfig) -> Result<()> { assert!(matches!(input_note_record.state(), InputNoteState::Committed { .. })); let tx_request = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([pass_through_note_1.script().root()]) .expected_output_recipients(vec![pass_through_note_details_1.recipient().clone()]) .build_consume_notes(vec![pass_through_note_1]) .unwrap(); @@ -159,6 +160,7 @@ pub async fn test_pass_through(client_config: ClientConfig) -> Result<()> { // now try another transaction against the pass-through account let tx_request = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([pass_through_note_2.script().root()]) .expected_output_recipients(vec![pass_through_note_details_2.recipient().clone()]) .build_consume_notes(vec![pass_through_note_2]) .unwrap(); diff --git a/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index 4b849088fd..e75fbcb6d1 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -6,12 +6,14 @@ use miden_client::account::AccountId; use miden_client::keystore::Keystore; use miden_client::note::{ BlockNumber, + Note, NoteType as MidenNoteType, SwapNote, get_input_note_with_id_prefix, }; use miden_client::store::NoteRecordError; use miden_client::transaction::{ + NoteArgs, PaymentNoteDescription, RawOutputNote, SwapTransactionData, @@ -293,6 +295,12 @@ pub struct ConsumeNotesCmd { /// Flag to delegate proving to the remote prover specified in the config file. #[arg(long, default_value_t = false)] delegate_proving: bool, + + /// Trust the script roots of the input notes that are not recognized standard scripts. By + /// default, the client only executes notes whose scripts match a known standard. Pass this + /// flag after independently verifying the note scripts you are about to run. + #[arg(long, default_value_t = false)] + allow_unlisted_note_scripts: bool, } impl ConsumeNotesCmd { @@ -302,7 +310,7 @@ impl ConsumeNotesCmd { ) -> Result<(), CliError> { let force = self.force; - let mut input_notes = Vec::new(); + let mut input_notes: Vec<(Note, Option)> = Vec::new(); for note_id in &self.list_of_notes { let note_record = get_input_note_with_id_prefix(&client, note_id) @@ -341,15 +349,18 @@ impl ConsumeNotesCmd { return Ok(()); } - let transaction_request = TransactionRequestBuilder::new() - .input_notes(input_notes) - .build() - .map_err(|err| { - CliError::Transaction( - err.into(), - "Failed to build consume notes transaction".to_string(), - ) - })?; + let mut builder = TransactionRequestBuilder::new(); + if self.allow_unlisted_note_scripts { + let trusted_roots: Vec<_> = + input_notes.iter().map(|(note, _)| note.script().root()).collect(); + builder = builder.trusted_input_note_script_roots(trusted_roots); + } + let transaction_request = builder.input_notes(input_notes).build().map_err(|err| { + CliError::Transaction( + err.into(), + "Failed to build consume notes transaction".to_string(), + ) + })?; execute_transaction( &mut client, diff --git a/bin/miden-cli/tests/cli.rs b/bin/miden-cli/tests/cli.rs index 36cae4ef42..cb2f9a20a5 100644 --- a/bin/miden-cli/tests/cli.rs +++ b/bin/miden-cli/tests/cli.rs @@ -749,7 +749,13 @@ async fn debug_mode_outputs_logs() -> Result<()> { // Consume the note and check the output let mut consume_note_cmd = cargo_bin_cmd!("miden-client"); let note_id = note.id().to_hex(); - let mut cli_args = vec!["consume-notes", "--account", &wallet_account_id, "--force"]; + let mut cli_args = vec![ + "consume-notes", + "--account", + &wallet_account_id, + "--force", + "--allow-unlisted-note-scripts", + ]; cli_args.extend_from_slice(vec![note_id.as_str()].as_slice()); consume_note_cmd.args(&cli_args); consume_note_cmd From 9fa3b390dabab33de17fac3db9d800c8d3e26472 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Wed, 29 Apr 2026 12:14:13 -0300 Subject: [PATCH 06/16] rename builder method to allow_unlisted_note_scripts and simplify CLI opt-in The CLI's --allow-unlisted-note-scripts flag was collecting every input note's script root and passing them as TrustedScriptRoots, which is equivalent to AllowUnlistedAfterApproval here since the notes are fixed at that point. Call the unlisted-policy method directly instead. Drop the _after_user_approval suffix on the builder method: calling the method is the approval, and the suffix is doc copy in name form. --- CHANGELOG.md | 2 +- bin/miden-cli/src/commands/new_transactions.rs | 4 +--- crates/rust-client/src/transaction/request/builder.rs | 2 +- .../src/transaction/request/note_script_policy.rs | 6 ++---- crates/testing/miden-client-tests/src/tests.rs | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c926248473..0992cb4390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ * [BREAKING][param][rust] `NodeRpcClient::get_block_by_number()` now takes an `include_proof: bool` parameter to control whether the block proof is included in the response. ([#1991](https://github.com/0xMiden/miden-client/pull/1991)) * [BREAKING][param][rust] `NodeRpcClient::sync_chain_mmr()` replaced `block_to: Option` with `upper_bound: SyncTarget` to match the RPC definition. Use `SyncTarget::CommittedChainTip` for previous default behavior (`None`), or `SyncTarget::BlockNumber(num)` for a specific block number. ([#1991](https://github.com/0xMiden/miden-client/pull/1991)) * [BREAKING][rust] Added `submit_proven_batch` to `NodeRpcClient` trait. ([#2075](https://github.com/0xMiden/miden-client/pull/2075)) -* [BREAKING][rust] `TransactionRequest` now defaults to `NoteScriptTrustPolicy::StandardScriptsOnly` for input-note scripts. Transactions consuming notes with non-standard scripts must explicitly opt in via `TransactionRequestBuilder::trusted_input_note_script_roots(...)` or `::allow_unlisted_note_scripts_after_user_approval()`. Previously, missing non-standard input-note scripts could be silently fetched from the node and executed. +* [BREAKING][rust] `TransactionRequest` now defaults to `NoteScriptTrustPolicy::StandardScriptsOnly` for input-note scripts. Transactions consuming notes with non-standard scripts must explicitly opt in via `TransactionRequestBuilder::trusted_input_note_script_roots(...)` or `::allow_unlisted_note_scripts()`. Previously, missing non-standard input-note scripts could be silently fetched from the node and executed. * [BREAKING][rust] `TransactionRequest` binary serialization format changed: a new `note_script_trust_policy` field is appended. Persisted/cached requests from previous versions will fail to deserialize. ### Enhancements diff --git a/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index e75fbcb6d1..a400365323 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -351,9 +351,7 @@ impl ConsumeNotesCmd { let mut builder = TransactionRequestBuilder::new(); if self.allow_unlisted_note_scripts { - let trusted_roots: Vec<_> = - input_notes.iter().map(|(note, _)| note.script().root()).collect(); - builder = builder.trusted_input_note_script_roots(trusted_roots); + builder = builder.allow_unlisted_note_scripts(); } let transaction_request = builder.input_notes(input_notes).build().map_err(|err| { CliError::Transaction( diff --git a/crates/rust-client/src/transaction/request/builder.rs b/crates/rust-client/src/transaction/request/builder.rs index 7c8bb80168..f929bbcdef 100644 --- a/crates/rust-client/src/transaction/request/builder.rs +++ b/crates/rust-client/src/transaction/request/builder.rs @@ -306,7 +306,7 @@ impl TransactionRequestBuilder { /// /// Use this only after the caller has approved the unlisted scripts through their own flow. #[must_use] - pub fn allow_unlisted_note_scripts_after_user_approval(mut self) -> Self { + pub fn allow_unlisted_note_scripts(mut self) -> Self { self.note_script_trust_policy = NoteScriptTrustPolicy::AllowUnlistedAfterApproval; self } diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs index a4259c4368..2e19dacb05 100644 --- a/crates/rust-client/src/transaction/request/note_script_policy.rs +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -27,7 +27,7 @@ pub enum NoteScriptTrustPolicy { /// Only allow scripts that match a known [`StandardNote`] (P2ID, P2IDE, SWAP, MINT, BURN). /// /// This is the default and rejects any custom or unknown script the executor encounters, - /// even if its bytes are already in the local store. + /// even if it is already in the local store. #[default] StandardScriptsOnly, /// Allow scripts whose root is in the provided set, in addition to standard scripts. Use this @@ -36,9 +36,7 @@ pub enum NoteScriptTrustPolicy { /// Allow any script root. Equivalent to disabling the trust gate entirely. /// /// Intended for clients that surface unknown scripts to the user behind their own approval - /// flow before submitting the transaction. The SDK does not enforce or record any such - /// approval; selecting this variant simply opts the request out of root-based gating, and the - /// integrator is responsible for whatever review process they implement. + /// flow before submitting the transaction. AllowUnlistedAfterApproval, } diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 5a038bbc88..61823c1710 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -2963,7 +2963,7 @@ async fn consume_note_with_custom_script() { let stored_script = client.test_store().get_note_script(note_script.root()).await.unwrap(); assert_eq!(stored_script.root().to_hex(), note_script.root().to_hex()); - // Consume note (the script is custom, so the consumer must explicitly trust its root). + // Consume note, trusting root of custom script. let transaction_request = TransactionRequestBuilder::new() .trusted_input_note_script_roots([note_script.root()]) .build_consume_notes(vec![custom_note.clone()]) From 94254bb3176f85a9bbb22d2f49936300b61ecf16 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Thu, 30 Apr 2026 13:23:50 -0300 Subject: [PATCH 07/16] clarify NoteScriptTrustPolicy doc-comment scope Distinguish gated user-authorized transaction execution from speculative consumability probes (NoteScreener during sync, consume-notes auto-discovery) that may run scripts but discard their effects and stay outside the policy. --- .../transaction/request/note_script_policy.rs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs index 2e19dacb05..ac9087bfe2 100644 --- a/crates/rust-client/src/transaction/request/note_script_policy.rs +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -1,4 +1,4 @@ -//! Trust policy applied to note scripts during transaction execution. +//! Trust policy applied to input-note scripts when executing a user transaction request. //! //! See [`NoteScriptTrustPolicy`] for the available variants and their semantics. @@ -15,13 +15,21 @@ use miden_tx::utils::serde::{ Serializable, }; -/// Per-request trust policy controlling which note scripts the transaction executor is allowed to -/// run. +/// Per-request trust policy controlling which input-note scripts may be included in a +/// user-authorized transaction. /// -/// The policy is checked at the start of every transaction execution by validating each input -/// note's script root before any execution-time work happens. Trust is therefore an *execution* -/// decision rather than a *fetch* decision: a script previously imported into the local store +/// The policy is checked at the start of [`Client::submit_new_transaction`] and +/// [`Client::execute_transaction`] by validating each input note's script root before +/// executing the requested transaction. A script previously imported into the local store /// does not bypass the policy. +/// +/// This gate applies to user-authorized transaction execution only. Speculative +/// consumability probes, such as [`crate::note::NoteScreener`] during sync or +/// `consume-notes` auto-discovery, may execute scripts but discard their effects and are +/// outside this policy. +/// +/// [`Client::submit_new_transaction`]: crate::Client::submit_new_transaction +/// [`Client::execute_transaction`]: crate::Client::execute_transaction #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum NoteScriptTrustPolicy { /// Only allow scripts that match a known [`StandardNote`] (P2ID, P2IDE, SWAP, MINT, BURN). @@ -41,7 +49,8 @@ pub enum NoteScriptTrustPolicy { } impl NoteScriptTrustPolicy { - /// Returns whether the policy permits the executor to run a script with the given root. + /// Returns whether the policy permits a user transaction to include an input note with the + /// given script root. pub fn allows(&self, root: Word) -> bool { match self { Self::StandardScriptsOnly => is_standard_script(root), From 12eb7f839898ba660f95cd747e820aec1264b9f5 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Wed, 6 May 2026 17:02:07 -0300 Subject: [PATCH 08/16] adapt note script trust policy code to NoteScriptRoot type After merging next, NoteScript::root() and StandardNote::script_root() now return NoteScriptRoot instead of Word. Convert at boundaries so the policy API and call sites continue to use Word. --- bin/integration-tests/src/tests/custom_transaction.rs | 6 +++--- bin/integration-tests/src/tests/network_transaction.rs | 2 +- bin/integration-tests/src/tests/pass_through.rs | 4 ++-- crates/rust-client/src/transaction/mod.rs | 2 +- .../src/transaction/request/note_script_policy.rs | 5 +++-- crates/testing/miden-client-tests/src/tests.rs | 7 ++++--- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bin/integration-tests/src/tests/custom_transaction.rs b/bin/integration-tests/src/tests/custom_transaction.rs index af9ed0f9a1..5ba087e17d 100644 --- a/bin/integration-tests/src/tests/custom_transaction.rs +++ b/bin/integration-tests/src/tests/custom_transaction.rs @@ -101,7 +101,7 @@ pub async fn test_transaction_request(client_config: ClientConfig) -> Result<()> // FAILURE ATTEMPT let transaction_request = TransactionRequestBuilder::new() .input_notes(note_args_map.clone()) - .trusted_input_note_script_roots([note.script().root()]) + .trusted_input_note_script_roots([Word::from(note.script().root())]) .custom_script(tx_script.clone()) .script_arg(Word::empty()) .extend_advice_map(advice_map.clone()) @@ -118,7 +118,7 @@ pub async fn test_transaction_request(client_config: ClientConfig) -> Result<()> // SUCCESS EXECUTION let transaction_request = TransactionRequestBuilder::new() .input_notes(note_args_map) - .trusted_input_note_script_roots([note.script().root()]) + .trusted_input_note_script_roots([Word::from(note.script().root())]) .custom_script(tx_script) .script_arg([Felt::new(4), Felt::new(3), Felt::new(2), Felt::new(1)].into()) .extend_advice_map(advice_map) @@ -189,7 +189,7 @@ pub async fn test_merkle_store(client_config: ClientConfig) -> Result<()> { // these exact arguments let note_args_commitment = Poseidon2::hash_elements(&NOTE_ARGS); - let trusted_script_root = note.script().root(); + let trusted_script_root = Word::from(note.script().root()); let note_args_map = vec![(note, Some(note_args_commitment))]; let mut advice_map = AdviceMap::default(); advice_map.insert(note_args_commitment, NOTE_ARGS.to_vec()); diff --git a/bin/integration-tests/src/tests/network_transaction.rs b/bin/integration-tests/src/tests/network_transaction.rs index 65885a724a..2adf2023ae 100644 --- a/bin/integration-tests/src/tests/network_transaction.rs +++ b/bin/integration-tests/src/tests/network_transaction.rs @@ -247,7 +247,7 @@ pub async fn test_recall_note_before_ntx_consumes_it(client_config: ClientConfig client.apply_transaction(&bump_result, current_height).await?; let tx_request = TransactionRequestBuilder::new() - .trusted_input_note_script_roots([network_note.script().root()]) + .trusted_input_note_script_roots([Word::from(network_note.script().root())]) .input_notes(vec![(network_note, None)]) .build()?; diff --git a/bin/integration-tests/src/tests/pass_through.rs b/bin/integration-tests/src/tests/pass_through.rs index 0dbc37e6af..76a7cff0a8 100644 --- a/bin/integration-tests/src/tests/pass_through.rs +++ b/bin/integration-tests/src/tests/pass_through.rs @@ -129,7 +129,7 @@ pub async fn test_pass_through(client_config: ClientConfig) -> Result<()> { assert!(matches!(input_note_record.state(), InputNoteState::Committed { .. })); let tx_request = TransactionRequestBuilder::new() - .trusted_input_note_script_roots([pass_through_note_1.script().root()]) + .trusted_input_note_script_roots([Word::from(pass_through_note_1.script().root())]) .expected_output_recipients(vec![pass_through_note_details_1.recipient().clone()]) .build_consume_notes(vec![pass_through_note_1]) .unwrap(); @@ -160,7 +160,7 @@ pub async fn test_pass_through(client_config: ClientConfig) -> Result<()> { // now try another transaction against the pass-through account let tx_request = TransactionRequestBuilder::new() - .trusted_input_note_script_roots([pass_through_note_2.script().root()]) + .trusted_input_note_script_roots([Word::from(pass_through_note_2.script().root())]) .expected_output_recipients(vec![pass_through_note_details_2.recipient().clone()]) .build_consume_notes(vec![pass_through_note_2]) .unwrap(); diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index 647919634e..f0774caf08 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -813,7 +813,7 @@ fn check_input_note_script_trust( ) -> Result<(), ClientError> { let policy = transaction_request.note_script_trust_policy(); for note in transaction_request.input_notes() { - let script_root = note.script().root(); + let script_root = Word::from(note.script().root()); if !policy.allows(script_root) { return Err(ClientError::UntrustedNoteScript { script_root, policy: policy.clone() }); } diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs index ac9087bfe2..4960180357 100644 --- a/crates/rust-client/src/transaction/request/note_script_policy.rs +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -6,6 +6,7 @@ use alloc::collections::BTreeSet; use alloc::string::ToString; use miden_protocol::Word; +use miden_protocol::note::NoteScriptRoot; use miden_standards::note::StandardNote; use miden_tx::utils::serde::{ ByteReader, @@ -61,7 +62,7 @@ impl NoteScriptTrustPolicy { } fn is_standard_script(root: Word) -> bool { - StandardNote::from_script_root(root).is_some() + StandardNote::from_script_root(NoteScriptRoot::from_raw(root)).is_some() } // SERIALIZATION @@ -106,7 +107,7 @@ mod tests { fn standard_root() -> Word { // Pick any standard variant; the exact one doesn't matter, only that // `StandardNote::from_script_root` recognizes it. - StandardNote::P2ID.script_root() + StandardNote::P2ID.script_root().into() } fn unknown_root() -> Word { diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index a387652100..9674cbfe16 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -3072,7 +3072,7 @@ async fn consume_note_with_custom_script() { // Consume note, trusting root of custom script. let transaction_request = TransactionRequestBuilder::new() - .trusted_input_note_script_roots([note_script.root()]) + .trusted_input_note_script_roots([Word::from(note_script.root())]) .build_consume_notes(vec![custom_note.clone()]) .unwrap(); @@ -3130,7 +3130,8 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { client.sync_state().await.unwrap(); // The script is now in the local store. - let stored_script = client.test_store().get_note_script(note_script.root()).await.unwrap(); + let stored_script = + client.test_store().get_note_script(note_script.root().into()).await.unwrap(); assert_eq!(stored_script.root(), note_script.root()); // Consume without trusting the root: must fail with `UntrustedNoteScript`, even though the @@ -3141,7 +3142,7 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { match Box::pin(client.submit_new_transaction(receiver_id, consume_request)).await { Err(ClientError::UntrustedNoteScript { script_root, .. }) => { - assert_eq!(script_root, note_script.root()); + assert_eq!(script_root, Word::from(note_script.root())); }, other => panic!("expected UntrustedNoteScript, got {other:?}"), } From 6944da510b5b2afba89b6f97f0e63582a38f24f8 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Thu, 7 May 2026 10:17:26 -0300 Subject: [PATCH 09/16] use new note script header in trust policy test --- crates/testing/miden-client-tests/src/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 9674cbfe16..0fda8ac027 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -3109,7 +3109,13 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { mock_rpc_api.prove_block(); client.sync_state().await.unwrap(); - let note_script = client.code_builder().compile_note_script("begin nop end").unwrap(); + let custom_note_script = " + @note_script + pub proc main + nop + end + "; + let note_script = client.code_builder().compile_note_script(custom_note_script).unwrap(); let note_storage = NoteStorage::new(vec![]).unwrap(); let serial_num = client.rng().draw_word(); From 839d45e0bf6b462f765d934ef9c4ef9ce6614ca6 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Thu, 7 May 2026 11:36:57 -0300 Subject: [PATCH 10/16] document input note script trust policy --- docs/external/src/rust-client/cli/index.md | 2 ++ docs/external/src/rust-client/library.md | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/external/src/rust-client/cli/index.md b/docs/external/src/rust-client/cli/index.md index ab36763929..89f2fd66b1 100644 --- a/docs/external/src/rust-client/cli/index.md +++ b/docs/external/src/rust-client/cli/index.md @@ -302,6 +302,8 @@ Additionally, you can optionally not specify note IDs, in which case any note th Either `Expected` or `Committed` notes may be consumed by this command, changing their state to `Processing`. It's state will be updated to `Consumed` after the next sync. +By default, `consume-notes` only accepts notes whose scripts match a recognized standard (P2ID, P2IDE, SWAP, MINT, BURN). To consume notes with custom scripts, pass `--allow-unlisted-note-scripts`, which acknowledges that the unlisted scripts have been reviewed and trusted. + #### `send` Sends assets to another account. Sender Account creates a note that a target Account ID can consume. The asset is identified by the tuple `(FAUCET ID, AMOUNT)`. The note can be configured to be recallable making the sender able to consume it after a height is reached. diff --git a/docs/external/src/rust-client/library.md b/docs/external/src/rust-client/library.md index 0863b23e5b..f8dd9180e0 100644 --- a/docs/external/src/rust-client/library.md +++ b/docs/external/src/rust-client/library.md @@ -137,3 +137,12 @@ client.submit_transaction(transaction_execution_result).await? You can decide whether you want the note details to be public or private through the `note_type` parameter. You may also customize the transaction request with the other `TransactionRequestBuilder` methods. This allows you to run custom code, with custom note arguments and additional output/input notes as well. + +### Input note script trust policy + +When the transaction consumes input notes, by default the client only executes notes whose scripts match a recognized standard (P2ID, P2IDE, SWAP, MINT, BURN). Notes carrying any other script are rejected before execution. To consume notes with custom scripts, opt in on the request builder: + +- `TransactionRequestBuilder::trusted_input_note_script_roots(roots)` to allow specific script roots. +- `TransactionRequestBuilder::allow_unlisted_note_scripts()` to allow any script root, intended for clients that surface unknown scripts to the user behind their own approval flow. + +See `NoteScriptTrustPolicy` for the full set of variants. From 4fe86f1d4877e12515c86f3b469db23fc186f0d3 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Thu, 14 May 2026 16:46:57 -0300 Subject: [PATCH 11/16] address review feedback on note script trust policy - collect all rejected note-script roots before erroring (was: first only) - add error hint with remediation for UntrustedNoteScript - display the active trust policy in the error message - note in builder rustdoc that the two policy setters overwrite rather than append - link StandardNote docs.rs in the library guide and polish wording - add multi-root regression test --- crates/rust-client/src/errors.rs | 37 +++++++- crates/rust-client/src/transaction/mod.rs | 18 ++-- .../src/transaction/request/builder.rs | 6 ++ .../transaction/request/note_script_policy.rs | 18 ++++ .../testing/miden-client-tests/src/tests.rs | 84 ++++++++++++++++++- docs/external/src/rust-client/library.md | 2 +- 6 files changed, 153 insertions(+), 12 deletions(-) diff --git a/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index 665aa12cc0..8fe493eb85 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -1,3 +1,4 @@ +use alloc::collections::BTreeSet; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; @@ -183,14 +184,37 @@ pub enum ClientError { source: RpcError, }, #[error( - "note script {script_root} is not allowed by this transaction's note-script trust policy" + "note script {} not allowed under the {policy} trust policy", + DisplayScriptRoots(script_roots) )] UntrustedNoteScript { - script_root: Word, + script_roots: BTreeSet, policy: NoteScriptTrustPolicy, }, } +/// Renders a set of script roots with singular/plural agreement for inclusion in error messages. +struct DisplayScriptRoots<'a>(&'a BTreeSet); + +impl fmt::Display for DisplayScriptRoots<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut iter = self.0.iter(); + match (iter.next(), iter.next()) { + (Some(only), None) => write!(f, "root {only} is"), + (Some(first), Some(second)) => { + f.write_str("roots {")?; + write!(f, "{first}")?; + write!(f, ", {second}")?; + for root in iter { + write!(f, ", {root}")?; + } + f.write_str("} are") + }, + _ => f.write_str("roots {} are"), + } + } +} + // CONVERSIONS // ================================================================================================ @@ -217,6 +241,15 @@ impl From<&ClientError> for Option { Some(missing_recipient_hint(recipients)) }, ClientError::TransactionRequestError(inner) => inner.into(), + ClientError::UntrustedNoteScript { .. } => Some(ErrorHint { + message: "The transaction includes input notes with scripts outside this \ + request's trusted set. If you have verified those scripts, use \ + `TransactionRequestBuilder::trusted_input_note_script_roots(...)` to \ + allow specific roots or \ + `TransactionRequestBuilder::allow_unlisted_note_scripts()` to allow any \ + unlisted script for that request.".to_string(), + docs_url: Some(TROUBLESHOOTING_DOC), + }), ClientError::TransactionExecutorError(inner) => transaction_executor_hint(inner), ClientError::NoteNotFoundOnChain(note_id) => Some(ErrorHint { message: format!( diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index 1b1f566aef..2c8b39e5d6 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -994,18 +994,22 @@ pub enum TransactionStoreUpdateError { // HELPERS // ================================================================================================ -/// Rejects the first input note whose script root is not allowed by the request policy. +/// Rejects input notes whose script roots are not allowed by the request policy. fn check_input_note_script_trust( transaction_request: &TransactionRequest, ) -> Result<(), ClientError> { let policy = transaction_request.note_script_trust_policy(); - for note in transaction_request.input_notes() { - let script_root = Word::from(note.script().root()); - if !policy.allows(script_root) { - return Err(ClientError::UntrustedNoteScript { script_root, policy: policy.clone() }); - } + let script_roots: BTreeSet = transaction_request + .input_notes() + .iter() + .map(|note| Word::from(note.script().root())) + .filter(|script_root| !policy.allows(*script_root)) + .collect(); + if script_roots.is_empty() { + Ok(()) + } else { + Err(ClientError::UntrustedNoteScript { script_roots, policy: policy.clone() }) } - Ok(()) } /// Output of [`Client::prepare_transaction`]: the data-store-independent state needed to diff --git a/crates/rust-client/src/transaction/request/builder.rs b/crates/rust-client/src/transaction/request/builder.rs index 03af15e062..d96e81bb1c 100644 --- a/crates/rust-client/src/transaction/request/builder.rs +++ b/crates/rust-client/src/transaction/request/builder.rs @@ -298,6 +298,9 @@ impl TransactionRequestBuilder { /// /// By default, a request rejects any non-standard input-note script. Use this to opt in /// scripts the caller has independently verified. + /// + /// This replaces any previously configured note-script trust policy. Repeated calls do not + /// append roots; pass the full set of trusted roots in a single call. #[must_use] pub fn trusted_input_note_script_roots( mut self, @@ -312,6 +315,9 @@ impl TransactionRequestBuilder { /// listed as trusted roots. /// /// Use this only after the caller has approved the unlisted scripts through their own flow. + /// + /// This replaces any previously configured note-script trust policy, including trusted root + /// allowlists set with [`Self::trusted_input_note_script_roots`]. #[must_use] pub fn allow_unlisted_note_scripts(mut self) -> Self { self.note_script_trust_policy = NoteScriptTrustPolicy::AllowUnlistedAfterApproval; diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs index 4960180357..b456fa6f74 100644 --- a/crates/rust-client/src/transaction/request/note_script_policy.rs +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -4,6 +4,7 @@ use alloc::collections::BTreeSet; use alloc::string::ToString; +use core::fmt; use miden_protocol::Word; use miden_protocol::note::NoteScriptRoot; @@ -61,6 +62,23 @@ impl NoteScriptTrustPolicy { } } +impl fmt::Display for NoteScriptTrustPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StandardScriptsOnly => f.write_str("StandardScriptsOnly"), + Self::TrustedScriptRoots(set) => { + let label = if set.len() == 1 { + "trusted root" + } else { + "trusted roots" + }; + write!(f, "TrustedScriptRoots ({} {label})", set.len()) + }, + Self::AllowUnlistedAfterApproval => f.write_str("AllowUnlistedAfterApproval"), + } + } +} + fn is_standard_script(root: Word) -> bool { StandardNote::from_script_root(NoteScriptRoot::from_raw(root)).is_some() } diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index e0c30fd13c..f0c7027624 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -85,6 +85,7 @@ use miden_protocol::note::{ NoteFile, NoteMetadata, NoteRecipient, + NoteScript, NoteStorage, NoteTag, NoteType, @@ -3370,13 +3371,92 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { .unwrap(); match Box::pin(client.submit_new_transaction(receiver_id, consume_request)).await { - Err(ClientError::UntrustedNoteScript { script_root, .. }) => { - assert_eq!(script_root, Word::from(note_script.root())); + Err(ClientError::UntrustedNoteScript { script_roots, .. }) => { + assert_eq!(script_roots, BTreeSet::from([Word::from(note_script.root())])); }, other => panic!("expected UntrustedNoteScript, got {other:?}"), } } +/// When more than one input note carries an untrusted script, the preflight check must surface all +/// rejected script roots, not just the first one encountered. +#[tokio::test] +async fn consume_notes_with_custom_scripts_reports_all_rejected_roots() { + let (mut client, mock_rpc_api, keystore) = create_test_client().await; + + let (sender_account, receiver_account, faucet_account) = setup_two_wallets_and_faucet( + &mut client, + AccountStorageMode::Private, + &keystore, + RPO_FALCON_SCHEME_ID, + ) + .await + .unwrap(); + + let sender_id = sender_account.id(); + let receiver_id = receiver_account.id(); + let faucet_id = faucet_account.id(); + + mint_and_consume(&mut client, sender_id, faucet_id, NoteType::Private).await; + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + let custom_note_script_a = " + @note_script + pub proc main + push.0 + drop + end + "; + let custom_note_script_b = " + @note_script + pub proc main + push.1 + drop + end + "; + let note_script_a = client.code_builder().compile_note_script(custom_note_script_a).unwrap(); + let note_script_b = client.code_builder().compile_note_script(custom_note_script_b).unwrap(); + assert_ne!(note_script_a.root(), note_script_b.root()); + + let mut build_note = |script: NoteScript| { + let serial_num = client.rng().draw_word(); + let metadata = NoteMetadata::new(sender_id, NoteType::Private) + .with_tag(NoteTag::with_account_target(receiver_id)); + let assets = NoteAssets::new(vec![]).unwrap(); + let recipient = NoteRecipient::new(serial_num, script, NoteStorage::new(vec![]).unwrap()); + Note::new(assets, metadata, recipient) + }; + let custom_note_a = build_note(note_script_a.clone()); + let custom_note_b = build_note(note_script_b.clone()); + + let create_request = TransactionRequestBuilder::new() + .own_output_notes(vec![custom_note_a.clone(), custom_note_b.clone()]) + .build() + .unwrap(); + let _ = Box::pin(client.submit_new_transaction(sender_id, create_request)) + .await + .unwrap(); + mock_rpc_api.prove_block(); + client.sync_state().await.unwrap(); + + let consume_request = TransactionRequestBuilder::new() + .build_consume_notes(vec![custom_note_a.clone(), custom_note_b.clone()]) + .unwrap(); + + match Box::pin(client.submit_new_transaction(receiver_id, consume_request)).await { + Err(ClientError::UntrustedNoteScript { script_roots, .. }) => { + assert_eq!( + script_roots, + BTreeSet::from([ + Word::from(note_script_a.root()), + Word::from(note_script_b.root()), + ]) + ); + }, + other => panic!("expected UntrustedNoteScript, got {other:?}"), + } +} // PAGINATION TESTS // ================================================================================================ diff --git a/docs/external/src/rust-client/library.md b/docs/external/src/rust-client/library.md index ce34bed536..413b07af37 100644 --- a/docs/external/src/rust-client/library.md +++ b/docs/external/src/rust-client/library.md @@ -143,7 +143,7 @@ You may also customize the transaction request with the other `TransactionReques ### Input note script trust policy -When the transaction consumes input notes, by default the client only executes notes whose scripts match a recognized standard (P2ID, P2IDE, SWAP, MINT, BURN). Notes carrying any other script are rejected before execution. To consume notes with custom scripts, opt in on the request builder: +When the transaction consumes input notes, by default the client only executes notes whose scripts match one of the protocol's [standard note types](https://docs.rs/miden-standards/latest/miden_standards/note/enum.StandardNote.html) (P2ID, P2IDE, SWAP, MINT, or BURN). Notes carrying any other script are rejected before execution. To consume notes with custom scripts, opt in on the request builder: - `TransactionRequestBuilder::trusted_input_note_script_roots(roots)` to allow specific script roots. - `TransactionRequestBuilder::allow_unlisted_note_scripts()` to allow any script root, intended for clients that surface unknown scripts to the user behind their own approval flow. From ba601520a1b64b6e130979c2fe520c4a987adff7 Mon Sep 17 00:00:00 2001 From: tomasarrachea Date: Thu, 4 Jun 2026 17:40:17 -0300 Subject: [PATCH 12/16] fix: merge --- crates/testing/miden-client-tests/src/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 8403c3c2f3..b9e41a3ead 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -3393,7 +3393,7 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { let note_storage = NoteStorage::new(vec![]).unwrap(); let serial_num = client.rng().draw_word(); - let note_metadata = NoteMetadata::new(sender_id, NoteType::Private) + let note_metadata = PartialNoteMetadata::new(sender_id, NoteType::Private) .with_tag(NoteTag::with_account_target(receiver_id)); let note_assets = NoteAssets::new(vec![]).unwrap(); let note_recipient = NoteRecipient::new(serial_num, note_script.clone(), note_storage); @@ -3471,7 +3471,7 @@ async fn consume_notes_with_custom_scripts_reports_all_rejected_roots() { let mut build_note = |script: NoteScript| { let serial_num = client.rng().draw_word(); - let metadata = NoteMetadata::new(sender_id, NoteType::Private) + let metadata = PartialNoteMetadata::new(sender_id, NoteType::Private) .with_tag(NoteTag::with_account_target(receiver_id)); let assets = NoteAssets::new(vec![]).unwrap(); let recipient = NoteRecipient::new(serial_num, script, NoteStorage::new(vec![]).unwrap()); From fe2bdb58a989fed111e838948266f1f125411397 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Tue, 16 Jun 2026 11:28:02 -0300 Subject: [PATCH 13/16] fix: adapt note-script trust policy tests to protocol 0.15 API After merging next, Felt::new is fallible and setup_two_wallets_and_faucet takes AccountType instead of AccountStorageMode. Update the trust-policy tests (Word::from integer arrays, AccountType::Private) so the test build compiles. --- .../src/transaction/request/note_script_policy.rs | 8 +++----- crates/testing/miden-client-tests/src/tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/rust-client/src/transaction/request/note_script_policy.rs b/crates/rust-client/src/transaction/request/note_script_policy.rs index b456fa6f74..485a45f7d6 100644 --- a/crates/rust-client/src/transaction/request/note_script_policy.rs +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -118,8 +118,6 @@ impl Deserializable for NoteScriptTrustPolicy { #[cfg(test)] mod tests { - use miden_protocol::Felt; - use super::*; fn standard_root() -> Word { @@ -130,7 +128,7 @@ mod tests { fn unknown_root() -> Word { // A deterministic non-standard root. - Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]) + Word::from([1u32, 2, 3, 4]) } #[test] @@ -152,7 +150,7 @@ mod tests { assert!(policy.allows(listed)); assert!(policy.allows(standard_root())); - let other_unknown = Word::from([Felt::new(9), Felt::new(9), Felt::new(9), Felt::new(9)]); + let other_unknown = Word::from([9u32, 9, 9, 9]); assert!(!policy.allows(other_unknown)); } @@ -179,7 +177,7 @@ mod tests { fn serialization_roundtrip_trusted_script_roots() { let mut roots = BTreeSet::new(); roots.insert(unknown_root()); - roots.insert(Word::from([Felt::new(7), Felt::new(7), Felt::new(7), Felt::new(7)])); + roots.insert(Word::from([7u32, 7, 7, 7])); roundtrip(&NoteScriptTrustPolicy::TrustedScriptRoots(roots)); } diff --git a/crates/testing/miden-client-tests/src/tests.rs b/crates/testing/miden-client-tests/src/tests.rs index 081207bd88..c6182e5527 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -3381,7 +3381,7 @@ async fn consume_note_with_custom_script_default_policy_rejects_local_hit() { let (sender_account, receiver_account, faucet_account) = setup_two_wallets_and_faucet( &mut client, - AccountStorageMode::Private, + AccountType::Private, &keystore, RPO_FALCON_SCHEME_ID, ) @@ -3449,7 +3449,7 @@ async fn consume_notes_with_custom_scripts_reports_all_rejected_roots() { let (sender_account, receiver_account, faucet_account) = setup_two_wallets_and_faucet( &mut client, - AccountStorageMode::Private, + AccountType::Private, &keystore, RPO_FALCON_SCHEME_ID, ) From 8c5aeef945d87dfce8b04da24485e425bdeceb7b Mon Sep 17 00:00:00 2001 From: JereSalo Date: Fri, 19 Jun 2026 11:37:32 -0300 Subject: [PATCH 14/16] fix: trust non-standard note script in ntx public-note integration test --- bin/integration-tests/src/tests/network_transaction.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bin/integration-tests/src/tests/network_transaction.rs b/bin/integration-tests/src/tests/network_transaction.rs index d390af7720..b362501dea 100644 --- a/bin/integration-tests/src/tests/network_transaction.rs +++ b/bin/integration-tests/src/tests/network_transaction.rs @@ -54,7 +54,6 @@ use miden_client::sync::NoteTagSource; use miden_client::testing::common::{ TestClient, assert_account_has_single_asset, - consume_notes, execute_tx_and_sync, insert_new_wallet, wait_for_blocks, @@ -802,14 +801,19 @@ pub async fn test_ntx_mint_produces_public_note_with_non_standard_script( "timed out waiting for committed public note {registered_output_commitment:?} with a non-standard script" ); - // Bob consumes the public note; the custom script moves the minted asset into his vault. + // Bob consumes the public note; the custom script moves the minted asset into his vault. The + // script is non-standard, so the request must explicitly trust its root to pass the trust + // policy gate. let note: Note = client_2 .get_input_notes(NoteFilter::DetailsCommitments(vec![registered_output_commitment])) .await? .pop() .context("expected the committed public note to be present on Bob's client")? .try_into()?; - let consume_tx_id = consume_notes(&mut client_2, bob.id(), &[note]).await; + let consume_tx = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([Word::from(note.script().root())]) + .build_consume_notes(vec![note])?; + let consume_tx_id = Box::pin(client_2.submit_new_transaction(bob.id(), consume_tx)).await?; wait_for_tx(&mut client_2, consume_tx_id).await?; assert_account_has_single_asset(&client_2, bob.id(), faucet.id(), amount.as_canonical_u64()) From ec50dc8943191aeb240da2801ad4a755ee1c8b3a Mon Sep 17 00:00:00 2001 From: JereSalo Date: Mon, 20 Jul 2026 11:53:56 -0300 Subject: [PATCH 15/16] docs: add changelog entries for the note-script trust policy --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0180bd58aa..017b59d86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.16.0-alpha.2 (TBD) + +### Breaking Changes + +* [BREAKING][behavior][rust] `TransactionRequest` now defaults to `NoteScriptTrustPolicy::StandardScriptsOnly` for input-note scripts. Transactions consuming notes with non-standard scripts must explicitly opt in via `TransactionRequestBuilder::trusted_input_note_script_roots(...)` or `::allow_unlisted_note_scripts()`. Previously, missing non-standard input-note scripts could be silently fetched from the node and executed. ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)) +* [BREAKING][behavior][rust] `TransactionRequest` binary serialization format changed: a new `note_script_trust_policy` field is appended. Persisted/cached requests from previous versions will fail to deserialize. ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)) + +### Features + +* [FEATURE][cli] Added `--allow-unlisted-note-scripts` flag to `consume-notes` to consume notes whose scripts are not recognized standards ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)). + ## 0.16.0-alpha.1 (2026-07-17) ### Breaking Changes @@ -85,8 +96,6 @@ * [BREAKING][param][rust] `NodeRpcClient::get_block_by_number()` now takes an `include_proof: bool` parameter to control whether the block proof is included in the response. ([#1991](https://github.com/0xMiden/rust-sdk/pull/1991)) * [BREAKING][param][rust] `NodeRpcClient::sync_chain_mmr()` replaced `block_to: Option` with `upper_bound: SyncTarget` to match the RPC definition. Use `SyncTarget::CommittedChainTip` for previous default behavior (`None`), or `SyncTarget::BlockNumber(num)` for a specific block number. ([#1991](https://github.com/0xMiden/rust-sdk/pull/1991)) * [BREAKING][rust] Added `submit_proven_batch` to `NodeRpcClient` trait. ([#2075](https://github.com/0xMiden/rust-sdk/pull/2075)) -* [BREAKING][rust] `TransactionRequest` now defaults to `NoteScriptTrustPolicy::StandardScriptsOnly` for input-note scripts. Transactions consuming notes with non-standard scripts must explicitly opt in via `TransactionRequestBuilder::trusted_input_note_script_roots(...)` or `::allow_unlisted_note_scripts()`. Previously, missing non-standard input-note scripts could be silently fetched from the node and executed. ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)) -* [BREAKING][rust] `TransactionRequest` binary serialization format changed: a new `note_script_trust_policy` field is appended. Persisted/cached requests from previous versions will fail to deserialize. ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)) * [BREAKING][param][cli] `address add` now takes ` ` instead of ` [TAG_LEN]`. Use the new `address encode` subcommand to build a bech32 string from ` [TAG_LEN]`. ([#2115](https://github.com/0xMiden/rust-sdk/pull/2115)) * [BREAKING][rust] `StateSync` no longer takes an `Option>`. `StateSyncInput::accounts` is now a `Vec` (header + `AccountStorageHeader`); when hints cover the account's map slots `StateSync` issues a single `get_account_proof` for non-oversized accounts, and when new map slots appear on-chain it only fetches the missing ones. The `Store` trait method `get_account_map_slot_names` was replaced with `get_account_storage_header`. ([#2132](https://github.com/0xMiden/rust-sdk/pull/2132)) * [BREAKING] `NodeRpcClient::get_account_details` now fetches a public account's storage maps in a single `/GetAccount` request and returns `Option`. No longer returns data for private accounts; instead use `NodeRpcClient::get_account` to fetch private account's commitment. ([#2215](https://github.com/0xMiden/rust-sdk/pull/2215)). @@ -137,7 +146,6 @@ * [FEATURE][web] Added `StorageView` JS wrapper over WASM `AccountStorage`. `account.storage()` now returns a `StorageView` that makes `getItem()` work intuitively for both Value and StorageMap slots. WASM primitives are unchanged; the raw `AccountStorage` is accessible via `.raw` ([#1955](https://github.com/0xMiden/rust-sdk/pull/1955)). * [FEATURE][web] Added `wordToBigInt()` utility export for losslessly converting a `Word`'s first felt to a `BigInt`. `StorageResult.toString()` is BigInt-backed, and `valueOf()` returns a JS number for values fitting in `Number.MAX_SAFE_INTEGER` and throws `RangeError` for larger u64 values — use `.toBigInt()` for exact access ([#1955](https://github.com/0xMiden/rust-sdk/pull/1955)). * [FEATURE][rust,cli] Added partial swap (PSWAP) support: `TransactionRequestBuilder::build_pswap_create` / `build_pswap_consume` / `build_pswap_cancel` and a `miden-client pswap` CLI command (`create`, `consume`, `cancel`) for partially-fillable fungible swaps ([#2162](https://github.com/0xMiden/rust-sdk/pull/2162)). -* [FEATURE][cli] Added `--allow-unlisted-note-scripts` flag to `consume-notes` to consume notes whose scripts are not recognized standards ([#2136](https://github.com/0xMiden/rust-sdk/pull/2136)). * Added verification of MMR responses during state sync: validated the returned block range matches the requested range and checked that post-delta MMR peaks match the block header's chain commitment ([#1887](https://github.com/0xMiden/rust-sdk/pull/1887)). ## 0.14.9 (2026-05-19) From 1399189db12893fec66d612ee095ed077ded82e4 Mon Sep 17 00:00:00 2001 From: JereSalo Date: Wed, 22 Jul 2026 13:13:24 -0300 Subject: [PATCH 16/16] docs: mark changelog section as unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 017b59d86a..8b7fb870e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.16.0-alpha.2 (TBD) +## Unreleased ### Breaking Changes