diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb492ad20..e874762eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Added `miden::protocol::output_note::seal` to prevent further asset and attachment changes to an output note and `is_sealed` to query its sealing state ([#3923](https://github.com/0xMiden/protocol/pull/3923)). + ### Changes - [BREAKING] Linked standard and AggLayer account components dynamically against their libraries ([#3925](https://github.com/0xMiden/protocol/pull/3925)). diff --git a/crates/miden-protocol/asm/kernels/transaction-core/src/memory.masm b/crates/miden-protocol/asm/kernels/transaction-core/src/memory.masm index 2bd3bd2e73..13540fcd70 100644 --- a/crates/miden-protocol/asm/kernels/transaction-core/src/memory.masm +++ b/crates/miden-protocol/asm/kernels/transaction-core/src/memory.masm @@ -344,15 +344,16 @@ const OUTPUT_NOTE_DETAILS_COMMITMENT_OFFSET = 0 const OUTPUT_NOTE_METADATA_OFFSET = 4 const OUTPUT_NOTE_RECIPIENT_OFFSET = 8 const OUTPUT_NOTE_DIRTY_FLAG_OFFSET = 12 -const OUTPUT_NOTE_NUM_ASSETS_OFFSET = 13 -const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET = 14 -const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET = 15 -const OUTPUT_NOTE_ATTACHMENT_0_OFFSET = 16 -const OUTPUT_NOTE_ATTACHMENT_1_OFFSET = 20 -const OUTPUT_NOTE_ATTACHMENT_2_OFFSET = 24 -const OUTPUT_NOTE_ATTACHMENT_3_OFFSET = 28 -const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET = 32 -const OUTPUT_NOTE_ASSETS_OFFSET = 36 +const OUTPUT_NOTE_SEALED_FLAG_OFFSET = 13 +const OUTPUT_NOTE_NUM_ASSETS_OFFSET = 16 +const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET = 17 +const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET = 18 +const OUTPUT_NOTE_ATTACHMENT_0_OFFSET = 20 +const OUTPUT_NOTE_ATTACHMENT_1_OFFSET = 24 +const OUTPUT_NOTE_ATTACHMENT_2_OFFSET = 28 +const OUTPUT_NOTE_ATTACHMENT_3_OFFSET = 32 +const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET = 36 +const OUTPUT_NOTE_ASSETS_OFFSET = 40 # LINK MAP MEMORY # ------------------------------------------------------------------------------------------------- @@ -2145,6 +2146,22 @@ pub proc set_output_note_dirty_flag(output_note_data_ptr: ptr, d add.OUTPUT_NOTE_DIRTY_FLAG_OFFSET mem_store end +#! Returns whether the output note has been sealed against further mutations. +#! +#! Inputs: [output_note_data_ptr] +#! Outputs: [sealed] +pub proc is_output_note_sealed(output_note_data_ptr: ptr) -> Bool + add.OUTPUT_NOTE_SEALED_FLAG_OFFSET mem_load +end + +#! Seals an output note for the remainder of the transaction. +#! +#! Inputs: [output_note_data_ptr] +#! Outputs: [] +pub proc seal_output_note(output_note_data_ptr: ptr) + push.1 swap add.OUTPUT_NOTE_SEALED_FLAG_OFFSET mem_store +end + #! Returns a pointer to the output note asset data. #! #! Inputs: [output_note_data_ptr] diff --git a/crates/miden-protocol/asm/kernels/transaction-core/src/output_note.masm b/crates/miden-protocol/asm/kernels/transaction-core/src/output_note.masm index e550e6013b..3ea9a48358 100644 --- a/crates/miden-protocol/asm/kernels/transaction-core/src/output_note.masm +++ b/crates/miden-protocol/asm/kernels/transaction-core/src/output_note.masm @@ -41,6 +41,8 @@ const ATTACHMENT_RESERVED_SCHEME = 0 const ERR_TX_NUMBER_OF_OUTPUT_NOTES_EXCEEDS_LIMIT = "number of output notes in the transaction exceeds the maximum limit of 1024" +const ERR_OUTPUT_NOTE_IS_SEALED = "sealed output notes cannot be modified" + const ERR_NOTE_INVALID_TYPE = "invalid note type" const ERR_OUTPUT_NOTE_INDEX_OUT_OF_BOUNDS = @@ -247,6 +249,7 @@ end #! - the max amount of fungible assets is exceeded. #! - the non-fungible asset already exists in the note. #! - the total number of ASSETs exceeds the maximum of 16. +#! - the output note is sealed, including by the asset callback. pub proc add_asset(asset: Asset, note_idx: u16) # reject assets with a Custom composition; only None and Fungible are supported today exec.asset::assert_supported_composition @@ -270,7 +273,10 @@ pub proc add_asset(asset: Asset, note_idx: u16) exec.callbacks::on_before_asset_added_to_note # => [ASSET_ID, ASSET_VALUE, note_idx] - movup.8 exec.memory::get_output_note_ptr dup + movup.8 exec.memory::get_output_note_ptr + # Check after the callback, which can itself seal the note. + dup exec.memory::is_output_note_sealed assertz.err=ERR_OUTPUT_NOTE_IS_SEALED + dup # => [note_ptr, note_ptr, ASSET_ID, ASSET_VALUE] movdn.9 movdn.9 @@ -313,6 +319,7 @@ end #! - the number of elements is not a multiple of 4, or num_words is zero or exceeds 256. #! - the computed hash of fetched attachment elements does not match ATTACHMENT_COMMITMENT. #! - the note already has 4 attachments. +#! - the output note is sealed. pub proc add_attachment( attachment_scheme: u16, attachment_commitment: NoteAttachmentCommitment, @@ -341,6 +348,9 @@ pub proc add_attachment( movup.6 exec.memory::get_output_note_ptr # => [note_ptr, attachment_scheme, num_words, ATTACHMENT_COMMITMENT] + dup exec.memory::is_output_note_sealed assertz.err=ERR_OUTPUT_NOTE_IS_SEALED + # => [note_ptr, attachment_scheme, num_words, ATTACHMENT_COMMITMENT] + # update and validate total attachment words dup exec.memory::get_output_note_total_attachment_words # => [total_num_words, note_ptr, attachment_scheme, num_words, ATTACHMENT_COMMITMENT] diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/api.masm b/crates/miden-protocol/asm/kernels/transaction/lib/api.masm index e875e57bb0..ccfecac2f9 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/api.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/api.masm @@ -1560,6 +1560,7 @@ end #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. +#! - the output note is sealed. #! #! Invocation: dynexec pub proc output_note_add_asset(asset: Asset, note_idx: u16, padding: [felt; 7]) -> [felt; 16] @@ -1574,6 +1575,49 @@ pub proc output_note_add_asset(asset: Asset, note_idx: u16, padding: [felt; 7]) # => [pad(16)] end +#! Prevents further changes to the specified output note's assets and attachments. +#! Sealing is idempotent. +#! +#! Inputs: [note_idx, pad(15)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the active account is not the native account. +#! - the note index points to a non-existent output note. +#! +#! Invocation: dynexec +pub proc output_note_seal(note_idx: u16, padding: [felt; 15]) -> [felt; 16] + exec.memory::assert_native_account + exec.output_note::assert_note_index_in_bounds + # => [note_idx, pad(15)] + + exec.memory::get_output_note_ptr + # => [note_ptr, pad(15)] + + exec.memory::seal_output_note + # => [pad(16)] +end + +#! Returns whether the specified output note is sealed against asset and attachment changes. +#! +#! Inputs: [note_idx, pad(15)] +#! Outputs: [is_sealed, pad(15)] +#! +#! Panics if: +#! - the note index points to a non-existent output note. +#! +#! Invocation: dynexec +pub proc output_note_is_sealed(note_idx: u16, padding: [felt; 15]) -> (Bool, [felt; 15]) + exec.output_note::assert_note_index_in_bounds + # => [note_idx, pad(15)] + + exec.memory::get_output_note_ptr + # => [note_ptr, pad(15)] + + exec.memory::is_output_note_sealed + # => [is_sealed, pad(15)] +end + #! Adds an attachment to the note specified by the index. #! #! Inputs: [attachment_scheme, ATTACHMENT, note_idx, pad(10)] @@ -1587,6 +1631,7 @@ end #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. +#! - the output note is sealed. #! - the attachment scheme is 0 or exceeds 65534. #! - the attachment num_words exceeds 256 or is zero. #! - the note already has 4 attachments. diff --git a/crates/miden-protocol/asm/protocol/src/kernel_proc_offsets.masm b/crates/miden-protocol/asm/protocol/src/kernel_proc_offsets.masm index d4b97344b7..9919e67374 100644 --- a/crates/miden-protocol/asm/protocol/src/kernel_proc_offsets.masm +++ b/crates/miden-protocol/asm/protocol/src/kernel_proc_offsets.masm @@ -75,33 +75,35 @@ pub const OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET = 43 pub const OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET = 44 pub const OUTPUT_NOTE_ADD_ASSET_OFFSET = 45 pub const OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET = 46 +pub const OUTPUT_NOTE_SEAL_OFFSET = 47 +pub const OUTPUT_NOTE_IS_SEALED_OFFSET = 48 ### Tx ########################################## # input notes -pub const TX_GET_NUM_INPUT_NOTES_OFFSET = 47 -pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET = 48 +pub const TX_GET_NUM_INPUT_NOTES_OFFSET = 49 +pub const TX_GET_INPUT_NOTES_COMMITMENT_OFFSET = 50 # output notes -pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET = 49 -pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET = 50 +pub const TX_GET_NUM_OUTPUT_NOTES_OFFSET = 51 +pub const TX_GET_OUTPUT_NOTES_COMMITMENT_OFFSET = 52 # block info -pub const TX_GET_BLOCK_COMMITMENT_OFFSET = 51 -pub const TX_GET_REFERENCE_BLOCK_NUMBER_OFFSET = 52 -pub const TX_GET_BLOCK_TIMESTAMP_OFFSET = 53 +pub const TX_GET_BLOCK_COMMITMENT_OFFSET = 53 +pub const TX_GET_REFERENCE_BLOCK_NUMBER_OFFSET = 54 +pub const TX_GET_BLOCK_TIMESTAMP_OFFSET = 55 # foreign context -pub const TX_PREPARE_FPI_OFFSET = 54 -pub const TX_EXEC_FOREIGN_PROC_OFFSET = 55 +pub const TX_PREPARE_FPI_OFFSET = 56 +pub const TX_EXEC_FOREIGN_PROC_OFFSET = 57 # expiration data -pub const TX_GET_EXPIRATION_DELTA_OFFSET = 56 # accessor -pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET = 57 # mutator +pub const TX_GET_EXPIRATION_DELTA_OFFSET = 58 # accessor +pub const TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET = 59 # mutator # tx script -pub const TX_GET_TX_SCRIPT_ROOT_OFFSET = 58 +pub const TX_GET_TX_SCRIPT_ROOT_OFFSET = 60 # fee -pub const TX_COMPUTE_FEE_OFFSET = 59 -pub const TX_GET_FEE_ASSET_ID_OFFSET = 60 # accessor +pub const TX_COMPUTE_FEE_OFFSET = 61 +pub const TX_GET_FEE_ASSET_ID_OFFSET = 62 # accessor diff --git a/crates/miden-protocol/asm/protocol/src/output_note.masm b/crates/miden-protocol/asm/protocol/src/output_note.masm index ec32537914..56a627d7ea 100644 --- a/crates/miden-protocol/asm/protocol/src/output_note.masm +++ b/crates/miden-protocol/asm/protocol/src/output_note.masm @@ -13,7 +13,7 @@ use miden::protocol::note use miden::protocol::note_internal use miden::core::crypto::hashes::poseidon2 use {WORD_NUM_ELEMENTS} from miden::protocol::constants -use {OUTPUT_NOTE_ADD_ASSET_OFFSET, OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET, OUTPUT_NOTE_CREATE_OFFSET, OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET, OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET, OUTPUT_NOTE_GET_METADATA_OFFSET, OUTPUT_NOTE_GET_RECIPIENT_OFFSET} +use {OUTPUT_NOTE_ADD_ASSET_OFFSET, OUTPUT_NOTE_ADD_ATTACHMENT_OFFSET, OUTPUT_NOTE_CREATE_OFFSET, OUTPUT_NOTE_GET_ASSETS_INFO_OFFSET, OUTPUT_NOTE_GET_ATTACHMENTS_COMMITMENT_OFFSET, OUTPUT_NOTE_GET_METADATA_OFFSET, OUTPUT_NOTE_GET_RECIPIENT_OFFSET, OUTPUT_NOTE_IS_SEALED_OFFSET, OUTPUT_NOTE_SEAL_OFFSET} from miden::protocol::kernel_proc_offsets # PROCEDURES @@ -57,6 +57,55 @@ pub proc create(tag: NoteTag, note_type: NoteType, recipient: NoteRecipient) -> # => [note_idx] end +#! Prevents further changes to an output note's assets and attachments. +#! +#! Call this after fully populating an output whose final NoteId must remain fixed. +#! Sealing is idempotent. +#! +#! Inputs: [note_index] +#! Outputs: [] +#! +#! Panics if: +#! - the active account is not the native account. +#! - the note index is greater or equal to the total number of output notes. +#! +#! Invocation: exec +pub proc seal(note_index: u16) + # Pad above the caller's remaining stack before bringing the note index to the top. + padw padw padw push.0.0 + movup.14 push.OUTPUT_NOTE_SEAL_OFFSET + # => [offset, note_index, pad(14)] + + syscall.exec_kernel_proc + # => [pad(16)] + + dropw dropw dropw dropw + # => [] +end + +#! Returns whether the specified output note is sealed against asset and attachment changes. +#! +#! Inputs: [note_index] +#! Outputs: [is_sealed] +#! +#! Panics if: +#! - the note index is greater or equal to the total number of output notes. +#! +#! Invocation: exec +pub proc is_sealed(note_index: u16) -> Bool + # Pad above the caller's remaining stack before bringing the note index to the top. + padw padw padw push.0.0 + movup.14 push.OUTPUT_NOTE_IS_SEALED_OFFSET + # => [offset, note_index, pad(14)] + + syscall.exec_kernel_proc + # => [is_sealed, pad(15)] + + # Remove padding while preserving the result. + swapw.3 dropw dropw dropw movdn.3 drop drop drop + # => [is_sealed] +end + #! Returns the information about assets in the output note with the specified index. #! #! This information can then be used to retrieve the actual assets from the advice map. @@ -177,6 +226,11 @@ end #! - ASSET_ID is the asset ID of the asset to add. #! - ASSET_VALUE is the value of the asset to add. #! +#! Panics if: +#! - the output note is sealed. +#! - the note index points to a non-existent output note. +#! - the active account is not the native account. +#! #! Invocation: exec pub proc add_asset(asset: Asset, note_idx: u16) push.OUTPUT_NOTE_ADD_ASSET_OFFSET @@ -218,6 +272,7 @@ end #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. +#! - the output note is sealed. #! - the attachment scheme is 0 or exceeds 65534. #! - the note already has 4 attachments. #! - the number of words in the attachment exceeds 256. @@ -260,6 +315,7 @@ end #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. +#! - the output note is sealed. #! - the attachment scheme is 0 or exceeds 65534. #! - the note already has 4 attachments. #! - the total number of attachment words in the note exceeds 512. @@ -319,6 +375,7 @@ end #! Panics if: #! - the procedure is called when the active account is not the native one. #! - the note index points to a non-existent output note. +#! - the output note is sealed. #! - the attachment scheme is 0 or exceeds 65534. #! - the note already has 4 attachments. #! - the number of words in the attachment exceeds 256. diff --git a/crates/miden-protocol/src/transaction/kernel/memory.rs b/crates/miden-protocol/src/transaction/kernel/memory.rs index d3175dfa06..a928ae9a65 100644 --- a/crates/miden-protocol/src/transaction/kernel/memory.rs +++ b/crates/miden-protocol/src/transaction/kernel/memory.rs @@ -474,23 +474,26 @@ pub const INPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 48; // The total number of output notes for a transaction is stored in the bookkeeping section of the // memory. Data section of each note is laid out like so: // -// ┌──────────────┬──────────┬───────────┬───────────────────────────────────────────┬ -// │ NOTE DETAILS │ METADATA │ RECIPIENT │ [dirty_flag, num_assets, │ -// │ COMMITMENT │ │ │ num_attachments, total_attachment_words] │ -// ├──────────────┼──────────┼───────────┼───────────────────────────────────────────┼ -// 0 4 8 12 +// | Offset | Section | +// | ------- | -------------------------------------------------------- | +// | 0 | NOTE DETAILS COMMITMENT | +// | 4 | METADATA | +// | 8 | RECIPIENT | +// | 12 | [dirty_flag, sealed_flag, 0, 0] | +// | 16 | [num_assets, num_attachments, total_attachment_words, 0] | +// | 20 | ATTACHMENT 0 | +// | 24 | ATTACHMENT 1 | +// | 28 | ATTACHMENT 2 | +// | 32 | ATTACHMENT 3 | +// | 36 | ASSETS COMMITMENT | +// | 40 + 8i | ASSET KEY i | +// | 44 + 8i | ASSET VALUE i | +// | 40 + 8n | PADDING | // -// ┬────────────┬────────────┬────────────┬────────────┬────────────┬ -// │ ATTACHMENT │ ATTACHMENT │ ATTACHMENT │ ATTACHMENT │ ASSETS │ -// │ 0 │ 1 │ 2 │ 3 │ COMMITMENT │ -// ┼────────────┼────────────┼────────────┼────────────┼────────────┼ -// 16 20 24 28 32 +// Offsets are measured in field elements, where n = num_assets and 0 <= i < n. // -// ┬───────┬─────────┬─────┬────────┬─────────┬─────────┐ -// │ ASSET │ ASSET │ ... │ ASSET │ ASSET │ PADDING │ -// │ KEY 0 │ VALUE 0 │ │ KEY n │ VALUE n │ │ -// ┼───────┼─────────┼─────┼────────┼─────────┼─────────┘ -// 36 40 36 + 8n 40 + 8n +// The sealed flag starts at zero. Once set to one, asset and attachment mutations are rejected; it is +// excluded from note commitments and serialization. // // The DIRTY_FLAG is the binary flag which specifies whether the assets commitment stored in this // note is outdated. It holds 1 if some changes were made to the note assets since the last @@ -507,15 +510,16 @@ pub const OUTPUT_NOTE_DETAILS_COMMITMENT_OFFSET: MemoryOffset = 0; pub const OUTPUT_NOTE_METADATA_OFFSET: MemoryOffset = 4; pub const OUTPUT_NOTE_RECIPIENT_OFFSET: MemoryOffset = 8; pub const OUTPUT_NOTE_DIRTY_FLAG_OFFSET: MemoryOffset = 12; -pub const OUTPUT_NOTE_NUM_ASSETS_OFFSET: MemoryOffset = 13; -pub const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET: MemoryOffset = 14; -pub const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET: MemoryOffset = 15; -pub const OUTPUT_NOTE_ATTACHMENT_0_OFFSET: MemoryOffset = 16; -pub const OUTPUT_NOTE_ATTACHMENT_1_OFFSET: MemoryOffset = 20; -pub const OUTPUT_NOTE_ATTACHMENT_2_OFFSET: MemoryOffset = 24; -pub const OUTPUT_NOTE_ATTACHMENT_3_OFFSET: MemoryOffset = 28; -pub const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET: MemoryOffset = 32; -pub const OUTPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 36; +pub const OUTPUT_NOTE_SEALED_FLAG_OFFSET: MemoryOffset = 13; +pub const OUTPUT_NOTE_NUM_ASSETS_OFFSET: MemoryOffset = 16; +pub const OUTPUT_NOTE_NUM_ATTACHMENTS_OFFSET: MemoryOffset = 17; +pub const OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_OFFSET: MemoryOffset = 18; +pub const OUTPUT_NOTE_ATTACHMENT_0_OFFSET: MemoryOffset = 20; +pub const OUTPUT_NOTE_ATTACHMENT_1_OFFSET: MemoryOffset = 24; +pub const OUTPUT_NOTE_ATTACHMENT_2_OFFSET: MemoryOffset = 28; +pub const OUTPUT_NOTE_ATTACHMENT_3_OFFSET: MemoryOffset = 32; +pub const OUTPUT_NOTE_ASSETS_COMMITMENT_OFFSET: MemoryOffset = 36; +pub const OUTPUT_NOTE_ASSETS_OFFSET: MemoryOffset = 40; // ASSETS // ------------------------------------------------------------------------------------------------ diff --git a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs index 0efc421786..313820d86e 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_callbacks.rs @@ -34,6 +34,7 @@ use miden_protocol::block::account_tree::AccountIdKey; use miden_protocol::errors::MasmError; use miden_protocol::errors::tx_kernel::{ ERR_FAUCET_CALLBACK_PROC_ROOT_NOT_PART_OF_ACCOUNT_CODE, + ERR_OUTPUT_NOTE_IS_SEALED, ERR_PROLOGUE_CALLBACK_SLOT_REQUIRES_ENABLED_ASSET_CALLBACK_FLAG, }; use miden_protocol::note::{NoteTag, NoteType}; @@ -683,8 +684,11 @@ async fn test_on_before_asset_added_to_note_callback_receives_correct_inputs() - /// target of the callback. /// /// This is a regression test for https://github.com/0xMiden/protocol/issues/2864. +#[rstest::rstest] +#[case::unsealed(false)] +#[case::sealed_in_callback(true)] #[tokio::test] -async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { +async fn test_faucet_with_callback_calls_itself(#[case] seal: bool) -> anyhow::Result<()> { let mut builder = MockChain::builder(); let account_callback_masm = r#" @@ -697,21 +701,30 @@ async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { end "#; - let note_callback_masm = r#" + // A native faucet callback can seal the note while add_asset is in progress. + let note_callback_masm = format!( + r#" #! Inputs: [ASSET_ID, ASSET_VALUE, note_idx, pad(7)] #! Outputs: [pad(16)] @account_procedure pub proc on_before_asset_added_to_note - dropw dropw drop + dropw dropw + {finish_note} # => [pad(16)] end - "#; + "#, + finish_note = if seal { + "exec.::miden::protocol::output_note::seal" + } else { + "drop" + } + ); - // Build an account that has both callbacks set to no-ops. + // Build an account with both callbacks. let faucet = add_faucet_with_callbacks( &mut builder, Some(account_callback_masm), - Some(note_callback_masm), + Some(¬e_callback_masm), )?; let recipient = Word::from([0, 1, 2, 3u32]); @@ -748,12 +761,17 @@ async fn test_faucet_with_callback_calls_itself() -> anyhow::Result<()> { let tx_script = CodeBuilder::default().compile_tx_script(tx_script_code)?; let mock_chain = builder.build()?; - mock_chain + let result = mock_chain .build_transaction(faucet.id()) .tx_script(tx_script) .build()? .execute() - .await?; + .await; + if seal { + assert_transaction_executor_error!(result, ERR_OUTPUT_NOTE_IS_SEALED); + } else { + result?; + } Ok(()) } diff --git a/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs b/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs index e53218416c..8dd5fd0f64 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_fpi.rs @@ -1912,11 +1912,16 @@ async fn test_fpi_get_account_id() -> anyhow::Result<()> { Ok(()) } -/// Test that `native_account::get_initial_item` cannot be called against a foreign account: it is a -/// native-account-only procedure, so invoking it from an FPI context must fail via +/// Native-only procedures, including output note sealing, cannot be called from a foreign account. +/// Invoking them from an FPI context must fail via /// `memory::assert_native_account` with `ERR_ACCOUNT_IS_NOT_NATIVE`. +#[rstest::rstest] +#[case::get_initial_item("push.MOCK_VALUE_SLOT0[0..2] exec.native_account::get_initial_item")] +#[case::seal_output_note("push.0 exec.::miden::protocol::output_note::seal")] #[tokio::test] -async fn get_initial_item_fails_for_foreign_account() -> anyhow::Result<()> { +async fn native_only_procedure_fails_for_foreign_account( + #[case] native_call: &str, +) -> anyhow::Result<()> { let native_account = AccountBuilder::new(rand::random()) .with_components(Auth::IncrNonce) .with_component(MockAccountComponent::with_empty_slots()) @@ -1925,7 +1930,7 @@ async fn get_initial_item_fails_for_foreign_account() -> anyhow::Result<()> { let mock_value_slot0 = AccountStorage::mock_value_slot0(); - // Foreign procedure that attempts to call the native-only get_initial_item. + // Foreign procedure that attempts to call a native-only API. let foreign_account_code_source = format!( r#" use miden::protocol::native_account @@ -1934,9 +1939,8 @@ async fn get_initial_item_fails_for_foreign_account() -> anyhow::Result<()> { const MOCK_VALUE_SLOT0 = word("{mock_value_slot0}") @account_procedure - pub proc test_get_initial_item - push.MOCK_VALUE_SLOT0[0..2] - exec.native_account::get_initial_item + pub proc test_native_only_procedure + {native_call} exec.sys::truncate_stack end "#, @@ -1969,9 +1973,9 @@ async fn get_initial_item_fails_for_foreign_account() -> anyhow::Result<()> { @transaction_script pub proc main - # attempt to call the native-only get_initial_item on a foreign account + # attempt to call the native-only procedure on a foreign account padw padw padw push.0.0.0 - procref.::foreign_account::test_get_initial_item + procref.::foreign_account::test_native_only_procedure push.{foreign_account_id_prefix} push.{foreign_account_id_suffix} exec.tx::execute_foreign_procedure @@ -1999,6 +2003,81 @@ async fn get_initial_item_fails_for_foreign_account() -> anyhow::Result<()> { Ok(()) } +/// A foreign account can query the sealing state of outputs created by the native account. +#[tokio::test] +async fn foreign_account_can_query_output_note_sealing() -> anyhow::Result<()> { + let foreign_account_component = AccountComponent::new( + CodeBuilder::default().compile_component_code( + "foreign_account", + " + use miden::protocol::output_note + + @account_procedure + pub proc check_output_note_sealing + push.0 exec.output_note::is_sealed assert + push.1 exec.output_note::is_sealed assertz + end + ", + )?, + vec![], + AccountComponentMetadata::mock("foreign_account"), + )?; + let foreign_account = AccountBuilder::new(rand::random()) + .with_components(Auth::IncrNonce) + .with_component(foreign_account_component.clone()) + .build_existing()?; + let native_account = AccountBuilder::new(rand::random()) + .with_components(Auth::IncrNonce) + .with_component(MockAccountComponent::with_empty_slots()) + .account_type(AccountType::Public) + .build_existing()?; + + let mut mock_chain = + MockChainBuilder::with_accounts([native_account.clone(), foreign_account.clone()])? + .build()?; + mock_chain.prove_next_block()?; + let foreign_account_inputs = mock_chain.get_foreign_account_inputs(foreign_account.clone())?; + + let code = format!( + " + use miden::core::sys + use miden::protocol::output_note + use miden::protocol::tx + use {{NOTE_TYPE_PRIVATE}} from miden::protocol::note + + @transaction_script + pub proc main + # Use different recipients to keep the outputs distinct; seal only the first. + push.1.2.3.4 push.NOTE_TYPE_PRIVATE.0 + call.::mock::account::create_note exec.output_note::seal + push.5.6.7.8 push.NOTE_TYPE_PRIVATE.0 + call.::mock::account::create_note drop + + padw padw padw push.0.0.0 + procref.::foreign_account::check_output_note_sealing + push.{foreign_prefix} push.{foreign_suffix} + exec.tx::execute_foreign_procedure + exec.sys::truncate_stack + end + ", + foreign_prefix = foreign_account.id().prefix().as_felt(), + foreign_suffix = foreign_account.id().suffix(), + ); + let tx_script = CodeBuilder::with_mock_packages() + .with_dynamically_linked_package(foreign_account_component.component_code())? + .compile_tx_script(code)?; + + mock_chain + .build_transaction(native_account.id()) + .foreign_accounts([foreign_account_inputs]) + .tx_script(tx_script) + .build()? + .execute() + .await?; + + Ok(()) +} + // HELPER FUNCTIONS // ================================================================================================ diff --git a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs index afe94e7cc0..cc94b3f3a0 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_output_note.rs @@ -14,6 +14,7 @@ use miden_protocol::errors::tx_kernel::{ ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MAX_EXCEEDED, ERR_OUTPUT_NOTE_ATTACHMENT_SIZE_MUST_BE_MULTIPLE_OF_WORD_SIZE, ERR_OUTPUT_NOTE_INDEX_OUT_OF_BOUNDS, + ERR_OUTPUT_NOTE_IS_SEALED, ERR_OUTPUT_NOTE_TOO_MANY_ATTACHMENTS, ERR_OUTPUT_NOTE_TOTAL_ATTACHMENT_WORDS_EXCEEDED, ERR_TX_NUMBER_OF_OUTPUT_NOTES_EXCEEDS_LIMIT, @@ -2051,6 +2052,8 @@ async fn test_add_attachments_with_too_many_overall_elements_fails() -> anyhow:: /// procedure under test with index 1, which is out of bounds. The bounds assertion fires before /// any parameter validation, so dummy values are sufficient. #[rstest] +#[case::seal(0, "seal")] +#[case::is_sealed(0, "is_sealed")] #[case::add_asset(8, "add_asset")] #[case::get_assets_info(0, "get_assets_info")] #[case::get_assets(1, "get_assets")] @@ -2111,9 +2114,137 @@ async fn test_output_note_index_out_of_bounds( Ok(()) } -// HELPER FUNCTIONS +/// A note script can seal a private output before returning control to the transaction script. +/// Both account procedures and direct transaction-script mutations must respect the seal. +#[rstest] +#[case::unchanged(OutputNoteMutation::None)] +#[case::merge_asset_via_account(OutputNoteMutation::Asset(FungibleAsset::mock(10)))] +#[case::append_asset_via_account(OutputNoteMutation::Asset(NonFungibleAsset::mock(&[1, 2, 3])))] +#[case::attachment_via_tx_script(OutputNoteMutation::Attachment)] +#[tokio::test] +async fn test_private_output_sealed_by_note_script( + #[case] mutation: OutputNoteMutation, +) -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet_with_assets( + Auth::IncrNonce, + [FungibleAsset::mock(20), NonFungibleAsset::mock(&[1, 2, 3])], + )?; + let asset = FungibleAsset::mock(10); + let attachment_word = Word::from([3, 4, 5, 6u32]); + let attachment_scheme = NoteAttachmentScheme::new(10)?; + let expected_note = NoteBuilder::new(account.id(), RandomCoin::new(Word::empty())) + .note_type(NoteType::Private) + .add_assets([asset]) + .attachment(NoteAttachment::with_word(attachment_scheme, attachment_word)) + .build()?; + let input_note = NoteBuilder::new(account.id(), RandomCoin::new(Word::empty())) + .dynamically_linked_packages(CodeBuilder::mock_packages()) + .code(format!( + "use miden::protocol::output_note + @note_script + pub proc main + {} + # => [note_index] + + dup push.{attachment_word} push.{attachment_scheme} + # => [attachment_scheme, ATTACHMENT, note_index, note_index] + + exec.output_note::add_word_attachment + # => [note_index] + + # keep a nonzero word below the index to check stack preservation and idempotence + push.91.92.93.94 movup.4 + # => [note_index, SENTINEL] + + dup exec.output_note::is_sealed assertz + # => [note_index, SENTINEL] + + dup exec.output_note::seal + dup exec.output_note::is_sealed assert + # => [note_index, SENTINEL] + + dup exec.output_note::seal + exec.output_note::is_sealed assert + # => [SENTINEL] + + push.91.92.93.94 assert_eqw + # => [] + + exec.::miden::core::sys::truncate_stack + end", + create_output_note(&expected_note), + attachment_scheme = attachment_scheme.as_u16(), + )) + .build()?; + builder.add_output_note(RawOutputNote::Full(input_note.clone())); + let chain = builder.build()?; + let mutation_code = match mutation { + OutputNoteMutation::None => String::new(), + OutputNoteMutation::Asset(added_asset) => format!( + "push.0 push.{asset_value} push.{asset_id} + # => [ASSET_ID, ASSET_VALUE, note_index = 0] + + call.::miden::standards::wallets::basic::move_asset_to_note + # => [pad(16)]", + asset_value = added_asset.to_value_word(), + asset_id = added_asset.to_id_word(), + ), + OutputNoteMutation::Attachment => String::from( + "push.0 push.1.2.3.4 push.5 + # => [attachment_scheme, ATTACHMENT, note_index = 0] + + exec.output_note::add_word_attachment + # => []", + ), + }; + let tx_script = CodeBuilder::default().compile_tx_script(format!( + "use miden::protocol::output_note + @transaction_script + pub proc main + # reading the commitment must preserve the seal established by the note script + push.0 exec.output_note::compute_note_id + # => [NOTE_ID] + + push.{expected_note_id} assert_eqw + # => [] + + push.0 exec.output_note::is_sealed assert + # => [] + + {mutation_code} + + exec.::miden::core::sys::truncate_stack + end", + expected_note_id = expected_note.id().as_word(), + ))?; + let result = chain + .build_transaction(account.id()) + .authenticated_input_note(input_note.id()) + .expected_output_note(RawOutputNote::Full(expected_note.clone())) + .tx_script(tx_script) + .build()? + .execute() + .await; + if matches!(mutation, OutputNoteMutation::None) { + let executed = result?; + assert_eq!(executed.output_notes().get_note(0).id(), expected_note.id()); + } else { + assert_transaction_executor_error!(result, ERR_OUTPUT_NOTE_IS_SEALED); + } + Ok(()) +} + +// HELPERS // ================================================================================================ +#[derive(Debug, Clone, Copy)] +enum OutputNoteMutation { + None, + Asset(Asset), + Attachment, +} + /// Returns a `masm` code which creates an output note and adds some assets to it. /// /// Data for the created output note and moved assets is obtained from the provided note. diff --git a/docs/src/protocol_library.md b/docs/src/protocol_library.md index c967ff533b..7946989d3d 100644 --- a/docs/src/protocol_library.md +++ b/docs/src/protocol_library.md @@ -143,6 +143,8 @@ Output note procedures can be used to fetch data on output notes created by the | `create` | Creates a new output note and returns its index.

**Inputs:** `[tag, note_type, RECIPIENT]`
**Outputs:** `[note_idx]` | Native & Account | | `get_assets_info` | Returns the information about assets in the output note with the specified index.

**Inputs:** `[note_index]`
**Outputs:** `[ASSETS_COMMITMENT, num_assets]` | Any | | `get_assets` | Writes the assets of the output note with the specified index into memory starting at the specified address.

**Inputs:** `[dest_ptr, note_index]`
**Outputs:** `[num_assets, dest_ptr, note_index]` | Any | +| `seal` | Prevents further asset and attachment changes to an existing output note for the rest of the transaction. Sealing is idempotent.

**Inputs:** `[note_idx]`
**Outputs:** `[]` | Native | +| `is_sealed` | Returns whether the specified output note is sealed against asset and attachment changes.

**Inputs:** `[note_idx]`
**Outputs:** `[is_sealed]` | Any | | `add_asset` | Adds the asset to the output note specified by the index.

**Inputs:** `[ASSET_ID, ASSET_VALUE, note_idx]`
**Outputs:** `[]` | Native | | `add_attachment` | Adds an attachment to the note specified by the index. There must be an advice map entry for ATTACHMENT_COMMITMENT that maps to the raw attachment elements.

**Inputs:**
`Operand Stack: [attachment_scheme, ATTACHMENT_COMMITMENT, note_idx]`
`Advice map: { ATTACHMENT_COMMITMENT: [[ATTACHMENT_ELEMENTS]] }`
**Outputs:** `[]` | Native | | `add_word_attachment` | Adds a single-word attachment to the note specified by the note index. Hashes the raw attachment word to produce the commitment, inserts the raw elements into the advice map, then delegates to `add_attachment`.

**Inputs:** `[attachment_scheme, ATTACHMENT, note_idx]`
**Outputs:** `[]` | Native |