use miden_processor::ExecutionError;
use miden_processor::operation::OperationError;
use miden_protocol::Word;
use miden_protocol::account::component::AccountComponentMetadata;
use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType};
use miden_protocol::asset::{AssetAmount, AssetId};
use miden_protocol::note::NoteScriptRoot;
use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET;
use miden_standards::account::access::{Authority, Ownable2Step};
use miden_standards::account::fees::{ConstantFeePolicy, FeeManager, FeePolicy};
use miden_standards::account::wallets::BasicWallet;
use miden_standards::code_builder::CodeBuilder;
use miden_standards::errors::standards::{
ERR_NOTE_SCRIPT_NOT_IN_FEE_SCHEDULE,
ERR_TIMEFRAME_OR_PRIORITY_NOT_U32,
};
use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error};
use rstest::rstest;
pub(super) const FEE_AMOUNT: u64 = 500;
pub(super) fn fee_faucet_id() -> anyhow::Result<AccountId> {
Ok(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?)
}
pub(super) fn priced_root() -> NoteScriptRoot {
NoteScriptRoot::from_array([1, 2, 3, 4])
}
fn free_root() -> NoteScriptRoot {
NoteScriptRoot::from_array([5, 6, 7, 8])
}
fn fee_manager() -> anyhow::Result<FeeManager> {
let constant_fee_policy = ConstantFeePolicy::new()
.with_fee(priced_root(), AssetAmount::new(FEE_AMOUNT)?)
.with_fee(free_root(), AssetAmount::ZERO);
Ok(FeeManager::builder()
.fee_faucet_id(fee_faucet_id()?)
.active_fee_policy(constant_fee_policy.into())
.allowed_fee_policy(custom_fee_policy()?)
.build())
}
pub(super) const CUSTOM_FEE_AMOUNT: u64 = 777;
pub(super) fn custom_fee_amount_for(
storage_commitment: Word,
timeframe: u64,
priority: u64,
) -> u64 {
let commitment_sum = (0..4).map(|idx| storage_commitment[idx].as_canonical_u64()).sum::<u64>();
CUSTOM_FEE_AMOUNT + 2 * timeframe + priority + commitment_sum
}
const CUSTOM_FEE_POLICY_NAME: &str = "test::fees::storage_commitment_fee";
pub(super) fn custom_fee_policy() -> anyhow::Result<FeePolicy> {
let masm_source = format!(
r#"
use miden::standards::fees::fee_manager
use miden::standards::note
use {{Asset, NoteRecipient}} from miden::protocol::types
#! Fee policy charging a fixed amount plus twice the timeframe plus the priority plus the
#! sum of the storage commitment elements (recovered from the recipient via the advice
#! provider), in the fee asset the manager is configured with.
#!
#! 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(
recipient: NoteRecipient,
assets_commitment: word,
attachments_commitment: word,
timeframe: u32,
priority: u32
) -> Asset
exec.note::get_recipient_preimage
# => [NOTE_SCRIPT_ROOT, STORAGE_COMMITMENT, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT,
# timeframe, priority, pad(2)]
# drop the script root and reduce the storage commitment to the sum of its elements
dropw add add add
# => [storage_commitment_sum, ASSETS_COMMITMENT, ATTACHMENTS_COMMITMENT, timeframe,
# priority, pad(2)]
# keep the sum and the timeframe and priority as pricing inputs, dropping the
# remaining note parameters
movdn.8 dropw dropw
# => [storage_commitment_sum, timeframe, priority, pad(10)]
# charge the base amount plus twice the timeframe plus the priority plus the storage
# commitment sum
swap mul.2 add add push.{CUSTOM_FEE_AMOUNT} add
# => [fee_amount, pad(12)]
push.0.0.0 movup.3
# => [FEE_ASSET_VALUE, pad(15)]
# drop the excess padding before reading the fee asset ID
movupw.3 dropw
# => [FEE_ASSET_VALUE, pad(12)]
# charge the fee in the asset the manager is configured with
exec.fee_manager::read_fee_asset_id
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(12)]
# drop the excess padding to restore the stack depth for the call boundary
movupw.3 dropw
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(8)]
end
"#
);
let code =
CodeBuilder::default().compile_component_code(CUSTOM_FEE_POLICY_NAME, &masm_source)?;
let root = code
.get_procedure_root_by_path(format!("{CUSTOM_FEE_POLICY_NAME}::compute_note_fee").as_str())
.expect("custom fee policy should export compute_note_fee");
let component = AccountComponent::new(
code,
vec![],
AccountComponentMetadata::mock(CUSTOM_FEE_POLICY_NAME),
)?;
Ok(FeePolicy::custom(root, [component])?)
}
pub(super) fn build_fee_account_with_switching(owner: AccountId) -> anyhow::Result<Account> {
Ok(AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_component(Ownable2Step::new(owner))
.with_component(Authority::OwnerControlled)
.with_components(fee_manager()?)
.build_existing()?)
}
pub(super) fn estimate_note_fee_tx_script_code(
storage_commitment: Word,
timeframe: u64,
priority: u64,
expected_fee_asset_id: Word,
expected_fee_value: Word,
) -> String {
format!(
r#"
use miden::core::crypto::hashes::poseidon2
use miden::standards::fees::fee_manager
@transaction_script
pub proc main
# => [NOTE_SCRIPT_ROOT, pad(12)]
# place STORAGE_COMMITMENT below NOTE_SCRIPT_ROOT and the all-zero serial number plus
# the empty word above it
push.{storage_commitment} swapw padw padw
# => [SERIAL_NUM = 0, EMPTY_WORD, NOTE_SCRIPT_ROOT, STORAGE_COMMITMENT, pad(12)]
# compute the note's recipient, inserting the recipient preimages into the advice map
# so the fee policy can recover the script root and storage commitment
adv.insert_hdword exec.poseidon2::merge
adv.insert_hdword exec.poseidon2::merge
adv.insert_hdword exec.poseidon2::merge
# => [RECIPIENT, pad(12)]
# place the timeframe and priority in their parameter slots; the zeros in between
# serve as the assets and attachments commitments
push.{priority} push.{timeframe} movdn.13 movdn.13
# => [RECIPIENT, ASSETS_COMMITMENT = 0, ATTACHMENTS_COMMITMENT = 0, timeframe,
# priority, pad(4)]
call.fee_manager::estimate_note_fee
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(10)]
push.{expected_fee_asset_id}
assert_eqw.err="estimate_note_fee should return the expected fee asset ID"
# => [FEE_ASSET_VALUE, pad(12)]
push.{expected_fee_value}
assert_eqw.err="estimate_note_fee should return the expected fee amount"
# => [pad(16)]
end
"#
)
}
pub(super) fn create_set_fee_policy_note_script(policy_root: Word) -> String {
format!(
r#"
use miden::standards::fees::fee_manager
@note_script
pub proc main
padw padw padw
push.{policy_root}
call.fee_manager::set_fee_policy
dropw dropw dropw dropw
end
"#
)
}
#[rstest]
#[case::priced_root(priced_root(), FEE_AMOUNT)]
#[case::zero_fee_root(free_root(), 0)]
#[tokio::test]
async fn estimate_note_fee_returns_scheduled_fee(
#[case] queried_root: NoteScriptRoot,
#[case] expected_amount: u64,
) -> anyhow::Result<()> {
let account = AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_components(fee_manager()?)
.build_existing()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
let mock_chain = builder.build()?;
let tx_script_code = estimate_note_fee_tx_script_code(
Word::empty(),
11,
7,
AssetId::new_fungible(fee_faucet_id()?).to_word(),
AssetAmount::new(expected_amount)?.to_word(),
);
let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;
mock_chain
.build_tx_context(account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(queried_root.as_word())
.build()?
.execute()
.await?;
Ok(())
}
#[rstest]
#[case::timeframe(u64::from(u32::MAX) + 1, 0)]
#[case::priority(0, u64::from(u32::MAX) + 1)]
#[tokio::test]
async fn estimate_note_fee_rejects_non_u32_timeframe_or_priority(
#[case] timeframe: u64,
#[case] priority: u64,
) -> anyhow::Result<()> {
let account = AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_components(fee_manager()?)
.build_existing()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
let mock_chain = builder.build()?;
let tx_script_code = estimate_note_fee_tx_script_code(
Word::empty(),
timeframe,
priority,
Word::empty(),
Word::empty(),
);
let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;
let result = mock_chain
.build_tx_context(account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(priced_root().as_word())
.build()?
.execute()
.await;
assert_transaction_executor_error!(
result,
matches ExecutionError::OperationError {
err: OperationError::U32AssertionFailed { err_code, .. },
..
} if err_code == ERR_TIMEFRAME_OR_PRIORITY_NOT_U32.code()
);
Ok(())
}
#[tokio::test]
async fn estimate_note_fee_aborts_for_unscheduled_root() -> anyhow::Result<()> {
let account = AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_components(fee_manager()?)
.build_existing()?;
let mut builder = MockChain::builder();
builder.add_account(account.clone())?;
let mock_chain = builder.build()?;
let tx_script_code =
estimate_note_fee_tx_script_code(Word::empty(), 0, 0, Word::empty(), Word::empty());
let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;
let result = mock_chain
.build_tx_context(account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(NoteScriptRoot::from_array([9, 10, 11, 12]).as_word())
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_NOTE_SCRIPT_NOT_IN_FEE_SCHEDULE);
Ok(())
}
#[tokio::test]
async fn estimate_note_fee_dispatches_to_custom_policy_via_fpi() -> anyhow::Result<()> {
let fee_manager = FeeManager::builder()
.fee_faucet_id(fee_faucet_id()?)
.active_fee_policy(custom_fee_policy()?)
.build();
let foreign_account = AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_components(fee_manager)
.build_existing()?;
let native_account = AccountBuilder::new([2; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.build_existing()?;
let mut mock_chain =
MockChainBuilder::with_accounts([native_account.clone(), foreign_account.clone()])?
.build()?;
mock_chain.prove_next_block()?;
let storage_commitment = Word::from([5u32, 6, 7, 8]);
let timeframe = 40u64;
let priority = 9u64;
let tx_script_code = format!(
r#"
use miden::core::crypto::hashes::poseidon2
use miden::protocol::tx
@transaction_script
pub proc main
# => [pad(16)]
push.{storage_commitment} push.{note_script_root} padw padw
# => [SERIAL_NUM = 0, EMPTY_WORD, NOTE_SCRIPT_ROOT, STORAGE_COMMITMENT, pad(16)]
# compute the note's recipient, inserting the recipient preimages into the advice map
# so the custom policy can recover the script root and storage commitment
adv.insert_hdword exec.poseidon2::merge
adv.insert_hdword exec.poseidon2::merge
adv.insert_hdword exec.poseidon2::merge
# => [RECIPIENT, pad(16)]
# place the timeframe and priority in their parameter slots; the zeros in between
# serve as the assets and attachments commitments
push.{priority} push.{timeframe} movdn.13 movdn.13
# => [RECIPIENT, ASSETS_COMMITMENT = 0, ATTACHMENTS_COMMITMENT = 0, timeframe,
# priority, pad(8)]
# push the estimate_note_fee procedure root and the foreign account ID
push.{estimate_note_fee_root}
push.{foreign_prefix} push.{foreign_suffix}
# => [foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT,
# RECIPIENT, ASSETS_COMMITMENT = 0, ATTACHMENTS_COMMITMENT = 0, timeframe,
# priority, pad(8)]
exec.tx::execute_foreign_procedure
# => [FEE_ASSET_ID, FEE_ASSET_VALUE, pad(14)]
push.{expected_fee_asset_id}
assert_eqw.err="estimate_note_fee should return the manager's fee asset ID"
# => [FEE_ASSET_VALUE, pad(12)]
push.{expected_fee_value}
assert_eqw.err="custom fee policy should charge the amount derived from the storage commitment, timeframe, and priority"
# => [pad(16)]
end
"#,
note_script_root = NoteScriptRoot::from_array([9, 9, 9, 9]).as_word(),
estimate_note_fee_root = FeeManager::estimate_note_fee_root().mast_root(),
foreign_prefix = foreign_account.id().prefix().as_felt(),
foreign_suffix = foreign_account.id().suffix(),
expected_fee_asset_id = AssetId::new_fungible(fee_faucet_id()?).to_word(),
expected_fee_value =
AssetAmount::new(custom_fee_amount_for(storage_commitment, timeframe, priority))?
.to_word(),
);
let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;
let foreign_account_inputs = mock_chain.get_foreign_account_inputs(foreign_account.id())?;
mock_chain
.build_tx_context(native_account.id(), &[], &[])?
.foreign_accounts([foreign_account_inputs])
.tx_script(tx_script)
.build()?
.execute()
.await?;
Ok(())
}
#[tokio::test]
async fn get_fee_asset_id_returns_configured_fee_asset_via_fpi() -> anyhow::Result<()> {
let foreign_account = AccountBuilder::new([1; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.with_components(fee_manager()?)
.build_existing()?;
let native_account = AccountBuilder::new([2; 32])
.account_type(AccountType::Public)
.with_auth_component(Auth::IncrNonce)
.with_component(BasicWallet)
.build_existing()?;
let mut mock_chain =
MockChainBuilder::with_accounts([native_account.clone(), foreign_account.clone()])?
.build()?;
mock_chain.prove_next_block()?;
let tx_script_code = format!(
r#"
use miden::protocol::tx
@transaction_script
pub proc main
# => [pad(16)]
# push the get_fee_asset_id procedure root and the foreign account ID
push.{get_fee_asset_id_root}
push.{foreign_prefix} push.{foreign_suffix}
# => [foreign_account_id_suffix, foreign_account_id_prefix, FOREIGN_PROC_ROOT, pad(16)]
exec.tx::execute_foreign_procedure
# => [FEE_ASSET_ID, pad(12)]
push.{expected_fee_asset_id}
assert_eqw.err="get_fee_asset_id should return the configured fee asset ID"
# => [pad(16)]
end
"#,
get_fee_asset_id_root = FeeManager::get_fee_asset_id_root().mast_root(),
foreign_prefix = foreign_account.id().prefix().as_felt(),
foreign_suffix = foreign_account.id().suffix(),
expected_fee_asset_id = AssetId::new_fungible(fee_faucet_id()?).to_word(),
);
let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?;
let foreign_account_inputs = mock_chain.get_foreign_account_inputs(foreign_account.id())?;
mock_chain
.build_tx_context(native_account.id(), &[], &[])?
.foreign_accounts([foreign_account_inputs])
.tx_script(tx_script)
.build()?
.execute()
.await?;
Ok(())
}