Skip to content

Commit fcbb257

Browse files
authored
fix(standards): bound the PSWAP lineage depth to u32 (#3777)
* fix(standards): bound the PSWAP lineage depth increment to u32 * fix(standards): validate the PSWAP attachment and drop the zero-depth assumption * test(standards): cover the PSWAP depth guards and relative lineage reconstruction * changelog * changelog * fix comments * fix comments
1 parent d7eef37 commit fcbb257

4 files changed

Lines changed: 328 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
- Fixed the authentication procedure not ending up at index 0 of an account's code when its MAST root was already exported by another component ([#3566](https://github.com/0xMiden/protocol/pull/3566)).
7373
- [BREAKING] Foreign procedure invocation now requires the provided procedure root to be part of the foreign account's code, so a caller can no longer execute arbitrary code under a foreign account's identity ([#3575](https://github.com/0xMiden/protocol/pull/3575)).
7474
- [BREAKING] Added canonical enforcement for `RoleSymbol` encodings in the `RBAC` entrypoints ([#3524](https://github.com/0xMiden/protocol/pull/3524)).
75+
- The PSWAP note script now bounds its lineage depth to a u32 and the `PswapNote` builder rejects a malformed `PswapAttachment` [#3777](https://github.com/0xMiden/protocol/pull/3777).
7576
- Verified each input note's storage-item count and preimage against its authenticated storage commitment ([#3593](https://github.com/0xMiden/protocol/issues/3593)).
7677
- Fixed `PrivateOutputNote` construction and deserialization accepting attachment data that is not committed by the note header ([#3579](https://github.com/0xMiden/protocol/pull/3579)).
7778
- Fixed `input_note::remove_asset` leaving a dangling asset slot when a non-canonical fungible value produced an empty removal remainder ([#3606](https://github.com/0xMiden/protocol/pull/3606), [#3755](https://github.com/0xMiden/protocol/pull/3755)).

crates/miden-standards/asm/standards/notes/pswap.masm

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const ERR_PSWAP_NOT_VALID_ASSET_AMOUNT="PSWAP computed amount exceeds max fungib
113113
const ERR_PSWAP_PAYOUT_OVERFLOW="PSWAP payout quotient does not fit in u64"
114114
const ERR_PSWAP_FILL_BELOW_MINIMUM="PSWAP fill amount is below the minimum fill step"
115115
const ERR_PSWAP_ATTACHMENT_INCORRECT_NUMBER_OF_WORDS="PSWAP attachment must consist of exactly one word"
116+
const ERR_PSWAP_PARENT_DEPTH_NOT_U32="PSWAP parent depth carried in the consumed note attachment is not a u32"
117+
const ERR_PSWAP_DEPTH_OVERFLOW="PSWAP lineage depth exceeds u32"
116118

117119
# U64 VALIDATION
118120
# =================================================================================================
@@ -552,6 +554,8 @@ end
552554
#!
553555
#! Panics if:
554556
#! - the PswapAttachment does not consist of exactly PSWAP_ATTACHMENT_NUM_WORDS words.
557+
#! - the parent depth is not a u32.
558+
#! - the incremented depth overflows a u32.
555559
@locals(4)
556560
proc get_current_depth
557561
push.PSWAP_ATTACHMENT_SCHEME
@@ -579,7 +583,13 @@ proc get_current_depth
579583
end
580584
# => [parent_depth]
581585

582-
add.1
586+
u32assert.err=ERR_PSWAP_PARENT_DEPTH_NOT_U32
587+
# => [parent_depth]
588+
589+
u32overflowing_add.1
590+
# => [is_overflow, current_depth]
591+
592+
assertz.err=ERR_PSWAP_DEPTH_OVERFLOW
583593
# => [current_depth]
584594
end
585595

crates/miden-standards/src/note/pswap.rs

Lines changed: 150 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)