Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@

### Fixes

- `SwapNote::new` and `PswapNoteBuilder::build` now reject a zero-amount asset on either side of the exchange. A zero requested asset produced a payback note carrying nothing, and a zero offered asset produced a note whose consumer pays and receives nothing ([#3840](https://github.com/0xMiden/protocol/pull/3840)).
- [BREAKING] `NetworkAccountTarget` decoding no longer discards the target account ID when the execution hint slot holds an unrecognized encoding ([#3811](https://github.com/0xMiden/protocol/pull/3811)).
- Fixed `AuthNetworkAccount` accepting empty fee-only transactions, which let callers drain the account's native fee-asset vault ([#3729](https://github.com/0xMiden/protocol/pull/3729)).
- [BREAKING] AggLayer bridge token registration now rejects keys owned by another faucet, and token-key cleanup verifies ownership before clearing a mapping ([#3754](https://github.com/0xMiden/protocol/pull/3754)).
Expand Down
52 changes: 50 additions & 2 deletions crates/miden-standards/src/note/pswap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,9 @@ where
///
/// # Errors
///
/// Returns an error if the offered and requested assets have the same faucet ID, or if the
/// note carries a malformed [`PswapNote::PSWAP_ATTACHMENT_SCHEME`] attachment.
/// Returns an error if the offered and requested assets have the same faucet ID, if either of
/// them is zero, or if the note carries a malformed
/// [`PswapNote::PSWAP_ATTACHMENT_SCHEME`] attachment.
pub fn build(self) -> Result<PswapNote, NoteError> {
let note = self.build_internal();

Expand All @@ -332,6 +333,13 @@ where
));
}

if note.offered_asset.amount() == AssetAmount::ZERO {
return Err(NoteError::other("offered asset must be non-zero"));
}
if note.storage.min_requested_asset().amount() == AssetAmount::ZERO {
return Err(NoteError::other("requested asset must be non-zero"));
}

if let Some(attachment) = note.attachment.as_ref()
&& attachment.attachment_scheme() == PswapNote::PSWAP_ATTACHMENT_SCHEME
{
Expand Down Expand Up @@ -989,6 +997,7 @@ impl NoteConsumptionCost for PswapNote {

#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use miden_protocol::account::{AccountId, AccountIdVersion, AccountType, AssetCallbackFlag};
use miden_protocol::asset::FungibleAsset;
use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
Expand Down Expand Up @@ -1405,4 +1414,43 @@ mod tests {
"an attachment from the parent's own round is not a later round",
);
}

/// A PSWAP note holds the offered asset and its payback holds the requested one, so a zero
/// amount on either side leaves one of those two notes empty.
#[test]
fn pswap_note_builder_rejects_a_zero_asset() {
let mut rng = RandomCoin::new(Word::default());
let creator = dummy_creator_id();
let zero = FungibleAsset::new(dummy_faucet_id(3), 0).unwrap();
let some = FungibleAsset::new(dummy_faucet_id(4), 100).unwrap();

let mut build = |offered: FungibleAsset, requested: FungibleAsset| {
PswapNote::builder()
.sender(creator)
.storage(
PswapNoteStorage::builder()
.min_requested_asset(requested)
.creator_account_id(creator)
.build(),
)
.serial_number(rng.draw_word())
.note_type(NoteType::Public)
.offered_asset(offered)
.build()
};

let err = build(zero, some).expect_err("a zero offered asset must be rejected");
assert_matches!(
err,
NoteError::Other { error_msg, .. }
if error_msg == "offered asset must be non-zero".into()
);

let err = build(some, zero).expect_err("a zero requested asset must be rejected");
assert_matches!(
err,
NoteError::Other { error_msg, .. }
if error_msg == "requested asset must be non-zero".into()
);
}
}
51 changes: 50 additions & 1 deletion crates/miden-standards/src/note/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use alloc::vec::Vec;

use miden_protocol::account::AccountId;
use miden_protocol::assembly::Path;
use miden_protocol::asset::Asset;
use miden_protocol::asset::{Asset, AssetAmount};
use miden_protocol::crypto::rand::FeltRng;
use miden_protocol::errors::NoteError;
use miden_protocol::note::{
Expand Down Expand Up @@ -64,6 +64,12 @@ pub struct SwapNote {
attachments: NoteAttachments,
}

/// Returns true when the asset is a fungible asset carrying no value. A non-fungible asset always
/// carries value.
fn is_zero_fungible(asset: &Asset) -> bool {
asset.is_fungible() && asset.unwrap_fungible().amount() == AssetAmount::ZERO
}

#[bon::bon]
impl SwapNote {
/// Builds a new [`SwapNote`].
Expand All @@ -75,6 +81,7 @@ impl SwapNote {
///
/// Returns an error if:
/// - The requested asset is the same as the offered asset.
/// - Either the offered or the requested asset is a zero-amount fungible asset.
/// - The attachments exceed their protocol limit (see [`NoteAttachments::new`]).
#[builder]
pub fn new(
Expand Down Expand Up @@ -102,6 +109,13 @@ impl SwapNote {
return Err(NoteError::other("requested asset same as offered asset"));
}

if is_zero_fungible(&offered_asset) {
return Err(NoteError::other("offered asset must be non-zero"));
}
if is_zero_fungible(&requested_asset) {
return Err(NoteError::other("requested asset must be non-zero"));
}

let attachments = NoteAttachments::new(attachments)?;

let payback_tag = NoteTag::with_account_target(sender);
Expand Down Expand Up @@ -814,4 +828,39 @@ mod tests {
"swap script root byte 1 should match with the highest bit set to zero"
);
}

/// A SWAP note holds the offered asset and its payback holds the requested one, so a zero
/// amount on either side leaves one of those two notes empty.
#[test]
fn swap_note_builder_rejects_a_zero_asset() {
let zero = Asset::from(FungibleAsset::new(fungible_faucet(), 0).unwrap());

let err = SwapNote::builder()
.sender(dummy_target_id())
.offered_asset(zero)
.requested_asset(non_fungible_asset())
.note_type(NoteType::Public)
.generate_serial_number(&mut RandomCoin::new(Word::from([1, 2, 3, 4u32])))
.build()
.expect_err("a zero offered asset must be rejected");
assert_matches!(
err,
NoteError::Other { error_msg, .. }
if error_msg == "offered asset must be non-zero".into()
);

let err = SwapNote::builder()
.sender(dummy_target_id())
.offered_asset(non_fungible_asset())
.requested_asset(zero)
.note_type(NoteType::Public)
.generate_serial_number(&mut RandomCoin::new(Word::from([1, 2, 3, 4u32])))
.build()
.expect_err("a zero requested asset must be rejected");
assert_matches!(
err,
NoteError::Other { error_msg, .. }
if error_msg == "requested asset must be non-zero".into()
);
}
}