From d38f4c0d7cbffcf6fd54477fb23d615ae89b5806 Mon Sep 17 00:00:00 2001 From: Marija Mijailovic Date: Mon, 27 Apr 2026 13:59:25 +0200 Subject: [PATCH 1/2] Note lifecycle assertion macros --- crates/miden-testing/src/asserts/mod.rs | 6 + crates/miden-testing/src/asserts/notes.rs | 296 +++++++++++++++++++ crates/miden-testing/src/lib.rs | 2 + crates/miden-testing/tests/asserts.rs | 129 ++++++++ crates/miden-testing/tests/scripts/faucet.rs | 23 +- 5 files changed, 441 insertions(+), 15 deletions(-) create mode 100644 crates/miden-testing/src/asserts/mod.rs create mode 100644 crates/miden-testing/src/asserts/notes.rs create mode 100644 crates/miden-testing/tests/asserts.rs diff --git a/crates/miden-testing/src/asserts/mod.rs b/crates/miden-testing/src/asserts/mod.rs new file mode 100644 index 0000000000..670485f63b --- /dev/null +++ b/crates/miden-testing/src/asserts/mod.rs @@ -0,0 +1,6 @@ +//! Assertion macros for note-lifecycle checks in tests. + +pub mod notes; + +#[doc(hidden)] +pub use notes::{AsNoteId, AsNullifier, MatchesTxInput, OutputNoteSpec, check_output_note_created}; diff --git a/crates/miden-testing/src/asserts/notes.rs b/crates/miden-testing/src/asserts/notes.rs new file mode 100644 index 0000000000..ff0bf34f6d --- /dev/null +++ b/crates/miden-testing/src/asserts/notes.rs @@ -0,0 +1,296 @@ +use alloc::vec::Vec; + +use miden_protocol::account::AccountId; +use miden_protocol::asset::Asset; +use miden_protocol::note::{Note, NoteId, NoteType, Nullifier}; +use miden_protocol::transaction::{ExecutedTransaction, InputNote}; + +use crate::MockChain; + +// TX-LEVEL +// ================================================================================================ + +/// Spec for [`assert_note_created!`]. Fields left as `None` are skipped. +#[derive(Default, Debug, Clone)] +pub struct OutputNoteSpec { + pub note_type: Option, + pub sender: Option, + pub assets: Option>, +} + +/// Returns `true` if at least one output note in `tx` matches `spec`. +pub fn check_output_note_created(tx: &ExecutedTransaction, spec: &OutputNoteSpec) -> bool { + tx.output_notes().iter().any(|note| { + if let Some(expected) = spec.note_type + && note.metadata().note_type() != expected + { + return false; + } + if let Some(expected) = spec.sender + && note.metadata().sender() != expected + { + return false; + } + if let Some(expected) = spec.assets.as_ref() { + let actual: Vec<&Asset> = note.assets().iter().collect(); + if actual.len() != expected.len() { + return false; + } + // Each actual matches at most once (otherwise [A,A] would match [A,B]). + let mut consumed = vec![false; actual.len()]; + let matched = expected.iter().all(|exp| { + let slot = actual.iter().enumerate().find(|(i, a)| !consumed[*i] && **a == exp); + if let Some((i, _)) = slot { + consumed[i] = true; + true + } else { + false + } + }); + if !matched { + return false; + } + } + true + }) +} + +/// Lets [`assert_note_consumed_by!`] take a `NoteId`, `Nullifier`, `Note`, or `InputNote`. +pub trait MatchesTxInput { + fn matches_tx_input(&self, input: &InputNote) -> bool; +} + +impl MatchesTxInput for NoteId { + fn matches_tx_input(&self, input: &InputNote) -> bool { + input.id() == *self + } +} + +impl MatchesTxInput for Nullifier { + fn matches_tx_input(&self, input: &InputNote) -> bool { + input.note().nullifier() == *self + } +} + +impl MatchesTxInput for Note { + fn matches_tx_input(&self, input: &InputNote) -> bool { + input.id() == self.id() + } +} + +impl MatchesTxInput for InputNote { + fn matches_tx_input(&self, input: &InputNote) -> bool { + input.id() == self.id() + } +} + +impl MatchesTxInput for &T { + fn matches_tx_input(&self, input: &InputNote) -> bool { + (**self).matches_tx_input(input) + } +} + +/// Asserts the tx emitted a note matching the spec. Fields are optional; unset ones are skipped. +/// +/// # Example +/// ```ignore +/// use miden_testing::assert_note_created; +/// use miden_protocol::note::NoteType; +/// +/// assert_note_created!( +/// executed_tx, +/// note_type: NoteType::Public, +/// sender: faucet.id(), +/// assets: [FungibleAsset::new(faucet.id(), amount)?.into()], +/// ); +/// ``` +#[macro_export] +macro_rules! assert_note_created { + ($tx:expr $(, $key:ident : $val:expr)* $(,)?) => {{ + #[allow(unused_mut)] + let mut spec = $crate::asserts::OutputNoteSpec::default(); + $( + $crate::__assert_note_created_field!(spec, $key, $val); + )* + let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; + assert!( + $crate::asserts::check_output_note_created(tx, &spec), + "no output note matches spec: {:?}\n tx produced {} output note(s)", + spec, + tx.output_notes().num_notes(), + ); + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __assert_note_created_field { + ($spec:ident,note_type, $val:expr) => { + $spec.note_type = ::core::option::Option::Some($val); + }; + ($spec:ident,sender, $val:expr) => { + $spec.sender = ::core::option::Option::Some($val); + }; + ($spec:ident,assets, $val:expr) => { + $spec.assets = ::core::option::Option::Some( + ::core::iter::IntoIterator::into_iter($val) + .map(::core::convert::Into::into) + .collect::<::alloc::vec::Vec<::miden_protocol::asset::Asset>>(), + ); + }; + ($spec:ident, $key:ident, $val:expr) => { + ::core::compile_error!(concat!( + "unknown field in assert_note_created!: `", + stringify!($key), + "`. Supported fields: note_type, sender, assets", + )); + }; +} + +/// Asserts the tx consumed the given note (checks `tx.input_notes()`). +/// +/// Tx-level counterpart to [`assert_note_consumed!`]. +/// +/// Accepts `NoteId`, `Nullifier`, `&Note`, or `&InputNote`. +#[macro_export] +macro_rules! assert_note_consumed_by { + ($tx:expr, $note_ref:expr $(,)?) => {{ + let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; + let matcher = &$note_ref; + let found = tx + .input_notes() + .iter() + .any(|n| $crate::asserts::MatchesTxInput::matches_tx_input(matcher, n)); + assert!( + found, + "tx does not consume the expected note\n tx has {} input note(s)", + tx.input_notes().num_notes(), + ); + }}; +} + +// CHAIN-LEVEL +// ================================================================================================ + +/// Lets chain-level macros take a [`NoteId`], a [`Note`], or an [`InputNote`]. +pub trait AsNoteId { + fn as_note_id(&self) -> NoteId; +} + +impl AsNoteId for NoteId { + fn as_note_id(&self) -> NoteId { + *self + } +} + +impl AsNoteId for Note { + fn as_note_id(&self) -> NoteId { + self.id() + } +} + +impl AsNoteId for InputNote { + fn as_note_id(&self) -> NoteId { + self.id() + } +} + +impl AsNoteId for &T { + fn as_note_id(&self) -> NoteId { + (**self).as_note_id() + } +} + +/// Lets chain-level macros take a [`Nullifier`], [`NoteId`], [`Note`], or [`InputNote`]. +/// +/// A bare [`NoteId`] needs the chain for the lookup; other impls ignore it. +pub trait AsNullifier { + /// # Panics + /// Panics if the `NoteId` isn't in `chain.committed_notes()` (e.g. a private note). + fn as_nullifier(&self, chain: &MockChain) -> Nullifier; +} + +impl AsNullifier for Nullifier { + fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { + *self + } +} + +impl AsNullifier for Note { + fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { + self.nullifier() + } +} + +impl AsNullifier for InputNote { + fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { + self.note().nullifier() + } +} + +impl AsNullifier for NoteId { + fn as_nullifier(&self, chain: &MockChain) -> Nullifier { + chain + .committed_notes() + .get(self) + .and_then(|n| n.note()) + .map(|n| n.nullifier()) + .unwrap_or_else(|| { + panic!( + "NoteId {self} not in chain.committed_notes() (private or unknown). Pass the full Note or Nullifier instead.", + ) + }) + } +} + +impl AsNullifier for &T { + fn as_nullifier(&self, chain: &MockChain) -> Nullifier { + (**self).as_nullifier(chain) + } +} + +/// Asserts the note is in [`MockChain::committed_notes()`](crate::MockChain::committed_notes). +/// +/// Accepts `NoteId`, `&Note`, or `&InputNote`. +#[macro_export] +macro_rules! assert_note_committed { + ($chain:expr, $note_ref:expr $(,)?) => {{ + let chain: &$crate::MockChain = &$chain; + let id = $crate::asserts::AsNoteId::as_note_id(&$note_ref); + assert!( + chain.committed_notes().contains_key(&id), + "note {id} is not in chain.committed_notes()", + ); + }}; +} + +/// Asserts the note's nullifier is not on-chain (note isn't consumed yet). +/// +/// Accepts `Nullifier`, `NoteId`, `&Note`, or `&InputNote`. A bare `NoteId` needs the note in +/// `chain.committed_notes()`. +#[macro_export] +macro_rules! assert_note_unspent { + ($chain:expr, $note_ref:expr $(,)?) => {{ + let chain: &$crate::MockChain = &$chain; + let nullifier = $crate::asserts::AsNullifier::as_nullifier(&$note_ref, chain); + assert!( + chain.nullifier_tree().get_block_num(&nullifier).is_none(), + "note {nullifier} already on-chain (expected unspent)", + ); + }}; +} + +/// Asserts the note's nullifier is on-chain (note is consumed). +/// +/// Accepts the same types as [`assert_note_unspent!`]. +#[macro_export] +macro_rules! assert_note_consumed { + ($chain:expr, $note_ref:expr $(,)?) => {{ + let chain: &$crate::MockChain = &$chain; + let nullifier = $crate::asserts::AsNullifier::as_nullifier(&$note_ref, chain); + assert!( + chain.nullifier_tree().get_block_num(&nullifier).is_some(), + "note {nullifier} not on-chain (expected consumed)", + ); + }}; +} diff --git a/crates/miden-testing/src/lib.rs b/crates/miden-testing/src/lib.rs index 6763012635..c48e162f1e 100644 --- a/crates/miden-testing/src/lib.rs +++ b/crates/miden-testing/src/lib.rs @@ -19,6 +19,8 @@ pub use mock_chain::{ mod tx_context; pub use tx_context::{ExecError, TransactionContext, TransactionContextBuilder}; +pub mod asserts; + pub mod executor; mod mock_host; diff --git a/crates/miden-testing/tests/asserts.rs b/crates/miden-testing/tests/asserts.rs new file mode 100644 index 0000000000..b2bbb15329 --- /dev/null +++ b/crates/miden-testing/tests/asserts.rs @@ -0,0 +1,129 @@ +//! Integration tests for the note-lifecycle assertion macros. + +extern crate alloc; + +use anyhow::Result; +use miden_protocol::account::AccountId; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::Word; +use miden_protocol::note::{Note, NoteId, NoteType}; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; +use miden_testing::{ + Auth, + MockChain, + assert_note_committed, + assert_note_consumed, + assert_note_consumed_by, + assert_note_created, + assert_note_unspent, +}; + +/// Builds a chain and runs a SPAWN tx that emits one P2ID output note with the given assets. +/// The returned chain is still in post-build state — execute doesn't mutate it. +async fn execute_with_output( + output_assets: &[Asset], +) -> Result<(AccountId, Note, MockChain, ExecutedTransaction)> { + let mut builder = MockChain::builder(); + + let sender = builder.add_existing_wallet_with_assets( + Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }, + output_assets.iter().copied(), + )?; + let target = builder.create_new_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let sender_id = sender.id(); + + let output = builder.add_p2id_note(sender_id, target.id(), output_assets, NoteType::Public)?; + let spawn = builder.add_spawn_note([&output])?; + + let chain = builder.build()?; + + let executed = chain + .build_tx_context(sender, &[spawn.id()], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output)]) + .build()? + .execute() + .await?; + + Ok((sender_id, spawn, chain, executed)) +} + +/// Full lifecycle: build, execute, prove block. +#[tokio::test] +async fn lifecycle_macros_full_round_trip() -> Result<()> { + let asset: Asset = FungibleAsset::mock(7); + let (sender_id, spawn, mut chain, executed) = execute_with_output(&[asset]).await?; + + // post-build: spawn is committed and unspent. + assert_note_committed!(chain, &spawn); + assert_note_committed!(chain, spawn.id()); + assert_note_unspent!(chain, &spawn); + assert_note_unspent!(chain, spawn.id()); + + // post-execute: tx-level checks against the executed transaction. + assert_note_consumed_by!(executed, &spawn); + assert_note_consumed_by!(executed, spawn.id()); + assert_note_consumed_by!(executed, spawn.nullifier()); + + assert_note_created!( + executed, + note_type: NoteType::Public, + sender: sender_id, + assets: [asset], + ); + + // post-block: spawn's nullifier is now on-chain. + chain.add_pending_executed_transaction(&executed)?; + chain.prove_next_block()?; + + assert_note_consumed!(chain, &spawn); + assert_note_consumed!(chain, spawn.nullifier()); + + Ok(()) +} + +/// Each field can be set on its own; unset fields aren't checked. +#[tokio::test] +async fn assert_note_created_partial_specs_match() -> Result<()> { + let asset: Asset = FungibleAsset::mock(7); + let (sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await?; + + assert_note_created!(executed, note_type: NoteType::Public); + assert_note_created!(executed, sender: sender_id); + assert_note_created!(executed, assets: [asset]); + assert_note_created!(executed, note_type: NoteType::Public, assets: [asset]); + Ok(()) +} + +#[tokio::test] +#[should_panic(expected = "no output note matches")] +async fn assert_note_created_panics_on_sender_mismatch() { + let asset: Asset = FungibleAsset::mock(7); + let (_sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await.unwrap(); + + // Faucet ID can't be the sender of a wallet-emitted P2ID. + assert_note_created!(executed, sender: FungibleAsset::mock_issuer()); +} + +#[tokio::test] +#[should_panic(expected = "no output note matches")] +async fn assert_note_created_panics_on_asset_count_mismatch() { + let asset: Asset = FungibleAsset::mock(7); + let (_sender_id, _spawn, _chain, executed) = execute_with_output(&[asset]).await.unwrap(); + + assert_note_created!(executed, assets: [asset, asset]); +} + +#[tokio::test] +#[should_panic(expected = "not in chain.committed_notes()")] +async fn assert_note_unspent_panics_for_unknown_note_id() { + let asset: Asset = FungibleAsset::mock(7); + let (_sender_id, _spawn, chain, _executed) = execute_with_output(&[asset]).await.unwrap(); + + let unknown = NoteId::new(Word::default(), Word::default()); + assert_note_unspent!(chain, unknown); +} diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index f8d7750bde..1e61dc795a 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -60,6 +60,7 @@ use miden_testing::{ Auth, MockChain, MockChainBuilder, + assert_note_created, assert_transaction_executor_error, }; use rand::Rng; @@ -589,28 +590,20 @@ async fn test_public_note_creation_with_script_from_datastore() -> anyhow::Resul .execute() .await?; - // Verify that a PUBLIC note was created assert_eq!(executed_transaction.output_notes().num_notes(), 1); - let output_note = executed_transaction.output_notes().get_note(0); + assert_note_created!( + executed_transaction, + note_type: NoteType::Public, + sender: faucet.id(), + assets: [FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?], + ); - // Extract the full note from the OutputNote enum + let output_note = executed_transaction.output_notes().get_note(0); let full_note = match output_note { RawOutputNote::Full(note) => note, _ => panic!("Expected OutputNote::Full variant"), }; - // Verify the output note is public - assert_eq!(full_note.metadata().note_type(), NoteType::Public); - - // Verify the output note contains the minted fungible asset - let expected_asset = FungibleAsset::new(faucet.id(), amount.as_canonical_u64())?; - let expected_asset_obj = Asset::from(expected_asset); - assert!(full_note.assets().iter().any(|asset| asset == &expected_asset_obj)); - - // Verify the note was created by the faucet - assert_eq!(full_note.metadata().sender(), faucet.id()); - - // Verify the note storage commitment matches the expected commitment assert_eq!( full_note.recipient().storage().commitment(), note_storage.commitment(), From 2fd747f12b2437248f18f010e237564a74b734f5 Mon Sep 17 00:00:00 2001 From: Marija Mijailovic Date: Mon, 4 May 2026 09:12:36 +0200 Subject: [PATCH 2/2] Replace chain/tx assert macros with helper methods Address comments from the issuse #2805, drop the 4 macros in favor of plain helper methods. - `assert_note_committed!` -> `MockChain::is_note_committed` - `assert_note_unspent!` -> `MockChain::is_note_unspent` - `assert_note_consumed!` -> `MockChain::is_note_consumed` - `assert_note_consumed_by!` -> `ExecutedTransaction::consumes_note` Keep `assert_note_created!` since it does spec matching with optional fields. --- crates/miden-protocol/src/testing/tx.rs | 6 + crates/miden-testing/src/asserts.rs | 112 +++++++ crates/miden-testing/src/asserts/mod.rs | 6 - crates/miden-testing/src/asserts/notes.rs | 296 ------------------- crates/miden-testing/src/mock_chain/chain.rs | 18 ++ crates/miden-testing/tests/asserts.rs | 40 +-- 6 files changed, 144 insertions(+), 334 deletions(-) create mode 100644 crates/miden-testing/src/asserts.rs delete mode 100644 crates/miden-testing/src/asserts/mod.rs delete mode 100644 crates/miden-testing/src/asserts/notes.rs diff --git a/crates/miden-protocol/src/testing/tx.rs b/crates/miden-protocol/src/testing/tx.rs index 978170f7ba..0696a713d5 100644 --- a/crates/miden-protocol/src/testing/tx.rs +++ b/crates/miden-protocol/src/testing/tx.rs @@ -1,3 +1,4 @@ +use crate::note::NoteId; use crate::transaction::ExecutedTransaction; impl ExecutedTransaction { @@ -9,4 +10,9 @@ impl ExecutedTransaction { self.block_header().fee_parameters().verification_base_fee() * verification_cycles; fee_amount as u64 } + + /// Returns `true` if the transaction consumes the note with the given ID. + pub fn consumes_note(&self, note_id: &NoteId) -> bool { + self.input_notes().iter().any(|n| n.id() == *note_id) + } } diff --git a/crates/miden-testing/src/asserts.rs b/crates/miden-testing/src/asserts.rs new file mode 100644 index 0000000000..bb2fb0ae7c --- /dev/null +++ b/crates/miden-testing/src/asserts.rs @@ -0,0 +1,112 @@ +//! Assertion macro for note-lifecycle checks in tests. + +use alloc::vec::Vec; + +use miden_protocol::account::AccountId; +use miden_protocol::asset::Asset; +use miden_protocol::note::NoteType; +use miden_protocol::transaction::ExecutedTransaction; + +/// Spec for [`assert_note_created!`]. Fields left as `None` are skipped. +#[doc(hidden)] +#[derive(Default, Debug, Clone)] +pub struct OutputNoteSpec { + pub note_type: Option, + pub sender: Option, + pub assets: Option>, +} + +/// Returns `true` if at least one output note in `tx` matches `spec`. +#[doc(hidden)] +pub fn check_output_note_created(tx: &ExecutedTransaction, spec: &OutputNoteSpec) -> bool { + tx.output_notes().iter().any(|note| { + if let Some(expected) = spec.note_type + && note.metadata().note_type() != expected + { + return false; + } + if let Some(expected) = spec.sender + && note.metadata().sender() != expected + { + return false; + } + if let Some(expected) = spec.assets.as_ref() { + let actual = note.assets(); + if actual.num_assets() != expected.len() { + return false; + } + // Each actual matches at most once (otherwise [A,A] would match [A,B]). + let mut consumed = vec![false; expected.len()]; + let matched = expected.iter().all(|exp| { + let slot = actual.iter().enumerate().find(|(i, a)| !consumed[*i] && *a == exp); + if let Some((i, _)) = slot { + consumed[i] = true; + true + } else { + false + } + }); + if !matched { + return false; + } + } + true + }) +} + +/// Asserts the tx emitted a note matching the spec. Fields are optional; unset ones are skipped. +/// +/// # Example +/// ```ignore +/// use miden_testing::assert_note_created; +/// use miden_protocol::note::NoteType; +/// +/// assert_note_created!( +/// executed_tx, +/// note_type: NoteType::Public, +/// sender: faucet.id(), +/// assets: [FungibleAsset::new(faucet.id(), amount)?.into()], +/// ); +/// ``` +#[macro_export] +macro_rules! assert_note_created { + ($tx:expr $(, $key:ident : $val:expr)* $(,)?) => {{ + #[allow(unused_mut)] + let mut spec = $crate::asserts::OutputNoteSpec::default(); + $( + $crate::__assert_note_created_field!(spec, $key, $val); + )* + let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; + assert!( + $crate::asserts::check_output_note_created(tx, &spec), + "no output note matches spec: {:?}\n tx produced {} output note(s)", + spec, + tx.output_notes().num_notes(), + ); + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __assert_note_created_field { + ($spec:ident,note_type, $val:expr) => { + $spec.note_type = ::core::option::Option::Some($val); + }; + ($spec:ident,sender, $val:expr) => { + $spec.sender = ::core::option::Option::Some($val); + }; + ($spec:ident,assets, $val:expr) => { + $spec.assets = ::core::option::Option::Some( + ::core::iter::IntoIterator::into_iter($val) + .map(::core::convert::Into::into) + .collect::<::alloc::vec::Vec<::miden_protocol::asset::Asset>>(), + ); + }; + ($spec:ident, $key:ident, $val:expr) => { + ::core::compile_error!(concat!( + "unknown field in assert_note_created!: `", + stringify!($key), + "`. Supported fields: note_type, sender, assets", + )); + }; +} diff --git a/crates/miden-testing/src/asserts/mod.rs b/crates/miden-testing/src/asserts/mod.rs deleted file mode 100644 index 670485f63b..0000000000 --- a/crates/miden-testing/src/asserts/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Assertion macros for note-lifecycle checks in tests. - -pub mod notes; - -#[doc(hidden)] -pub use notes::{AsNoteId, AsNullifier, MatchesTxInput, OutputNoteSpec, check_output_note_created}; diff --git a/crates/miden-testing/src/asserts/notes.rs b/crates/miden-testing/src/asserts/notes.rs deleted file mode 100644 index ff0bf34f6d..0000000000 --- a/crates/miden-testing/src/asserts/notes.rs +++ /dev/null @@ -1,296 +0,0 @@ -use alloc::vec::Vec; - -use miden_protocol::account::AccountId; -use miden_protocol::asset::Asset; -use miden_protocol::note::{Note, NoteId, NoteType, Nullifier}; -use miden_protocol::transaction::{ExecutedTransaction, InputNote}; - -use crate::MockChain; - -// TX-LEVEL -// ================================================================================================ - -/// Spec for [`assert_note_created!`]. Fields left as `None` are skipped. -#[derive(Default, Debug, Clone)] -pub struct OutputNoteSpec { - pub note_type: Option, - pub sender: Option, - pub assets: Option>, -} - -/// Returns `true` if at least one output note in `tx` matches `spec`. -pub fn check_output_note_created(tx: &ExecutedTransaction, spec: &OutputNoteSpec) -> bool { - tx.output_notes().iter().any(|note| { - if let Some(expected) = spec.note_type - && note.metadata().note_type() != expected - { - return false; - } - if let Some(expected) = spec.sender - && note.metadata().sender() != expected - { - return false; - } - if let Some(expected) = spec.assets.as_ref() { - let actual: Vec<&Asset> = note.assets().iter().collect(); - if actual.len() != expected.len() { - return false; - } - // Each actual matches at most once (otherwise [A,A] would match [A,B]). - let mut consumed = vec![false; actual.len()]; - let matched = expected.iter().all(|exp| { - let slot = actual.iter().enumerate().find(|(i, a)| !consumed[*i] && **a == exp); - if let Some((i, _)) = slot { - consumed[i] = true; - true - } else { - false - } - }); - if !matched { - return false; - } - } - true - }) -} - -/// Lets [`assert_note_consumed_by!`] take a `NoteId`, `Nullifier`, `Note`, or `InputNote`. -pub trait MatchesTxInput { - fn matches_tx_input(&self, input: &InputNote) -> bool; -} - -impl MatchesTxInput for NoteId { - fn matches_tx_input(&self, input: &InputNote) -> bool { - input.id() == *self - } -} - -impl MatchesTxInput for Nullifier { - fn matches_tx_input(&self, input: &InputNote) -> bool { - input.note().nullifier() == *self - } -} - -impl MatchesTxInput for Note { - fn matches_tx_input(&self, input: &InputNote) -> bool { - input.id() == self.id() - } -} - -impl MatchesTxInput for InputNote { - fn matches_tx_input(&self, input: &InputNote) -> bool { - input.id() == self.id() - } -} - -impl MatchesTxInput for &T { - fn matches_tx_input(&self, input: &InputNote) -> bool { - (**self).matches_tx_input(input) - } -} - -/// Asserts the tx emitted a note matching the spec. Fields are optional; unset ones are skipped. -/// -/// # Example -/// ```ignore -/// use miden_testing::assert_note_created; -/// use miden_protocol::note::NoteType; -/// -/// assert_note_created!( -/// executed_tx, -/// note_type: NoteType::Public, -/// sender: faucet.id(), -/// assets: [FungibleAsset::new(faucet.id(), amount)?.into()], -/// ); -/// ``` -#[macro_export] -macro_rules! assert_note_created { - ($tx:expr $(, $key:ident : $val:expr)* $(,)?) => {{ - #[allow(unused_mut)] - let mut spec = $crate::asserts::OutputNoteSpec::default(); - $( - $crate::__assert_note_created_field!(spec, $key, $val); - )* - let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; - assert!( - $crate::asserts::check_output_note_created(tx, &spec), - "no output note matches spec: {:?}\n tx produced {} output note(s)", - spec, - tx.output_notes().num_notes(), - ); - }}; -} - -#[doc(hidden)] -#[macro_export] -macro_rules! __assert_note_created_field { - ($spec:ident,note_type, $val:expr) => { - $spec.note_type = ::core::option::Option::Some($val); - }; - ($spec:ident,sender, $val:expr) => { - $spec.sender = ::core::option::Option::Some($val); - }; - ($spec:ident,assets, $val:expr) => { - $spec.assets = ::core::option::Option::Some( - ::core::iter::IntoIterator::into_iter($val) - .map(::core::convert::Into::into) - .collect::<::alloc::vec::Vec<::miden_protocol::asset::Asset>>(), - ); - }; - ($spec:ident, $key:ident, $val:expr) => { - ::core::compile_error!(concat!( - "unknown field in assert_note_created!: `", - stringify!($key), - "`. Supported fields: note_type, sender, assets", - )); - }; -} - -/// Asserts the tx consumed the given note (checks `tx.input_notes()`). -/// -/// Tx-level counterpart to [`assert_note_consumed!`]. -/// -/// Accepts `NoteId`, `Nullifier`, `&Note`, or `&InputNote`. -#[macro_export] -macro_rules! assert_note_consumed_by { - ($tx:expr, $note_ref:expr $(,)?) => {{ - let tx: &::miden_protocol::transaction::ExecutedTransaction = &$tx; - let matcher = &$note_ref; - let found = tx - .input_notes() - .iter() - .any(|n| $crate::asserts::MatchesTxInput::matches_tx_input(matcher, n)); - assert!( - found, - "tx does not consume the expected note\n tx has {} input note(s)", - tx.input_notes().num_notes(), - ); - }}; -} - -// CHAIN-LEVEL -// ================================================================================================ - -/// Lets chain-level macros take a [`NoteId`], a [`Note`], or an [`InputNote`]. -pub trait AsNoteId { - fn as_note_id(&self) -> NoteId; -} - -impl AsNoteId for NoteId { - fn as_note_id(&self) -> NoteId { - *self - } -} - -impl AsNoteId for Note { - fn as_note_id(&self) -> NoteId { - self.id() - } -} - -impl AsNoteId for InputNote { - fn as_note_id(&self) -> NoteId { - self.id() - } -} - -impl AsNoteId for &T { - fn as_note_id(&self) -> NoteId { - (**self).as_note_id() - } -} - -/// Lets chain-level macros take a [`Nullifier`], [`NoteId`], [`Note`], or [`InputNote`]. -/// -/// A bare [`NoteId`] needs the chain for the lookup; other impls ignore it. -pub trait AsNullifier { - /// # Panics - /// Panics if the `NoteId` isn't in `chain.committed_notes()` (e.g. a private note). - fn as_nullifier(&self, chain: &MockChain) -> Nullifier; -} - -impl AsNullifier for Nullifier { - fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { - *self - } -} - -impl AsNullifier for Note { - fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { - self.nullifier() - } -} - -impl AsNullifier for InputNote { - fn as_nullifier(&self, _chain: &MockChain) -> Nullifier { - self.note().nullifier() - } -} - -impl AsNullifier for NoteId { - fn as_nullifier(&self, chain: &MockChain) -> Nullifier { - chain - .committed_notes() - .get(self) - .and_then(|n| n.note()) - .map(|n| n.nullifier()) - .unwrap_or_else(|| { - panic!( - "NoteId {self} not in chain.committed_notes() (private or unknown). Pass the full Note or Nullifier instead.", - ) - }) - } -} - -impl AsNullifier for &T { - fn as_nullifier(&self, chain: &MockChain) -> Nullifier { - (**self).as_nullifier(chain) - } -} - -/// Asserts the note is in [`MockChain::committed_notes()`](crate::MockChain::committed_notes). -/// -/// Accepts `NoteId`, `&Note`, or `&InputNote`. -#[macro_export] -macro_rules! assert_note_committed { - ($chain:expr, $note_ref:expr $(,)?) => {{ - let chain: &$crate::MockChain = &$chain; - let id = $crate::asserts::AsNoteId::as_note_id(&$note_ref); - assert!( - chain.committed_notes().contains_key(&id), - "note {id} is not in chain.committed_notes()", - ); - }}; -} - -/// Asserts the note's nullifier is not on-chain (note isn't consumed yet). -/// -/// Accepts `Nullifier`, `NoteId`, `&Note`, or `&InputNote`. A bare `NoteId` needs the note in -/// `chain.committed_notes()`. -#[macro_export] -macro_rules! assert_note_unspent { - ($chain:expr, $note_ref:expr $(,)?) => {{ - let chain: &$crate::MockChain = &$chain; - let nullifier = $crate::asserts::AsNullifier::as_nullifier(&$note_ref, chain); - assert!( - chain.nullifier_tree().get_block_num(&nullifier).is_none(), - "note {nullifier} already on-chain (expected unspent)", - ); - }}; -} - -/// Asserts the note's nullifier is on-chain (note is consumed). -/// -/// Accepts the same types as [`assert_note_unspent!`]. -#[macro_export] -macro_rules! assert_note_consumed { - ($chain:expr, $note_ref:expr $(,)?) => {{ - let chain: &$crate::MockChain = &$chain; - let nullifier = $crate::asserts::AsNullifier::as_nullifier(&$note_ref, chain); - assert!( - chain.nullifier_tree().get_block_num(&nullifier).is_some(), - "note {nullifier} not on-chain (expected consumed)", - ); - }}; -} diff --git a/crates/miden-testing/src/mock_chain/chain.rs b/crates/miden-testing/src/mock_chain/chain.rs index f56269851b..e34ecfb549 100644 --- a/crates/miden-testing/src/mock_chain/chain.rs +++ b/crates/miden-testing/src/mock_chain/chain.rs @@ -454,6 +454,24 @@ impl MockChain { &self.committed_notes } + /// Returns `true` if a note with the given ID is recorded in committed notes. + pub fn is_note_committed(&self, note_id: &NoteId) -> bool { + self.committed_notes.contains_key(note_id) + } + + /// Returns `true` if the nullifier has been recorded on-chain (note was consumed). + pub fn is_note_consumed(&self, nullifier: &Nullifier) -> bool { + self.nullifier_tree.get_block_num(nullifier).is_some() + } + + /// Returns `true` if the nullifier is not yet on-chain. + /// + /// A nullifier can be unspent without the chain having seen the underlying note. Pair with + /// [`Self::is_note_committed`] when both conditions matter. + pub fn is_note_unspent(&self, nullifier: &Nullifier) -> bool { + !self.is_note_consumed(nullifier) + } + /// Returns an [`InputNote`] for the given note ID. If the note does not exist or is not /// public, `None` is returned. pub fn get_public_note(&self, note_id: &NoteId) -> Option { diff --git a/crates/miden-testing/tests/asserts.rs b/crates/miden-testing/tests/asserts.rs index b2bbb15329..23d287f3dd 100644 --- a/crates/miden-testing/tests/asserts.rs +++ b/crates/miden-testing/tests/asserts.rs @@ -1,4 +1,4 @@ -//! Integration tests for the note-lifecycle assertion macros. +//! Integration tests for the note-lifecycle assertions extern crate alloc; @@ -6,18 +6,9 @@ use anyhow::Result; use miden_protocol::account::AccountId; use miden_protocol::account::auth::AuthScheme; use miden_protocol::asset::{Asset, FungibleAsset}; -use miden_protocol::Word; -use miden_protocol::note::{Note, NoteId, NoteType}; +use miden_protocol::note::{Note, NoteType}; use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; -use miden_testing::{ - Auth, - MockChain, - assert_note_committed, - assert_note_consumed, - assert_note_consumed_by, - assert_note_created, - assert_note_unspent, -}; +use miden_testing::{Auth, MockChain, assert_note_created}; /// Builds a chain and runs a SPAWN tx that emits one P2ID output note with the given assets. /// The returned chain is still in post-build state — execute doesn't mutate it. @@ -54,20 +45,16 @@ async fn execute_with_output( /// Full lifecycle: build, execute, prove block. #[tokio::test] -async fn lifecycle_macros_full_round_trip() -> Result<()> { +async fn note_lifecycle_full_flow() -> Result<()> { let asset: Asset = FungibleAsset::mock(7); let (sender_id, spawn, mut chain, executed) = execute_with_output(&[asset]).await?; // post-build: spawn is committed and unspent. - assert_note_committed!(chain, &spawn); - assert_note_committed!(chain, spawn.id()); - assert_note_unspent!(chain, &spawn); - assert_note_unspent!(chain, spawn.id()); + assert!(chain.is_note_committed(&spawn.id())); + assert!(chain.is_note_unspent(&spawn.nullifier())); // post-execute: tx-level checks against the executed transaction. - assert_note_consumed_by!(executed, &spawn); - assert_note_consumed_by!(executed, spawn.id()); - assert_note_consumed_by!(executed, spawn.nullifier()); + assert!(executed.consumes_note(&spawn.id())); assert_note_created!( executed, @@ -80,8 +67,7 @@ async fn lifecycle_macros_full_round_trip() -> Result<()> { chain.add_pending_executed_transaction(&executed)?; chain.prove_next_block()?; - assert_note_consumed!(chain, &spawn); - assert_note_consumed!(chain, spawn.nullifier()); + assert!(chain.is_note_consumed(&spawn.nullifier())); Ok(()) } @@ -117,13 +103,3 @@ async fn assert_note_created_panics_on_asset_count_mismatch() { assert_note_created!(executed, assets: [asset, asset]); } - -#[tokio::test] -#[should_panic(expected = "not in chain.committed_notes()")] -async fn assert_note_unspent_panics_for_unknown_note_id() { - let asset: Asset = FungibleAsset::mock(7); - let (_sender_id, _spawn, chain, _executed) = execute_with_output(&[asset]).await.unwrap(); - - let unknown = NoteId::new(Word::default(), Word::default()); - assert_note_unspent!(chain, unknown); -}