diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da69c204f..8b7fb870e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### 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 diff --git a/bin/integration-tests/src/tests/custom_transaction.rs b/bin/integration-tests/src/tests/custom_transaction.rs index 7e2b258f80..97149b64e5 100644 --- a/bin/integration-tests/src/tests/custom_transaction.rs +++ b/bin/integration-tests/src/tests/custom_transaction.rs @@ -98,6 +98,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([Word::from(note.script().root())]) .custom_script(tx_script.clone()) .script_arg(Word::empty()) .extend_advice_map(advice_map.clone()) @@ -114,6 +115,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([Word::from(note.script().root())]) .custom_script(tx_script) .script_arg([Felt::from(4u32), Felt::from(3u32), Felt::from(2u32), Felt::from(1u32)].into()) .extend_advice_map(advice_map) @@ -180,6 +182,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 = 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()); @@ -227,6 +230,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 4cb77ce207..e067edbba1 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, @@ -482,6 +481,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([Word::from(network_note.script().root())]) .input_notes(vec![(network_note, None)]) .build()?; @@ -803,14 +803,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()) diff --git a/bin/integration-tests/src/tests/pass_through.rs b/bin/integration-tests/src/tests/pass_through.rs index 05de62596b..8c52a37747 100644 --- a/bin/integration-tests/src/tests/pass_through.rs +++ b/bin/integration-tests/src/tests/pass_through.rs @@ -131,6 +131,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([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(); @@ -161,6 +162,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([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/bin/miden-cli/src/commands/new_transactions.rs b/bin/miden-cli/src/commands/new_transactions.rs index b12f3af48b..2971cfe58d 100644 --- a/bin/miden-cli/src/commands/new_transactions.rs +++ b/bin/miden-cli/src/commands/new_transactions.rs @@ -14,6 +14,7 @@ use miden_client::note::{ }; use miden_client::store::NoteRecordError; use miden_client::transaction::{ + NoteArgs, PaymentNoteDescription, PswapTransactionData, RawOutputNote, @@ -296,6 +297,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 { @@ -305,7 +312,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 { input_notes.push((resolve_input_note(&client, note_id).await?, None)); @@ -335,15 +342,16 @@ 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 { + builder = builder.allow_unlisted_note_scripts(); + } + 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/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index 21a4c5a922..b117cf1e1c 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -1,4 +1,5 @@ use alloc::boxed::Box; +use alloc::collections::BTreeSet; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; @@ -35,7 +36,12 @@ use crate::note::NoteScreenerError; use crate::note_transport::NoteTransportError; use crate::rpc::RpcError; use crate::store::{NoteRecordError, StoreError}; -use crate::transaction::{BatchBuilderError, TransactionRequestError, TransactionStoreUpdateError}; +use crate::transaction::{ + BatchBuilderError, + NoteScriptTrustPolicy, + TransactionRequestError, + TransactionStoreUpdateError, +}; // ACTIONABLE HINTS // ================================================================================================ @@ -182,6 +188,14 @@ pub enum ClientError { #[source] source: RpcError, }, + #[error( + "note script {} not allowed under the {policy} trust policy", + DisplayScriptRoots(script_roots) + )] + UntrustedNoteScript { + script_roots: BTreeSet, + policy: NoteScriptTrustPolicy, + }, #[error( "transaction {} was accepted into the node's mempool at block {} but the local store \ update failed. The pending store update is attached and can be re-applied later via \ @@ -220,6 +234,28 @@ pub(crate) fn log_observer_failure( } } +/// 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 // ================================================================================================ @@ -246,6 +282,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 d0326c8951..44e812e061 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -137,6 +137,7 @@ mod request; pub use request::{ ForeignAccount, NoteArgs, + NoteScriptTrustPolicy, PaymentNoteDescription, PswapTransactionData, SwapTransactionData, @@ -249,6 +250,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() { @@ -370,6 +374,9 @@ where self.validate_recency().await?; validate_account_request(&transaction_request, account)?; + // 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 @@ -987,6 +994,24 @@ pub enum TransactionStoreUpdateError { // HELPERS // ================================================================================================ +/// 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(); + 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() }) + } +} + /// Data-store-independent state produced during transaction preparation. pub(crate) struct PreparedTransaction { pub(crate) notes: InputNotes, diff --git a/crates/rust-client/src/transaction/request/builder.rs b/crates/rust-client/src/transaction/request/builder.rs index 935d6582be..d1fb1bba29 100644 --- a/crates/rust-client/src/transaction/request/builder.rs +++ b/crates/rust-client/src/transaction/request/builder.rs @@ -34,6 +34,7 @@ use miden_standards::note::{P2idNote, P2ideNote, PswapNote, PswapNoteStorage, Sw use super::{ ForeignAccount, NoteArgs, + NoteScriptTrustPolicy, TransactionRequest, TransactionRequestError, TransactionScriptTemplate, @@ -94,6 +95,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 { @@ -117,6 +120,7 @@ impl TransactionRequestBuilder { script_arg: None, auth_arg: None, expected_ntx_scripts: vec![], + note_script_trust_policy: NoteScriptTrustPolicy::default(), } } @@ -289,6 +293,36 @@ 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. + /// + /// 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, + 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. + /// + /// 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; + self + } + // STANDARDIZED REQUESTS // -------------------------------------------------------------------------------------------- @@ -613,6 +647,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 bc6b45f30c..c047949014 100644 --- a/crates/rust-client/src/transaction/request/mod.rs +++ b/crates/rust-client/src/transaction/request/mod.rs @@ -55,6 +55,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 @@ -127,6 +130,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 { @@ -237,6 +242,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). @@ -389,6 +399,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); } } @@ -429,6 +440,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, @@ -444,6 +456,7 @@ impl Deserializable for TransactionRequest { script_arg, auth_arg, expected_ntx_scripts, + note_script_trust_policy, }) } } @@ -588,7 +601,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; @@ -682,4 +695,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..485a45f7d6 --- /dev/null +++ b/crates/rust-client/src/transaction/request/note_script_policy.rs @@ -0,0 +1,193 @@ +//! Trust policy applied to input-note scripts when executing a user transaction request. +//! +//! See [`NoteScriptTrustPolicy`] for the available variants and their semantics. + +use alloc::collections::BTreeSet; +use alloc::string::ToString; +use core::fmt; + +use miden_protocol::Word; +use miden_protocol::note::NoteScriptRoot; +use miden_standards::note::StandardNote; +use miden_tx::utils::serde::{ + ByteReader, + ByteWriter, + Deserializable, + DeserializationError, + Serializable, +}; + +/// 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 [`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). + /// + /// This is the default and rejects any custom or unknown script the executor encounters, + /// 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 + /// 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. + AllowUnlistedAfterApproval, +} + +impl NoteScriptTrustPolicy { + /// 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), + Self::TrustedScriptRoots(set) => is_standard_script(root) || set.contains(&root), + Self::AllowUnlistedAfterApproval => true, + } + } +} + +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() +} + +// 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 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().into() + } + + fn unknown_root() -> Word { + // A deterministic non-standard root. + Word::from([1u32, 2, 3, 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([9u32, 9, 9, 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([7u32, 7, 7, 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 4ea863c794..5f46e78709 100644 --- a/crates/testing/miden-client-tests/src/tests.rs +++ b/crates/testing/miden-client-tests/src/tests.rs @@ -86,6 +86,7 @@ use miden_protocol::note::{ NoteAssets, NoteAttachments, NoteRecipient, + NoteScript, NoteStorage, NoteTag, NoteType, @@ -4094,8 +4095,9 @@ async fn consume_note_with_custom_script() { client.test_store().get_note_script(note_script.root().into()).await.unwrap(); assert_eq!(stored_script.root().to_hex(), note_script.root().to_hex()); - // Consume note + // Consume note, trusting root of custom script. let transaction_request = TransactionRequestBuilder::new() + .trusted_input_note_script_roots([Word::from(note_script.root())]) .build_consume_notes(vec![custom_note.clone()]) .unwrap(); @@ -4108,6 +4110,155 @@ 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, + AccountType::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 = " + @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(); + 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); + 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().into()).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_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, + AccountType::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 = 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()); + 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/cli/index.md b/docs/external/src/rust-client/cli/index.md index 277be2dad7..4e2fbec337 100644 --- a/docs/external/src/rust-client/cli/index.md +++ b/docs/external/src/rust-client/cli/index.md @@ -292,6 +292,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. + #### `transfer` Transfers 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 59db50f9a3..2f5431452d 100644 --- a/docs/external/src/rust-client/library.md +++ b/docs/external/src/rust-client/library.md @@ -156,6 +156,15 @@ 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 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. + +See `NoteScriptTrustPolicy` for the full set of variants. + ## Note screening ### When to use note screening