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::FungibleAsset;
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, PartialEq, Eq)]
74pub struct FeeSponsorshipNote {
75    sender: AccountId,
76    serial_number: Word,
77    asset: FungibleAsset,
78    tag: NoteTag,
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 fungible asset; the note script rejects notes carrying any other
89    /// number of assets, which keeps fee collection simple. Fees are always denominated in the fee
90    /// asset the collecting account configures.
91    ///
92    /// The reclaimer, the account allowed to reclaim the note after `reclaim_height`, defaults to
93    /// `sender` when left unset.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - the target account is not public. A network note's tag must route to a public account.
99    #[builder]
100    pub fn new(
101        sender: AccountId,
102        #[builder(name = target_account)] target: AccountId,
103        feature_note_id: NoteId,
104        asset: FungibleAsset,
105        serial_number: Word,
106        reclaimer: Option<AccountId>,
107        reclaim_height: Option<BlockNumber>,
108    ) -> Result<Self, NoteError> {
109        if !target.is_public() {
110            return Err(NoteError::other("fee sponsorship target account must be public"));
111        }
112
113        // The reclaimer is the account allowed to reclaim the note; it defaults to the sender.
114        let reclaimer = reclaimer.unwrap_or(sender);
115        let storage = FeeSponsorshipNoteStorage::new(feature_note_id, reclaimer, reclaim_height);
116
117        Ok(Self {
118            sender,
119            serial_number,
120            asset,
121            tag: NoteTag::with_account_target(target),
122            storage,
123        })
124    }
125}
126
127impl FeeSponsorshipNote {
128    // CONSTANTS
129    // --------------------------------------------------------------------------------------------
130
131    /// Expected number of storage items of the FEE_SPONSORSHIP note.
132    pub const NUM_STORAGE_ITEMS: usize = FeeSponsorshipNoteStorage::NUM_ITEMS;
133
134    /// Expected number of assets of the FEE_SPONSORSHIP note.
135    ///
136    /// Must match `NUM_ASSETS` in `asm/standards/notes/fee_sponsorship.masm`.
137    pub const NUM_ASSETS: usize = 1;
138
139    // PUBLIC ACCESSORS
140    // --------------------------------------------------------------------------------------------
141
142    /// Returns the script of the FEE_SPONSORSHIP note.
143    pub fn script() -> NoteScript {
144        FEE_SPONSORSHIP_SCRIPT.clone()
145    }
146
147    /// Returns the FEE_SPONSORSHIP note script root.
148    pub fn script_root() -> NoteScriptRoot {
149        FEE_SPONSORSHIP_SCRIPT.root()
150    }
151
152    /// Returns the account ID of the sponsor which created the note.
153    pub fn sender(&self) -> AccountId {
154        self.sender
155    }
156
157    /// Returns the note's serial number.
158    pub fn serial_number(&self) -> Word {
159        self.serial_number
160    }
161
162    /// Returns the tag of the note, which routes it to the network account the feature note
163    /// targets.
164    ///
165    /// The tag is a discovery hint for the network transaction builder; the script itself does not
166    /// restrict consumption to the targeted account.
167    pub fn tag(&self) -> NoteTag {
168        self.tag
169    }
170
171    /// Returns the single fungible asset the note carries as the fee.
172    pub fn asset(&self) -> FungibleAsset {
173        self.asset
174    }
175
176    /// Returns the ID of the bound feature note this note sponsors.
177    pub fn feature_note_id(&self) -> NoteId {
178        self.storage.feature_note_id()
179    }
180
181    /// Returns the account ID allowed to reclaim the note after `reclaim_height`.
182    pub fn reclaimer(&self) -> AccountId {
183        self.storage.reclaimer()
184    }
185
186    /// Returns the block height at or after which the reclaimer may reclaim the note, if reclaim is
187    /// enabled.
188    pub fn reclaim_height(&self) -> Option<BlockNumber> {
189        self.storage.reclaim_height()
190    }
191}
192
193// BUILDER EXTENSIONS
194// ================================================================================================
195
196impl<S: fee_sponsorship_note_builder::State> FeeSponsorshipNoteBuilder<S>
197where
198    S::SerialNumber: fee_sponsorship_note_builder::IsUnset,
199{
200    /// Draws a serial number from `rng` and sets it on the builder.
201    pub fn generate_serial_number(
202        self,
203        rng: &mut impl FeltRng,
204    ) -> FeeSponsorshipNoteBuilder<fee_sponsorship_note_builder::SetSerialNumber<S>> {
205        self.serial_number(rng.draw_word())
206    }
207}
208
209// CONVERSIONS
210// ================================================================================================
211
212impl From<FeeSponsorshipNote> for Note {
213    fn from(note: FeeSponsorshipNote) -> Self {
214        let assets = NoteAssets::new(vec![note.asset.into()])
215            .expect("a single asset is a valid note asset list");
216
217        // Network notes must be public so the network can discover and execute them.
218        let metadata = PartialNoteMetadata::new(note.sender, NoteType::Public).with_tag(note.tag);
219
220        Note::new(assets, metadata, note.storage.into_recipient(note.serial_number))
221    }
222}
223
224impl TryFrom<&Note> for FeeSponsorshipNote {
225    type Error = NoteError;
226
227    /// Attempts to interpret `note` as a FEE_SPONSORSHIP note.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if:
232    /// - the note's script root is not the FEE_SPONSORSHIP script root.
233    /// - the note is not public, or carries attachments. Neither shape can be built, so accepting
234    ///   one would break the round-trip back into a [`Note`].
235    /// - the note storage does not decode as [`FeeSponsorshipNoteStorage`].
236    /// - the note does not carry exactly one fungible asset.
237    ///
238    /// The note script asserts the storage length and the asset count itself, and fee collection
239    /// only accepts the collecting account's fungible fee asset, so a note rejected here could
240    /// never be consumed as a sponsorship anyway.
241    fn try_from(note: &Note) -> Result<Self, Self::Error> {
242        if note.script().root() != Self::script_root() {
243            return Err(NoteError::other(
244                "note script root does not match the FEE_SPONSORSHIP script root",
245            ));
246        }
247
248        if note.metadata().note_type() != NoteType::Public {
249            return Err(NoteError::other("FEE_SPONSORSHIP note must be public"));
250        }
251
252        if note.attachments().num_attachments() != 0 {
253            return Err(NoteError::other("FEE_SPONSORSHIP note must not carry attachments"));
254        }
255
256        let storage = FeeSponsorshipNoteStorage::try_from(note.storage().items())?;
257
258        if note.assets().num_assets() != Self::NUM_ASSETS {
259            return Err(NoteError::other("FEE_SPONSORSHIP note must carry exactly one asset"));
260        }
261
262        let asset = note
263            .assets()
264            .iter()
265            .next()
266            .expect("note carries exactly one asset as asserted above")
267            .as_fungible()
268            .ok_or_else(|| NoteError::other("FEE_SPONSORSHIP note asset must be fungible"))?;
269
270        Ok(Self {
271            sender: note.metadata().sender(),
272            serial_number: note.recipient().serial_num(),
273            asset,
274            tag: note.metadata().tag(),
275            storage,
276        })
277    }
278}
279
280// FEE SPONSORSHIP NOTE STORAGE
281// ================================================================================================
282
283/// Canonical storage representation for a FEE_SPONSORSHIP note.
284///
285/// Binds the sponsorship to its feature note by [`NoteId`] and stores the reclaimer together
286/// with the optional reclaim height controlling when the note can be reclaimed.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct FeeSponsorshipNoteStorage {
289    feature_note_id: NoteId,
290    reclaimer: AccountId,
291    reclaim_height: Option<BlockNumber>,
292}
293
294impl FeeSponsorshipNoteStorage {
295    // CONSTANTS
296    // --------------------------------------------------------------------------------------------
297
298    /// Number of storage items in this layout.
299    pub const NUM_ITEMS: usize = 7;
300
301    // Indices of the storage items. Must match the `*_ITEM` offsets from `STORAGE_PTR` in
302    // `asm/standards/notes/fee_sponsorship.masm`. The feature note ID occupies items 0 to 3.
303    const FEATURE_NOTE_ID_IDX: usize = 0;
304    const RECLAIMER_SUFFIX_IDX: usize = 4;
305    const RECLAIMER_PREFIX_IDX: usize = 5;
306    const RECLAIM_HEIGHT_IDX: usize = 6;
307
308    /// Creates new FEE_SPONSORSHIP note storage.
309    pub fn new(
310        feature_note_id: NoteId,
311        reclaimer: AccountId,
312        reclaim_height: Option<BlockNumber>,
313    ) -> Self {
314        Self {
315            feature_note_id,
316            reclaimer,
317            reclaim_height,
318        }
319    }
320
321    /// Consumes the storage and returns a FEE_SPONSORSHIP [`NoteRecipient`] with the provided
322    /// serial number.
323    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
324        NoteRecipient::new(serial_num, FeeSponsorshipNote::script(), self.into())
325    }
326
327    /// Returns the ID of the feature note the sponsorship is bound to.
328    pub fn feature_note_id(&self) -> NoteId {
329        self.feature_note_id
330    }
331
332    /// Returns the reclaimer account ID.
333    pub fn reclaimer(&self) -> AccountId {
334        self.reclaimer
335    }
336
337    /// Returns the reclaim block height (if any).
338    pub fn reclaim_height(&self) -> Option<BlockNumber> {
339        self.reclaim_height
340    }
341}
342
343impl From<FeeSponsorshipNoteStorage> for NoteStorage {
344    fn from(storage: FeeSponsorshipNoteStorage) -> Self {
345        // an absent height is encoded as zero, which the script reads as "reclaim disabled"
346        let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
347
348        // the item order must match the `*_IDX` constants that `try_from` decodes with
349        let mut items = Vec::with_capacity(FeeSponsorshipNoteStorage::NUM_ITEMS);
350        items.extend_from_slice(storage.feature_note_id.as_word().as_elements());
351        items.push(storage.reclaimer.suffix());
352        items.push(storage.reclaimer.prefix().as_felt());
353        items.push(reclaim);
354
355        NoteStorage::new(items)
356            .expect("number of storage items should not exceed max storage items")
357    }
358}
359
360impl TryFrom<&[Felt]> for FeeSponsorshipNoteStorage {
361    type Error = NoteError;
362
363    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
364        if note_storage.len() != Self::NUM_ITEMS {
365            return Err(NoteError::InvalidNoteStorageLength {
366                expected: Self::NUM_ITEMS,
367                actual: note_storage.len(),
368            });
369        }
370
371        let feature_note_id = NoteId::from_raw(Word::new([
372            note_storage[Self::FEATURE_NOTE_ID_IDX],
373            note_storage[Self::FEATURE_NOTE_ID_IDX + 1],
374            note_storage[Self::FEATURE_NOTE_ID_IDX + 2],
375            note_storage[Self::FEATURE_NOTE_ID_IDX + 3],
376        ]));
377
378        let reclaimer = AccountId::try_from_elements(
379            note_storage[Self::RECLAIMER_SUFFIX_IDX],
380            note_storage[Self::RECLAIMER_PREFIX_IDX],
381        )
382        .map_err(|err| {
383            NoteError::other_with_source("failed to create reclaimer account id", err)
384        })?;
385
386        let reclaim_height = decode_optional_block_height(
387            note_storage[Self::RECLAIM_HEIGHT_IDX],
388            "invalid reclaim height in note storage",
389        )?;
390
391        Ok(Self::new(feature_note_id, reclaimer, reclaim_height))
392    }
393}
394
395// NOTE CONSUMPTION COST
396// ================================================================================================
397
398impl NoteConsumptionCost for FeeSponsorshipNote {
399    fn consumption_cycles() -> u32 {
400        FEE_SPONSORSHIP_CONSUMPTION_CYCLES
401    }
402}
403
404// TESTS
405// ================================================================================================
406
407#[cfg(test)]
408mod tests {
409    use assert_matches::assert_matches;
410    use miden_protocol::account::AccountType;
411    use miden_protocol::asset::{Asset, NonFungibleAsset};
412    use miden_protocol::crypto::rand::RandomCoin;
413    use miden_protocol::note::{NoteAttachment, NoteAttachmentScheme, NoteAttachments};
414    use rstest::rstest;
415
416    use super::*;
417    use crate::note::P2idNote;
418
419    fn sponsor() -> AccountId {
420        AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
421    }
422
423    fn faucet() -> AccountId {
424        AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
425    }
426
427    fn other_faucet() -> AccountId {
428        AccountId::builder().account_type(AccountType::Public).build_with_seed([4; 32])
429    }
430
431    fn network_account() -> AccountId {
432        AccountId::builder().account_type(AccountType::Public).build_with_seed([3; 32])
433    }
434
435    fn other_reclaimer() -> AccountId {
436        AccountId::builder().account_type(AccountType::Public).build_with_seed([5; 32])
437    }
438
439    fn feature_note_id() -> NoteId {
440        NoteId::from_raw(Word::from([7, 8, 9, 10u32]))
441    }
442
443    fn fee_asset() -> FungibleAsset {
444        FungibleAsset::new(faucet(), 100).unwrap()
445    }
446
447    /// The builder produces a public note tagged for the target, carrying no attachments and the
448    /// seven storage items: the bound feature note ID, the reclaimer (defaulting to the sender)
449    /// and the reclaim height (zero when reclaim is disabled, which the script reads as "reclaim
450    /// disabled").
451    #[rstest]
452    #[case::default_reclaimer(None, Some(BlockNumber::from(42u32)), sponsor(), Felt::from(42u32))]
453    #[case::absent_reclaim_height(None, None, sponsor(), Felt::ZERO)]
454    #[case::explicit_reclaimer(Some(other_reclaimer()), None, other_reclaimer(), Felt::ZERO)]
455    fn builder_builds_public_sponsorship_note(
456        #[case] reclaimer: Option<AccountId>,
457        #[case] reclaim_height: Option<BlockNumber>,
458        #[case] expected_reclaimer: AccountId,
459        #[case] expected_reclaim_height: Felt,
460    ) {
461        let mut rng = RandomCoin::new(Word::empty());
462
463        let sponsorship = FeeSponsorshipNote::builder()
464            .sender(sponsor())
465            .target_account(network_account())
466            .feature_note_id(feature_note_id())
467            .asset(fee_asset())
468            .maybe_reclaimer(reclaimer)
469            .maybe_reclaim_height(reclaim_height)
470            .generate_serial_number(&mut rng)
471            .build()
472            .unwrap();
473
474        assert_eq!(sponsorship.tag(), NoteTag::with_account_target(network_account()));
475        assert_eq!(sponsorship.feature_note_id(), feature_note_id());
476        assert_eq!(sponsorship.reclaimer(), expected_reclaimer);
477
478        let note = Note::from(sponsorship);
479        assert_eq!(note.metadata().note_type(), NoteType::Public);
480        assert_eq!(note.metadata().tag(), NoteTag::with_account_target(network_account()));
481        assert_eq!(note.storage().num_items(), FeeSponsorshipNote::NUM_STORAGE_ITEMS as u16);
482        assert_eq!(&note.storage().items()[..4], feature_note_id().as_word().as_elements());
483        assert_eq!(note.storage().items()[4], expected_reclaimer.suffix());
484        assert_eq!(note.storage().items()[5], expected_reclaimer.prefix().as_felt());
485        assert_eq!(note.storage().items()[6], expected_reclaim_height);
486        assert_eq!(note.attachments().num_attachments(), 0);
487    }
488
489    /// The tag of a network note must route to a public account.
490    #[test]
491    fn builder_rejects_private_target() {
492        let private_target =
493            AccountId::builder().account_type(AccountType::Private).build_with_seed([9; 32]);
494
495        let err = FeeSponsorshipNote::builder()
496            .sender(sponsor())
497            .target_account(private_target)
498            .feature_note_id(feature_note_id())
499            .asset(fee_asset())
500            .serial_number(Word::empty())
501            .build()
502            .expect_err("a private target must be rejected");
503
504        assert_matches!(err, NoteError::Other { error_msg, .. } => {
505            assert!(error_msg.contains("must be public"))
506        });
507    }
508
509    // CONVERSION TESTS
510    // --------------------------------------------------------------------------------------------
511
512    /// Builds a public note without attachments from the given script, storage items and assets,
513    /// bypassing the builder so that shapes the builder cannot produce can be constructed.
514    fn note_with(script: NoteScript, storage_items: Vec<Felt>, assets: Vec<Asset>) -> Note {
515        note_with_shape(script, storage_items, assets, NoteType::Public, NoteAttachments::default())
516    }
517
518    /// Same as [`note_with`], but with the given note type and attachments.
519    fn note_with_shape(
520        script: NoteScript,
521        storage_items: Vec<Felt>,
522        assets: Vec<Asset>,
523        note_type: NoteType,
524        attachments: NoteAttachments,
525    ) -> Note {
526        let metadata = PartialNoteMetadata::new(sponsor(), note_type)
527            .with_tag(NoteTag::with_account_target(network_account()));
528        let recipient =
529            NoteRecipient::new(Word::empty(), script, NoteStorage::new(storage_items).unwrap());
530
531        Note::with_attachments(NoteAssets::new(assets).unwrap(), metadata, recipient, attachments)
532    }
533
534    /// Valid FEE_SPONSORSHIP storage items, with the sponsor as the reclaimer.
535    fn valid_storage() -> Vec<Felt> {
536        raw_storage(sponsor().suffix(), sponsor().prefix().as_felt(), Felt::from(42u32))
537    }
538
539    /// A single attachment, which a FEE_SPONSORSHIP note never carries.
540    fn one_attachment() -> NoteAttachments {
541        NoteAttachments::new(vec![NoteAttachment::with_word(
542            NoteAttachmentScheme::new(64).unwrap(),
543            Word::empty(),
544        )])
545        .unwrap()
546    }
547
548    /// A built note round-trips through [`Note`] and back with its fields intact.
549    #[test]
550    fn try_from_round_trips_built_note() {
551        let mut rng = RandomCoin::new(Word::empty());
552
553        let sponsorship = FeeSponsorshipNote::builder()
554            .sender(sponsor())
555            .target_account(network_account())
556            .feature_note_id(feature_note_id())
557            .asset(fee_asset())
558            .reclaim_height(BlockNumber::from(42u32))
559            .generate_serial_number(&mut rng)
560            .build()
561            .unwrap();
562
563        let note = Note::from(sponsorship.clone());
564        let decoded = FeeSponsorshipNote::try_from(&note)
565            .expect("a built FEE_SPONSORSHIP note must be detected");
566
567        assert_eq!(decoded, sponsorship);
568        assert_eq!(Note::from(decoded.clone()), note);
569        assert_eq!(decoded.sender(), sponsor());
570        assert_eq!(decoded.tag(), NoteTag::with_account_target(network_account()));
571        assert_eq!(decoded.asset(), fee_asset());
572        assert_eq!(decoded.feature_note_id(), feature_note_id());
573        assert_eq!(decoded.reclaimer(), sponsor());
574        assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
575    }
576
577    /// A note carrying a different script is not a FEE_SPONSORSHIP note, even with matching
578    /// storage and assets.
579    #[test]
580    fn try_from_rejects_other_script_root() {
581        let note = note_with(P2idNote::script(), valid_storage(), vec![fee_asset().into()]);
582
583        let err = FeeSponsorshipNote::try_from(&note)
584            .expect_err("a note with another script must be rejected");
585
586        assert_matches!(err, NoteError::Other { error_msg, .. } => {
587            assert!(error_msg.contains("script root"))
588        });
589    }
590
591    /// The fee is exactly one fungible asset: the note script asserts the asset count, and fees
592    /// are denominated in the collecting account's fee asset, which is fungible.
593    #[rstest]
594    #[case::no_assets(vec![], "exactly one asset")]
595    #[case::two_assets(
596        vec![fee_asset().into(), FungibleAsset::new(other_faucet(), 100).unwrap().into()],
597        "exactly one asset"
598    )]
599    #[case::non_fungible_asset(
600        vec![NonFungibleAsset::from_parts(faucet(), Word::from([1, 2, 3, 4u32])).into()],
601        "must be fungible"
602    )]
603    fn try_from_rejects_invalid_assets(#[case] assets: Vec<Asset>, #[case] expected_error: &str) {
604        let note = note_with(FeeSponsorshipNote::script(), valid_storage(), assets);
605
606        let err = FeeSponsorshipNote::try_from(&note).expect_err("invalid assets must be rejected");
607
608        assert_matches!(err, NoteError::Other { error_msg, .. } => {
609            assert!(error_msg.contains(expected_error))
610        });
611    }
612
613    /// A sponsorship is always built as a public note without attachments, so neither shape parses
614    /// back: accepting one would break the round-trip into a [`Note`].
615    #[rstest]
616    #[case::private_note(NoteType::Private, NoteAttachments::default(), "must be public")]
617    #[case::with_attachments(NoteType::Public, one_attachment(), "attachments")]
618    fn try_from_rejects_unbuildable_shapes(
619        #[case] note_type: NoteType,
620        #[case] attachments: NoteAttachments,
621        #[case] expected_error: &str,
622    ) {
623        let note = note_with_shape(
624            FeeSponsorshipNote::script(),
625            valid_storage(),
626            vec![fee_asset().into()],
627            note_type,
628            attachments,
629        );
630
631        let err = FeeSponsorshipNote::try_from(&note)
632            .expect_err("a shape the builder cannot produce must be rejected");
633
634        assert_matches!(err, NoteError::Other { error_msg, .. } => {
635            assert!(error_msg.contains(expected_error))
636        });
637    }
638
639    // STORAGE TESTS
640    // --------------------------------------------------------------------------------------------
641
642    // A suffix/prefix pair that does not decode to a valid account ID: the prefix's version check
643    // runs first, and `888 & 0xf == 8` is not a known version.
644    const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
645    const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
646
647    /// Builds the seven storage items with the layout spelled out literally, so these tests pin
648    /// the item order independently of the encoder.
649    fn raw_storage(reclaimer_suffix: Felt, reclaimer_prefix: Felt, height: Felt) -> Vec<Felt> {
650        let mut storage = feature_note_id().as_word().as_elements().to_vec();
651        storage.push(reclaimer_suffix);
652        storage.push(reclaimer_prefix);
653        storage.push(height);
654        storage
655    }
656
657    /// A zero height decodes as `None`, which the script reads as "reclaim disabled".
658    #[rstest]
659    #[case::with_reclaim_height(Felt::from(42u32), Some(BlockNumber::from(42u32)))]
660    #[case::zero_height_disables_reclaim(Felt::ZERO, None)]
661    fn try_from_decodes_valid_storage(
662        #[case] height: Felt,
663        #[case] expected_reclaim_height: Option<BlockNumber>,
664    ) {
665        let reclaimer = network_account();
666        let storage = raw_storage(reclaimer.suffix(), reclaimer.prefix().as_felt(), height);
667
668        let decoded = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
669            .expect("valid FEE_SPONSORSHIP storage should decode");
670
671        assert_eq!(decoded.feature_note_id(), feature_note_id());
672        assert_eq!(decoded.reclaimer(), reclaimer);
673        assert_eq!(decoded.reclaim_height(), expected_reclaim_height);
674    }
675
676    #[test]
677    fn try_from_invalid_length_fails() {
678        let storage = vec![Felt::ZERO; 3];
679
680        let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
681            .expect_err("wrong length must fail");
682
683        assert_matches!(
684            err,
685            NoteError::InvalidNoteStorageLength {
686                expected: FeeSponsorshipNoteStorage::NUM_ITEMS,
687                actual: 3
688            }
689        );
690    }
691
692    #[test]
693    fn try_from_invalid_reclaimer_fails() {
694        let storage = raw_storage(INVALID_ID_SUFFIX, INVALID_ID_PREFIX, Felt::ZERO);
695
696        let err = FeeSponsorshipNoteStorage::try_from(storage.as_slice())
697            .expect_err("invalid reclaimer encoding must fail");
698
699        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
700            assert!(error_msg.contains("reclaimer"));
701        });
702    }
703
704    /// The encoder and the decoder must agree on the item order. The layout itself is pinned by
705    /// the hand-built storage vectors in the `try_from_*` tests above.
706    #[test]
707    fn storage_round_trips_through_note_storage() {
708        let storage = FeeSponsorshipNoteStorage::new(
709            feature_note_id(),
710            network_account(),
711            Some(BlockNumber::from(42u32)),
712        );
713
714        let encoded: NoteStorage = storage.into();
715        let decoded = FeeSponsorshipNoteStorage::try_from(encoded.items()).unwrap();
716
717        assert_eq!(decoded, storage);
718    }
719}