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/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/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 new file mode 100644 index 0000000000..23d287f3dd --- /dev/null +++ b/crates/miden-testing/tests/asserts.rs @@ -0,0 +1,105 @@ +//! Integration tests for the note-lifecycle assertions + +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::note::{Note, NoteType}; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; +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. +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 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!(chain.is_note_committed(&spawn.id())); + assert!(chain.is_note_unspent(&spawn.nullifier())); + + // post-execute: tx-level checks against the executed transaction. + assert!(executed.consumes_note(&spawn.id())); + + 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!(chain.is_note_consumed(&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]); +} 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(),