diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a6accca..b71f5185d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - [BREAKING] Introduce `NoteAttachment` as part of `NoteMetadata` and remove `aux` and `execution_hint` ([#2249](https://github.com/0xMiden/miden-base/pull/2249), [#2252](https://github.com/0xMiden/miden-base/pull/2252), [#2260](https://github.com/0xMiden/miden-base/pull/2260)). - [BREAKING] Introduce `NoteAttachment` as part of `NoteMetadata` and remove `aux` and `execution_hint` ([#2249](https://github.com/0xMiden/miden-base/pull/2249), [#2252](https://github.com/0xMiden/miden-base/pull/2252), [#2260](https://github.com/0xMiden/miden-base/pull/2260), [#2268](https://github.com/0xMiden/miden-base/pull/2268), [#2279](https://github.com/0xMiden/miden-base/pull/2279)). - Introduce standard `NetworkAccountTarget` attachment for use in network transactions which replaces `NoteTag::NetworkAccount` ([#2257](https://github.com/0xMiden/miden-base/pull/2257)). +- Added `miden::standards::access::ownable` standard module for component ownership management, and integrated it into the `network_fungible` faucet (including new tests). ([#2228](https://github.com/0xMiden/miden-base/pull/2228)). ### Changes diff --git a/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm b/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm index 604239c7f..7d350a422 100644 --- a/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm +++ b/crates/miden-standards/asm/account_components/faucets/network_fungible_faucet.masm @@ -4,3 +4,5 @@ pub use ::miden::standards::faucets::network_fungible::distribute pub use ::miden::standards::faucets::network_fungible::burn +pub use ::miden::standards::faucets::network_fungible::transfer_ownership +pub use ::miden::standards::faucets::network_fungible::renounce_ownership diff --git a/crates/miden-standards/asm/standards/access/ownable.masm b/crates/miden-standards/asm/standards/access/ownable.masm new file mode 100644 index 000000000..79702c194 --- /dev/null +++ b/crates/miden-standards/asm/standards/access/ownable.masm @@ -0,0 +1,175 @@ +# miden::standards::access::ownable +# +# Provides ownership management functionality for account components. +# This template can be imported and used by any component that needs owner controls. + +use miden::protocol::active_account +use miden::protocol::account_id +use miden::protocol::active_note +use miden::protocol::native_account + +# CONSTANTS +# ================================================================================================ + +# The slot in this component's storage layout where the owner config is stored. +const OWNER_CONFIG_SLOT = word("miden::standards::access::ownable::owner_config") + +# ZERO_ADDRESS word (all zeros) used to represent no owner +# Format: [prefix=0, suffix=0, 0, 0] as stored in account storage +const ZERO_ADDRESS = [0, 0, 0, 0] + +# ERRORS +# ================================================================================================ + +const ERR_SENDER_NOT_OWNER = "note sender is not the owner" + +# INTERNAL PROCEDURES +# ================================================================================================ + +#! Returns the owner AccountId from storage. +#! +#! Inputs: [] +#! Outputs: [owner_prefix, owner_suffix] +#! +#! Where: +#! - owner_{prefix, suffix} are the prefix and suffix felts of the owner AccountId. +proc owner + push.OWNER_CONFIG_SLOT[0..2] exec.active_account::get_item + # => [owner_prefix, owner_suffix, 0, 0] + + # Storage format in memory: [0, 0, suffix, prefix] (word[0], word[1], word[2], word[3]) + # mem_loadw_be loads big-endian (reversed), so stack gets: [prefix, suffix, 0, 0] + # Stack: [owner_prefix (pos 0), owner_suffix (pos 1), 0 (pos 2), 0 (pos 3)] + # We want: [owner_prefix, owner_suffix] + # Move zeros to top using movup, then drop them + movup.2 + # => [0, owner_prefix, owner_suffix, 0] (moves element at pos 2 to pos 0) + + movup.3 + # => [0, 0, owner_prefix, owner_suffix] (moves element at pos 3 to pos 0) + + drop drop + # => [owner_prefix, owner_suffix] +end + +#! Checks if the given account ID is the owner of this component. +#! +#! Inputs: [account_id_prefix, account_id_suffix] +#! Outputs: [is_owner] +#! +#! Where: +#! - account_id_{prefix, suffix} are the prefix and suffix felts of the AccountId to check. +#! - is_owner is 1 if the account is the owner, 0 otherwise. +proc is_owner + + exec.owner + # => [owner_prefix, owner_suffix, account_id_prefix, account_id_suffix] + + exec.account_id::is_equal + # => [is_owner] + +end + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Checks if the note sender is the owner and panics if not. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the note sender is not the owner. +pub proc verify_owner + exec.active_note::get_sender + # => [sender_prefix, sender_suffix] + + exec.is_owner + # => [is_owner] + + assert.err=ERR_SENDER_NOT_OWNER + # => [] +end + +#! Returns the owner AccountId. +#! +#! Inputs: [pad(16)] +#! Outputs: [owner_prefix, owner_suffix, pad(14)] +#! +#! Where: +#! - owner_{prefix, suffix} are the prefix and suffix felts of the owner AccountId. +#! +#! Invocation: call +pub proc get_owner + exec.owner + # => [owner_prefix, owner_suffix, pad(14)] +end + +#! Transfers ownership to a new account. +#! +#! Can only be called by the current owner. +#! +#! Inputs: [new_owner_prefix, new_owner_suffix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - new_owner_{prefix, suffix} are the prefix and suffix felts of the new owner AccountId. +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +pub proc transfer_ownership + # Check that the caller is the owner + exec.verify_owner + # => [new_owner_prefix, new_owner_suffix, pad(14)] + + push.0 movdn.2 push.0 movdn.2 + # => [new_owner_prefix, new_owner_suffix, 0, 0, pad(14)] + + push.OWNER_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix, new_owner_prefix, new_owner_suffix, 0, 0, pad(14)] + + exec.native_account::set_item + # => [OLD_OWNER_WORD, pad(14)] + + # When the stack has 16 elements, dropw will shift in zeros from the right, + # resulting in [pad(16)]. So dropw is sufficient here. + dropw + # => [pad(16)] +end + +#! Renounces ownership, leaving the component without an owner. +#! +#! Can only be called by the current owner. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +#! +#! Important Note! +#! This feature allows the owner to relinquish administrative privileges, a common pattern +#! after an initial stage with centralized administration is over. Once ownership is renounced, +#! the component becomes permanently ownerless and cannot be managed by any account. +pub proc renounce_ownership + exec.verify_owner + # => [pad(16)] + + # ---- Push ZERO_ADDRESS to storage ---- + push.ZERO_ADDRESS + # => [0, 0, 0, 0, pad(16)] + + push.OWNER_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix, 0, 0, 0, 0, pad(16)] + + exec.native_account::set_item + # => [OLD_OWNER_WORD, pad(16)] + + dropw + # => [pad(16)] +end + diff --git a/crates/miden-standards/asm/standards/faucets/network_fungible.masm b/crates/miden-standards/asm/standards/faucets/network_fungible.masm index 04cabea24..5f405db8f 100644 --- a/crates/miden-standards/asm/standards/faucets/network_fungible.masm +++ b/crates/miden-standards/asm/standards/faucets/network_fungible.masm @@ -1,38 +1,52 @@ -use miden::protocol::active_account -use miden::protocol::account_id use miden::protocol::active_note use miden::standards::faucets -use miden::standards::faucets::basic_fungible +use miden::standards::access::ownable -# CONSTANTS +# PUBLIC INTERFACE # ================================================================================================ -# The slot in this component's storage layout where the owner config is stored. -const OWNER_CONFIG_SLOT=word("miden::standards::network_fungible_faucet::owner_config") +# OWNER MANAGEMENT +# ------------------------------------------------------------------------------------------------ -# ERRORS -const ERR_ONLY_OWNER_CAN_MINT="note sender is not the owner of the faucet who can mint assets" - -#! Checks if the note sender is the owner of this faucet. +#! Returns the owner AccountId. #! #! Inputs: [] -#! Outputs: [is_owner] +#! Outputs: [owner_prefix, owner_suffix, pad(14)] #! -#! Where: -#! - is_owner is 1 if the sender is the owner, 0 otherwise. -proc is_owner - push.OWNER_CONFIG_SLOT[0..2] exec.active_account::get_item - # => [owner_prefix, owner_suffix, 0, 0] +#! Invocation: call +pub use ownable::get_owner - exec.active_note::get_sender - # => [sender_prefix, sender_suffix, owner_prefix, owner_suffix, 0, 0] +#! Transfers ownership to a new account. +#! +#! Can only be called by the current owner. +#! +#! Inputs: [new_owner_prefix, new_owner_suffix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - new_owner_{prefix, suffix} are the prefix and suffix felts of the new owner AccountId. +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +pub use ownable::transfer_ownership - exec.account_id::is_equal - # => [are_equal, 0, 0] +#! Renounces ownership, leaving the component without an owner. +#! +#! Can only be called by the current owner. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the owner. +#! +#! Invocation: call +pub use ownable::renounce_ownership - movdn.2 drop drop - # => [is_owner] -end +# ASSET DISTRIBUTION +# ------------------------------------------------------------------------------------------------ #! Distributes freshly minted fungible assets to the provided recipient. #! @@ -55,11 +69,8 @@ end #! #! Invocation: call pub proc distribute - exec.is_owner - # => [is_owner, amount, tag, note_type, RECIPIENT, pad(9)] - - assert.err=ERR_ONLY_OWNER_CAN_MINT - # => [amount, tag, note_type, RECIPIENT, pad(9)] + exec.ownable::verify_owner + # => [amount, tag, aux, note_type, execution_hint, RECIPIENT, pad(7)] exec.faucets::distribute # => [note_idx, pad(15)] diff --git a/crates/miden-standards/src/account/faucets/network_fungible.rs b/crates/miden-standards/src/account/faucets/network_fungible.rs index 502e8582d..fb150d2d9 100644 --- a/crates/miden-standards/src/account/faucets/network_fungible.rs +++ b/crates/miden-standards/src/account/faucets/network_fungible.rs @@ -37,7 +37,7 @@ procedure_digest!( ); static OWNER_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::network_fungible_faucet::owner_config") + StorageSlotName::new("miden::standards::access::ownable::owner_config") .expect("storage slot name should be valid") }); diff --git a/crates/miden-standards/src/errors/standards.rs b/crates/miden-standards/src/errors/standards.rs index 85a861122..007723901 100644 --- a/crates/miden-standards/src/errors/standards.rs +++ b/crates/miden-standards/src/errors/standards.rs @@ -21,9 +21,6 @@ pub const ERR_MALFORMED_MULTISIG_CONFIG: MasmError = MasmError::from_static_str( /// Error Message: "MINT script expects exactly 12 inputs for private or 16+ inputs for public output notes" pub const ERR_MINT_WRONG_NUMBER_OF_INPUTS: MasmError = MasmError::from_static_str("MINT script expects exactly 12 inputs for private or 16+ inputs for public output notes"); -/// Error Message: "note sender is not the owner of the faucet who can mint assets" -pub const ERR_ONLY_OWNER_CAN_MINT: MasmError = MasmError::from_static_str("note sender is not the owner of the faucet who can mint assets"); - /// Error Message: "failed to reclaim P2IDE note because the reclaiming account is not the sender" pub const ERR_P2IDE_RECLAIM_ACCT_IS_NOT_SENDER: MasmError = MasmError::from_static_str("failed to reclaim P2IDE note because the reclaiming account is not the sender"); /// Error Message: "P2IDE reclaim is disabled" @@ -40,6 +37,9 @@ pub const ERR_P2ID_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str( /// Error Message: "P2ID note expects exactly 2 note inputs" pub const ERR_P2ID_WRONG_NUMBER_OF_INPUTS: MasmError = MasmError::from_static_str("P2ID note expects exactly 2 note inputs"); +/// Error Message: "note sender is not the owner" +pub const ERR_SENDER_NOT_OWNER: MasmError = MasmError::from_static_str("note sender is not the owner"); + /// Error Message: "SWAP script requires exactly 1 note asset" pub const ERR_SWAP_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_str("SWAP script requires exactly 1 note asset"); /// Error Message: "SWAP script expects exactly 16 note inputs" diff --git a/crates/miden-testing/tests/scripts/faucet.rs b/crates/miden-testing/tests/scripts/faucet.rs index 46db71e59..aaca772c5 100644 --- a/crates/miden-testing/tests/scripts/faucet.rs +++ b/crates/miden-testing/tests/scripts/faucet.rs @@ -33,7 +33,10 @@ use miden_standards::account::faucets::{ NetworkFungibleFaucet, }; use miden_standards::code_builder::CodeBuilder; -use miden_standards::errors::standards::ERR_FUNGIBLE_ASSET_DISTRIBUTE_WOULD_CAUSE_MAX_SUPPLY_TO_BE_EXCEEDED; +use miden_standards::errors::standards::{ + ERR_FUNGIBLE_ASSET_DISTRIBUTE_WOULD_CAUSE_MAX_SUPPLY_TO_BE_EXCEEDED, + ERR_SENDER_NOT_OWNER, +}; use miden_standards::note::{MintNoteInputs, WellKnownNote, create_burn_note, create_mint_note}; use miden_standards::testing::note::NoteBuilder; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; @@ -560,6 +563,487 @@ async fn network_faucet_mint() -> anyhow::Result<()> { Ok(()) } +// TESTS FOR NETWORK FAUCET OWNERSHIP +// ================================================================================================ + +/// Tests that the owner can mint assets on network faucet. +#[tokio::test] +async fn test_network_faucet_owner_can_mint() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = builder.add_existing_network_faucet("NET", 1000, owner_account_id, Some(50))?; + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let mock_chain = builder.build()?; + + let amount = Felt::new(75); + let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.into())?.into(); + + let output_note_tag = NoteTag::with_account_target(target_account.id()); + let p2id_note = create_p2id_note_exact( + faucet.id(), + target_account.id(), + vec![mint_asset], + NoteType::Private, + Word::default(), + )?; + let recipient = p2id_note.recipient().digest(); + + let mint_inputs = MintNoteInputs::new_private(recipient, amount, output_note_tag.into()); + + let mut rng = RpoRandomCoin::new([Felt::from(42u32); 4].into()); + let mint_note = create_mint_note( + faucet.id(), + owner_account_id, + mint_inputs, + NoteAttachment::default(), + &mut rng, + )?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[], &[mint_note])?.build()?; + let executed_transaction = tx_context.execute().await?; + + assert_eq!(executed_transaction.output_notes().num_notes(), 1); + + Ok(()) +} + +/// Tests that a non-owner cannot mint assets on network faucet. +#[tokio::test] +async fn test_network_faucet_non_owner_cannot_mint() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let non_owner_account_id = AccountId::dummy( + [2; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = builder.add_existing_network_faucet("NET", 1000, owner_account_id, Some(50))?; + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + let mock_chain = builder.build()?; + + let amount = Felt::new(75); + let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.into())?.into(); + + let output_note_tag = NoteTag::with_account_target(target_account.id()); + let p2id_note = create_p2id_note_exact( + faucet.id(), + target_account.id(), + vec![mint_asset], + NoteType::Private, + Word::default(), + )?; + let recipient = p2id_note.recipient().digest(); + + let mint_inputs = MintNoteInputs::new_private(recipient, amount, output_note_tag.into()); + + // Create mint note from NON-OWNER + let mut rng = RpoRandomCoin::new([Felt::from(42u32); 4].into()); + let mint_note = create_mint_note( + faucet.id(), + non_owner_account_id, + mint_inputs, + NoteAttachment::default(), + &mut rng, + )?; + + let tx_context = mock_chain.build_tx_context(faucet.id(), &[], &[mint_note])?.build()?; + let result = tx_context.execute().await; + + // The distribute function uses ERR_ONLY_OWNER, which is "note sender is not the owner" + let expected_error = ERR_SENDER_NOT_OWNER; + assert_transaction_executor_error!(result, expected_error); + + Ok(()) +} + +/// Tests that the owner is correctly stored and can be read from storage. +#[tokio::test] +async fn test_network_faucet_owner_storage() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = builder.add_existing_network_faucet("NET", 1000, owner_account_id, Some(50))?; + let _mock_chain = builder.build()?; + + // Verify owner is stored correctly + let stored_owner = faucet.storage().get_item(NetworkFungibleFaucet::owner_config_slot())?; + + // Storage format: [0, 0, suffix, prefix] + assert_eq!(stored_owner[3], owner_account_id.prefix().as_felt()); + assert_eq!(stored_owner[2], Felt::new(owner_account_id.suffix().as_int())); + assert_eq!(stored_owner[1], Felt::new(0)); + assert_eq!(stored_owner[0], Felt::new(0)); + + Ok(()) +} + +/// Tests that transfer_ownership updates the owner correctly. +#[tokio::test] +async fn test_network_faucet_transfer_ownership() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // Setup: Create initial owner and new owner accounts + let initial_owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let new_owner_account_id = AccountId::dummy( + [2; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = + builder.add_existing_network_faucet("NET", 1000, initial_owner_account_id, Some(50))?; + let target_account = builder.add_existing_wallet(Auth::IncrNonce)?; + + let amount = Felt::new(75); + let mint_asset: Asset = FungibleAsset::new(faucet.id(), amount.into())?.into(); + + let output_note_tag = NoteTag::with_account_target(target_account.id()); + let p2id_note = create_p2id_note_exact( + faucet.id(), + target_account.id(), + vec![mint_asset], + NoteType::Private, + Word::default(), + )?; + let recipient = p2id_note.recipient().digest(); + + // Sanity Check: Prove that the initial owner can mint assets + let mint_inputs = MintNoteInputs::new_private(recipient, amount, output_note_tag.into()); + + let mut rng = RpoRandomCoin::new([Felt::from(42u32); 4].into()); + let mint_note = create_mint_note( + faucet.id(), + initial_owner_account_id, + mint_inputs.clone(), + NoteAttachment::default(), + &mut rng, + )?; + + // Action: Create transfer_ownership note script + let transfer_note_script_code = format!( + r#" + use miden::standards::faucets::network_fungible->network_faucet + + begin + repeat.14 push.0 end + push.{new_owner_suffix} + push.{new_owner_prefix} + call.network_faucet::transfer_ownership + dropw dropw dropw dropw + end + "#, + new_owner_prefix = new_owner_account_id.prefix().as_felt(), + new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_int()), + ); + + let source_manager = Arc::new(DefaultSourceManager::default()); + let transfer_note_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_note_script(transfer_note_script_code.clone())?; + + // Create the transfer note and add it to the builder so it exists on-chain + let mut rng = RpoRandomCoin::new([Felt::from(200u32); 4].into()); + let transfer_note = NoteBuilder::new(initial_owner_account_id, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([11, 22, 33, 44u32])) + .code(transfer_note_script_code.clone()) + .build()?; + + // Add the transfer note to the builder before building the chain + builder.add_output_note(OutputNote::Full(transfer_note.clone())); + let mut mock_chain = builder.build()?; + + // Prove the block to make the transfer note exist on-chain + mock_chain.prove_next_block()?; + + // Sanity Check: Execute mint transaction to verify initial owner can mint + let tx_context = mock_chain.build_tx_context(faucet.id(), &[], &[mint_note])?.build()?; + let executed_transaction = tx_context.execute().await?; + assert_eq!(executed_transaction.output_notes().num_notes(), 1); + + // Action: Execute transfer_ownership via note script + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[transfer_note.id()], &[])? + .add_note_script(transfer_note_script.clone()) + .with_source_manager(source_manager.clone()) + .build()?; + let executed_transaction = tx_context.execute().await?; + + // Persistence: Apply the transaction to update the faucet state + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + mock_chain.prove_next_block()?; + + // Apply the delta to the faucet account to reflect the ownership change + let mut updated_faucet = faucet.clone(); + updated_faucet.apply_delta(executed_transaction.account_delta())?; + + // Validation 1: Try to mint using the old owner - should fail + let mut rng = RpoRandomCoin::new([Felt::from(300u32); 4].into()); + let mint_note_old_owner = create_mint_note( + updated_faucet.id(), + initial_owner_account_id, + mint_inputs.clone(), + NoteAttachment::default(), + &mut rng, + )?; + + // Use the note as an unauthenticated note (full note object) - it will be created in this + // transaction + let tx_context = mock_chain + .build_tx_context(updated_faucet.id(), &[], &[mint_note_old_owner])? + .build()?; + let result = tx_context.execute().await; + + // The distribute function uses ERR_ONLY_OWNER, which is "note sender is not the owner" + let expected_error = ERR_SENDER_NOT_OWNER; + assert_transaction_executor_error!(result, expected_error); + + // Validation 2: Try to mint using the new owner - should succeed + let mut rng = RpoRandomCoin::new([Felt::from(400u32); 4].into()); + let mint_note_new_owner = create_mint_note( + updated_faucet.id(), + new_owner_account_id, + mint_inputs, + NoteAttachment::default(), + &mut rng, + )?; + + let tx_context = mock_chain + .build_tx_context(updated_faucet.id(), &[], &[mint_note_new_owner])? + .build()?; + let executed_transaction = tx_context.execute().await?; + + // Verify that minting succeeded + assert_eq!(executed_transaction.output_notes().num_notes(), 1); + + Ok(()) +} + +/// Tests that only the owner can transfer ownership. +#[tokio::test] +async fn test_network_faucet_only_owner_can_transfer() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let non_owner_account_id = AccountId::dummy( + [2; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let new_owner_account_id = AccountId::dummy( + [3; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = builder.add_existing_network_faucet("NET", 1000, owner_account_id, Some(50))?; + let mock_chain = builder.build()?; + + // Create transfer ownership note script + let transfer_note_script_code = format!( + r#" + use miden::standards::faucets::network_fungible->network_faucet + + begin + repeat.14 push.0 end + push.{new_owner_suffix} + push.{new_owner_prefix} + call.network_faucet::transfer_ownership + dropw dropw dropw dropw + end + "#, + new_owner_prefix = new_owner_account_id.prefix().as_felt(), + new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_int()), + ); + + let source_manager = Arc::new(DefaultSourceManager::default()); + let transfer_note_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_note_script(transfer_note_script_code.clone())?; + + // Create a note from NON-OWNER that tries to transfer ownership + let mut rng = RpoRandomCoin::new([Felt::from(100u32); 4].into()); + let transfer_note = NoteBuilder::new(non_owner_account_id, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([10, 20, 30, 40u32])) + .code(transfer_note_script_code.clone()) + .build()?; + + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[], &[transfer_note])? + .add_note_script(transfer_note_script.clone()) + .with_source_manager(source_manager.clone()) + .build()?; + let result = tx_context.execute().await; + + // Verify that the transaction failed with ERR_ONLY_OWNER + let expected_error = ERR_SENDER_NOT_OWNER; + assert_transaction_executor_error!(result, expected_error); + + Ok(()) +} + +/// Tests that renounce_ownership clears the owner correctly. +#[tokio::test] +async fn test_network_faucet_renounce_ownership() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let owner_account_id = AccountId::dummy( + [1; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let new_owner_account_id = AccountId::dummy( + [2; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + + let faucet = builder.add_existing_network_faucet("NET", 1000, owner_account_id, Some(50))?; + + // Check stored value before renouncing + let stored_owner_before = + faucet.storage().get_item(NetworkFungibleFaucet::owner_config_slot())?; + assert_eq!(stored_owner_before[3], owner_account_id.prefix().as_felt()); + assert_eq!(stored_owner_before[2], Felt::new(owner_account_id.suffix().as_int())); + + // Create renounce_ownership note script + let renounce_note_script_code = r#" + use miden::standards::faucets::network_fungible->network_faucet + + begin + repeat.16 push.0 end + call.network_faucet::renounce_ownership + dropw dropw dropw dropw + end + "#; + + let source_manager = Arc::new(DefaultSourceManager::default()); + let renounce_note_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_note_script(renounce_note_script_code)?; + + // Create transfer note script (will be used after renounce) + let transfer_note_script_code = format!( + r#" + use miden::standards::faucets::network_fungible->network_faucet + + begin + repeat.14 push.0 end + push.{new_owner_suffix} + push.{new_owner_prefix} + call.network_faucet::transfer_ownership + dropw dropw dropw dropw + end + "#, + new_owner_prefix = new_owner_account_id.prefix().as_felt(), + new_owner_suffix = Felt::new(new_owner_account_id.suffix().as_int()), + ); + + let transfer_note_script = CodeBuilder::with_source_manager(source_manager.clone()) + .compile_note_script(transfer_note_script_code.clone())?; + + let mut rng = RpoRandomCoin::new([Felt::from(200u32); 4].into()); + let renounce_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([11, 22, 33, 44u32])) + .code(renounce_note_script_code) + .build()?; + + let mut rng = RpoRandomCoin::new([Felt::from(300u32); 4].into()); + let transfer_note = NoteBuilder::new(owner_account_id, &mut rng) + .note_type(NoteType::Private) + .tag(NoteTag::default().into()) + .serial_number(Word::from([50, 60, 70, 80u32])) + .code(transfer_note_script_code.clone()) + .build()?; + + builder.add_output_note(OutputNote::Full(renounce_note.clone())); + builder.add_output_note(OutputNote::Full(transfer_note.clone())); + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Execute renounce_ownership + let tx_context = mock_chain + .build_tx_context(faucet.id(), &[renounce_note.id()], &[])? + .add_note_script(renounce_note_script.clone()) + .with_source_manager(source_manager.clone()) + .build()?; + let executed_transaction = tx_context.execute().await?; + + mock_chain.add_pending_executed_transaction(&executed_transaction)?; + mock_chain.prove_next_block()?; + + let mut updated_faucet = faucet.clone(); + updated_faucet.apply_delta(executed_transaction.account_delta())?; + + // Check stored value after renouncing - should be zero + let stored_owner_after = + updated_faucet.storage().get_item(NetworkFungibleFaucet::owner_config_slot())?; + assert_eq!(stored_owner_after[0], Felt::new(0)); + assert_eq!(stored_owner_after[1], Felt::new(0)); + assert_eq!(stored_owner_after[2], Felt::new(0)); + assert_eq!(stored_owner_after[3], Felt::new(0)); + + // Try to transfer ownership - should fail because there's no owner + // The transfer note was already added to the builder, so we need to prove another block + // to make it available on-chain after the renounce transaction + mock_chain.prove_next_block()?; + + let tx_context = mock_chain + .build_tx_context(updated_faucet.id(), &[transfer_note.id()], &[])? + .add_note_script(transfer_note_script.clone()) + .with_source_manager(source_manager.clone()) + .build()?; + let result = tx_context.execute().await; + + let expected_error = ERR_SENDER_NOT_OWNER; + assert_transaction_executor_error!(result, expected_error); + + Ok(()) +} + // TESTS FOR FAUCET PROCEDURE COMPATIBILITY // ================================================================================================