Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 @@ -49,6 +49,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 and BURN 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)).
- Storage slot types are now validated against the supported set at account creation, and the delta commitment rejects an unrecognized slot type instead of treating it as a map ([#3598](https://github.com/0xMiden/protocol/pull/3608)).
Expand Down
71 changes: 49 additions & 22 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,10 @@ 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.
///
/// A note whose faucet is public is routed to it by a
/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment derived from the asset. A
/// private faucet can never be a network account, so such a note carries no target.
///
/// Construct one with the [builder](BurnNote::builder); convert it into a protocol [`Note`]
/// infallibly via `Note::from`.
#[derive(Debug, Clone)]
Expand All @@ -69,24 +73,25 @@ impl BurnNote {
///
/// # Errors
///
/// Returns an error if the attachments exceed their protocol limit (see
/// [`NoteAttachments::new`]).
/// Returns an error if:
/// - the attachments carry a `NetworkAccountTarget` for an account other than that faucet.
/// - 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. That bind is a plain value comparison and so
// works for a private faucet too, which has no network target to derive.
Comment thread
mmagician marked this conversation as resolved.
Outdated
NetworkAccountTarget::ensure_presence_if_public(&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,
Expand Down Expand Up @@ -215,7 +220,7 @@ mod tests {
use miden_protocol::note::NoteTag;

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

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

/// 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();
fn private_faucet() -> AccountId {
AccountId::builder().account_type(AccountType::Private).build_with_seed([2; 32])
}

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

/// The builder produces a public note carrying the asset to burn and routed to its faucet by a
/// derived network target. How that target treats caller-supplied attachments is covered by the
/// `network_account_target` tests.
#[test]
fn builder_builds_public_burn_note() {
let asset = FungibleAsset::new(faucet(), 100).unwrap();

let burn_note = build_burn_note(faucet());

assert_eq!(burn_note.sender(), sender());
assert_eq!(burn_note.faucet_id(), faucet());
assert_eq!(burn_note.asset(), asset.into());
assert_ne!(burn_note.serial_number(), Word::empty());
assert_eq!(burn_note.attachments().num_attachments(), 1);

let note = Note::from(burn_note);
assert_eq!(note.metadata().note_type(), NoteType::Public);
assert_eq!(note.metadata().tag(), NoteTag::default());
assert_eq!(note.assets().num_assets(), 1);
assert_eq!(note.recipient().storage().items(), Asset::from(asset).as_elements());
assert!(note.is_network_note());

let target = NetworkAccountTarget::try_from(note.attachments()).unwrap();
assert_eq!(target.target_id(), faucet());
assert_eq!(target.execution_hint(), NoteExecutionHint::Always);
}

/// A private faucet is never a network account, so no target is derived for it. The note stays
/// consumable by that faucet, which is bound by the asset the note carries.
#[test]
fn builder_omits_network_target_for_private_faucet() {
let burn_note = build_burn_note(private_faucet());

assert_eq!(burn_note.attachments().num_attachments(), 0);
assert!(!Note::from(burn_note).is_network_note());
}
}
66 changes: 54 additions & 12 deletions crates/miden-standards/src/note/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use miden_protocol::utils::sync::LazyLock;
use miden_protocol::{Felt, MAX_NOTE_STORAGE_ITEMS, Word};

use crate::StandardsLib;
use crate::note::P2idNote;
use crate::note::costs::{MINT_CONSUMPTION_CYCLES, NoteConsumptionCost};
use crate::note::{NetworkAccountTarget, P2idNote};

// NOTE SCRIPT
// ================================================================================================
Expand Down Expand Up @@ -51,6 +51,11 @@ static MINT_SCRIPT: LazyLock<NoteScript> = LazyLock::new(|| {
/// the output note minted on consumption can be private or public depending on the
/// [`MintNoteStorage`] variant.
///
/// A note whose faucet is public is routed to it by a
/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment derived from the asset in
/// its storage, which is also what the note is tagged for. A private faucet can never be a network
/// account, so such a note carries no target and is only tagged.
///
Comment thread
mmagician marked this conversation as resolved.
Outdated
/// Construct one with the [builder](MintNote::builder); convert it into a protocol [`Note`]
/// infallibly via `Note::from`.
#[derive(Debug, Clone)]
Expand All @@ -69,15 +74,23 @@ impl MintNote {
///
/// # Errors
///
/// Returns an error if the attachments exceed their protocol limit (see
/// [`NoteAttachments::new`]).
/// Returns an error if:
/// - the attachments carry a `NetworkAccountTarget` for an account other than the faucet.
/// - 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(name = mint_storage)] storage: MintNoteStorage,
serial_number: Word,
) -> Result<Self, NoteError> {
// The network routes the note on this attachment; the stored ASSET_ID is what binds the
// script to the same faucet on consumption.
NetworkAccountTarget::ensure_presence_if_public(&mut attachments, storage.faucet_id())
.map_err(|err| {
NoteError::other_with_source("failed to target the MINT note at its faucet", err)
})?;
Comment on lines +90 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why for MINT notes we use ensure_presence_if_public() but for BURN notes we use just ensure_presence()? Couldn't both of them be directed to non-network accounts?


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

Ok(Self {
Expand Down Expand Up @@ -317,34 +330,63 @@ mod tests {
use miden_protocol::crypto::rand::RandomCoin;

use super::*;
use crate::note::{NetworkNoteExt, NoteExecutionHint};

fn faucet() -> AccountId {
AccountId::builder().account_type(AccountType::Public).build_with_seed([1; 32])
}

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

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

/// The builder produces a public, asset-less note tagged for the faucet.
#[test]
fn builder_builds_public_mint_note() {
fn build_mint_note(faucet_id: AccountId) -> MintNote {
let asset = FungibleAsset::new(faucet_id, 50).unwrap();
let mut rng = RandomCoin::new(Word::empty());
let asset = FungibleAsset::new(faucet(), 50).unwrap();
let mint_storage = MintNoteStorage::new_private(Word::empty(), asset, NoteTag::default());
let mint_note = MintNote::builder()
MintNote::builder()
.sender(owner())
.mint_storage(mint_storage)
.mint_storage(MintNoteStorage::new_private(Word::empty(), asset, NoteTag::default()))
.generate_serial_number(&mut rng)
.build()
.unwrap();
.unwrap()
}

/// The builder produces a public, asset-less note tagged for the faucet and routed to it by a
/// derived network target. How that target treats caller-supplied attachments is covered by the
/// `network_account_target` tests.
#[test]
fn builder_builds_public_mint_note() {
let mint_note = build_mint_note(faucet());

assert_eq!(mint_note.faucet_id(), faucet());
assert_eq!(mint_note.sender(), owner());
assert_eq!(mint_note.attachments().num_attachments(), 1);

let note = Note::from(mint_note);
assert_eq!(note.metadata().note_type(), NoteType::Public);
assert_eq!(note.metadata().tag(), NoteTag::with_account_target(faucet()));
assert_eq!(note.assets().num_assets(), 0);
assert!(note.is_network_note());

let target = NetworkAccountTarget::try_from(note.attachments()).unwrap();
assert_eq!(target.target_id(), faucet());
assert_eq!(target.execution_hint(), NoteExecutionHint::Always);
}

/// A private faucet is never a network account, so no target is derived for it. The note is
/// still tagged for the faucet and remains consumable by it.
#[test]
fn builder_omits_network_target_for_private_faucet() {
let mint_note = build_mint_note(private_faucet());

assert_eq!(mint_note.attachments().num_attachments(), 0);

let note = Note::from(mint_note);
assert_eq!(note.metadata().tag(), NoteTag::with_account_target(private_faucet()));
assert!(!note.is_network_note());
}
}
84 changes: 76 additions & 8 deletions crates/miden-standards/src/note/network_account_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,53 @@ impl NetworkAccountTarget {
attachments: &mut Vec<NoteAttachment>,
target_id: AccountId,
) -> Result<(), NetworkAccountTargetError> {
// Every attachment of the scheme is validated, so no attachment can claim a target other
// than `target_id`.
if !Self::contains_target(attachments, target_id)? {
let target = Self::new(target_id, NoteExecutionHint::Always)?;
attachments.push(NoteAttachment::from(target));
}

Ok(())
}

/// Behaves like [`Self::ensure_presence`], except that a non-public `target_id` is accepted
/// without appending a target.
///
/// A private account is never a network account, so it has no routing target to derive. This
/// lets a note whose target may be either kind of account carry the target exactly when it is
/// meaningful, while a caller-supplied target for another account is rejected either way.
///
/// # Errors
///
/// Returns an error if an attachment with the [`NetworkAccountTarget::ATTACHMENT_SCHEME`] does
/// not decode as a [`NetworkAccountTarget`] or targets an account other than `target_id`.
pub(crate) fn ensure_presence_if_public(
attachments: &mut Vec<NoteAttachment>,
target_id: AccountId,
) -> Result<(), NetworkAccountTargetError> {
if target_id.is_public() {
return Self::ensure_presence(attachments, target_id);
}

// No target is derived, but a caller-supplied attachment of the scheme must still be
// validated, so the note cannot claim a network target it does not have. The returned flag
// is discarded because it is always false here: an attachment naming a non-public account
// never decodes into a `NetworkAccountTarget`, so a present target can only be an error.
Comment thread
mmagician marked this conversation as resolved.
Outdated
Self::contains_target(attachments, target_id).map(|_| ())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We need this line to check if a private account has malformed network target attachment, right? Or is there some other reason?

Regardless - we should add a brief comment explaining why this line is needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm struggling to understand the comment, it's very claude-ish. I think the idea is to make sure there is no attachment that a user added that claims to be a NetworkAccountTarget but actually contains a private target_id. If so, I would suggest something like:

// Ensure that none of the user-provided attachments claims to be a
// `NetworkAccountTarget` with a private target.

}

/// Returns whether `attachments` carries a [`NetworkAccountTarget`] for `target_id`.
///
/// Every attachment of the scheme is validated, so no attachment can claim a target other than
/// `target_id`.
///
/// # Errors
///
/// Returns an error if an attachment with the [`NetworkAccountTarget::ATTACHMENT_SCHEME`] does
/// not decode as a [`NetworkAccountTarget`] or targets an account other than `target_id`.
fn contains_target(
attachments: &[NoteAttachment],
target_id: AccountId,
) -> Result<bool, NetworkAccountTargetError> {
let mut is_present = false;
for attachment in attachments
.iter()
Expand All @@ -92,12 +137,7 @@ impl NetworkAccountTarget {
is_present = true;
}

if !is_present {
let target = Self::new(target_id, NoteExecutionHint::Always)?;
attachments.push(NoteAttachment::from(target));
}

Ok(())
Ok(is_present)
}

// ACCESSORS
Expand Down Expand Up @@ -287,6 +327,34 @@ mod tests {
Ok(())
}

/// A non-public target has no network routing target, so none is appended, but a
/// caller-supplied target for another account is still rejected.
#[test]
fn ensure_presence_if_public_skips_private_target() -> anyhow::Result<()> {
let private_id = AccountIdBuilder::new()
.account_type(AccountType::Private)
.build_with_rng(&mut rand::rng());
let mut attachments = vec![];

NetworkAccountTarget::ensure_presence_if_public(&mut attachments, private_id)?;
assert!(attachments.is_empty());

let other_id = public_account_id();
let supplied = NetworkAccountTarget::new(other_id, NoteExecutionHint::Always)?;
let mut attachments = vec![NoteAttachment::from(supplied)];

let err = NetworkAccountTarget::ensure_presence_if_public(&mut attachments, private_id)
.unwrap_err();

assert_matches!(
err,
NetworkAccountTargetError::TargetMismatch { expected, actual }
if expected == private_id && actual == other_id
);

Ok(())
}

#[test]
fn network_account_target_fails_on_private_target_account() -> anyhow::Result<()> {
let id = AccountIdBuilder::new()
Expand Down
Loading
Loading