use alloc::sync::Arc;
use std::collections::BTreeMap;
use anyhow::Context;
use miden_crypto::merkle::MerkleError;
use miden_lib::transaction::TransactionKernel;
use miden_objects::{
BatchAccountUpdateError, ProposedBatchError,
account::{Account, AccountId},
batch::ProposedBatch,
block::BlockNumber,
note::{Note, NoteType},
testing::{account_id::AccountIdBuilder, note::NoteBuilder},
transaction::{ChainMmr, InputNote, InputNoteCommitment, OutputNote},
};
use miden_tx::testing::{Auth, MockChain};
use rand::{SeedableRng, rngs::SmallRng};
use vm_core::assert_matches;
use vm_processor::Digest;
use crate::testing::MockProvenTxBuilder;
fn mock_account_id(num: u8) -> AccountId {
AccountIdBuilder::new().build_with_rng(&mut SmallRng::from_seed([num; 32]))
}
pub fn mock_note(num: u8) -> Note {
let sender = mock_account_id(num);
NoteBuilder::new(sender, SmallRng::from_seed([num; 32]))
.build(&TransactionKernel::assembler().with_debug_mode(true))
.unwrap()
}
pub fn mock_output_note(num: u8) -> OutputNote {
OutputNote::Full(mock_note(num))
}
struct TestSetup {
chain: MockChain,
account1: Account,
account2: Account,
}
fn setup_chain() -> TestSetup {
let mut chain = MockChain::new();
let account1 = chain.add_new_wallet(Auth::NoAuth);
let account2 = chain.add_new_wallet(Auth::NoAuth);
chain.seal_next_block();
TestSetup { chain, account1, account2 }
}
#[test]
fn empty_transaction_batch() -> anyhow::Result<()> {
let TestSetup { chain, .. } = setup_chain();
let block1 = chain.block_header(1);
let error = ProposedBatch::new(vec![], block1, chain.latest_chain_mmr(), BTreeMap::default())
.unwrap_err();
assert_matches!(error, ProposedBatchError::EmptyTransactionBatch);
Ok(())
}
#[test]
fn note_created_and_consumed_in_same_batch() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let block2 = chain.seal_next_block();
let note = mock_note(40);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.output_notes(vec![OutputNote::Full(note.clone())])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note.clone()])
.build()?;
let batch = ProposedBatch::new(
[tx1, tx2].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
assert_eq!(batch.input_notes().num_notes(), 0);
assert_eq!(batch.output_notes().len(), 0);
Ok(())
}
#[test]
fn duplicate_unauthenticated_input_notes() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let note = mock_note(50);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note.clone()])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note.clone()])
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::DuplicateInputNote {
note_nullifier,
first_transaction_id,
second_transaction_id
} if note_nullifier == note.nullifier() &&
first_transaction_id == tx1.id() &&
second_transaction_id == tx2.id()
);
Ok(())
}
#[test]
fn duplicate_authenticated_input_notes() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2 } = setup_chain();
let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?;
let block1 = chain.block_header(1);
let block2 = chain.seal_next_block();
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.authenticated_notes(vec![note.clone()])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.authenticated_notes(vec![note.clone()])
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::DuplicateInputNote {
note_nullifier,
first_transaction_id,
second_transaction_id
} if note_nullifier == note.nullifier() &&
first_transaction_id == tx1.id() &&
second_transaction_id == tx2.id()
);
Ok(())
}
#[test]
fn duplicate_mixed_input_notes() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2 } = setup_chain();
let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?;
let block1 = chain.block_header(1);
let block2 = chain.seal_next_block();
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note.clone()])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.authenticated_notes(vec![note.clone()])
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::DuplicateInputNote {
note_nullifier,
first_transaction_id,
second_transaction_id
} if note_nullifier == note.nullifier() &&
first_transaction_id == tx1.id() &&
second_transaction_id == tx2.id()
);
Ok(())
}
#[test]
fn duplicate_output_notes() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let note0 = mock_output_note(50);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.output_notes(vec![note0.clone()])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.output_notes(vec![note0.clone()])
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::DuplicateOutputNote {
note_id,
first_transaction_id,
second_transaction_id
} if note_id == note0.id() &&
first_transaction_id == tx1.id() &&
second_transaction_id == tx2.id());
Ok(())
}
#[test]
fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2 } = setup_chain();
let note0 = chain.add_p2id_note(account2.id(), account1.id(), &[], NoteType::Private, None)?;
let note1 = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?;
let block2 = chain.seal_next_block();
let block3 = chain.seal_next_block();
let block4 = chain.seal_next_block();
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block3.commitment())
.unauthenticated_notes(vec![note1.clone()])
.build()?;
let input_note0 = chain.available_notes_map().get(¬e0.id()).expect("note not found");
let note_inclusion_proof0 = input_note0.proof().expect("note should be of type authenticated");
let input_note1 = chain.available_notes_map().get(¬e1.id()).expect("note not found");
let note_inclusion_proof1 = input_note1.proof().expect("note should be of type authenticated");
let chain_mmr = chain.latest_chain_mmr();
let error = ProposedBatch::new(
[tx1.clone()].into_iter().map(Arc::new).collect(),
block4.header().clone(),
chain_mmr.clone(),
BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof0.clone())]),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::UnauthenticatedNoteAuthenticationFailed {
note_id,
block_num,
source: MerkleError::ConflictingRoots { .. },
} if note_id == note1.id() &&
block_num == block2.header().block_num()
);
let mut mmr = chain_mmr.mmr().clone();
mmr.untrack(block2.header().block_num().as_usize());
let blocks = chain_mmr
.block_headers()
.filter(|header| header.block_num() != block2.header().block_num())
.cloned();
let error = ProposedBatch::new(
[tx1.clone()].into_iter().map(Arc::new).collect(),
block4.header().clone(),
ChainMmr::new(mmr, blocks).context("failed to build chain mmr with missing block")?,
BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof1.clone())]),
)
.unwrap_err();
assert_matches!(
error,
ProposedBatchError::UnauthenticatedInputNoteBlockNotInChainMmr {
block_number,
note_id
} if block_number == note_inclusion_proof1.location().block_num() &&
note_id == input_note1.id()
);
let batch = ProposedBatch::new(
[tx1].into_iter().map(Arc::new).collect(),
block4.header().clone(),
chain_mmr,
BTreeMap::from_iter([(input_note1.id(), note_inclusion_proof1.clone())]),
)?;
assert_eq!(batch.input_notes().num_notes(), 1);
assert!(
batch
.input_notes()
.iter()
.any(|commitment| commitment == &InputNoteCommitment::from(input_note1))
);
assert_eq!(batch.output_notes().len(), 0);
Ok(())
}
#[test]
fn authenticated_note_created_in_same_batch() -> anyhow::Result<()> {
let TestSetup { mut chain, account1, account2 } = setup_chain();
let note = chain.add_p2id_note(account1.id(), account2.id(), &[], NoteType::Private, None)?;
let block1 = chain.block_header(1);
let block2 = chain.seal_next_block();
let note0 = mock_note(50);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.output_notes(vec![OutputNote::Full(note0.clone())])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.authenticated_notes(vec![note.clone()])
.build()?;
let batch = ProposedBatch::new(
[tx1, tx2].into_iter().map(Arc::new).collect(),
block2.header().clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
assert_eq!(batch.input_notes().num_notes(), 1);
assert_eq!(batch.output_notes().len(), 1);
Ok(())
}
#[test]
fn multiple_transactions_against_same_account() -> anyhow::Result<()> {
let TestSetup { chain, account1, .. } = setup_chain();
let block1 = chain.block_header(1);
let initial_state_commitment = Digest::default();
let tx1 = MockProvenTxBuilder::with_account(
account1.id(),
initial_state_commitment,
account1.commitment(),
)
.ref_block_commitment(block1.commitment())
.output_notes(vec![mock_output_note(0)])
.build()?;
let final_state_commitment = mock_note(10).commitment();
let tx2 = MockProvenTxBuilder::with_account(
account1.id(),
account1.commitment(),
final_state_commitment,
)
.ref_block_commitment(block1.commitment())
.build()?;
let batch = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block1.clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
assert_eq!(batch.account_updates().len(), 1);
assert_eq!(
batch.account_updates().get(&account1.id()).unwrap().initial_state_commitment(),
initial_state_commitment
);
assert_eq!(
batch.account_updates().get(&account1.id()).unwrap().final_state_commitment(),
final_state_commitment
);
let error = ProposedBatch::new(
[tx2.clone(), tx1.clone()].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(
error,
ProposedBatchError::AccountUpdateError {
source: BatchAccountUpdateError::AccountUpdateInitialStateMismatch(tx_id),
..
} if tx_id == tx1.id()
);
Ok(())
}
#[test]
fn input_and_output_notes_commitment() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let note0 = mock_output_note(50);
let note1 = mock_note(60);
let note2 = mock_output_note(70);
let note3 = mock_output_note(80);
let note4 = mock_note(90);
let note5 = mock_note(100);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note1.clone(), note5.clone()])
.output_notes(vec![note0.clone()])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note4.clone()])
.output_notes(vec![OutputNote::Full(note1.clone()), note2.clone(), note3.clone()])
.build()?;
let batch = ProposedBatch::new(
[tx1.clone(), tx2.clone()].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
let mut expected_output_notes = [note0, note2, note3];
expected_output_notes.sort_unstable_by_key(OutputNote::id);
assert_eq!(batch.output_notes().len(), 3);
assert_eq!(batch.output_notes(), expected_output_notes);
assert_eq!(batch.input_notes().num_notes(), 2);
assert_eq!(
batch.input_notes().clone().into_vec(),
&[
InputNoteCommitment::from(&InputNote::unauthenticated(note5)),
InputNoteCommitment::from(&InputNote::unauthenticated(note4)),
]
);
Ok(())
}
#[test]
fn batch_expiration() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.expiration_block_num(BlockNumber::from(35))
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.expiration_block_num(block1.block_num() + 1)
.build()?;
let batch = ProposedBatch::new(
[tx1, tx2].into_iter().map(Arc::new).collect(),
block1.clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
assert_eq!(batch.batch_expiration_block_num(), block1.block_num() + 1);
Ok(())
}
#[test]
fn duplicate_transaction() -> anyhow::Result<()> {
let TestSetup { chain, account1, .. } = setup_chain();
let block1 = chain.block_header(1);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.expiration_block_num(BlockNumber::from(35))
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx1.clone()].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(error, ProposedBatchError::DuplicateTransaction { transaction_id } if transaction_id == tx1.id());
Ok(())
}
#[test]
fn circular_note_dependency() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let note_x = mock_note(20);
let note_y = mock_note(30);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note_x.clone()])
.output_notes(vec![OutputNote::Full(note_y.clone())])
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.unauthenticated_notes(vec![note_y.clone()])
.output_notes(vec![OutputNote::Full(note_x.clone())])
.build()?;
let batch = ProposedBatch::new(
[tx1, tx2].into_iter().map(Arc::new).collect(),
block1,
chain.latest_chain_mmr(),
BTreeMap::default(),
)?;
assert_eq!(batch.input_notes().num_notes(), 0);
assert_eq!(batch.output_notes().len(), 0);
Ok(())
}
#[test]
fn expired_transaction() -> anyhow::Result<()> {
let TestSetup { chain, account1, account2 } = setup_chain();
let block1 = chain.block_header(1);
let tx1 =
MockProvenTxBuilder::with_account(account1.id(), Digest::default(), account1.commitment())
.ref_block_commitment(block1.commitment())
.expiration_block_num(block1.block_num())
.build()?;
let tx2 =
MockProvenTxBuilder::with_account(account2.id(), Digest::default(), account2.commitment())
.ref_block_commitment(block1.commitment())
.expiration_block_num(block1.block_num() + 3)
.build()?;
let error = ProposedBatch::new(
[tx1.clone(), tx2].into_iter().map(Arc::new).collect(),
block1.clone(),
chain.latest_chain_mmr(),
BTreeMap::default(),
)
.unwrap_err();
assert_matches!(
error,
ProposedBatchError::ExpiredTransaction {
transaction_id,
transaction_expiration_num,
reference_block_num
} if transaction_id == tx1.id() &&
transaction_expiration_num == block1.block_num() &&
reference_block_num == block1.block_num()
);
Ok(())
}