@@ -252,6 +252,35 @@ impl From<PswapNoteAttachment> for NoteAttachment {
252252 }
253253}
254254
255+ /// Parses a [`NoteAttachment`] carrying [`PswapNote::PSWAP_ATTACHMENT_SCHEME`] into its typed
256+ /// form.
257+ impl TryFrom < & NoteAttachment > for PswapNoteAttachment {
258+ type Error = NoteError ;
259+
260+ fn try_from ( attachment : & NoteAttachment ) -> Result < Self , Self :: Error > {
261+ if attachment. attachment_scheme ( ) != PswapNote :: PSWAP_ATTACHMENT_SCHEME {
262+ return Err ( NoteError :: other ( "attachment scheme is not the PSWAP attachment scheme" ) ) ;
263+ }
264+
265+ let [ word] = attachment. content ( ) . as_words ( ) else {
266+ return Err ( NoteError :: other ( "PSWAP attachment must carry exactly one word" ) ) ;
267+ } ;
268+
269+ let amount = AssetAmount :: new ( word[ 0 ] . as_canonical_u64 ( ) )
270+ . map_err ( |e| NoteError :: other_with_source ( "invalid PSWAP attachment amount" , e) ) ?;
271+ let order_id = word[ 1 ] ;
272+ let depth =
273+ u32:: try_from ( word[ PswapNote :: PARENT_ATTACHMENT_DEPTH_OFFSET ] . as_canonical_u64 ( ) )
274+ . map_err ( |_| NoteError :: other ( "PSWAP depth does not fit in u32" ) ) ?;
275+
276+ if word[ 3 ] != ZERO {
277+ return Err ( NoteError :: other ( "PSWAP attachment must be zero-padded" ) ) ;
278+ }
279+
280+ Ok ( Self :: new ( amount, order_id, depth) )
281+ }
282+ }
283+
255284// PSWAP NOTE
256285// ================================================================================================
257286
@@ -292,7 +321,8 @@ where
292321 ///
293322 /// # Errors
294323 ///
295- /// Returns an error if the offered and requested assets have the same faucet ID.
324+ /// Returns an error if the offered and requested assets have the same faucet ID, or if the
325+ /// note carries a malformed [`PswapNote::PSWAP_ATTACHMENT_SCHEME`] attachment.
296326 pub fn build ( self ) -> Result < PswapNote , NoteError > {
297327 let note = self . build_internal ( ) ;
298328
@@ -302,6 +332,12 @@ where
302332 ) ) ;
303333 }
304334
335+ if let Some ( attachment) = note. attachment . as_ref ( )
336+ && attachment. attachment_scheme ( ) == PswapNote :: PSWAP_ATTACHMENT_SCHEME
337+ {
338+ PswapNoteAttachment :: try_from ( attachment) ?;
339+ }
340+
305341 Ok ( note)
306342 }
307343}
@@ -411,14 +447,11 @@ impl PswapNote {
411447 ///
412448 /// The next round's `current_depth` is computed as `parent_depth() + 1`, matching the
413449 /// on-chain `get_current_depth` MASM procedure.
414- pub fn parent_depth ( & self ) -> u64 {
415- match self . attachment . as_ref ( ) {
416- Some ( att) if att. attachment_scheme ( ) == Self :: PSWAP_ATTACHMENT_SCHEME => {
417- let attachment_word = att. content ( ) . as_words ( ) [ 0 ] ;
418- attachment_word[ Self :: PARENT_ATTACHMENT_DEPTH_OFFSET ] . as_canonical_u64 ( )
419- } ,
420- _ => 0 ,
421- }
450+ pub fn parent_depth ( & self ) -> u32 {
451+ self . attachment
452+ . as_ref ( )
453+ . and_then ( |attachment| PswapNoteAttachment :: try_from ( attachment) . ok ( ) )
454+ . map_or ( 0 , |attachment| attachment. depth ( ) )
422455 }
423456
424457 // INSTANCE METHODS
@@ -571,6 +604,22 @@ impl PswapNote {
571604 // LINEAGE DISCOVERY
572605 // --------------------------------------------------------------------------------------------
573606
607+ /// Returns the number of fill rounds between this note and the round `attachment` was
608+ /// stamped in.
609+ ///
610+ /// # Errors
611+ ///
612+ /// Returns an error if the attachment was not stamped in a round after this note.
613+ fn rounds_since ( & self , attachment : & PswapNoteAttachment ) -> Result < u32 , NoteError > {
614+ attachment
615+ . depth ( )
616+ . checked_sub ( self . parent_depth ( ) )
617+ . filter ( |rounds| * rounds > 0 )
618+ . ok_or_else ( || {
619+ NoteError :: other ( "attachment depth must be greater than this note's depth" )
620+ } )
621+ }
622+
574623 /// Reconstructs the depth-`d` payback P2ID [`Note`], so the creator can consume it as an
575624 /// unauthenticated input note.
576625 ///
@@ -580,23 +629,21 @@ impl PswapNote {
580629 ///
581630 /// # Errors
582631 ///
583- /// Returns an error if `attachment.depth() == 0` or if the fill amount is not a valid
584- /// asset amount.
632+ /// Returns an error if the attachment's depth is not greater than this note's depth,
633+ /// or if the attachment's fill amount is not a valid fungible asset amount.
585634 pub fn payback_note (
586635 & self ,
587636 consumer_account_id : AccountId ,
588637 attachment : & PswapNoteAttachment ,
589638 ) -> Result < Note , NoteError > {
590- let depth = attachment. depth ( ) ;
591- if depth == 0 {
592- return Err ( NoteError :: other ( "depth must be >= 1" ) ) ;
593- }
594- let parent_depth = Felt :: from ( depth - 1 ) ;
639+ // Payback serial = consumed PSWAP's serial (last element bumped `rounds - 1`
640+ // times from this note's) with the first element incremented by one.
641+ let rounds = self . rounds_since ( attachment) ?;
595642 let p2id_serial = Word :: from ( [
596643 self . serial_number [ 0 ] + ONE ,
597644 self . serial_number [ 1 ] ,
598645 self . serial_number [ 2 ] ,
599- self . serial_number [ 3 ] + parent_depth ,
646+ self . serial_number [ 3 ] + Felt :: from ( rounds - 1 ) ,
600647 ] ) ;
601648
602649 let recipient =
@@ -634,24 +681,22 @@ impl PswapNote {
634681 ///
635682 /// # Errors
636683 ///
637- /// Returns an error if `attachment.depth() == 0` or if any amount is not a valid asset
638- /// amount.
684+ /// Returns an error if `attachment` was not stamped in a round after this note, or if any
685+ /// amount is not a valid asset amount .
639686 pub fn remainder_note (
640687 & self ,
641688 consumer_account_id : AccountId ,
642689 attachment : & PswapNoteAttachment ,
643690 remaining_offered : AssetAmount ,
644691 remaining_requested : AssetAmount ,
645692 ) -> Result < Note , NoteError > {
646- let depth = attachment. depth ( ) ;
647- if depth == 0 {
648- return Err ( NoteError :: other ( "depth must be >= 1" ) ) ;
649- }
693+ // Every round bumps the remainder's serial once, so the offset is the round distance.
694+ let rounds = self . rounds_since ( attachment) ?;
650695 let remainder_serial = Word :: from ( [
651696 self . serial_number [ 0 ] ,
652697 self . serial_number [ 1 ] ,
653698 self . serial_number [ 2 ] ,
654- self . serial_number [ 3 ] + Felt :: from ( depth ) ,
699+ self . serial_number [ 3 ] + Felt :: from ( rounds ) ,
655700 ] ) ;
656701
657702 let min_requested_asset =
@@ -794,7 +839,7 @@ impl PswapNote {
794839 let recipient =
795840 P2idNoteStorage :: new ( self . storage . creator_account_id ) . into_recipient ( p2id_serial_num) ;
796841
797- let current_depth = self . parent_depth ( ) + 1 ;
842+ let current_depth = u64 :: from ( self . parent_depth ( ) ) + 1 ;
798843 let attachment =
799844 Self :: pswap_output_attachment ( fill_amount, self . order_id ( ) , current_depth) ?;
800845
@@ -843,7 +888,7 @@ impl PswapNote {
843888 self . serial_number [ 3 ] + ONE ,
844889 ] ) ;
845890
846- let current_depth = self . parent_depth ( ) + 1 ;
891+ let current_depth = u64 :: from ( self . parent_depth ( ) ) + 1 ;
847892 let attachment =
848893 Self :: pswap_output_attachment ( offered_amount_for_fill, self . order_id ( ) , current_depth) ?;
849894
@@ -1281,4 +1326,83 @@ mod tests {
12811326 // Full fill → no remainder note.
12821327 assert ! ( remainder. is_none( ) , "full fill must not produce a remainder" ) ;
12831328 }
1329+
1330+ /// A depth outside the u32 range the on-chain script enforces must be rejected when the
1331+ /// note is built, and therefore also when a protocol note is decoded back into a
1332+ /// [`PswapNote`].
1333+ #[ rstest]
1334+ #[ case:: above_u32( Felt :: new_unchecked( u64 :: from( u32 :: MAX ) + 1 ) ) ]
1335+ #[ case:: wraps_the_field( Felt :: MAX ) ]
1336+ fn pswap_rejects_out_of_range_attachment_depth ( #[ case] depth : Felt ) {
1337+ let creator_id = dummy_creator_id ( ) ;
1338+ let offered_asset = FungibleAsset :: new ( dummy_faucet_id ( 0xaa ) , 100 ) . unwrap ( ) ;
1339+ let min_requested_asset = FungibleAsset :: new ( dummy_faucet_id ( 0xbb ) , 50 ) . unwrap ( ) ;
1340+
1341+ let storage = PswapNoteStorage :: builder ( )
1342+ . min_requested_asset ( min_requested_asset)
1343+ . creator_account_id ( creator_id)
1344+ . build ( ) ;
1345+ let attachment = NoteAttachment :: with_word (
1346+ PswapNote :: PSWAP_ATTACHMENT_SCHEME ,
1347+ Word :: from ( [ ONE , ONE , depth, ZERO ] ) ,
1348+ ) ;
1349+
1350+ let result = PswapNote :: builder ( )
1351+ . sender ( creator_id)
1352+ . storage ( storage)
1353+ . serial_number ( RandomCoin :: new ( Word :: default ( ) ) . draw_word ( ) )
1354+ . note_type ( NoteType :: Public )
1355+ . offered_asset ( offered_asset)
1356+ . attachment ( attachment)
1357+ . build ( ) ;
1358+
1359+ assert ! ( result. is_err( ) , "an out-of-range depth must not build a PswapNote" ) ;
1360+ }
1361+
1362+ /// The lineage helpers offset the serial number by the distance between the note they are
1363+ /// called on and the attachment's round, so a note that itself sits at a non-zero depth
1364+ /// reconstructs the same round as the original does.
1365+ #[ test]
1366+ fn pswap_lineage_helpers_are_relative_to_the_parent_depth ( ) {
1367+ let creator_id = dummy_creator_id ( ) ;
1368+ let consumer_id = dummy_consumer_id ( ) ;
1369+ let offered_faucet = dummy_faucet_id ( 0xaa ) ;
1370+ let requested_faucet = dummy_faucet_id ( 0xbb ) ;
1371+
1372+ let offered_asset = FungibleAsset :: new ( offered_faucet, 100 ) . unwrap ( ) ;
1373+ let min_requested_asset = FungibleAsset :: new ( requested_faucet, 50 ) . unwrap ( ) ;
1374+ let ( original, _) = build_pswap_note ( offered_asset, min_requested_asset, creator_id) ;
1375+
1376+ // Round 1 leaves a remainder sitting at depth 1, which round 2 then consumes.
1377+ let fill = FungibleAsset :: new ( requested_faucet, 20 ) . unwrap ( ) ;
1378+ let ( _, remainder) = original. execute ( consumer_id, Some ( fill) , None ) . unwrap ( ) ;
1379+ let remainder = remainder. expect ( "partial fill should produce a remainder" ) ;
1380+ assert_eq ! ( remainder. parent_depth( ) , 1 ) ;
1381+
1382+ let ( round_two_payback, _) = remainder. execute ( consumer_id, Some ( fill) , None ) . unwrap ( ) ;
1383+ let round_one_attachment = PswapNoteAttachment :: try_from (
1384+ remainder. attachments ( ) . expect ( "remainder carries an attachment" ) ,
1385+ )
1386+ . unwrap ( ) ;
1387+ let round_two_attachment = PswapNoteAttachment :: new (
1388+ AssetAmount :: new ( 20 ) . unwrap ( ) ,
1389+ round_one_attachment. order_id ( ) ,
1390+ 2 ,
1391+ ) ;
1392+
1393+ assert_eq ! (
1394+ original. payback_note( consumer_id, & round_two_attachment) . unwrap( ) . id( ) ,
1395+ round_two_payback. id( ) ,
1396+ "the original must reconstruct round 2 from its absolute depth" ,
1397+ ) ;
1398+ assert_eq ! (
1399+ remainder. payback_note( consumer_id, & round_two_attachment) . unwrap( ) . id( ) ,
1400+ round_two_payback. id( ) ,
1401+ "the round's own parent must reconstruct it as well" ,
1402+ ) ;
1403+ assert ! (
1404+ remainder. payback_note( consumer_id, & round_one_attachment) . is_err( ) ,
1405+ "an attachment from the parent's own round is not a later round" ,
1406+ ) ;
1407+ }
12841408}
0 commit comments