Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
29e6a0a
verify root of RPC-fetched note scripts
JereSalo Apr 27, 2026
d7ac558
move note-script root verification down to the RPC client
JereSalo Apr 28, 2026
f62aaa8
format
JereSalo Apr 28, 2026
07d1703
add per-request note-script trust policy
JereSalo Apr 28, 2026
584adb3
trust custom note scripts where intentionally consumed
JereSalo Apr 28, 2026
9fa3b39
rename builder method to allow_unlisted_note_scripts and simplify CLI…
JereSalo Apr 29, 2026
5ee11ca
Merge remote-tracking branch 'origin/next' into jere/verify-fetched-n…
JereSalo Apr 29, 2026
f6d8030
Merge branch 'jere/verify-fetched-note-script-root' into note-script-…
JereSalo Apr 29, 2026
94254bb
clarify NoteScriptTrustPolicy doc-comment scope
JereSalo Apr 30, 2026
2853955
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo May 6, 2026
12eb7f8
adapt note script trust policy code to NoteScriptRoot type
JereSalo May 6, 2026
6944da5
use new note script header in trust policy test
JereSalo May 7, 2026
839d45e
document input note script trust policy
JereSalo May 7, 2026
3eff659
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo May 14, 2026
4fe86f1
address review feedback on note script trust policy
JereSalo May 14, 2026
6263c59
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo May 26, 2026
078e3fb
Merge branch 'next' into note-script-trust-policy
TomasArrachea Jun 4, 2026
ba60152
fix: merge
TomasArrachea Jun 4, 2026
1e1fda5
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo Jun 16, 2026
fe2bdb5
fix: adapt note-script trust policy tests to protocol 0.15 API
JereSalo Jun 16, 2026
8c5aeef
fix: trust non-standard note script in ntx public-note integration test
JereSalo Jun 19, 2026
566d974
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo Jul 13, 2026
cf149f1
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo Jul 17, 2026
7c96a7b
Merge remote-tracking branch 'origin/next' into note-script-trust-policy
JereSalo Jul 20, 2026
ec50dc8
docs: add changelog entries for the note-script trust policy
JereSalo Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.16.0-alpha.2 (TBD)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is the right entry to put. I just saw the last one was ## 0.16.0-alpha.1 (2026-07-17) and went for this one. We can change it if necessary.


### 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
Expand Down
4 changes: 4 additions & 0 deletions bin/integration-tests/src/tests/custom_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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())
Expand Down
11 changes: 8 additions & 3 deletions bin/integration-tests/src/tests/network_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()?;

Expand Down Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions bin/integration-tests/src/tests/pass_through.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 18 additions & 10 deletions bin/miden-cli/src/commands/new_transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use miden_client::note::{
};
use miden_client::store::NoteRecordError;
use miden_client::transaction::{
NoteArgs,
PaymentNoteDescription,
PswapTransactionData,
RawOutputNote,
Expand Down Expand Up @@ -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 {
Expand All @@ -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<NoteArgs>)> = Vec::new();

for note_id in &self.list_of_notes {
input_notes.push((resolve_input_note(&client, note_id).await?, None));
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 46 additions & 1 deletion crates/rust-client/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use alloc::boxed::Box;
use alloc::collections::BTreeSet;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
Expand Down Expand Up @@ -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
// ================================================================================================
Expand Down Expand Up @@ -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<Word>,
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 \
Expand Down Expand Up @@ -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<Word>);

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
// ================================================================================================

Expand All @@ -246,6 +282,15 @@ impl From<&ClientError> for Option<ErrorHint> {
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!(
Expand Down
25 changes: 25 additions & 0 deletions crates/rust-client/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ mod request;
pub use request::{
ForeignAccount,
NoteArgs,
NoteScriptTrustPolicy,
PaymentNoteDescription,
PswapTransactionData,
SwapTransactionData,
Expand Down Expand Up @@ -249,6 +250,9 @@ where
transaction_request: TransactionRequest,
tx_prover: Arc<dyn TransactionProver>,
) -> Result<TransactionId, ClientError> {
// 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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Word> = 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() {
Comment thread
gabrielbosio marked this conversation as resolved.
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<InputNote>,
Expand Down
35 changes: 35 additions & 0 deletions crates/rust-client/src/transaction/request/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use miden_standards::note::{P2idNote, P2ideNote, PswapNote, PswapNoteStorage, Sw
use super::{
ForeignAccount,
NoteArgs,
NoteScriptTrustPolicy,
TransactionRequest,
TransactionRequestError,
TransactionScriptTemplate,
Expand Down Expand Up @@ -94,6 +95,8 @@ pub struct TransactionRequestBuilder {
///
/// See [`TransactionRequestBuilder::expected_ntx_scripts`] for details.
expected_ntx_scripts: Vec<NoteScript>,
/// Trust policy for input-note scripts during transaction execution.
note_script_trust_policy: NoteScriptTrustPolicy,
}

impl TransactionRequestBuilder {
Expand All @@ -117,6 +120,7 @@ impl TransactionRequestBuilder {
script_arg: None,
auth_arg: None,
expected_ntx_scripts: vec![],
note_script_trust_policy: NoteScriptTrustPolicy::default(),
}
}

Expand Down Expand Up @@ -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(
Comment thread
JereSalo marked this conversation as resolved.
mut self,
roots: impl IntoIterator<Item = Word>,
) -> 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
// --------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -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,
})
}
}
Expand Down
Loading
Loading