Skip to main content

miden_standards/note/
fee_sponsorship.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::AccountId;
4use miden_protocol::assembly::Path;
5use miden_protocol::asset::Asset;
6use miden_protocol::block::BlockNumber;
7use miden_protocol::crypto::rand::FeltRng;
8use miden_protocol::errors::NoteError;
9use miden_protocol::note::{
10    Note,
11    NoteAssets,
12    NoteId,
13    NoteRecipient,
14    NoteScript,
15    NoteScriptRoot,
16    NoteStorage,
17    NoteTag,
18    NoteType,
19    PartialNoteMetadata,
20};
21use miden_protocol::utils::sync::LazyLock;
22use miden_protocol::{Felt, Word};
23
24use super::decode_optional_block_height;
25use crate::StandardsLib;
26use crate::note::costs::{FEE_SPONSORSHIP_CONSUMPTION_CYCLES, NoteConsumptionCost};
27
28// NOTE SCRIPT
29// ================================================================================================
30
31/// Path to the FEE_SPONSORSHIP note script procedure in the standards library.
32const FEE_SPONSORSHIP_SCRIPT_PATH: &str = "::miden::standards::notes::fee_sponsorship::main";
33
34// Initialize the FEE_SPONSORSHIP note script only once
35static FEE_SPONSORSHIP_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
36    let standards_lib = StandardsLib::default();
37    let path = Path::new(FEE_SPONSORSHIP_SCRIPT_PATH);
38    NoteScript::from_package_reference(standards_lib.as_ref(), path)
39        .expect("Standards library contains FEE_SPONSORSHIP note script procedure")
40});
41
42// FEE SPONSORSHIP NOTE
43// ================================================================================================
44
45/// A FEE_SPONSORSHIP note: carries the fee for exactly one feature note.
46///
47/// Under the sponsorship fee model, the feature note (`BURN`, `CLAIM`, `B2AGG`, ...) stays
48/// entirely fee-unaware. The fee travels in this separate note as exactly one asset; the note
49/// names the feature note it pays for by carrying that note's [`NoteId`] in its note storage. The
50/// note carries no attachments; its tag routes it to the network account the feature note targets.
51///
52/// # Consumption
53///
54/// The note may only be consumed in a transaction that also consumes the bound feature note; it
55/// does not restrict who that consumer is. Consumption rights are thereby inherited from the
56/// feature note: whoever may consume the feature note may take its sponsorship in the same
57/// transaction. The script enforces the pairing itself, rather than relying on the account: the
58/// sponsor trusts neither the consuming account nor the transaction builder, but does choose the
59/// note's script root.
60///
61/// The mirror-image check (that a feature note is not consumed *without* sponsorship) protects
62/// the consuming account rather than the sponsor, and so lives in the account's auth procedure.
63/// That check binds sponsorships to feature notes by note ID, so the note can be at any position in
64/// the input notes. Several sponsorship notes may be bound to the same feature note to top up its
65/// fee between them.
66///
67/// # Reclaim
68///
69/// Every consumption without the bound feature note is a reclaim: the note returns to its
70/// `reclaimer` once `reclaim_height` is reached. If the bound feature note is consumed by some
71/// other transaction, reclaim is the only way to recover the assets. A reclaim cannot happen in a
72/// transaction that also collects fees, which rejects a sponsorship whose feature note is absent.
73#[derive(Debug, Clone)]
74pub struct FeeSponsorshipNote {
75    sender: AccountId,
76    serial_number: Word,
77    assets: NoteAssets,
78    target: AccountId,
79    storage: FeeSponsorshipNoteStorage,
80}
81
82#[bon::bon]
83impl FeeSponsorshipNote {
84    /// Builds a new [`FeeSponsorshipNote`] sponsoring `feature_note_id`, tagged for `target`.
85    ///
86    /// Prefer the builder's `generate_serial_number` over supplying a serial number by hand.
87    ///
88    /// The fee is exactly one asset; the note script rejects notes carrying any other number of
89    /// assets, which keeps fee collection simple.
90    ///
91    /// The reclaimer, the account allowed to reclaim the note after `reclaim_height`, defaults to
92    /// `sender` when left unset.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if:
97    /// - the target account is not public. A network note's tag must route to a public account.
98    #[builder]
99    pub fn new(
100        sender: AccountId,
101        #[builder(name = target_account)] target: AccountId,
102        feature_note_id: NoteId,
103        #[builder(into)] asset: Asset,
104        serial_number: Word,
105        reclaimer: Option<AccountId>,
106        reclaim_height: Option<BlockNumber>,
107    ) -> Result<Self, NoteError> {
108        if !target.is_public() {
109            return Err(NoteError::other("fee sponsorship target account must be public"));
110        }
111
112        let assets =
113            NoteAssets::new(vec![asset]).expect("a single asset is a valid note asset list");
114        // The reclaimer is the account allowed to reclaim the note; it defaults to the sender.
115        let reclaimer = reclaimer.unwrap_or(sender);
116        let storage = FeeSponsorshipNoteStorage::new(feature_note_id, reclaimer, reclaim_height);
117
118        Ok(Self {
119            sender,
120            serial_number,
121            assets,
122            target,
123            storage,
124        })
125    }
126}
127
128impl FeeSponsorshipNote {
129    // CONSTANTS
130    // --------------------------------------------------------------------------------------------
131
132    /// Expected number of storage items of the FEE_SPONSORSHIP note.
133    pub const NUM_STORAGE_ITEMS: usize = FeeSponsorshipNoteStorage::NUM_ITEMS;
134
135    // PUBLIC ACCESSORS
136    // --------------------------------------------------------------------------------------------
137
138    /// Returns the script of the FEE_SPONSORSHIP note.
139    pub fn script() -> NoteScript {
140        FEE_SPONSORSHIP_SCRIPT.clone()
141    }
142
143    /// Returns the FEE_SPONSORSHIP note script root.
144    pub fn script_root() -> NoteScriptRoot {
145        FEE_SPONSORSHIP_SCRIPT.root()
146    }
147
148    /// Returns the account ID of the network account the note's tag routes to.
149    ///
150    /// The tag is a discovery hint for the network transaction builder; the script itself does not
151    /// restrict consumption to this account.
152    pub fn target_id(&self) -> AccountId {
153        self.target
154    }
155
156    /// Returns the ID of the bound feature note this note sponsors.
157    pub fn feature_note_id(&self) -> NoteId {
158        self.storage.feature_note_id()
159    }
160
161    /// Returns the account ID allowed to reclaim the note after `reclaim_height`.
162    pub fn reclaimer(&self) -> AccountId {
163        self.storage.reclaimer()
164    }
165
166    /// Returns the block height at or after which the reclaimer may reclaim the note, if reclaim is
167    /// enabled.
168    pub fn reclaim_height(&self) -> Option<BlockNumber> {
169        self.storage.reclaim_height()
170    }
171}
172
173// BUILDER EXTENSIONS
174// ================================================================================================
175
176impl<S: fee_sponsorship_note_builder::State> FeeSponsorshipNoteBuilder<S>
177where
178    S::SerialNumber: fee_sponsorship_note_builder::IsUnset,
179{
180    /// Draws a serial number from `rng` and sets it on the builder.
181    pub fn generate_serial_number(
182        self,
183        rng: &mut impl FeltRng,
184    ) -> FeeSponsorshipNoteBuilder<fee_sponsorship_note_builder::SetSerialNumber<S>> {
185        self.serial_number(rng.draw_word())
186    }
187}
188
189// CONVERSIONS
190// ================================================================================================
191
192impl From<FeeSponsorshipNote> for Note {
193    fn from(note: FeeSponsorshipNote) -> Self {
194        // Network notes must be public so the network can discover and execute them. The tag routes
195        // the note to the network account the feature note targets.
196        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public)
197            .with_tag(NoteTag::with_account_target(note.target));
198
199        let recipient = note.storage.into_recipient(note.serial_number);
200
201        Note::new(note.assets, metadata, recipient)
202    }
203}
204
205// FEE SPONSORSHIP NOTE STORAGE
206// ================================================================================================
207
208/// Canonical storage representation for a FEE_SPONSORSHIP note.
209///
210/// Binds the sponsorship to its feature note by [`NoteId`] and stores the reclaimer together
211/// with the optional reclaim height controlling when the note can be reclaimed.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct FeeSponsorshipNoteStorage {
214    feature_note_id: NoteId,
215    reclaimer: AccountId,
216    reclaim_height: Option<BlockNumber>,
217}
218
219impl FeeSponsorshipNoteStorage {
220    // CONSTANTS
221    // --------------------------------------------------------------------------------------------
222
223    /// Number of storage items in this layout.
224    pub const NUM_ITEMS: usize = 7;
225
226    // Indices of the storage items. Must match the `*_ITEM` offsets from `STORAGE_PTR` in
227    // `asm/standards/notes/fee_sponsorship.masm`. The feature note ID occupies items 0 to 3.
228    const FEATURE_NOTE_ID_IDX: usize = 0;
229    const RECLAIMER_SUFFIX_IDX: usize = 4;
230    const RECLAIMER_PREFIX_IDX: usize = 5;
231    const RECLAIM_HEIGHT_IDX: usize = 6;
232
233    /// Creates new FEE_SPONSORSHIP note storage.
234    pub fn new(
235        feature_note_id: NoteId,
236        reclaimer: AccountId,
237        reclaim_height: Option<BlockNumber>,
238    ) -> Self {
239        Self {
240            feature_note_id,
241            reclaimer,
242            reclaim_height,
243        }
244    }
245
246    /// Consumes the storage and returns a FEE_SPONSORSHIP [`NoteRecipient`] with the provided
247    /// serial number.
248    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
249        NoteRecipient::new(serial_num, FeeSponsorshipNote::script(), self.into())
250    }
251
252    /// Returns the ID of the feature note the sponsorship is bound to.
253    pub fn feature_note_id(&self) -> NoteId {
254        self.feature_note_id
255    }
256
257    /// Returns the reclaimer account ID.
258    pub fn reclaimer(&self) -> AccountId {
259        self.reclaimer
260    }
261
262    /// Returns the reclaim block height (if any).
263    pub fn reclaim_height(&self) -> Option<BlockNumber> {
264        self.reclaim_height
265    }
266}
267
268impl From<FeeSponsorshipNoteStorage> for NoteStorage {
269    fn from(storage: FeeSponsorshipNoteStorage) -> Self {
270        // an absent height is encoded as zero, which the script reads as "reclaim disabled"
271        let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
272
273        // the item order must match the `*_IDX` constants that `try_from` decodes with
274        let mut items = Vec::with_capacity(FeeSponsorshipNoteStorage::NUM_ITEMS);
275        items.extend_from_slice(storage.feature_note_id.as_word().as_elements());
276        items.push(storage.reclaimer.suffix());
277        items.push(storage.reclaimer.prefix().as_felt());
278        items.push(reclaim);
279
280        NoteStorage::new(items)
281            .expect("number of storage items should not exceed max storage items")
282    }
283}
284
285impl TryFrom<&[Felt]> for FeeSponsorshipNoteStorage {
286    type Error = NoteError;
287
288    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
289        if note_storage.len() != Self::NUM_ITEMS {
290            return Err(NoteError::InvalidNoteStorageLength {
291                expected: Self::NUM_ITEMS,
292                actual: note_storage.len(),
293            });
294        }
295
296        let feature_note_id = NoteId::from_raw(Word::new([
297            note_storage[Self::FEATURE_NOTE_ID_IDX],
298            note_storage[Self::FEATURE_NOTE_ID_IDX + 1],
299            note_storage[Self::FEATURE_NOTE_ID_IDX + 2],
300            note_storage[Self::FEATURE_NOTE_ID_IDX + 3],
301        ]));
302
303        let reclaimer = AccountId::try_from_elements(
304            note_storage[Self::RECLAIMER_SUFFIX_IDX],
305            note_storage[Self::RECLAIMER_PREFIX_IDX],
306        )
307        .map_err(|err| {
308            NoteError::other_with_source("failed to create reclaimer account id", err)
309        })?;
310
311        let reclaim_height = decode_optional_block_height(
312            note_storage[Self::RECLAIM_HEIGHT_IDX],
313            "invalid reclaim height in note storage",
314        )?;
315
316        Ok(Self::new(feature_note_id, reclaimer, reclaim_height))
317    }
318}
319
320// NOTE CONSUMPTION COST
321// ================================================================================================
322
323impl NoteConsumptionCost for FeeSponsorshipNote {
324    fn consumption_cycles() -> u32 {
325        FEE_SPONSORSHIP_CONSUMPTION_CYCLES
326    }
327}
328
329// TESTS
330// ================================================================================================
331
332#[cfg(test)]
333mod tests {
334    use assert_matches::assert_matches;
335    use miden_protocol::account::AccountType;
336    use miden_protocol::asset::FungibleAsset;
337    use miden_protocol::crypto::rand::RandomCoin;
338
339    use super::*;
340
341    fn sponsor() -> AccountId {
342        AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
343    }
344
345    fn faucet() -> AccountId {
346        AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
347    }
348
349    fn network_account() -> AccountId {
350        AccountId::builder().account_type(AccountType::Public).build_with_seed([3; 32])
351    }
352
353    fn feature_note_id() -> NoteId {
354        NoteId::from_raw(Word::from([7, 8, 9, 10u32]))
355    }
356
357    /// The builder produces a public note tagged for the target, carrying no attachments and the
358    /// seven storage items.
359    #[test]
360    fn builder_builds_public_sponsorship_note() {
361        let mut rng = RandomCoin::new(Word::empty());
362        let asset = FungibleAsset::new(faucet(), 100).unwrap();
363
364        let sponsorship = FeeSponsorshipNote::builder()
365            .sender(sponsor())
366            .target_account(network_account())
367            .feature_note_id(feature_note_id())
368            .asset(asset)
369            .reclaim_height(BlockNumber::from(42u32))
370            .generate_serial_number(&mut rng)
371            .build()
372            .unwrap();
373
374        assert_eq!(sponsorship.target_id(), network_account());
375        assert_eq!(sponsorship.feature_note_id(), feature_note_id());
376
377        let note = Note::from(sponsorship);
378        assert_eq!(note.metadata().note_type(), NoteType::Public);
379        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(network_account()));
380        assert_eq!(note.storage().num_items(), FeeSponsorshipNote::NUM_STORAGE_ITEMS as u16);
381        // The bound feature note ID comes first, then the reclaimer (defaulting to the sender),
382        // then the reclaim height.
383        assert_eq!(&note.storage().items()[..4], feature_note_id().as_word().as_elements());
384        assert_eq!(note.storage().items()[4], sponsor().suffix());
385        assert_eq!(note.storage().items()[5], sponsor().prefix().as_felt());
386        assert_eq!(note.storage().items()[6], Felt::from(42u32));
387        assert_eq!(note.attachments().num_attachments(), 0);
388    }
389
390    /// A reclaim height of `None` encodes as 0, which the script reads as "reclaim disabled".
391    #[test]
392    fn absent_reclaim_height_encodes_as_zero() {
393        let mut rng = RandomCoin::new(Word::empty());
394        let asset = FungibleAsset::new(faucet(), 100).unwrap();
395
396        let sponsorship = FeeSponsorshipNote::builder()
397            .sender(sponsor())
398            .target_account(network_account())
399            .feature_note_id(feature_note_id())
400            .asset(asset)
401            .generate_serial_number(&mut rng)
402            .build()
403            .unwrap();
404
405        let note = Note::from(sponsorship);
406        assert_eq!(note.storage().items()[6], Felt::from(0u32));
407    }
408
409    /// An explicit reclaimer overrides the sender in the note storage.
410    #[test]
411    fn explicit_reclaimer_is_stored() {
412        let mut rng = RandomCoin::new(Word::empty());
413        let asset = FungibleAsset::new(faucet(), 100).unwrap();
414        let reclaimer =
415            AccountId::builder().account_type(AccountType::Public).build_with_seed([5; 32]);
416
417        let sponsorship = FeeSponsorshipNote::builder()
418            .sender(sponsor())
419            .target_account(network_account())
420            .feature_note_id(feature_note_id())
421            .asset(asset)
422            .reclaimer(reclaimer)
423            .generate_serial_number(&mut rng)
424            .build()
425            .unwrap();
426
427        assert_eq!(sponsorship.reclaimer(), reclaimer);
428
429        let note = Note::from(sponsorship);
430        assert_eq!(note.storage().items()[4], reclaimer.suffix());
431        assert_eq!(note.storage().items()[5], reclaimer.prefix().as_felt());
432    }
433
434    /// The tag of a network note must route to a public account.
435    #[test]
436    fn builder_rejects_private_target() {
437        let private_target =
438            AccountId::builder().account_type(AccountType::Private).build_with_seed([9; 32]);
439        let asset = FungibleAsset::new(faucet(), 100).unwrap();
440
441        let err = FeeSponsorshipNote::builder()
442            .sender(sponsor())
443            .target_account(private_target)
444            .feature_note_id(feature_note_id())
445            .asset(asset)
446            .serial_number(Word::empty())
447            .build()
448            .expect_err("a private target must be rejected");
449
450        assert_matches!(err, NoteError::Other { error_msg, .. } => {
451            assert!(error_msg.contains("must be public"))
452        });
453    }
454
455    // STORAGE TESTS
456    // --------------------------------------------------------------------------------------------
457
458    // A suffix/prefix pair that does not decode to a valid account ID: the prefix's version check
459    // runs first, and `888 & 0xf == 8` is not a known version.
460    const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
461    const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
462
463    /// Builds the seven storage items with the layout spelled out literally, so these tests pin
464    /// the item order independently of the encoder.
465    fn raw_storage(reclaimer_suffix: Felt, reclaimer_prefix: Felt, height: Felt) -> Vec<Felt> {
466        let mut storage = feature_note_id().as_word().as_elements().to_vec();
467        storage.push(reclaimer_suffix);
468        storage.push(reclaimer_prefix);
469        storage.push(height);
470        storage
471    }
472
473    #[test]
474    fn try_from_valid_storage_succeeds() {
475        let reclaimer = network_account();
476        let storage =
477            raw_storage(reclaimer.suffix(), reclaimer.prefix().as_felt(), Felt::from(42u32));
478
479        let decoded = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
480            .expect("valid FEE_SPONSORSHIP storage should decode");
481
482        assert_eq!(decoded.feature_note_id(), feature_note_id());
483        assert_eq!(decoded.reclaimer(), reclaimer);
484        assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
485    }
486
487    #[test]
488    fn try_from_zero_height_maps_to_none() {
489        let reclaimer = network_account();
490        let storage = raw_storage(reclaimer.suffix(), reclaimer.prefix().as_felt(), Felt::ZERO);
491
492        let decoded = FeeSponsorshipNoteStorage::try_from(storage.as_slice()).unwrap();
493
494        assert_eq!(decoded.reclaim_height(), None);
495    }
496
497    #[test]
498    fn try_from_invalid_length_fails() {
499        let storage = vec![Felt::ZERO; 3];
500
501        let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
502            .expect_err("wrong length must fail");
503
504        assert!(matches!(
505            err,
506            NoteError::InvalidNoteStorageLength {
507                expected: FeeSponsorshipNoteStorage::NUM_ITEMS,
508                actual: 3
509            }
510        ));
511    }
512
513    #[test]
514    fn try_from_invalid_reclaimer_fails() {
515        let storage = raw_storage(INVALID_ID_SUFFIX, INVALID_ID_PREFIX, Felt::ZERO);
516
517        let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
518            .expect_err("invalid reclaimer encoding must fail");
519
520        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
521            assert!(error_msg.contains("reclaimer"));
522        });
523    }
524
525    /// The encoder and the decoder must agree on the item order. The layout itself is pinned by
526    /// the hand-built storage vectors in the `try_from_*` tests above.
527    #[test]
528    fn storage_round_trips_through_note_storage() {
529        let storage = FeeSponsorshipNoteStorage::new(
530            feature_note_id(),
531            network_account(),
532            Some(BlockNumber::from(42u32)),
533        );
534
535        let encoded: NoteStorage = storage.into();
536        let decoded = FeeSponsorshipNoteStorage::try_from(encoded.items()).unwrap();
537
538        assert_eq!(decoded, storage);
539    }
540}