diff --git a/CHANGELOG.md b/CHANGELOG.md index 971d4929ac..c55e092603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,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)). diff --git a/crates/miden-standards/src/note/burn.rs b/crates/miden-standards/src/note/burn.rs index a714c2f73c..b296adbd8b 100644 --- a/crates/miden-standards/src/note/burn.rs +++ b/crates/miden-standards/src/note/burn.rs @@ -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 // ================================================================================================ @@ -51,6 +51,11 @@ static BURN_SCRIPT: LazyLock = 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 BURN note for a public faucet carries a +/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment naming that faucet, +/// derived from the asset the note burns, so the network can route the note to it. A private faucet +/// can never be a network account, so a note for one carries no such attachment. +/// /// Construct one with the [builder](BurnNote::builder); convert it into a protocol [`Note`] /// infallibly via `Note::from`. #[derive(Debug, Clone)] @@ -69,24 +74,24 @@ 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, + #[builder(field)] mut attachments: Vec, sender: AccountId, #[builder(into)] asset: Asset, serial_number: Word, ) -> Result { - 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 for both private and network faucets. + 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, @@ -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]) @@ -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()); + } } diff --git a/crates/miden-standards/src/note/mint.rs b/crates/miden-standards/src/note/mint.rs index 7dd5fa0b5c..a90e431072 100644 --- a/crates/miden-standards/src/note/mint.rs +++ b/crates/miden-standards/src/note/mint.rs @@ -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 // ================================================================================================ @@ -51,6 +51,12 @@ static MINT_SCRIPT: LazyLock = LazyLock::new(|| { /// the output note minted on consumption can be private or public depending on the /// [`MintNoteStorage`] variant. /// +/// A MINT note for a public faucet is tagged for that faucet and carries a +/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment naming it, both derived +/// from the asset in the note's storage, so the network can route the note to it. A private faucet +/// can never be a network account, so a note for one is only tagged and carries no such +/// attachment. +/// /// Construct one with the [builder](MintNote::builder); convert it into a protocol [`Note`] /// infallibly via `Note::from`. #[derive(Debug, Clone)] @@ -69,15 +75,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, + #[builder(field)] mut attachments: Vec, sender: AccountId, #[builder(name = mint_storage)] storage: MintNoteStorage, serial_number: Word, ) -> Result { + // 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) + })?; + let attachments = NoteAttachments::new(attachments)?; Ok(Self { @@ -317,34 +331,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()); } } diff --git a/crates/miden-standards/src/note/network_account_target.rs b/crates/miden-standards/src/note/network_account_target.rs index 03c253edf7..5571f93885 100644 --- a/crates/miden-standards/src/note/network_account_target.rs +++ b/crates/miden-standards/src/note/network_account_target.rs @@ -74,8 +74,50 @@ impl NetworkAccountTarget { attachments: &mut Vec, 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::validate_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, + target_id: AccountId, + ) -> Result<(), NetworkAccountTargetError> { + if target_id.is_public() { + return Self::ensure_presence(attachments, target_id); + } + + // No target is derived, but any attachment the caller supplied under the scheme is still + // validated against `target_id`. + Self::validate_target(attachments, target_id).map(|_| ()) + } + + /// Validates every attachment carrying the [`NetworkAccountTarget::ATTACHMENT_SCHEME`] + /// against `target_id`, returning whether one of them is present. + /// + /// # Errors + /// + /// Returns an error if such an attachment does not decode as a [`NetworkAccountTarget`], which + /// is the case for one naming a non-public account, or targets an account other than + /// `target_id`. + fn validate_target( + attachments: &[NoteAttachment], + target_id: AccountId, + ) -> Result { let mut is_present = false; for attachment in attachments .iter() @@ -92,12 +134,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 @@ -287,6 +324,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() diff --git a/crates/miden-testing/tests/scripts/non_fungible_faucet.rs b/crates/miden-testing/tests/scripts/non_fungible_faucet.rs index dfd61a5af4..f7c1956a0c 100644 --- a/crates/miden-testing/tests/scripts/non_fungible_faucet.rs +++ b/crates/miden-testing/tests/scripts/non_fungible_faucet.rs @@ -333,6 +333,68 @@ async fn nft_burn_succeeds() -> anyhow::Result<()> { Ok(()) } +/// A private faucet can consume a BURN note, mirroring the MINT path: the note's consume-side bind +/// is the asset it carries, which `receive_and_burn` validates against the active faucet, so it +/// carries no requirement on the faucet's account type. Such a note derives no network target, +/// since a private account can never be a network account. +#[tokio::test] +async fn nft_burn_succeeds_for_a_private_faucet() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let owner = AccountId::builder() + .account_type(AccountType::Private) + .build_with_seed([16; 32]); + let faucet = build_nft_faucet_with_type( + &mut builder, + "EC", + owner, + MintPolicy::allow_all(), + AccountType::Private, + )?; + let mut mock_chain = builder.build()?; + assert!(faucet.id().is_private()); + + let commitment = NonFungibleFaucet::compute_asset_commitment( + b"token burned by a private faucet", + Word::from([1, 3, 5, 7u32]), + ); + let recipient = Word::from([8, 8, 8, 8u32]); + + // mint first, so the commitment is ISSUED and the burn below can transition it to BURNED + let minted = execute_nft_mint(&mut mock_chain, faucet.clone(), commitment, recipient).await?; + let mut faucet = faucet; + faucet.apply_patch(minted.account_patch())?; + + let asset: Asset = NonFungibleAsset::from_parts(faucet.id(), commitment).into(); + let sender = AccountId::builder() + .account_type(AccountType::Private) + .build_with_seed([17; 32]); + let mut rng = RandomCoin::new([Felt::from(12u32); 4].into()); + let burn_note: Note = BurnNote::builder() + .sender(sender) + .asset(asset) + .generate_serial_number(&mut rng) + .build()? + .into(); + + // a private faucet is no network account, so the note carries no routing target + assert_eq!(burn_note.attachments().num_attachments(), 0); + + let burned = mock_chain + .build_transaction(faucet.clone()) + .unauthenticated_input_note(burn_note) + .build()? + .execute() + .await?; + + faucet.apply_patch(burned.account_patch())?; + assert_eq!( + NonFungibleFaucet::get_asset_status(faucet.storage(), commitment)?, + AssetStatus::Burned, + ); + + Ok(()) +} + /// Minting via the production MINT note (private mode) succeeds: the note's storage carries the /// recipient, commitment and tag, and the `mint` script calls `mint_and_send`, producing one /// output note. This exercises the MINT note script end-to-end.