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
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)]
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 #[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 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 pub const NUM_STORAGE_ITEMS: usize = FeeSponsorshipNoteStorage::NUM_ITEMS;
134
135 pub fn script() -> NoteScript {
140 FEE_SPONSORSHIP_SCRIPT.clone()
141 }
142
143 pub fn script_root() -> NoteScriptRoot {
145 FEE_SPONSORSHIP_SCRIPT.root()
146 }
147
148 pub fn target_id(&self) -> AccountId {
153 self.target
154 }
155
156 pub fn feature_note_id(&self) -> NoteId {
158 self.storage.feature_note_id()
159 }
160
161 pub fn reclaimer(&self) -> AccountId {
163 self.storage.reclaimer()
164 }
165
166 pub fn reclaim_height(&self) -> Option<BlockNumber> {
169 self.storage.reclaim_height()
170 }
171}
172
173impl<S: fee_sponsorship_note_builder::State> FeeSponsorshipNoteBuilder<S>
177where
178 S::SerialNumber: fee_sponsorship_note_builder::IsUnset,
179{
180 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
189impl From<FeeSponsorshipNote> for Note {
193 fn from(note: FeeSponsorshipNote) -> Self {
194 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#[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 pub const NUM_ITEMS: usize = 7;
225
226 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 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 pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
249 NoteRecipient::new(serial_num, FeeSponsorshipNote::script(), self.into())
250 }
251
252 pub fn feature_note_id(&self) -> NoteId {
254 self.feature_note_id
255 }
256
257 pub fn reclaimer(&self) -> AccountId {
259 self.reclaimer
260 }
261
262 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 let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
272
273 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
320impl NoteConsumptionCost for FeeSponsorshipNote {
324 fn consumption_cycles() -> u32 {
325 FEE_SPONSORSHIP_CONSUMPTION_CYCLES
326 }
327}
328
329#[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 #[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 assert_eq!(¬e.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 #[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 #[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 #[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 const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
461 const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
462
463 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 #[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}