use alloc::sync::Arc;
use std::collections::BTreeSet;
use std::sync::LazyLock;
use anyhow::Context;
use miden_processor::crypto::random::RandomCoin;
use miden_protocol::account::component::{AccountComponentCode, AccountComponentMetadata};
use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType};
use miden_protocol::assembly::DefaultSourceManager;
use miden_protocol::asset::{Asset, AssetAmount, AssetId, FungibleAsset};
use miden_protocol::block::BlockNumber;
use miden_protocol::block::account_tree::AccountWitness;
use miden_protocol::note::{Note, NoteAssets, NoteId, NoteScriptRoot, NoteType};
use miden_protocol::testing::account_id::{
ACCOUNT_ID_FEE_FAUCET,
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1,
};
use miden_protocol::transaction::{RawOutputNote, RawOutputNotes, TransactionScript};
use miden_protocol::{Felt, Word};
use miden_standards::account::auth::{AuthNetworkAccount, NetworkAccount, SponsorshipPolicy};
use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicy, FeePolicyManager};
use miden_standards::account::wallets::{BasicWallet, NoteCreator};
use miden_standards::code_builder::CodeBuilder;
use miden_standards::errors::standards::{
ERR_FEE_MANAGER_EXPECTED_FEE_ASSET_MISMATCH,
ERR_FEE_MANAGER_INPUT_NOTE_FEE_NOT_COVERED,
ERR_FEE_MANAGER_SPONSORED_NOTE_NOT_FOUND,
ERR_FEE_MANAGER_SPONSORSHIP_WRONG_ASSET,
ERR_FEE_MANAGER_TARGET_FEE_ASSET_MISMATCH,
ERR_FEE_POLICY_FEE_ASSET_MISMATCH,
ERR_FEE_POLICY_ROOT_NOT_ALLOWED,
ERR_NETWORK_ACCOUNT_FEE_ASSET_NOT_NATIVE,
ERR_NETWORK_ACCOUNT_SPONSORED_FEES_EXCEED_COLLECTED,
ERR_NOTE_SCRIPT_NOT_IN_FEE_SCHEDULE,
ERR_SENDER_NOT_OWNER,
};
use miden_standards::note::{
FeeSponsorshipNote,
FeeSponsorshipNoteStorage,
NetworkAccountTarget,
P2idNote,
};
use miden_standards::testing::note::NoteBuilder;
use miden_testing::{Auth, MockChain, MockTransaction, assert_transaction_executor_error};
use rand::SeedableRng;
use rand::rngs::Xoshiro256PlusPlus;
use rand::seq::SliceRandom;
use rstest::rstest;
use crate::scripts::fee_manager::{
FEE_AMOUNT,
build_fee_account_with_switching,
create_fee_manager_note_script,
custom_fee_amount_for,
custom_fee_policy,
fee_faucet_id,
};
fn fee_asset(amount: u64) -> anyhow::Result<Asset> {
Ok(FungibleAsset::new(fee_faucet_id()?, amount)?.into())
}
fn other_asset(amount: u64) -> anyhow::Result<Asset> {
Ok(
FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, amount)?
.into(),
)
}
fn native_fee_faucet_id() -> anyhow::Result<AccountId> {
Ok(ACCOUNT_ID_FEE_FAUCET.try_into()?)
}
fn native_fee_asset(amount: u64) -> anyhow::Result<Asset> {
Ok(FungibleAsset::new(native_fee_faucet_id()?, amount)?.into())
}
const FEE_COLLECTOR_NAME: &str = "test::fee_collector";
static FEE_COLLECTOR_CODE: LazyLock<AccountComponentCode> = LazyLock::new(|| {
let src = r#"
use miden::standards::fees
@account_procedure
pub proc collect_sponsored_fees_wrong_asset
# pass an expected fee asset that differs from the manager's configured fee asset
push.1.2.3.4
# => [WRONG_FEE_ASSET_ID, pad(16)]
exec.fees::collect_sponsored_fees drop
# => [pad(16)]
end
"#;
CodeBuilder::default()
.compile_component_code(FEE_COLLECTOR_NAME, src)
.expect("fee collector component should compile")
});
fn fee_collector_component() -> anyhow::Result<AccountComponent> {
Ok(AccountComponent::new(
FEE_COLLECTOR_CODE.clone(),
vec![],
AccountComponentMetadata::mock(FEE_COLLECTOR_NAME),
)?)
}
fn network_account(
fee_entry: Option<(NoteScriptRoot, AssetAmount)>,
allowed_note_roots: BTreeSet<NoteScriptRoot>,
) -> anyhow::Result<Account> {
let mut policy = BasicConstantFeePolicy::new();
if let Some((root, fee)) = fee_entry {
policy = policy.with_fee(root, fee);
}
let fee_policy_manager = FeePolicyManager::builder()
.fee_faucet_id(fee_faucet_id()?)
.active_fee_policy(policy.into())
.build();
Ok(NetworkAccount::builder([7; 32], allowed_note_roots, fee_policy_manager)?
.with_component(BasicWallet)
.build_existing()?)
}
struct Test {
mock_chain: MockChain,
network_account: Account,
feature_notes: Vec<Note>,
sponsorship_notes: Vec<Note>,
}
#[bon::bon]
impl Test {
#[builder]
fn new(
#[builder(field)] sponsorships: Vec<(usize, Asset)>,
feature_note_fee: Option<AssetAmount>,
#[builder(default = 1)]
num_feature_notes: usize,
) -> anyhow::Result<Self> {
let mut rng = RandomCoin::new(Word::empty());
let mut builder = MockChain::builder();
let sponsor = builder.add_existing_wallet(Auth::IncrNonce)?;
let feature_notes: Vec<Note> = (0..num_feature_notes)
.map(|_| builder.add_p2any_note(sponsor.id(), NoteType::Public, []))
.collect::<anyhow::Result<_>>()?;
let num_unique_notes = feature_notes.iter().map(Note::id).collect::<BTreeSet<_>>().len();
assert_eq!(feature_notes.len(), num_unique_notes, "feature notes should be unique");
let mut allowed_note_roots = BTreeSet::new();
let mut fee_entry = None;
if let Some(feature_note) = feature_notes.first() {
allowed_note_roots.insert(feature_note.script().root());
fee_entry = feature_note_fee.map(|fee| (feature_note.script().root(), fee));
}
let network_account = network_account(fee_entry, allowed_note_roots)?;
builder.add_account(network_account.clone())?;
let mut sponsorship_notes = Vec::new();
for (feature_note_idx, asset) in sponsorships {
let feature_note = feature_notes.get(feature_note_idx).with_context(|| {
format!("sponsorship should name an existing feature note, got {feature_note_idx}")
})?;
let note = Note::from(
FeeSponsorshipNote::builder()
.sender(sponsor.id())
.target_account(network_account.id())
.feature_note_id(feature_note.id())
.asset(asset)
.generate_serial_number(&mut rng)
.build()?,
);
builder.add_output_note(RawOutputNote::Full(note.clone()));
sponsorship_notes.push(note);
}
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
Ok(Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
})
}
}
impl<S: test_builder::State> TestBuilder<S> {
fn sponsorship(mut self, feature_note_idx: usize, asset: Asset) -> Self {
self.sponsorships.push((feature_note_idx, asset));
self
}
}
async fn collect_fee_balance(
mock_chain: MockChain,
mut network_account: Account,
input_notes: &[NoteId],
) -> anyhow::Result<u64> {
let mut builder = mock_chain.build_transaction(network_account.id());
for note_id in input_notes {
builder = builder.authenticated_input_note(*note_id);
}
let executed = builder.build()?.execute().await?;
network_account.apply_patch(executed.account_patch())?;
Ok(network_account
.vault()
.get_balance(AssetId::new_fungible(fee_faucet_id()?))?
.as_u64())
}
#[rstest]
#[tokio::test]
async fn collects_sponsored_fee_for_a_bound_pair(
#[values(0, FEE_AMOUNT)] feature_note_fee: u64,
#[values(FEE_AMOUNT, FEE_AMOUNT + 250)] sponsored_amount: u64,
#[values(false, true)] sponsorship_first: bool,
) -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(feature_note_fee)?)
.sponsorship(0, fee_asset(sponsored_amount)?)
.build()?;
let input_notes = if sponsorship_first {
[sponsorship_notes[0].id(), feature_notes[0].id()]
} else {
[feature_notes[0].id(), sponsorship_notes[0].id()]
};
let balance = collect_fee_balance(mock_chain, network_account, &input_notes).await?;
assert_eq!(
balance, sponsored_amount,
"the account should collect the sponsored fee into its vault"
);
Ok(())
}
#[rstest]
#[case::even_split(FEE_AMOUNT / 2, FEE_AMOUNT / 2)]
#[case::uneven_split(FEE_AMOUNT - 1, 1)]
#[tokio::test]
async fn multiple_sponsorships_top_up_one_feature_note(
#[case] first_amount: u64,
#[case] second_amount: u64,
) -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(FEE_AMOUNT)?)
.sponsorship(0, fee_asset(first_amount)?)
.sponsorship(0, fee_asset(second_amount)?)
.build()?;
let input_notes = [feature_notes[0].id(), sponsorship_notes[0].id(), sponsorship_notes[1].id()];
let balance = collect_fee_balance(mock_chain, network_account, &input_notes).await?;
assert_eq!(
balance,
first_amount + second_amount,
"both sponsorships should be collected and together cover the feature note's fee"
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn sponsorships_are_attributed_by_note_id(
#[values(0, 1, 2, 3)] seed: u8,
) -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(FEE_AMOUNT)?)
.num_feature_notes(2)
.sponsorship(0, fee_asset(FEE_AMOUNT)?)
.sponsorship(1, fee_asset(FEE_AMOUNT / 2)?)
.sponsorship(1, fee_asset(FEE_AMOUNT / 2)?)
.build()?;
let mut input_notes = [
feature_notes[0].id(),
feature_notes[1].id(),
sponsorship_notes[0].id(),
sponsorship_notes[1].id(),
sponsorship_notes[2].id(),
];
let mut rng = Xoshiro256PlusPlus::from_seed([seed; 32]);
input_notes.shuffle(&mut rng);
let balance = collect_fee_balance(mock_chain, network_account, &input_notes).await?;
assert_eq!(
balance,
2 * FEE_AMOUNT,
"both sponsorships should pay for the note they name regardless of position"
);
Ok(())
}
#[tokio::test]
async fn over_sponsoring_one_note_does_not_cover_another() -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(FEE_AMOUNT)?)
.num_feature_notes(2)
.sponsorship(0, fee_asset(2 * FEE_AMOUNT)?)
.build()?;
let result = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(feature_notes[0].id())
.authenticated_input_note(sponsorship_notes[0].id())
.authenticated_input_note(feature_notes[1].id())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_INPUT_NOTE_FEE_NOT_COVERED);
Ok(())
}
#[tokio::test]
async fn collect_rejects_expected_fee_asset_mismatch() -> anyhow::Result<()> {
let account = AccountBuilder::new([7; 32])
.account_type(AccountType::Public)
.with_components(Auth::NetworkAccount {
allowed_script_roots: BTreeSet::new(),
allowed_tx_script_roots: BTreeSet::new(),
fee_policy_manager: FeePolicyManager::mock(fee_faucet_id()?),
sponsorship_policy: SponsorshipPolicy::default(),
})
.with_component(BasicWallet)
.with_component(fee_collector_component()?)
.build_existing()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
let mock_chain = builder.build()?;
let src = r#"
use test::fee_collector
@transaction_script
pub proc main
call.fee_collector::collect_sponsored_fees_wrong_asset
# => [pad(16)]
dropw dropw dropw dropw
end
"#;
let tx_script = CodeBuilder::default()
.with_dynamically_linked_package(&*FEE_COLLECTOR_CODE)?
.compile_tx_script(src)?;
let result = mock_chain
.build_transaction(account.id())
.tx_script(tx_script)
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_EXPECTED_FEE_ASSET_MISMATCH);
Ok(())
}
#[tokio::test]
async fn set_fee_policy_switches_to_custom_policy() -> anyhow::Result<()> {
let owner_account_id =
AccountId::builder().account_type(AccountType::Private).build_with_seed([4; 32]);
let set_policy_note_script =
create_fee_manager_note_script("set_fee_policy", custom_fee_policy()?.root().as_word());
let mut rng = RandomCoin::new([Felt::from(600u32); 4].into());
let set_policy_note = NoteBuilder::new(owner_account_id, &mut rng)
.note_type(NoteType::Private)
.code(set_policy_note_script.as_str())
.build()?;
let custom_fee =
custom_fee_amount_for(set_policy_note.recipient().storage().commitment(), 0, 0);
let account = build_fee_account_with_switching(
owner_account_id,
BTreeSet::from([set_policy_note.script().root()]),
)?;
let sponsorship_note = Note::from(
FeeSponsorshipNote::builder()
.sender(owner_account_id)
.target_account(account.id())
.feature_note_id(set_policy_note.id())
.asset(fee_asset(custom_fee.as_u64())?)
.generate_serial_number(&mut rng)
.build()?,
);
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
builder.add_output_note(RawOutputNote::Full(set_policy_note.clone()));
builder.add_output_note(RawOutputNote::Full(sponsorship_note.clone()));
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
mock_chain
.build_transaction(account.id())
.authenticated_input_note(set_policy_note.id())
.authenticated_input_note(sponsorship_note.id())
.build()?
.execute()
.await?;
Ok(())
}
#[tokio::test]
async fn aggregates_fees_across_pairs() -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(FEE_AMOUNT)?)
.num_feature_notes(2)
.sponsorship(0, fee_asset(FEE_AMOUNT)?)
.sponsorship(1, fee_asset(FEE_AMOUNT)?)
.build()?;
let input_notes = [
feature_notes[0].id(),
sponsorship_notes[0].id(),
feature_notes[1].id(),
sponsorship_notes[1].id(),
];
let balance = collect_fee_balance(mock_chain, network_account, &input_notes).await?;
assert_eq!(
balance,
2 * FEE_AMOUNT,
"fees from both pairs should be aggregated into the account's vault"
);
Ok(())
}
#[tokio::test]
async fn set_fee_policy_rejects_non_allowed_root() -> anyhow::Result<()> {
let owner_account_id =
AccountId::builder().account_type(AccountType::Private).build_with_seed([4; 32]);
let account = build_fee_account_with_switching(owner_account_id, BTreeSet::new())?;
let invalid_policy_root = AuthNetworkAccount::get_fee_policy_root().as_word();
let set_policy_note_script =
create_fee_manager_note_script("set_fee_policy", invalid_policy_root);
let mut rng = RandomCoin::new([Felt::from(601u32); 4].into());
let set_policy_note = NoteBuilder::new(owner_account_id, &mut rng)
.note_type(NoteType::Private)
.code(set_policy_note_script.as_str())
.build()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
builder.add_output_note(RawOutputNote::Full(set_policy_note.clone()));
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
let source_manager = Arc::new(DefaultSourceManager::default());
let result = mock_chain
.build_transaction(account.id())
.authenticated_input_note(set_policy_note.id())
.with_source_manager(source_manager)
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_POLICY_ROOT_NOT_ALLOWED);
Ok(())
}
#[tokio::test]
async fn unscheduled_feature_note_aborts_fee_collection() -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
..
} = Test::builder().build()?;
let result = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(feature_notes[0].id())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_NOT_IN_FEE_SCHEDULE);
Ok(())
}
#[tokio::test]
async fn zero_fee_feature_note_requires_no_sponsorship() -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
..
} = Test::builder().feature_note_fee(AssetAmount::ZERO).build()?;
let input_notes = [feature_notes[0].id()];
let balance = collect_fee_balance(mock_chain, network_account, &input_notes).await?;
assert_eq!(balance, 0, "a 0-fee feature note should collect no fee");
Ok(())
}
#[tokio::test]
async fn non_owner_cannot_set_fee_policy() -> anyhow::Result<()> {
let owner_account_id =
AccountId::builder().account_type(AccountType::Private).build_with_seed([4; 32]);
let non_owner_account_id =
AccountId::builder().account_type(AccountType::Private).build_with_seed([5; 32]);
let account = build_fee_account_with_switching(owner_account_id, BTreeSet::new())?;
let set_policy_note_script =
create_fee_manager_note_script("set_fee_policy", custom_fee_policy()?.root().as_word());
let mut rng = RandomCoin::new([Felt::from(602u32); 4].into());
let set_policy_note = NoteBuilder::new(non_owner_account_id, &mut rng)
.note_type(NoteType::Private)
.code(set_policy_note_script.as_str())
.build()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
builder.add_output_note(RawOutputNote::Full(set_policy_note.clone()));
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
let source_manager = Arc::new(DefaultSourceManager::default());
let result = mock_chain
.build_transaction(account.id())
.authenticated_input_note(set_policy_note.id())
.with_source_manager(source_manager)
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER);
Ok(())
}
#[rstest]
#[case::no_sponsorship(None)]
#[case::underfunded(Some(FEE_AMOUNT - 1))]
#[tokio::test]
async fn uncovered_feature_note_fee_is_rejected(
#[case] sponsored_amount: Option<u64>,
) -> anyhow::Result<()> {
let mut test_builder = Test::builder().feature_note_fee(AssetAmount::new(FEE_AMOUNT)?);
if let Some(amount) = sponsored_amount {
test_builder = test_builder.sponsorship(0, fee_asset(amount)?);
}
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = test_builder.build()?;
let mut builder = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(feature_notes[0].id());
for sponsorship_note in &sponsorship_notes {
builder = builder.authenticated_input_note(sponsorship_note.id());
}
let result = builder.build()?.execute().await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_INPUT_NOTE_FEE_NOT_COVERED);
Ok(())
}
#[tokio::test]
async fn sponsorship_with_wrong_asset_is_rejected() -> anyhow::Result<()> {
let Test {
mock_chain,
network_account,
feature_notes,
sponsorship_notes,
} = Test::builder()
.feature_note_fee(AssetAmount::new(FEE_AMOUNT)?)
.sponsorship(0, other_asset(FEE_AMOUNT)?)
.build()?;
let result = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(feature_notes[0].id())
.authenticated_input_note(sponsorship_notes[0].id())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_SPONSORSHIP_WRONG_ASSET);
Ok(())
}
#[tokio::test]
async fn sponsorship_for_absent_feature_note_is_rejected() -> anyhow::Result<()> {
let mut rng = RandomCoin::new(Word::empty());
let mut builder = MockChain::builder();
let sponsor = builder.add_existing_wallet(Auth::IncrNonce)?;
let feature_note = builder.add_p2any_note(sponsor.id(), NoteType::Public, [])?;
let network_account = network_account(
Some((feature_note.script().root(), AssetAmount::new(FEE_AMOUNT)?)),
BTreeSet::from([feature_note.script().root()]),
)?;
builder.add_account(network_account.clone())?;
let sponsorship_note = Note::from(
FeeSponsorshipNote::builder()
.sender(sponsor.id())
.target_account(network_account.id())
.feature_note_id(feature_note.id())
.asset(fee_asset(FEE_AMOUNT)?)
.reclaimer(network_account.id())
.reclaim_height(BlockNumber::from(1u32))
.generate_serial_number(&mut rng)
.build()?,
);
builder.add_output_note(RawOutputNote::Full(sponsorship_note.clone()));
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
let result = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(sponsorship_note.id())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_SPONSORED_NOTE_NOT_FOUND);
Ok(())
}
fn asset_commitment_fee_policy(
fee_asset_note_commitment: Word,
fee_asset_id: Word,
other_fee_asset_id: Word,
) -> anyhow::Result<FeePolicy> {
const POLICY_NAME: &str = "test::fees::asset_commitment_fee";
let masm_source = format!(
r#"
use miden::core::word
use miden::standards::assets::fungible_asset
#! Fee policy pricing a note in one of two assets, selected by its assets commitment.
#!
#! Inputs: [RECIPIENT, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT, timeframe, priority, pad(2)]
#! Outputs: [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(8)]
#!
#! Invocation: call
@account_procedure
pub proc compute_note_fee
# compare the note's assets commitment against the fee-asset note
dupw.1 push.{fee_asset_note_commitment} exec.word::eq
# => [is_fee_asset_note, RECIPIENT, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT, timeframe, priority, pad(2)]
# price in the fee asset when the note matches, otherwise in a different asset
if.true
push.{fee_asset_id}
else
push.{other_fee_asset_id}
end
# => [FEE_ASSET_ID, RECIPIENT, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT, timeframe, priority, pad(2)]
push.{fee_amount} exec.fungible_asset::create_value swapw
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, RECIPIENT, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT, timeframe, priority, pad(2)]
# drop the note parameters
repeat.4 movupw.2 dropw end
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(8)]
end
"#,
fee_amount = FEE_AMOUNT,
);
let code = CodeBuilder::default().compile_component_code(POLICY_NAME, &masm_source)?;
let root = code
.get_procedure_root_by_path(format!("{POLICY_NAME}::compute_note_fee").as_str())
.expect("asset commitment fee policy should export compute_note_fee");
let component =
AccountComponent::new(code, vec![], AccountComponentMetadata::mock(POLICY_NAME))?;
Ok(FeePolicy::custom(root, [component])?)
}
#[tokio::test]
async fn feature_notes_priced_in_different_assets_are_rejected() -> anyhow::Result<()> {
let mut rng = RandomCoin::new(Word::empty());
let mut builder = MockChain::builder();
let sponsor = builder.add_existing_wallet(Auth::IncrNonce)?;
let fee_asset_id = AssetId::new_fungible(fee_faucet_id()?).to_word();
let other_fee_asset_id =
AssetId::new_fungible(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?).to_word();
let feature_note_a_asset = fee_asset(1)?;
let feature_note_b_asset = other_asset(1)?;
let policy = asset_commitment_fee_policy(
NoteAssets::new(vec![feature_note_a_asset])?.commitment(),
fee_asset_id,
other_fee_asset_id,
)?;
let network_account = NetworkAccount::builder(
[7; 32],
BTreeSet::from([P2idNote::script_root()]),
FeePolicyManager::builder()
.fee_faucet_id(fee_faucet_id()?)
.active_fee_policy(policy)
.build(),
)?
.with_component(BasicWallet)
.build_existing()?;
builder.add_account(network_account.clone())?;
let feature_note_a = builder.add_p2id_note(
sponsor.id(),
network_account.id(),
&[feature_note_a_asset],
NoteType::Public,
)?;
let feature_note_b = builder.add_p2id_note(
sponsor.id(),
network_account.id(),
&[feature_note_b_asset],
NoteType::Public,
)?;
let sponsorship_note = Note::from(
FeeSponsorshipNote::builder()
.sender(sponsor.id())
.target_account(network_account.id())
.feature_note_id(feature_note_a.id())
.asset(fee_asset(FEE_AMOUNT)?)
.generate_serial_number(&mut rng)
.build()?,
);
builder.add_output_note(RawOutputNote::Full(sponsorship_note.clone()));
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
let result = mock_chain
.build_transaction(network_account.id())
.authenticated_input_note(feature_note_a.id())
.authenticated_input_note(sponsorship_note.id())
.authenticated_input_note(feature_note_b.id())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_FEE_POLICY_FEE_ASSET_MISMATCH);
Ok(())
}
fn create_network_notes_tx_script(
target_id: AccountId,
num_notes: u32,
) -> anyhow::Result<TransactionScript> {
let script_root = P2idNote::script_root().as_word();
let target_prefix = target_id.prefix().as_felt();
let target_suffix = target_id.suffix();
let create_note_blocks: String = (0..num_notes)
.map(|note_idx| {
format!(
r#"
# => [pad(16)]
# compute the recipient of the storage-less network note
push.{script_root}
push.{serial_num}
push.0.0
# => [storage_ptr = 0, num_storage_items = 0, SERIAL_NUM, SCRIPT_ROOT, pad(16)]
exec.note::compute_and_store_recipient
# => [RECIPIENT, pad(16)]
# tag the note for the target network account, then create it via the NoteCreator
push.NOTE_TYPE_PUBLIC push.{target_prefix} exec.note_tag::create_account_target
# => [tag, note_type, RECIPIENT, pad(16)]
call.::miden::standards::note::note_creator::create_note
# => [note_idx, pad(21)]
movdn.15 dropw dropw dropw drop drop drop
# => [note_idx, pad(6)]
# attach the network account target so the auth procedure sponsors the note
push.0 push.{target_prefix} push.{target_suffix}
# => [target_id_suffix, target_id_prefix, exec_hint_tag = 0, note_idx, pad(6)]
exec.network_account_target::new
# => [attachment_scheme, NOTE_ATTACHMENT, note_idx, pad(6)]
exec.output_note::add_word_attachment
# => [pad(6)]
"#,
serial_num = Word::from([21u32, 22, 23, 24 + note_idx]),
)
})
.collect();
let tx_script_src = format!(
r#"
use miden::protocol::note
use miden::protocol::output_note
use miden::standards::attachments::network_account_target
use miden::standards::note_tag
use {{NOTE_TYPE_PUBLIC}} from miden::protocol::note
@transaction_script
pub proc main
{create_note_blocks}
end
"#
);
Ok(CodeBuilder::default().compile_tx_script(tx_script_src)?)
}
struct SponsorshipTest {
mock_chain: MockChain,
sponsor: Account,
target_id: AccountId,
tx_script: TransactionScript,
foreign_inputs: (Account, AccountWitness),
input_notes: Vec<NoteId>,
}
#[bon::bon]
impl SponsorshipTest {
#[builder]
fn new(
target_fee_faucet: Option<AccountId>,
sponsor_fee_faucet: Option<AccountId>,
#[builder(default)]
sponsorship_policy: SponsorshipPolicy,
#[builder(default = 1)]
num_network_notes: u32,
#[builder(default)]
num_collected_notes: u32,
) -> anyhow::Result<Self> {
let target_fee_faucet = target_fee_faucet.unwrap_or(native_fee_faucet_id()?);
let sponsor_fee_faucet = sponsor_fee_faucet.unwrap_or(native_fee_faucet_id()?);
let target_policy = BasicConstantFeePolicy::new()
.with_fee(P2idNote::script_root(), AssetAmount::new(FEE_AMOUNT)?);
let target_fee_policy_manager = FeePolicyManager::builder()
.fee_faucet_id(target_fee_faucet)
.active_fee_policy(target_policy.into())
.build();
let target = AccountBuilder::new([9; 32])
.account_type(AccountType::Public)
.with_components(AuthNetworkAccount::new(BTreeSet::new(), target_fee_policy_manager)?)
.with_component(BasicWallet)
.build_existing()?;
let tx_script = create_network_notes_tx_script(target.id(), num_network_notes)?;
let mut rng = RandomCoin::new(Word::empty());
let mut builder = MockChain::builder();
builder.add_account(target.clone())?;
let funder = builder.add_existing_wallet(Auth::IncrNonce)?;
let feature_notes: Vec<Note> = (0..num_collected_notes)
.map(|_| builder.add_p2any_note(funder.id(), NoteType::Public, []))
.collect::<anyhow::Result<_>>()?;
let mut sponsor_policy = BasicConstantFeePolicy::new();
let mut sponsor_allowed_notes = BTreeSet::new();
if let Some(feature_note) = feature_notes.first() {
sponsor_policy = sponsor_policy
.with_fee(feature_note.script().root(), AssetAmount::new(FEE_AMOUNT)?);
sponsor_allowed_notes.insert(feature_note.script().root());
}
let sponsor_fee_policy_manager = FeePolicyManager::builder()
.fee_faucet_id(sponsor_fee_faucet)
.active_fee_policy(sponsor_policy.into())
.build();
let sponsor = AccountBuilder::new([8; 32])
.account_type(AccountType::Public)
.with_components(
AuthNetworkAccount::new(sponsor_allowed_notes, sponsor_fee_policy_manager)?
.with_allowed_tx_scripts(BTreeSet::from([tx_script.root()]))
.with_sponsorship_policy(sponsorship_policy),
)
.with_component(NoteCreator)
.with_assets([native_fee_asset(u64::from(num_network_notes) * FEE_AMOUNT)?])
.build_existing()?;
builder.add_account(sponsor.clone())?;
let mut input_notes = Vec::new();
for feature_note in &feature_notes {
let sponsorship_note = Note::from(
FeeSponsorshipNote::builder()
.sender(feature_note.metadata().sender())
.target_account(sponsor.id())
.feature_note_id(feature_note.id())
.asset(Asset::from(FungibleAsset::new(sponsor_fee_faucet, FEE_AMOUNT)?))
.generate_serial_number(&mut rng)
.build()?,
);
builder.add_output_note(RawOutputNote::Full(sponsorship_note.clone()));
input_notes.extend([feature_note.id(), sponsorship_note.id()]);
}
let mut mock_chain = builder.build()?;
mock_chain.prove_next_block()?;
let foreign_inputs = mock_chain.get_foreign_account_inputs(target.id())?;
Ok(SponsorshipTest {
mock_chain,
sponsor,
target_id: target.id(),
tx_script,
foreign_inputs,
input_notes,
})
}
}
impl SponsorshipTest {
fn transaction(&self) -> anyhow::Result<MockTransaction> {
let mut builder = self
.mock_chain
.build_transaction(self.sponsor.id())
.foreign_accounts([self.foreign_inputs.clone()])
.tx_script(self.tx_script.clone());
for note_id in &self.input_notes {
builder = builder.authenticated_input_note(*note_id);
}
builder.build()
}
}
fn assert_network_note_is_sponsored(
output_notes: &RawOutputNotes,
network_note_idx: usize,
sponsorship_note_idx: usize,
) -> anyhow::Result<()> {
let network_note = output_notes.get_note(network_note_idx);
let sponsorship_note = output_notes.get_note(sponsorship_note_idx);
let network_recipient =
network_note.recipient().expect("recipient should exist for public notes");
assert_eq!(network_recipient.script().root(), P2idNote::script_root());
let sponsorship_recipient =
sponsorship_note.recipient().expect("recipient should exist for public notes");
assert_eq!(sponsorship_recipient.script().root(), FeeSponsorshipNote::script_root());
let sponsorship_storage =
FeeSponsorshipNoteStorage::try_from(sponsorship_recipient.storage().items())?;
assert_eq!(sponsorship_storage.feature_note_id(), network_note.id());
assert_eq!(sponsorship_note.assets().as_slice(), &[native_fee_asset(FEE_AMOUNT)?]);
Ok(())
}
#[tokio::test]
async fn create_sponsorships_funds_note_in_native_fee_asset() -> anyhow::Result<()> {
let test = SponsorshipTest::builder()
.sponsor_fee_faucet(fee_faucet_id()?)
.sponsorship_policy(SponsorshipPolicy::Unlimited)
.build()?;
let mut sponsor = test.sponsor.clone();
let executed = test.transaction()?.execute().await?;
let output_notes = executed.output_notes();
assert_eq!(output_notes.num_notes(), 2);
assert_network_note_is_sponsored(output_notes, 0, 1)?;
let network_target = NetworkAccountTarget::try_from(output_notes.get_note(0).attachments())?;
assert_eq!(network_target.target_id(), test.target_id);
sponsor.apply_patch(executed.account_patch())?;
assert_eq!(
sponsor
.vault()
.get_balance(AssetId::new_fungible(native_fee_faucet_id()?))?
.as_u64(),
0,
"the sponsorship note should be funded with the fee from the sponsor's vault"
);
Ok(())
}
#[tokio::test]
async fn create_sponsorships_reject_target_with_different_fee_asset() -> anyhow::Result<()> {
let test = SponsorshipTest::builder()
.target_fee_faucet(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?)
.sponsorship_policy(SponsorshipPolicy::Unlimited)
.build()?;
let result = test.transaction()?.execute().await;
assert_transaction_executor_error!(result, ERR_FEE_MANAGER_TARGET_FEE_ASSET_MISMATCH);
Ok(())
}
#[tokio::test]
async fn sponsors_and_collects_multiple_notes() -> anyhow::Result<()> {
let test = SponsorshipTest::builder().num_network_notes(2).num_collected_notes(2).build()?;
let executed = test.transaction()?.execute().await?;
let output_notes = executed.output_notes();
assert_eq!(output_notes.num_notes(), 4);
assert_network_note_is_sponsored(output_notes, 0, 2)?;
assert_network_note_is_sponsored(output_notes, 1, 3)?;
Ok(())
}
#[tokio::test]
async fn sponsoring_more_than_collected_is_rejected() -> anyhow::Result<()> {
let test = SponsorshipTest::builder().build()?;
let result = test.transaction()?.execute().await;
assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_SPONSORED_FEES_EXCEED_COLLECTED);
Ok(())
}
#[tokio::test]
async fn sponsoring_within_collected_is_accepted() -> anyhow::Result<()> {
let test = SponsorshipTest::builder().num_collected_notes(1).build()?;
let executed = test.transaction()?.execute().await?;
assert_eq!(executed.output_notes().num_notes(), 2);
assert_network_note_is_sponsored(executed.output_notes(), 0, 1)?;
Ok(())
}
#[tokio::test]
async fn sponsoring_with_a_non_native_fee_asset_is_rejected() -> anyhow::Result<()> {
let test = SponsorshipTest::builder().sponsor_fee_faucet(fee_faucet_id()?).build()?;
let result = test.transaction()?.execute().await;
assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_FEE_ASSET_NOT_NATIVE);
Ok(())
}