Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -37,6 +37,7 @@
- [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)).
- Verified each input note's storage-item count and preimage against its authenticated storage commitment ([#3593](https://github.com/0xMiden/protocol/issues/3593)).
- Fixed `PrivateOutputNote` construction and deserialization accepting attachment data that is not committed by the note header ([#3556](https://github.com/0xMiden/protocol/pull/3579)).
- MINT notes for a public faucet now carry the `NetworkAccountTarget` attachment that identifies them as network notes([#3664](https://github.com/0xMiden/protocol/pull/3664)).
- Fixed `input_note::remove_asset` leaving a dangling asset slot when a non-canonical fungible value produced an empty removal remainder ([#3591](https://github.com/0xMiden/protocol/pull/3606)).
- Fixed `input_note::remove_asset` succeeding when asked to remove an empty or malformed asset ID instead of reporting the asset as not found ([#3592](https://github.com/0xMiden/protocol/pull/3607)).
- Faucet asset-callback procedure roots are now verified against the faucet's account code before dispatch, so a misconfigured callback root can no longer make an asset nontransferable ([#3612](https://github.com/0xMiden/protocol/pull/3612)).
Expand Down
127 changes: 106 additions & 21 deletions crates/miden-standards/src/note/burn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use miden_protocol::note::{
use miden_protocol::utils::sync::LazyLock;

use crate::StandardsLib;
use crate::note::NetworkAccountTarget;
use crate::note::costs::{BURN_CONSUMPTION_CYCLES, NoteConsumptionCost};
use crate::note::{NetworkAccountTarget, NoteExecutionHint};

// NOTE SCRIPT
// ================================================================================================
Expand Down Expand Up @@ -51,6 +51,11 @@ static BURN_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
/// visible on-chain and discoverable by the network; whether consuming one requires a signature
/// depends on the target faucet's auth component.
///
/// The note is routed to that faucet by a
/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment derived from the asset.
/// The attachment is the canonical target encoding the network routes on; the consume-side bind is
/// the asset itself, which the faucet's `receive_and_burn` rejects if it did not issue it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The attachment is the canonical target encoding the network routes on; the consume-side bind is
/// the asset itself, which the faucet's `receive_and_burn` rejects if it did not issue it.

we can easily skip the extra detail without losing the important information

///
/// Construct one with the [builder](BurnNote::builder); convert it into a protocol [`Note`]
/// infallibly via `Note::from`.
#[derive(Debug, Clone)]
Expand All @@ -69,25 +74,27 @@ impl BurnNote {
///
/// # Errors
///
/// Returns an error if the attachments exceed their protocol limit (see
/// [`NoteAttachments::new`]).
/// Returns an error if:
/// - the asset's faucet is not a public account (the note is routed to it via a
/// `NetworkAccountTarget`, which requires a public target).
/// - the attachments carry a `NetworkAccountTarget` for an account other than that faucet.
/// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
/// attachment occupies one of the available slots when the caller does not supply it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]); the target
/// attachment occupies one of the available slots when the caller does not supply it.
/// - the attachments exceed their protocol limit (see [`NoteAttachments::new`]).

#[builder]
pub fn new(
#[builder(field)] attachments: Vec<NoteAttachment>,
#[builder(field)] mut attachments: Vec<NoteAttachment>,
sender: AccountId,
#[builder(into)] asset: Asset,
serial_number: Word,
) -> Result<Self, NoteError> {
let network_target =
NetworkAccountTarget::new(asset.faucet_id(), NoteExecutionHint::Always).map_err(
|err| {
NoteError::other_with_source("failed to target BURN note at asset faucet", err)
},
)?;
let attachments = NoteAttachments::new(
core::iter::once(network_target.into()).chain(attachments).collect(),
// The network routes the note on this attachment; the asset it carries is what binds the
// script to the same faucet on consumption.
NetworkAccountTarget::ensure_presence(&mut attachments, asset.faucet_id()).map_err(
|err| NoteError::other_with_source("failed to target the BURN note at its faucet", err),
)?;

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

Ok(Self {
sender,
serial_number,
Expand Down Expand Up @@ -209,13 +216,14 @@ impl NoteConsumptionCost for BurnNote {

#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use miden_protocol::account::AccountType;
use miden_protocol::asset::FungibleAsset;
use miden_protocol::crypto::rand::RandomCoin;
use miden_protocol::note::NoteTag;
use miden_protocol::note::{NoteAttachmentScheme, NoteTag};

use super::*;
use crate::note::NetworkAccountTarget;
use crate::note::{NetworkAccountTarget, NetworkAccountTargetError, NoteExecutionHint};

fn sender() -> AccountId {
AccountId::builder().account_type(AccountType::Private).build_with_seed([1; 32])
Expand All @@ -225,18 +233,38 @@ mod tests {
AccountId::builder().account_type(AccountType::Public).build_with_seed([2; 32])
}

fn private_faucet() -> AccountId {
AccountId::builder().account_type(AccountType::Private).build_with_seed([2; 32])
}

/// Unwraps the [`NetworkAccountTargetError`] a note builder wrapped into `NoteError::Other`.
fn target_error(err: NoteError) -> NetworkAccountTargetError {
let NoteError::Other { source: Some(source), .. } = err else {
panic!("expected NoteError::Other with a source, got: {err}");
};

*source.downcast().expect("the source should be a NetworkAccountTargetError")
}

fn build_burn_note(
faucet_id: AccountId,
attachments: Vec<NoteAttachment>,
) -> Result<BurnNote, NoteError> {
let mut rng = RandomCoin::new(Word::empty());
BurnNote::builder()
.attachments(attachments)
.sender(sender())
.asset(FungibleAsset::new(faucet_id, 100).unwrap())
.generate_serial_number(&mut rng)
.build()
}

/// The builder produces a public note targeted at the faucet and carrying the asset to burn.
#[test]
fn builder_builds_public_burn_note() {
let mut rng = RandomCoin::new(Word::empty());
let asset = FungibleAsset::new(faucet(), 100).unwrap();

let burn_note = BurnNote::builder()
.sender(sender())
.asset(asset)
.generate_serial_number(&mut rng)
.build()
.unwrap();
let burn_note = build_burn_note(faucet(), Vec::new()).unwrap();

assert_eq!(burn_note.sender(), sender());
assert_eq!(burn_note.faucet_id(), faucet());
Expand All @@ -253,4 +281,61 @@ mod tests {
assert_eq!(target.target_id(), faucet());
assert_eq!(target.execution_hint(), NoteExecutionHint::Always);
}

/// Caller-supplied attachments are kept in their order, with the derived network target
/// appended.
#[test]
fn builder_keeps_caller_attachments() {
let custom_scheme = NoteAttachmentScheme::new(64).unwrap();
let custom = NoteAttachment::with_word(custom_scheme, Word::from([7u32, 0, 0, 0]));

let burn_note = build_burn_note(faucet(), vec![custom.clone()]).unwrap();

// The target is appended, so the caller's attachment comes first.
assert_eq!(burn_note.attachments().num_attachments(), 2);
assert_eq!(burn_note.attachments().get(0), Some(&custom));
assert_eq!(
NetworkAccountTarget::try_from(burn_note.attachments()).unwrap().target_id(),
faucet()
);
}

/// A caller-supplied target for the faucet is kept as-is, so its execution hint survives and
/// no duplicate attachment is added.
#[test]
fn builder_keeps_caller_target_for_faucet() {
let supplied = NetworkAccountTarget::new(faucet(), NoteExecutionHint::None).unwrap();

let burn_note = build_burn_note(faucet(), vec![supplied.into()]).unwrap();

assert_eq!(burn_note.attachments().num_attachments(), 1);
assert_eq!(NetworkAccountTarget::try_from(burn_note.attachments()).unwrap(), supplied);
}

/// A caller-supplied `NetworkAccountTarget` for another account is rejected rather than
/// silently coexisting with the note's own target.
#[test]
fn builder_rejects_target_for_other_account() {
let other = AccountId::builder().account_type(AccountType::Public).build_with_seed([3; 32]);
let rogue_target = NetworkAccountTarget::new(other, NoteExecutionHint::None).unwrap();

let err = build_burn_note(faucet(), vec![rogue_target.into()]).unwrap_err();

assert_matches!(
target_error(err),
NetworkAccountTargetError::TargetMismatch { expected, actual }
if expected == faucet() && actual == other
);
}

/// A non-public faucet cannot be a network target, so the builder rejects it.
#[test]
fn builder_rejects_non_public_faucet() {
let err = build_burn_note(private_faucet(), Vec::new()).unwrap_err();

assert_matches!(
target_error(err),
NetworkAccountTargetError::TargetNotPublic(account_id) if account_id == private_faucet()
);
}
}
Loading
Loading