Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions crates/miden-protocol/src/testing/tx.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::note::NoteId;
use crate::transaction::ExecutedTransaction;

impl ExecutedTransaction {
Expand All @@ -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)
}
}
112 changes: 112 additions & 0 deletions crates/miden-testing/src/asserts.rs
Original file line number Diff line number Diff line change
@@ -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<NoteType>,
pub sender: Option<AccountId>,
pub assets: Option<Vec<Asset>>,
}

/// 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",
));
};
}
2 changes: 2 additions & 0 deletions crates/miden-testing/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions crates/miden-testing/src/mock_chain/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputNote> {
Expand Down
105 changes: 105 additions & 0 deletions crates/miden-testing/tests/asserts.rs
Original file line number Diff line number Diff line change
@@ -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]);
}
23 changes: 8 additions & 15 deletions crates/miden-testing/tests/scripts/faucet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ use miden_testing::{
Auth,
MockChain,
MockChainBuilder,
assert_note_created,
assert_transaction_executor_error,
};
use rand::Rng;
Expand Down Expand Up @@ -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(),
Expand Down
Loading