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
28const FEE_SPONSORSHIP_SCRIPT_PATH: &str = "::miden::standards::notes::fee_sponsorship::main";
33
34static 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#[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 #[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 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 pub const NUM_STORAGE_ITEMS: usize = FeeSponsorshipNoteStorage::NUM_ITEMS;
133
134 pub const NUM_ASSETS: usize = 1;
138
139 pub fn script() -> NoteScript {
144 FEE_SPONSORSHIP_SCRIPT.clone()
145 }
146
147 pub fn script_root() -> NoteScriptRoot {
149 FEE_SPONSORSHIP_SCRIPT.root()
150 }
151
152 pub fn sender(&self) -> AccountId {
154 self.sender
155 }
156
157 pub fn serial_number(&self) -> Word {
159 self.serial_number
160 }
161
162 pub fn tag(&self) -> NoteTag {
168 self.tag
169 }
170
171 pub fn asset(&self) -> FungibleAsset {
173 self.asset
174 }
175
176 pub fn feature_note_id(&self) -> NoteId {
178 self.storage.feature_note_id()
179 }
180
181 pub fn reclaimer(&self) -> AccountId {
183 self.storage.reclaimer()
184 }
185
186 pub fn reclaim_height(&self) -> Option<BlockNumber> {
189 self.storage.reclaim_height()
190 }
191}
192
193impl<S: fee_sponsorship_note_builder::State> FeeSponsorshipNoteBuilder<S>
197where
198 S::SerialNumber: fee_sponsorship_note_builder::IsUnset,
199{
200 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
209impl 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 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 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#[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 pub const NUM_ITEMS: usize = 7;
300
301 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 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 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
324 NoteRecipient::new(serial_num, FeeSponsorshipNote::script(), self.into())
325 }
326
327 pub fn feature_note_id(&self) -> NoteId {
329 self.feature_note_id
330 }
331
332 pub fn reclaimer(&self) -> AccountId {
334 self.reclaimer
335 }
336
337 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 let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
347
348 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
395impl NoteConsumptionCost for FeeSponsorshipNote {
399 fn consumption_cycles() -> u32 {
400 FEE_SPONSORSHIP_CONSUMPTION_CYCLES
401 }
402}
403
404#[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 #[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!(¬e.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 #[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 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 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 fn valid_storage() -> Vec<Felt> {
536 raw_storage(sponsor().suffix(), sponsor().prefix().as_felt(), Felt::from(42u32))
537 }
538
539 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 #[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(¬e)
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 #[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(¬e)
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 #[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(¬e).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 #[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(¬e)
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 const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
645 const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
646
647 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 #[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 #[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}