Skip to main content

miden_standards/note/
p2ide.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    NoteAttachment,
13    NoteAttachments,
14    NoteRecipient,
15    NoteScript,
16    NoteScriptRoot,
17    NoteStorage,
18    NoteTag,
19    NoteType,
20    PartialNoteMetadata,
21};
22use miden_protocol::utils::sync::LazyLock;
23use miden_protocol::{Felt, Word};
24
25use crate::StandardsLib;
26// NOTE SCRIPT
27// ================================================================================================
28
29/// Path to the P2IDE note script procedure in the standards library.
30const P2IDE_SCRIPT_PATH: &str = "::miden::standards::notes::p2ide::main";
31
32// Initialize the P2IDE note script only once
33static P2IDE_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
34    let standards_lib = StandardsLib::default();
35    let path = Path::new(P2IDE_SCRIPT_PATH);
36    NoteScript::from_library_reference(standards_lib.as_ref(), path)
37        .expect("Standards library contains P2IDE note script procedure")
38});
39
40// P2IDE NOTE
41// ================================================================================================
42
43/// Pay-to-ID Extended (P2IDE) note abstraction.
44///
45/// A P2IDE note enables transferring assets to a target account specified in the note storage.
46/// The note may optionally include:
47///
48/// - A reclaim height allowing the reclaimer to recover assets if the note remains unconsumed
49/// - A timelock height preventing consumption before a given block
50///
51/// These constraints are encoded in `P2ideNoteStorage` and enforced by the associated note script.
52#[derive(Debug, Clone)]
53pub struct P2ideNote {
54    sender: AccountId,
55    storage: P2ideNoteStorage,
56    serial_number: Word,
57    note_type: NoteType,
58    assets: NoteAssets,
59    attachments: NoteAttachments,
60}
61
62#[bon::bon]
63impl P2ideNote {
64    /// Builds a new [`P2ideNote`].
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if:
69    /// - No assets were provided.
70    /// - The assets or attachments exceed their protocol limits (see [`NoteAssets::new`] and
71    ///   [`NoteAttachments::new`]).
72    #[builder]
73    pub fn new(
74        #[builder(field)] assets: Vec<Asset>,
75        #[builder(field)] attachments: Vec<NoteAttachment>,
76        sender: AccountId,
77        target: AccountId,
78        reclaimer: Option<AccountId>,
79        reclaim_height: Option<BlockNumber>,
80        timelock_height: Option<BlockNumber>,
81        serial_number: Word,
82        #[builder(default)] note_type: NoteType,
83    ) -> Result<Self, NoteError> {
84        if assets.is_empty() {
85            return Err(NoteError::other("a P2IDE note must contain at least one asset"));
86        }
87
88        // The reclaimer is the account allowed to reclaim the note; it defaults to the sender.
89        let reclaimer = reclaimer.unwrap_or(sender);
90        let storage = P2ideNoteStorage::new(reclaimer, target, reclaim_height, timelock_height);
91        let assets = NoteAssets::new(assets)?;
92        let attachments = NoteAttachments::new(attachments)?;
93
94        Ok(Self {
95            sender,
96            storage,
97            serial_number,
98            note_type,
99            assets,
100            attachments,
101        })
102    }
103}
104
105impl P2ideNote {
106    // CONSTANTS
107    // --------------------------------------------------------------------------------------------
108
109    /// Expected number of storage items of the P2IDE note.
110    pub const NUM_STORAGE_ITEMS: usize = P2ideNoteStorage::NUM_ITEMS;
111
112    // PUBLIC ACCESSORS
113    // --------------------------------------------------------------------------------------------
114
115    /// Returns the script of the P2IDE (Pay-to-ID extended) note.
116    pub fn script() -> NoteScript {
117        P2IDE_SCRIPT.clone()
118    }
119
120    /// Returns the P2IDE (Pay-to-ID extended) note script root.
121    pub fn script_root() -> NoteScriptRoot {
122        P2IDE_SCRIPT.root()
123    }
124
125    /// Returns the account ID of the note's sender.
126    pub fn sender(&self) -> AccountId {
127        self.sender
128    }
129
130    /// Returns the note's storage.
131    pub fn storage(&self) -> P2ideNoteStorage {
132        self.storage
133    }
134
135    /// Returns the account ID of the note's target (the only account that can consume it).
136    pub fn target(&self) -> AccountId {
137        self.storage.target()
138    }
139
140    /// Returns the account ID of the note's reclaimer.
141    pub fn reclaimer(&self) -> AccountId {
142        self.storage.reclaimer()
143    }
144
145    /// Returns the reclaim block height (if any).
146    pub fn reclaim_height(&self) -> Option<BlockNumber> {
147        self.storage.reclaim_height()
148    }
149
150    /// Returns the timelock block height (if any).
151    pub fn timelock_height(&self) -> Option<BlockNumber> {
152        self.storage.timelock_height()
153    }
154
155    /// Returns the note's serial number.
156    pub fn serial_number(&self) -> Word {
157        self.serial_number
158    }
159
160    /// Returns the note's type.
161    pub fn note_type(&self) -> NoteType {
162        self.note_type
163    }
164
165    /// Returns the assets carried by the note.
166    pub fn assets(&self) -> &NoteAssets {
167        &self.assets
168    }
169
170    /// Returns the attachments carried by the note.
171    pub fn attachments(&self) -> &NoteAttachments {
172        &self.attachments
173    }
174}
175
176// BUILDER EXTENSIONS
177// ================================================================================================
178
179impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S> {
180    /// Adds a single asset to the note. At least one asset is required for `.build()` to succeed.
181    pub fn asset(mut self, asset: impl Into<Asset>) -> Self {
182        self.assets.push(asset.into());
183        self
184    }
185
186    /// Adds multiple assets to the note.
187    pub fn assets(mut self, assets: impl IntoIterator<Item = impl Into<Asset>>) -> Self {
188        self.assets.extend(assets.into_iter().map(Into::into));
189        self
190    }
191
192    /// Adds a single attachment to the note.
193    pub fn attachment(mut self, attachment: impl Into<NoteAttachment>) -> Self {
194        self.attachments.push(attachment.into());
195        self
196    }
197
198    /// Adds multiple attachments to the note.
199    pub fn attachments(
200        mut self,
201        attachments: impl IntoIterator<Item = impl Into<NoteAttachment>>,
202    ) -> Self {
203        self.attachments.extend(attachments.into_iter().map(Into::into));
204        self
205    }
206}
207
208impl<S: p2ide_note_builder::State> P2ideNoteBuilder<S>
209where
210    S::SerialNumber: p2ide_note_builder::IsUnset,
211{
212    /// Draws a serial number from `rng` and sets it on the builder.
213    pub fn generate_serial_number(
214        self,
215        rng: &mut impl FeltRng,
216    ) -> P2ideNoteBuilder<p2ide_note_builder::SetSerialNumber<S>> {
217        self.serial_number(rng.draw_word())
218    }
219}
220
221// CONVERSIONS
222// ================================================================================================
223
224impl From<P2ideNote> for Note {
225    fn from(note: P2ideNote) -> Self {
226        let recipient = note.storage.into_recipient(note.serial_number);
227        let tag = NoteTag::with_account_target(note.storage.target());
228        let metadata = PartialNoteMetadata::new(note.sender, note.note_type).with_tag(tag);
229
230        Note::with_attachments(note.assets, metadata, recipient, note.attachments)
231    }
232}
233
234// P2IDE NOTE STORAGE
235// ================================================================================================
236
237/// Canonical storage representation for a P2IDE note.
238///
239/// Stores the reclaimer account ID and the target account ID together with optional reclaim
240/// and timelock constraints controlling when the note can be spent or reclaimed.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct P2ideNoteStorage {
243    reclaimer: AccountId,
244    target: AccountId,
245    reclaim_height: Option<BlockNumber>,
246    timelock_height: Option<BlockNumber>,
247}
248
249impl P2ideNoteStorage {
250    // CONSTANTS
251    // --------------------------------------------------------------------------------------------
252
253    /// Expected number of storage items of the P2IDE note.
254    pub const NUM_ITEMS: usize = 6;
255
256    // Indices of the storage items. Must match the `*_ITEM` offsets from `STORAGE_PTR` in
257    // `asm/standards/notes/p2ide.masm`.
258    const RECLAIMER_SUFFIX_IDX: usize = 0;
259    const RECLAIMER_PREFIX_IDX: usize = 1;
260    const TARGET_SUFFIX_IDX: usize = 2;
261    const TARGET_PREFIX_IDX: usize = 3;
262    const RECLAIM_HEIGHT_IDX: usize = 4;
263    const TIMELOCK_HEIGHT_IDX: usize = 5;
264
265    /// Creates new P2IDE note storage.
266    pub fn new(
267        reclaimer: AccountId,
268        target: AccountId,
269        reclaim_height: Option<BlockNumber>,
270        timelock_height: Option<BlockNumber>,
271    ) -> Self {
272        Self {
273            reclaimer,
274            target,
275            reclaim_height,
276            timelock_height,
277        }
278    }
279
280    /// Consumes the storage and returns a P2IDE [`NoteRecipient`] with the provided serial number.
281    pub fn into_recipient(self, serial_num: Word) -> NoteRecipient {
282        NoteRecipient::new(serial_num, P2ideNote::script(), self.into())
283    }
284
285    /// Returns the reclaimer account ID.
286    pub fn reclaimer(&self) -> AccountId {
287        self.reclaimer
288    }
289
290    /// Returns the target account ID.
291    pub fn target(&self) -> AccountId {
292        self.target
293    }
294
295    /// Returns the reclaim block height (if any).
296    pub fn reclaim_height(&self) -> Option<BlockNumber> {
297        self.reclaim_height
298    }
299
300    /// Returns the timelock block height (if any).
301    pub fn timelock_height(&self) -> Option<BlockNumber> {
302        self.timelock_height
303    }
304}
305
306impl From<P2ideNoteStorage> for NoteStorage {
307    fn from(storage: P2ideNoteStorage) -> Self {
308        // an absent height is encoded as zero
309        let reclaim = storage.reclaim_height.map_or(Felt::ZERO, Felt::from);
310        let timelock = storage.timelock_height.map_or(Felt::ZERO, Felt::from);
311
312        // the item order must match the `*_IDX` constants that `try_from` decodes with
313        NoteStorage::new(vec![
314            storage.reclaimer.suffix(),
315            storage.reclaimer.prefix().as_felt(),
316            storage.target.suffix(),
317            storage.target.prefix().as_felt(),
318            reclaim,
319            timelock,
320        ])
321        .expect("number of storage items should not exceed max storage items")
322    }
323}
324
325impl TryFrom<&[Felt]> for P2ideNoteStorage {
326    type Error = NoteError;
327
328    fn try_from(note_storage: &[Felt]) -> Result<Self, Self::Error> {
329        if note_storage.len() != P2ideNote::NUM_STORAGE_ITEMS {
330            return Err(NoteError::InvalidNoteStorageLength {
331                expected: P2ideNote::NUM_STORAGE_ITEMS,
332                actual: note_storage.len(),
333            });
334        }
335
336        let reclaimer = AccountId::try_from_elements(
337            note_storage[Self::RECLAIMER_SUFFIX_IDX],
338            note_storage[Self::RECLAIMER_PREFIX_IDX],
339        )
340        .map_err(|err| {
341            NoteError::other_with_source("failed to create reclaimer account id", err)
342        })?;
343
344        let target = AccountId::try_from_elements(
345            note_storage[Self::TARGET_SUFFIX_IDX],
346            note_storage[Self::TARGET_PREFIX_IDX],
347        )
348        .map_err(|err| NoteError::other_with_source("failed to create target account id", err))?;
349
350        let reclaim_height = decode_block_height(
351            note_storage[Self::RECLAIM_HEIGHT_IDX],
352            "invalid reclaim height in note storage",
353        )?;
354        let timelock_height = decode_block_height(
355            note_storage[Self::TIMELOCK_HEIGHT_IDX],
356            "invalid timelock height in note storage",
357        )?;
358
359        Ok(Self {
360            reclaimer,
361            target,
362            reclaim_height,
363            timelock_height,
364        })
365    }
366}
367
368/// Decodes an optional block height stored as a single storage item, where zero encodes `None`.
369///
370/// `error_msg` names the field being decoded so that a caller can tell the heights apart.
371fn decode_block_height(
372    item: Felt,
373    error_msg: &'static str,
374) -> Result<Option<BlockNumber>, NoteError> {
375    if item == Felt::ZERO {
376        return Ok(None);
377    }
378
379    let height: u32 = item
380        .as_canonical_u64()
381        .try_into()
382        .map_err(|e| NoteError::other_with_source(error_msg, e))?;
383
384    Ok(Some(BlockNumber::from(height)))
385}
386
387// TESTS
388// ================================================================================================
389
390#[cfg(test)]
391mod tests {
392    use assert_matches::assert_matches;
393    use miden_protocol::account::{AccountId, AccountType};
394    use miden_protocol::asset::FungibleAsset;
395    use miden_protocol::block::BlockNumber;
396    use miden_protocol::crypto::rand::RandomCoin;
397    use miden_protocol::errors::NoteError;
398    use miden_protocol::{Felt, Word};
399
400    use super::*;
401
402    // The suffix and prefix of an ID that `AccountId::try_from_elements` rejects. Both felts are
403    // individually invalid, but the prefix's version check runs first, so that is the error the
404    // pair produces: the version is the prefix's least significant nibble, and `888 & 0xf == 8` is
405    // not a known version.
406    const INVALID_ID_SUFFIX: Felt = Felt::new_unchecked(999);
407    const INVALID_ID_PREFIX: Felt = Felt::new_unchecked(888);
408
409    fn dummy_account() -> AccountId {
410        AccountId::builder()
411            .account_type(AccountType::Private)
412            .build_with_seed([3u8; 32])
413    }
414
415    // STORAGE TESTS
416    // --------------------------------------------------------------------------------------------
417
418    #[test]
419    fn try_from_valid_storage_with_all_fields_succeeds() {
420        let reclaimer = sender();
421        let target = dummy_account();
422
423        let storage = vec![
424            reclaimer.suffix(),
425            reclaimer.prefix().as_felt(),
426            target.suffix(),
427            target.prefix().as_felt(),
428            Felt::from(42u32),
429            Felt::from(100u32),
430        ];
431
432        let decoded = P2ideNoteStorage::try_from(storage.as_slice())
433            .expect("valid P2IDE storage should decode");
434
435        assert_eq!(decoded.reclaimer(), reclaimer);
436        assert_eq!(decoded.target(), target);
437        assert_eq!(decoded.reclaim_height(), Some(BlockNumber::from(42u32)));
438        assert_eq!(decoded.timelock_height(), Some(BlockNumber::from(100u32)));
439    }
440
441    #[test]
442    fn try_from_zero_heights_map_to_none() {
443        let reclaimer = sender();
444        let target = dummy_account();
445
446        let storage = vec![
447            reclaimer.suffix(),
448            reclaimer.prefix().as_felt(),
449            target.suffix(),
450            target.prefix().as_felt(),
451            Felt::ZERO,
452            Felt::ZERO,
453        ];
454
455        let decoded = P2ideNoteStorage::try_from(storage.as_slice()).unwrap();
456
457        assert_eq!(decoded.reclaim_height(), None);
458        assert_eq!(decoded.timelock_height(), None);
459    }
460
461    #[test]
462    fn try_from_invalid_length_fails() {
463        let storage = vec![Felt::ZERO; 3];
464
465        let err =
466            P2ideNoteStorage::try_from(storage.as_slice()).expect_err("wrong length must fail");
467
468        assert!(matches!(
469            err,
470            NoteError::InvalidNoteStorageLength {
471                expected: P2ideNote::NUM_STORAGE_ITEMS,
472                actual: 3
473            }
474        ));
475    }
476
477    /// The reclaimer and the target are decoded from different storage items, so each must
478    /// be validated on its own.
479    #[test]
480    fn try_from_invalid_reclaimer_fails() {
481        let target = dummy_account();
482
483        let storage = vec![
484            INVALID_ID_SUFFIX,
485            INVALID_ID_PREFIX,
486            target.suffix(),
487            target.prefix().as_felt(),
488            Felt::ZERO,
489            Felt::ZERO,
490        ];
491
492        let err = P2ideNoteStorage::try_from(storage.as_slice())
493            .expect_err("invalid reclaimer encoding must fail");
494
495        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
496            assert!(error_msg.contains("reclaimer"));
497        });
498    }
499
500    #[test]
501    fn try_from_invalid_target_fails() {
502        let reclaimer = sender();
503
504        let storage = vec![
505            reclaimer.suffix(),
506            reclaimer.prefix().as_felt(),
507            INVALID_ID_SUFFIX,
508            INVALID_ID_PREFIX,
509            Felt::ZERO,
510            Felt::ZERO,
511        ];
512
513        let err = P2ideNoteStorage::try_from(storage.as_slice())
514            .expect_err("invalid target encoding must fail");
515
516        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
517            assert!(error_msg.contains("target"));
518        });
519    }
520
521    /// The encoder and the decoder must agree on the item order. This does not pin the order to
522    /// `p2ide.masm` - a transposition applied to both halves round-trips fine. That contract is
523    /// held by the hand-built storage vectors in the `try_from_*` tests above, which spell the
524    /// layout out literally, and by the note script execution tests in `miden-testing`.
525    ///
526    /// Zero means "disabled" rather than a height, so it is excluded here, see
527    /// [`zero_reclaim_height_means_reclaim_disabled`].
528    #[test]
529    fn storage_round_trips_through_note_storage() {
530        let storage = P2ideNoteStorage::new(
531            sender(),
532            target(),
533            Some(BlockNumber::from(42u32)),
534            Some(BlockNumber::from(100u32)),
535        );
536
537        let encoded: NoteStorage = storage.into();
538        let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
539
540        assert_eq!(decoded, storage);
541    }
542
543    /// A zero reclaim height means "reclaim disabled", both in the storage encoding and in the note
544    /// script, which rejects it with `ERR_P2IDE_RECLAIM_DISABLED`. Zero is thus not a height, and
545    /// `Some(BlockNumber::GENESIS)` encodes identically to `None`.
546    #[test]
547    fn zero_reclaim_height_means_reclaim_disabled() {
548        let storage = P2ideNoteStorage::new(sender(), target(), Some(BlockNumber::GENESIS), None);
549
550        let encoded: NoteStorage = storage.into();
551        let decoded = P2ideNoteStorage::try_from(encoded.items()).unwrap();
552
553        assert_eq!(decoded.reclaim_height(), None);
554    }
555
556    #[test]
557    fn try_from_reclaim_height_overflow_fails() {
558        let reclaimer = sender();
559        let target = dummy_account();
560
561        // > u32::MAX
562        let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 1);
563
564        let storage = vec![
565            reclaimer.suffix(),
566            reclaimer.prefix().as_felt(),
567            target.suffix(),
568            target.prefix().as_felt(),
569            overflow,
570            Felt::ZERO,
571        ];
572
573        let err = P2ideNoteStorage::try_from(storage.as_slice())
574            .expect_err("overflow reclaim height must fail");
575
576        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
577            assert!(error_msg.contains("reclaim height"));
578        });
579    }
580
581    #[test]
582    fn try_from_timelock_height_overflow_fails() {
583        let reclaimer = sender();
584        let target = dummy_account();
585
586        let overflow = Felt::new_unchecked(u64::from(u32::MAX) + 10);
587
588        let storage = vec![
589            reclaimer.suffix(),
590            reclaimer.prefix().as_felt(),
591            target.suffix(),
592            target.prefix().as_felt(),
593            Felt::ZERO,
594            overflow,
595        ];
596
597        let err = P2ideNoteStorage::try_from(storage.as_slice())
598            .expect_err("overflow timelock height must fail");
599
600        assert_matches!(err, NoteError::Other { error_msg, source: Some(_), .. } => {
601            assert!(error_msg.contains("timelock height"));
602        });
603    }
604
605    // BUILDER TESTS
606    // --------------------------------------------------------------------------------------------
607
608    fn sender() -> AccountId {
609        AccountId::builder()
610            .account_type(AccountType::Private)
611            .build_with_seed([1u8; 32])
612    }
613
614    fn target() -> AccountId {
615        AccountId::builder()
616            .account_type(AccountType::Private)
617            .build_with_seed([2u8; 32])
618    }
619
620    fn faucet_a() -> AccountId {
621        AccountId::builder()
622            .account_type(AccountType::Public)
623            .build_with_seed([3u8; 32])
624    }
625
626    fn faucet_b() -> AccountId {
627        AccountId::builder()
628            .account_type(AccountType::Public)
629            .build_with_seed([4u8; 32])
630    }
631
632    /// The minimal builder uses defaults for everything but the required fields (no reclaim or
633    /// timelock height, private note type).
634    #[test]
635    fn builder_minimal_uses_defaults() {
636        let note = P2ideNote::builder()
637            .sender(sender())
638            .target(target())
639            .serial_number(Word::empty())
640            .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
641            .build()
642            .unwrap();
643
644        assert_eq!(note.sender(), sender());
645        assert_eq!(note.target(), target());
646        // the reclaimer defaults to the sender when not set explicitly
647        assert_eq!(note.reclaimer(), sender());
648        assert_eq!(note.note_type(), NoteType::default());
649        assert_eq!(note.reclaim_height(), None);
650        assert_eq!(note.timelock_height(), None);
651        assert_eq!(note.assets().num_assets(), 1);
652        assert_eq!(note.attachments().num_attachments(), 0);
653    }
654
655    /// `.asset()` and `.assets()` both append, so they can be combined and called repeatedly.
656    #[test]
657    fn builder_accumulates_assets() {
658        let mut rng = RandomCoin::new(Word::empty());
659        let note = P2ideNote::builder()
660            .sender(sender())
661            .target(target())
662            .asset(FungibleAsset::new(faucet_a(), 100).unwrap())
663            .assets([Asset::from(FungibleAsset::new(faucet_b(), 200).unwrap())])
664            .generate_serial_number(&mut rng)
665            .build()
666            .unwrap();
667
668        assert_eq!(note.assets().num_assets(), 2);
669        assert_ne!(note.serial_number(), Word::empty());
670    }
671
672    /// A P2IDE note must carry at least one asset.
673    #[test]
674    fn builder_rejects_empty_assets() {
675        let err = P2ideNote::builder()
676            .sender(sender())
677            .target(target())
678            .serial_number(Word::empty())
679            .build()
680            .expect_err("a note without assets must be rejected");
681
682        assert_matches!(err, NoteError::Other { error_msg, .. } => {
683            assert!(error_msg.contains("note must contain at least one asset"))
684        });
685    }
686
687    /// The reclaim and timelock heights are optional and surfaced through the getters.
688    #[test]
689    fn builder_sets_reclaim_and_timelock() {
690        let note = P2ideNote::builder()
691            .sender(sender())
692            .target(target())
693            .serial_number(Word::empty())
694            .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
695            .reclaim_height(BlockNumber::from(42u32))
696            .timelock_height(BlockNumber::from(100u32))
697            .build()
698            .unwrap();
699
700        assert_eq!(note.reclaim_height(), Some(BlockNumber::from(42u32)));
701        assert_eq!(note.timelock_height(), Some(BlockNumber::from(100u32)));
702    }
703
704    /// An explicit reclaimer (distinct from the sender) is stored and surfaced via
705    /// `reclaimer()`, and round-trips through the note storage.
706    #[test]
707    fn builder_explicit_reclaimer_differs_from_sender() {
708        let note = P2ideNote::builder()
709            .sender(sender())
710            .target(target())
711            .reclaimer(dummy_account())
712            .serial_number(Word::empty())
713            .asset(FungibleAsset::new(faucet_a(), 1).unwrap())
714            .build()
715            .unwrap();
716
717        assert_eq!(note.sender(), sender());
718        assert_eq!(note.reclaimer(), dummy_account());
719        assert_ne!(note.reclaimer(), note.sender());
720
721        // the explicit reclaimer round-trips through the encoded note storage
722        let storage: NoteStorage = note.storage().into();
723        let decoded = P2ideNoteStorage::try_from(storage.items()).unwrap();
724        assert_eq!(decoded.reclaimer(), dummy_account());
725        assert_eq!(decoded.target(), target());
726    }
727}