use alloc::vec::Vec;
use miden_protocol::account::AccountId;
use miden_protocol::assembly::Path;
use miden_protocol::asset::Asset;
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::rand::FeltRng;
use miden_protocol::errors::NoteError;
use miden_protocol::note::{
Note,
NoteAssets,
NoteId,
NoteRecipient,
NoteScript,
NoteScriptRoot,
NoteStorage,
NoteTag,
NoteType,
PartialNoteMetadata,
};
use miden_protocol::utils::sync::LazyLock;
use miden_protocol::{Felt, Word};
use super::decode_optional_block_height;
use crate::StandardsLib;
use crate::note::costs::{FEE_SPONSORSHIP_CONSUMPTION_CYCLES, NoteConsumptionCost};
const FEE_SPONSORSHIP_SCRIPT_PATH: &str = "::miden::standards::notes::fee_sponsorship::main";
static FEE_SPONSORSHIP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
let standards_lib = StandardsLib::default();
let path = Path::new(FEE_SPONSORSHIP_SCRIPT_PATH);
NoteScript::from_package_reference(standards_lib.as_ref(), path)
.expect("Standards library contains FEE_SPONSORSHIP note script procedure")
});
#[derive(Debug, Clone)]
pub struct FeeSponsorshipNote {
sender: AccountId,
serial_number: Word,
assets: NoteAssets,
target: AccountId,
storage: FeeSponsorshipNoteStorage,
}
#[bon::bon]
impl FeeSponsorshipNote {
#[builder]
pub fn new(
sender: AccountId,
#[builder(name = target_account)] target: AccountId,
feature_note_id: NoteId,
#[builder(into)] asset: Asset,
serial_number: Word,
reclaimer: Option<AccountId>,
reclaim_height: Option<BlockNumber>,
) -> Result<Self, NoteError> {
if !target.is_public() {
return Err(NoteError::other("fee sponsorship target account must be public"));
}
let assets =
NoteAssets::new(vec![asset]).expect("a single asset is a valid note asset list");
let reclaimer = reclaimer.unwrap_or(sender);
let storage = FeeSponsorshipNoteStorage::new(feature_note_id, reclaimer, reclaim_height);
Ok(Self {
sender,
serial_number,
assets,
target,
storage,
})
}
}
impl FeeSponsorshipNote {
pub const NUM_STORAGE_ITEMS: usize = FeeSponsorshipNoteStorage::NUM_ITEMS;
pub fn script() -> NoteScript {
FEE_SPONSORSHIP_SCRIPT.clone()
}
pub fn script_root() -> NoteScriptRoot {
FEE_SPONSORSHIP_SCRIPT.root()
}
pub fn target_id(&self) -> AccountId {
self.target
}
pub fn feature_note_id(&self) -> NoteId {
self.storage.feature_note_id()
}
pub fn reclaimer(&self) -> AccountId {
self.storage.reclaimer()
}
pub fn reclaim_height(&self) -> Option<BlockNumber> {
self.storage.reclaim_height()
}
}
impl<S: fee_sponsorship_note_builder::State> FeeSponsorshipNoteBuilder<S>
where
S::SerialNumber: fee_sponsorship_note_builder::IsUnset,
{
pub fn generate_serial_number(
self,
rng: &mut impl FeltRng,
) -> FeeSponsorshipNoteBuilder<fee_sponsorship_note_builder::SetSerialNumber<S>> {
self.serial_number(rng.draw_word())
}
}
impl From<FeeSponsorshipNote> for Note {
fn from(note: FeeSponsorshipNote) -> Self {
let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
.with_tag(NoteTag::with_account_target(note.target));
let recipient = note.storage.into_recipient(note.serial_number);
Note::new(note.assets, metadata, recipient)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeeSponsorshipNoteStorage {
feature_note_id: NoteId,
reclaimer: AccountId,
reclaim_height: Option<BlockNumber>,
}
impl FeeSponsorshipNoteStorage {
pub const NUM_ITEMS: usize = 7;
const FEATURE_NOTE_ID_IDX: usize = 0;
const RECLAIMER_SUFFIX_IDX: usize = 4;
const RECLAIMER_PREFIX_IDX: usize = 5;
const RECLAIM_HEIGHT_IDX: usize = 6;
pub fn new(
feature_note_id: NoteId,
reclaimer: AccountId,
reclaim_height: Option<BlockNumber>,
) -> Self {
Self {
feature_note_id,
reclaimer,
reclaim_height,
}
}
pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
NoteRecipient::new(serial_num, FeeSponsorshipNote::script(), self.into())
}
pub fn feature_note_id(&self) -> NoteId {
self.feature_note_id
}
pub fn reclaimer(&self) -> AccountId {
self.reclaimer
}
pub fn reclaim_height(&self) -> Option<BlockNumber> {
self.reclaim_height
}
}
impl From<FeeSponsorshipNoteStorage> for NoteStorage {
fn from(storage: FeeSponsorshipNoteStorage) -> Self {
let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
let mut items = Vec::with_capacity(FeeSponsorshipNoteStorage::NUM_ITEMS);
items.extend_from_slice(storage.feature_note_id.as_word().as_elements());
items.push(storage.reclaimer.suffix());
items.push(storage.reclaimer.prefix().as_felt());
items.push(reclaim);
NoteStorage::new(items)
.expect("number of storage items should not exceed max storage items")
}
}
impl TryFrom<&[Felt]> for FeeSponsorshipNoteStorage {
type Error = NoteError;
fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
if note_storage.len() != Self::NUM_ITEMS {
return Err(NoteError::InvalidNoteStorageLength {
expected: Self::NUM_ITEMS,
actual: note_storage.len(),
});
}
let feature_note_id = NoteId::from_raw(Word::new([
note_storage[Self::FEATURE_NOTE_ID_IDX],
note_storage[Self::FEATURE_NOTE_ID_IDX + 1],
note_storage[Self::FEATURE_NOTE_ID_IDX + 2],
note_storage[Self::FEATURE_NOTE_ID_IDX + 3],
]));
let reclaimer = AccountId::try_from_elements(
note_storage[Self::RECLAIMER_SUFFIX_IDX],
note_storage[Self::RECLAIMER_PREFIX_IDX],
)
.map_err(|err| {
NoteError::other_with_source("failed to create reclaimer account id", err)
})?;
let reclaim_height = decode_optional_block_height(
note_storage[Self::RECLAIM_HEIGHT_IDX],
"invalid reclaim height in note storage",
)?;
Ok(Self::new(feature_note_id, reclaimer, reclaim_height))
}
}
impl NoteConsumptionCost for FeeSponsorshipNote {
fn consumption_cycles() -> u32 {
FEE_SPONSORSHIP_CONSUMPTION_CYCLES
}
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use miden_protocol::account::AccountType;
use miden_protocol::asset::FungibleAsset;
use miden_protocol::crypto::rand::RandomCoin;
use super::*;
fn sponsor() -> AccountId {
AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
}
fn faucet() -> AccountId {
AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
}
fn network_account() -> AccountId {
AccountId::builder().account_type(AccountType::Public).build_with_seed([3; 32])
}
fn feature_note_id() -> NoteId {
NoteId::from_raw(Word::from([7, 8, 9, 10u32]))
}
#[test]
fn builder_builds_public_sponsorship_note() {
let mut rng = RandomCoin::new(Word::empty());
let asset = FungibleAsset::new(faucet(), 100).unwrap();
let sponsorship = FeeSponsorshipNote::builder()
.sender(sponsor())
.target_account(network_account())
.feature_note_id(feature_note_id())
.asset(asset)
.reclaim_height(BlockNumber::from(42u32))
.generate_serial_number(&mut rng)
.build()
.unwrap();
assert_eq!(sponsorship.target_id(), network_account());
assert_eq!(sponsorship.feature_note_id(), feature_note_id());
let note = Note::from(sponsorship);
assert_eq!(note.metadata().note_type(), NoteType::Public);
assert_eq!(note.metadata().tag(), NoteTag::with_account_target(network_account()));
assert_eq!(note.storage().num_items(), FeeSponsorshipNote::NUM_STORAGE_ITEMS as u16);
assert_eq!(¬e.storage().items()[..4], feature_note_id().as_word().as_elements());
assert_eq!(note.storage().items()[4], sponsor().suffix());
assert_eq!(note.storage().items()[5], sponsor().prefix().as_felt());
assert_eq!(note.storage().items()[6], Felt::from(42u32));
assert_eq!(note.attachments().num_attachments(), 0);
}
#[test]
fn absent_reclaim_height_encodes_as_zero() {
let mut rng = RandomCoin::new(Word::empty());
let asset = FungibleAsset::new(faucet(), 100).unwrap();
let sponsorship = FeeSponsorshipNote::builder()
.sender(sponsor())
.target_account(network_account())
.feature_note_id(feature_note_id())
.asset(asset)
.generate_serial_number(&mut rng)
.build()
.unwrap();
let note = Note::from(sponsorship);
assert_eq!(note.storage().items()[6], Felt::from(0u32));
}
#[test]
fn explicit_reclaimer_is_stored() {
let mut rng = RandomCoin::new(Word::empty());
let asset = FungibleAsset::new(faucet(), 100).unwrap();
let reclaimer =
AccountId::builder().account_type(AccountType::Public).build_with_seed([5; 32]);
let sponsorship = FeeSponsorshipNote::builder()
.sender(sponsor())
.target_account(network_account())
.feature_note_id(feature_note_id())
.asset(asset)
.reclaimer(reclaimer)
.generate_serial_number(&mut rng)
.build()
.unwrap();
assert_eq!(sponsorship.reclaimer(), reclaimer);
let note = Note::from(sponsorship);
assert_eq!(note.storage().items()[4], reclaimer.suffix());
assert_eq!(note.storage().items()[5], reclaimer.prefix().as_felt());
}
#[test]
fn builder_rejects_private_target() {
let private_target =
AccountId::builder().account_type(AccountType::Private).build_with_seed([9; 32]);
let asset = FungibleAsset::new(faucet(), 100).unwrap();
let err = FeeSponsorshipNote::builder()
.sender(sponsor())
.target_account(private_target)
.feature_note_id(feature_note_id())
.asset(asset)
.serial_number(Word::empty())
.build()
.expect_err("a private target must be rejected");
assert_matches!(err, NoteError::Other { error_msg, .. } => {
assert!(error_msg.contains("must be public"))
});
}
const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
fn raw_storage(reclaimer_suffix: Felt, reclaimer_prefix: Felt, height: Felt) -> Vec<Felt> {
let mut storage = feature_note_id().as_word().as_elements().to_vec();
storage.push(reclaimer_suffix);
storage.push(reclaimer_prefix);
storage.push(height);
storage
}
#[test]
fn try_from_valid_storage_succeeds() {
let reclaimer = network_account();
let storage =
raw_storage(reclaimer.suffix(), reclaimer.prefix().as_felt(), Felt::from(42u32));
let decoded = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
.expect("valid FEE_SPONSORSHIP storage should decode");
assert_eq!(decoded.feature_note_id(), feature_note_id());
assert_eq!(decoded.reclaimer(), reclaimer);
assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
}
#[test]
fn try_from_zero_height_maps_to_none() {
let reclaimer = network_account();
let storage = raw_storage(reclaimer.suffix(), reclaimer.prefix().as_felt(), Felt::ZERO);
let decoded = FeeSponsorshipNoteStorage::try_from(storage.as_slice()).unwrap();
assert_eq!(decoded.reclaim_height(), None);
}
#[test]
fn try_from_invalid_length_fails() {
let storage = vec![Felt::ZERO; 3];
let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
.expect_err("wrong length must fail");
assert!(matches!(
err,
NoteError::InvalidNoteStorageLength {
expected: FeeSponsorshipNoteStorage::NUM_ITEMS,
actual: 3
}
));
}
#[test]
fn try_from_invalid_reclaimer_fails() {
let storage = raw_storage(INVALID_ID_SUFFIX, INVALID_ID_PREFIX, Felt::ZERO);
let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
.expect_err("invalid reclaimer encoding must fail");
assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
assert!(error_msg.contains("reclaimer"));
});
}
#[test]
fn storage_round_trips_through_note_storage() {
let storage = FeeSponsorshipNoteStorage::new(
feature_note_id(),
network_account(),
Some(BlockNumber::from(42u32)),
);
let encoded: NoteStorage = storage.into();
let decoded = FeeSponsorshipNoteStorage::try_from(encoded.items()).unwrap();
assert_eq!(decoded, storage);
}
}