diff --git a/CHANGELOG.md b/CHANGELOG.md index c7857b8be5..37970ebbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- [BREAKING] Implemented partial batch kernel verification to check the transaction list against `BATCH_ID` and compute `INPUT_NOTES_COMMITMENT`; `BatchExecutor::execute` now additionally takes caller `AdviceInputs` ([#2905](https://github.com/0xMiden/protocol/pull/2905)). + ### Changes - [BREAKING] Moved the internal shared helpers of `miden::protocol::input_note`, `miden::protocol::active_note`, and the note memory-write helpers into private `input_note_internal` and `note_internal` modules ([#3501](https://github.com/0xMiden/protocol/pull/3501)). diff --git a/crates/miden-protocol/asm/kernels/batch/lib/epilogue.masm b/crates/miden-protocol/asm/kernels/batch/lib/epilogue.masm new file mode 100644 index 0000000000..1d07106ded --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/epilogue.masm @@ -0,0 +1,187 @@ +use miden::core::crypto::hashes::poseidon2 + +use miden::batch_kernel::memory +use { + INPUT_NOTE_CONSUMPTION_OFFSET, + INPUT_NOTE_ERASED, + INPUT_NOTE_ERASURE_EXPECTED, + NOTE_ENTRY_FELT_LEN, + OUTPUT_NOTE_IS_CREATED_OFFSET, +} from miden::batch_kernel::memory +use {ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED} from miden::batch_kernel::errors + +# ERRORS +# ================================================================================================= + +const ERR_BATCH_INPUT_NOTE_NOT_CONSUMED = + "an input-note list entry was not consumed by any transaction" + +const ERR_BATCH_OUTPUT_NOTE_NOT_CREATED = + "an output-note list entry was not created by any transaction" + +# ASSERTIONS +# ================================================================================================= + +#! Asserts every output-note list entry was created by exactly one transaction (with the per-tx +#! binding, this proves the list is exactly the union of the per-transaction output notes, so a host +#! cannot fabricate an erasure with a note no transaction creates). +#! +#! The matching input-note checks (every entry consumed exactly once, none left pending-erasure) are +#! folded into the single pass in [`compute_input_notes_commitment`]. +#! +#! Inputs: [] +#! Outputs: [] +proc assert_all_output_notes_created + exec.memory::get_num_output_notes + # => [num_output_notes] + + # Iterate from num_output_notes - 1 down to 0. + dup neq.0 + while.true + sub.1 + # => [idx] + + dup exec.memory::output_note_flags_ptr add.OUTPUT_NOTE_IS_CREATED_OFFSET mem_load + # => [is_created, idx] + assert.err=ERR_BATCH_OUTPUT_NOTE_NOT_CREATED + + dup neq.0 + # => [should_loop, idx] + end + drop +end + +# INPUT NOTES COMMITMENT +# ================================================================================================= + +#! Absorbs input-note list entry `idx`'s 8-felt `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuple into the batch +#! hasher state held in memory (overwrite-mode poseidon2, matching `Hasher::hash_elements`). +#! +#! Inputs: [idx] +#! Outputs: [] +proc absorb_input_entry + # The entry occupies one double word [entry_ptr, entry_ptr + NOTE_ENTRY_FELT_LEN). + exec.memory::input_note_entry_ptr + # => [entry_ptr] + dup add.NOTE_ENTRY_FELT_LEN swap + # => [entry_ptr, end_ptr] + exec.memory::load_batch_hasher_state + # => [RATE0, RATE1, CAPACITY, entry_ptr, end_ptr] + exec.poseidon2::absorb_double_words_from_memory + # => [RATE0', RATE1', CAPACITY', end_ptr, end_ptr] + exec.memory::save_batch_hasher_state + # => [end_ptr, end_ptr] + drop drop +end + +#! Processes one input-note list entry: asserts the entry was consumed exactly once and is not +#! left expected-to-be-erased, then absorbs it into the batch hasher unless it was erased. +#! +#! The absorbed_count is only incremented by 1 if the entry was not erased. +#! +#! Inputs: [idx, absorbed_count, num] +#! Outputs: [idx + 1, absorbed_count, num] +proc process_input_entry + # Assert this entry was consumed exactly once and is not left expected-to-be-erased. + dup exec.memory::input_note_flags_ptr + # => [flags_ptr, idx, absorbed_count, num] + dup add.INPUT_NOTE_CONSUMPTION_OFFSET mem_load + # => [consumption, flags_ptr, idx, absorbed_count, num] + assert.err=ERR_BATCH_INPUT_NOTE_NOT_CONSUMED + # => [flags_ptr, idx, absorbed_count, num] + mem_load + # => [erasure, idx, absorbed_count, num] + dup neq.INPUT_NOTE_ERASURE_EXPECTED assert.err=ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED + # => [erasure, idx, absorbed_count, num] + + # Absorb the entry unless it was erased (created-and-consumed in this batch). + neq.INPUT_NOTE_ERASED + # => [not_erased, idx, absorbed_count, num] + if.true + dup exec.absorb_input_entry + swap add.1 swap + # => [idx, absorbed_count+1, num] + end + add.1 +end + +#! Computes INPUT_NOTES_COMMITMENT as the sequential poseidon2 hash of the non-erased +#! `(NULLIFIER, NOTE_ID_OR_EMPTY)` entries of the nullifier-sorted input-note list (erased entries +#! are skipped). +#! +#! The single pass over the input entries also enforces the epilogue invariants on each entry: it +#! was consumed exactly once and is not left expected-to-be-erased (i.e. any created-and-consumed +#! note had its creator processed). +#! +#! Inputs: [] +#! Outputs: [INPUT_NOTES_COMMITMENT] +proc compute_input_notes_commitment + # Initialize the batch hasher state in memory. + exec.poseidon2::init_no_padding + exec.memory::save_batch_hasher_state + + exec.memory::get_num_input_notes + push.0 push.0 + # => [idx, absorbed_count, num] + + dup dup.3 neq + # => [should_loop, idx, absorbed_count, num] + while.true + exec.process_input_entry + # => [idx+1, absorbed_count, num] (absorbed_count + 1 if entry was not erased) + dup dup.3 neq + # => [should_loop, idx+1, absorbed_count, num] + end + # => [idx, absorbed_count, num] + drop swap drop + # => [absorbed_count] + + # With no non-erased entries the commitment is the empty word, matching the early return in + # `build_input_note_commitment` (this is not the hash of zero elements). Otherwise squeeze the + # accumulated state. + eq.0 + if.true + padw + # => [EMPTY_WORD] + else + exec.memory::load_batch_hasher_state + exec.poseidon2::squeeze_digest + # => [INPUT_NOTES_COMMITMENT] + end +end + +# OUTPUT NOTES COMMITMENT +# ================================================================================================= + +#! Computes the batch's output-notes commitment (the batch note tree root). +#! +#! Placeholder: returns the empty word until the batch note tree is wired up. +#! +#! Inputs: [] +#! Outputs: [OUTPUT_NOTES_COMMITMENT] +#! +#! TODO: hash the batch's output notes into the batch note tree (SMT) root. +proc compute_output_notes_commitment + padw +end + +# EPILOGUE +# ================================================================================================= + +#! Verifies the note-tracking results and computes the batch's note commitments. +#! +#! Asserts every output-note list entry was created, then computes the input- and output-notes +#! commitments. The per-input-entry invariants (consumed exactly once, no pending erasure) are +#! enforced inside the input-commitment pass. +#! +#! Inputs: [] +#! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT] +#! +#! TODO: authenticate unauthenticated, non-erased input notes against BLOCK_COMMITMENT's chain MMR. +pub proc finalize + exec.assert_all_output_notes_created + exec.compute_output_notes_commitment + # => [OUTPUT_NOTES_COMMITMENT] + exec.compute_input_notes_commitment + # => [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT] +end diff --git a/crates/miden-protocol/asm/kernels/batch/lib/errors.masm b/crates/miden-protocol/asm/kernels/batch/lib/errors.masm new file mode 100644 index 0000000000..78baefc1c7 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/errors.masm @@ -0,0 +1,7 @@ +# Error constants shared by batch kernel modules. + +# ERRORS +# ================================================================================================= + +pub const ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED = + "an erased input note was consumed before the transaction that created it" diff --git a/crates/miden-protocol/asm/kernels/batch/lib/memory.masm b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm new file mode 100644 index 0000000000..1b1f767f7d --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm @@ -0,0 +1,364 @@ +# MEMORY LAYOUT +# ================================================================================================= +# +# Below is the memory layout used by the batch kernel: +# +# +-------------------+-------------------------+----------------------------------------------------------------------+ +# | Address range | Constant | Contents | +# +-------------------+-------------------------+----------------------------------------------------------------------+ +# | 0 | NUM_TRANSACTIONS_PTR | num_transactions (1 felt). | +# | 4..8 | BATCH_HASHER_RATE0_PTR | RATE0 of the batch-level poseidon2 hasher state. | +# | 8..12 | BATCH_HASHER_RATE1_PTR | RATE1 of the batch-level poseidon2 hasher state. | +# | 12..16 | BATCH_HASHER_CAP_PTR | CAPACITY of the batch-level poseidon2 hasher state. | +# | 18 | NUM_INPUT_NOTES_PTR | number of entries in the nullifier-sorted input-note list. | +# | 19 | NUM_OUTPUT_NOTES_PTR | number of entries in the note-id-sorted output-note list. | +# | 20..8212 | TX_TUPLES_PTR | Layer 1 piped data: `[TX_ID, account_id_suffix, account_id_prefix, | +# | | | 0, 0]` per transaction (8 felts each, up to 1024 transactions). | +# | 8212..24596 | TX_HEADERS_PTR | Layer 2 piped data: `[INIT, FINAL, INPUT_NOTES_COMMITMENT, | +# | | | OUTPUT_NOTES_COMMITMENT]` (16 felts per transaction, up to 1024). | +# | 24596..32788 | TX_NOTES_SCRATCH_PTR | per-transaction scratch space for Layer 3a / Layer 3b note data | +# | | | (overwritten each transaction). | +# | 32788..40980 | INPUT_NOTES_PTR | nullifier-sorted input-note list: `[NULLIFIER, NOTE_ID_OR_EMPTY]` | +# | | | per note (8 felts each, up to 1024 notes). | +# | 40980..45076 | INPUT_NOTE_FLAGS_PTR | parallel input-note flags: `[erasure, consumption, 0, 0]` per note | +# | | | (4 felts each). | +# | 45076..53268 | OUTPUT_NOTES_PTR | note-id-sorted output-note list: `[NOTE_ID, 0, 0, 0, 0]` per note | +# | | | (8 felts each, up to 1024 notes). | +# | 53268..57364 | OUTPUT_NOTE_FLAGS_PTR | parallel output-note flags: `[will_be_erased, is_created, | +# | | | linked_input_index, 0]` per note (4 felts each). | +# +-------------------+-------------------------+----------------------------------------------------------------------+ + +# GENERAL CONSTANTS +# ================================================================================================= + +# The number of felts in a word. +pub const WORD_NUM_ELEMENTS = 4 + +# BOOK KEEPING +# ================================================================================================= + +# Single-felt slot holding `num_transactions` after Layer 1 verification. +const NUM_TRANSACTIONS_PTR = 0 + +# BATCH HASHER STATE +# ================================================================================================= + +# Word holding the RATE0 portion of the batch-level poseidon2 hasher state. +const BATCH_HASHER_RATE0_PTR = 4 + +# Word holding the RATE1 portion of the batch-level poseidon2 hasher state. +const BATCH_HASHER_RATE1_PTR = 8 + +# Word holding the CAPACITY portion of the batch-level poseidon2 hasher state. +const BATCH_HASHER_CAP_PTR = 12 + +# NOTE LIST BOOKKEEPING +# ================================================================================================= + +# Single-felt slot holding the number of entries in the nullifier-sorted input-note list. +const NUM_INPUT_NOTES_PTR = 18 + +# Single-felt slot holding the number of entries in the note-id-sorted output-note list. +const NUM_OUTPUT_NOTES_PTR = 19 + +# PIPED DATA REGIONS +# ================================================================================================= + +# Base of the Layer 1 piped data region. Per transaction, 8 felts: +# `[TX_ID, account_id_suffix, account_id_prefix, 0, 0]`. +pub const TX_TUPLES_PTR = 20 + +# Number of felts each transaction occupies in TX_TUPLES_PTR. +const TX_TUPLE_FELT_LEN = 8 + +# Number of words in a Layer 1 tuple (a TX_ID word plus an account-id word). +pub const TX_TUPLE_NUM_WORDS = 2 + +# Base of the Layer 2 piped data region. Per transaction, 16 felts: +# `[INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT]`. +# This must match the felt-sequence layout of `TransactionId::new`. +const TX_HEADERS_PTR = 8212 + +# Number of felts each transaction occupies in TX_HEADERS_PTR. +const TX_HEADER_FELT_LEN = 16 + +# Felt offset within a transaction header where INPUT_NOTES_COMMITMENT starts. +const TX_HEADER_INPUT_NOTES_OFFSET = 8 + +# Felt offset within a transaction header where OUTPUT_NOTES_COMMITMENT starts. +const TX_HEADER_OUTPUT_NOTES_OFFSET = 12 + +# Per-transaction scratch space for Layer 3a / 3b note data, overwritten between iterations. +pub const TX_NOTES_SCRATCH_PTR = 24596 + +# SORTED NOTE LISTS +# ================================================================================================= + +# Base of the nullifier-sorted input-note list. Per note, an 8-felt sorted-array entry +# `[NULLIFIER, NOTE_ID_OR_EMPTY]` +# where the KEY word is the nullifier (the `sorted_array` lookup key) and the VALUE word is the +# note id (for unauthenticated notes) or the empty word. The VALUE word doubles as the second half +# of the `(nullifier, note_id_or_empty)` tuple hashed into INPUT_NOTES_COMMITMENT. +pub const INPUT_NOTES_PTR = 32788 + +# Number of felts each note occupies in INPUT_NOTES_PTR / OUTPUT_NOTES_PTR (KEY word + VALUE word). +pub const NOTE_ENTRY_FELT_LEN = 8 + +# Number of words in a sorted note list entry (a KEY word plus a VALUE word). +pub const NOTE_ENTRY_NUM_WORDS = 2 + +# Offset of the VALUE word within a sorted note list entry. +pub const NOTE_ENTRY_VALUE_OFFSET = 4 + +# Base of the parallel input-note flags array. Per note, one word `[erasure, consumption, 0, 0]`. +# `erasure` is 0 (not erased / external), `INPUT_NOTE_ERASURE_EXPECTED` or +# `INPUT_NOTE_ERASED`. `consumption` is 0 or 1. +pub const INPUT_NOTE_FLAGS_PTR = 40980 + +# Number of felts each note occupies in the flag arrays (one word). +pub const NOTE_FLAGS_FELT_LEN = 4 + +# Felt offset of the `erasure` flag within an input-note flag word. +pub const INPUT_NOTE_ERASURE_OFFSET = 0 + +# Felt offset of the `consumption` flag within an input-note flag word. +pub const INPUT_NOTE_CONSUMPTION_OFFSET = 1 + +# Erasure flag value: the input note is expected to be erased, i.e. its note id matches an output +# note created by another transaction within this batch (its "creator") which has not been +# processed yet. +pub const INPUT_NOTE_ERASURE_EXPECTED = 1 + +# Erasure flag value: the input note is erased, i.e. its creator output note has been processed. +pub const INPUT_NOTE_ERASED = 2 + +# Base of the note-id-sorted output-note list. Per note, an 8-felt sorted-array entry +# `[NOTE_ID, 0, 0, 0, 0]` +# where the KEY word is the note id (the `sorted_array` lookup key); the VALUE word is unused. +pub const OUTPUT_NOTES_PTR = 45076 + +# Base of the parallel output-note flags array. Per note, one word +# `[will_be_erased, is_created, linked_input_index, 0]`. +pub const OUTPUT_NOTE_FLAGS_PTR = 53268 + +# Felt offset of the `will_be_erased` flag within an output-note flag word. +pub const OUTPUT_NOTE_WILL_BE_ERASED_OFFSET = 0 + +# Felt offset of the `is_created` flag within an output-note flag word. +pub const OUTPUT_NOTE_IS_CREATED_OFFSET = 1 + +# Felt offset of the `linked_input_index` field within an output-note flag word. +pub const OUTPUT_NOTE_LINKED_INPUT_OFFSET = 2 + +# CAPACITY LIMITS +# ================================================================================================= + +# Maximum number of transactions in a batch. The Layer 1/2 piped-data regions are sized to hold +# exactly this many transactions. +pub const MAX_TRANSACTIONS_PER_BATCH = 1024 + +# Maximum number of entries in a sorted note list and in a single transaction's note set. The +# input/output note regions and the per-transaction scratch region each hold exactly this many +# entries. Equals `MAX_INPUT_NOTES_PER_BATCH` = `MAX_OUTPUT_NOTES_PER_BATCH` in `src/constants.rs`. +pub const MAX_NOTES_PER_BATCH = 1024 + +# NUM TRANSACTIONS +# ================================================================================================= + +#! Stores `num_transactions`. +#! +#! Inputs: [num_transactions] +#! Outputs: [] +pub proc set_num_transactions + mem_store.NUM_TRANSACTIONS_PTR +end + +#! Returns `num_transactions`. +#! +#! Inputs: [] +#! Outputs: [num_transactions] +pub proc get_num_transactions + mem_load.NUM_TRANSACTIONS_PTR +end + +# BATCH HASHER STATE +# ================================================================================================= + +#! Persists the batch hasher state from the operand stack into memory. +#! +#! Inputs: [RATE0, RATE1, CAPACITY] +#! Outputs: [] +pub proc save_batch_hasher_state + mem_storew_le.BATCH_HASHER_RATE0_PTR dropw + mem_storew_le.BATCH_HASHER_RATE1_PTR dropw + mem_storew_le.BATCH_HASHER_CAP_PTR dropw +end + +#! Loads the batch hasher state from memory onto the operand stack. +#! +#! Inputs: [] +#! Outputs: [RATE0, RATE1, CAPACITY] +pub proc load_batch_hasher_state + padw mem_loadw_le.BATCH_HASHER_CAP_PTR + padw mem_loadw_le.BATCH_HASHER_RATE1_PTR + padw mem_loadw_le.BATCH_HASHER_RATE0_PTR +end + +# TRANSACTION TUPLE / HEADER ACCESSORS +# ================================================================================================= + +#! Returns a pointer to transaction `tx_index`'s entry in TX_TUPLES_PTR. +#! +#! Inputs: [tx_index] +#! Outputs: [tx_tuple_ptr] +pub proc tx_tuple_ptr + mul.TX_TUPLE_FELT_LEN add.TX_TUPLES_PTR +end + +#! Returns the verified `tx_id` for transaction `tx_index` (loaded from TX_TUPLES_PTR). +#! +#! Inputs: [tx_index] +#! Outputs: [TX_ID] +pub proc get_tx_id + exec.tx_tuple_ptr + # => [tx_tuple_ptr] + padw movup.4 + # => [PAD, tx_tuple_ptr] + mem_loadw_le + # => [TX_ID] +end + +#! Returns a pointer to transaction `tx_index`'s entry in TX_HEADERS_PTR. +#! +#! Inputs: [tx_index] +#! Outputs: [tx_header_ptr] +pub proc tx_header_ptr + mul.TX_HEADER_FELT_LEN add.TX_HEADERS_PTR +end + +#! Returns the verified per-transaction INPUT_NOTES_COMMITMENT for transaction `tx_index`. +#! +#! Inputs: [tx_index] +#! Outputs: [INPUT_NOTES_COMMITMENT_idx] +pub proc get_tx_input_notes_commitment + exec.tx_header_ptr add.TX_HEADER_INPUT_NOTES_OFFSET + # => [input_notes_commitment_ptr] + padw movup.4 + # => [PAD, input_notes_commitment_ptr] + mem_loadw_le + # => [INPUT_NOTES_COMMITMENT] +end + +#! Returns the verified per-transaction OUTPUT_NOTES_COMMITMENT for transaction `tx_index`. +#! +#! Inputs: [tx_index] +#! Outputs: [OUTPUT_NOTES_COMMITMENT_idx] +pub proc get_tx_output_notes_commitment + exec.tx_header_ptr add.TX_HEADER_OUTPUT_NOTES_OFFSET + # => [output_notes_commitment_ptr] + padw movup.4 + # => [PAD, output_notes_commitment_ptr] + mem_loadw_le + # => [OUTPUT_NOTES_COMMITMENT] +end + +# SORTED NOTE LIST ACCESSORS +# ================================================================================================= + +#! Stores the number of entries in the nullifier-sorted input-note list. +#! +#! Inputs: [num_input_notes] +#! Outputs: [] +pub proc set_num_input_notes + mem_store.NUM_INPUT_NOTES_PTR +end + +#! Returns the number of entries in the nullifier-sorted input-note list. +#! +#! Inputs: [] +#! Outputs: [num_input_notes] +pub proc get_num_input_notes + mem_load.NUM_INPUT_NOTES_PTR +end + +#! Stores the number of entries in the note-id-sorted output-note list. +#! +#! Inputs: [num_output_notes] +#! Outputs: [] +pub proc set_num_output_notes + mem_store.NUM_OUTPUT_NOTES_PTR +end + +#! Returns the number of entries in the note-id-sorted output-note list. +#! +#! Inputs: [] +#! Outputs: [num_output_notes] +pub proc get_num_output_notes + mem_load.NUM_OUTPUT_NOTES_PTR +end + +#! Returns the end pointer of the nullifier-sorted input-note list (base + num_input_notes * 8), +#! as required by `sorted_array::find_key_value`. +#! +#! Inputs: [] +#! Outputs: [input_notes_end_ptr] +pub proc get_input_notes_end_ptr + exec.get_num_input_notes mul.NOTE_ENTRY_FELT_LEN add.INPUT_NOTES_PTR +end + +#! Returns the end pointer of the note-id-sorted output-note list (base + num_output_notes * 8). +#! +#! Inputs: [] +#! Outputs: [output_notes_end_ptr] +pub proc get_output_notes_end_ptr + exec.get_num_output_notes mul.NOTE_ENTRY_FELT_LEN add.OUTPUT_NOTES_PTR +end + +#! Returns a pointer to input-note entry `idx` in INPUT_NOTES_PTR. +#! +#! Inputs: [idx] +#! Outputs: [input_note_entry_ptr] +pub proc input_note_entry_ptr + mul.NOTE_ENTRY_FELT_LEN add.INPUT_NOTES_PTR +end + +#! Returns a pointer to output-note entry `idx` in OUTPUT_NOTES_PTR. +#! +#! Inputs: [idx] +#! Outputs: [output_note_entry_ptr] +pub proc output_note_entry_ptr + mul.NOTE_ENTRY_FELT_LEN add.OUTPUT_NOTES_PTR +end + +#! Returns a pointer to input-note flag word `idx` in INPUT_NOTE_FLAGS_PTR. +#! +#! Inputs: [idx] +#! Outputs: [input_note_flags_ptr] +pub proc input_note_flags_ptr + mul.NOTE_FLAGS_FELT_LEN add.INPUT_NOTE_FLAGS_PTR +end + +#! Returns a pointer to output-note flag word `idx` in OUTPUT_NOTE_FLAGS_PTR. +#! +#! Inputs: [idx] +#! Outputs: [output_note_flags_ptr] +pub proc output_note_flags_ptr + mul.NOTE_FLAGS_FELT_LEN add.OUTPUT_NOTE_FLAGS_PTR +end + +#! Converts an input-note `key_ptr` (as returned by `sorted_array::find_key_value`) into its entry +#! index `(key_ptr - INPUT_NOTES_PTR) / 8`. +#! +#! Inputs: [key_ptr] +#! Outputs: [idx] +pub proc input_entry_index_from_key_ptr + sub.INPUT_NOTES_PTR div.NOTE_ENTRY_FELT_LEN +end + +#! Converts an output-note `key_ptr` into its entry index `(key_ptr - OUTPUT_NOTES_PTR) / 8`. +#! +#! Inputs: [key_ptr] +#! Outputs: [idx] +pub proc output_entry_index_from_key_ptr + sub.OUTPUT_NOTES_PTR div.NOTE_ENTRY_FELT_LEN +end diff --git a/crates/miden-protocol/asm/kernels/batch/lib/mod.masm b/crates/miden-protocol/asm/kernels/batch/lib/mod.masm new file mode 100644 index 0000000000..a42b917eb2 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/mod.masm @@ -0,0 +1,11 @@ +# Root module of the `miden::batch_kernel` library. +# +# This library holds the internal implementation modules of the batch kernel (prologue, note +# tracker, epilogue and the shared memory layout). They are linked into the batch kernel +# executable defined in `src/main.masm`. + +pub mod epilogue +pub mod errors +pub mod memory +pub mod note_tracker +pub mod prologue diff --git a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm new file mode 100644 index 0000000000..3586d3d3e3 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -0,0 +1,475 @@ +use miden::core::mem +use miden::core::crypto::hashes::poseidon2 +use miden::core::word +use miden::core::collections::sorted_array + +use miden::batch_kernel::memory +use { + INPUT_NOTE_CONSUMPTION_OFFSET, + INPUT_NOTE_ERASED, + INPUT_NOTE_ERASURE_EXPECTED, + INPUT_NOTES_PTR, + MAX_NOTES_PER_BATCH, + NOTE_ENTRY_FELT_LEN, + NOTE_ENTRY_NUM_WORDS, + NOTE_ENTRY_VALUE_OFFSET, + OUTPUT_NOTE_IS_CREATED_OFFSET, + OUTPUT_NOTE_LINKED_INPUT_OFFSET, + OUTPUT_NOTES_PTR, + TX_NOTES_SCRATCH_PTR, + WORD_NUM_ELEMENTS, +} from miden::batch_kernel::memory +use {ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED} from miden::batch_kernel::errors + +# ERRORS +# ================================================================================================= + +const ERR_BATCH_INPUT_NOTE_NOT_IN_LIST = + "a transaction input note is missing from the nullifier-sorted input-note list" + +const ERR_BATCH_INPUT_NOTE_ID_MISMATCH = + "a transaction input note id does not match its nullifier-sorted input-note list entry" + +const ERR_BATCH_INPUT_NOTE_CONSUMED_TWICE = + "a nullifier-sorted input-note list entry was consumed by more than one transaction" + +const ERR_BATCH_OUTPUT_NOTE_NOT_IN_LIST = + "a transaction output note is missing from the note-id-sorted output-note list" + +const ERR_BATCH_OUTPUT_NOTE_CREATED_TWICE = + "a note-id-sorted output-note list entry was created by more than one transaction" + +const ERR_BATCH_TX_TOO_MANY_NOTES = + "a transaction's note set contains more notes than the maximum allowed" + +# ERASURE CROSS-REFERENCE +# ================================================================================================= + +#! Marks input-note list entry `idx` as expected-to-be-erased (erasure flag, at offset 0, set to +#! 1 = `INPUT_NOTE_ERASURE_EXPECTED`). +#! +#! Inputs: [idx] +#! Outputs: [] +proc set_input_erasure_expected + exec.memory::input_note_flags_ptr + # => [erasure_ptr] (erasure flag is at offset 0) + + push.INPUT_NOTE_ERASURE_EXPECTED swap mem_store +end + +#! Records on output-note list entry `j` that it erases input-note list entry `idx`: sets +#! `will_be_erased` (offset 0) to 1 and `linked_input_index` to `idx`. +#! +#! Inputs: [j, idx] +#! Outputs: [] +proc set_output_erases_input + dup exec.memory::output_note_flags_ptr + # => [flags_ptr, j, idx] + push.1 dup.1 mem_store + # => [flags_ptr, j, idx] (will_be_erased = 1 at offset 0) + add.OUTPUT_NOTE_LINKED_INPUT_OFFSET + # => [linked_ptr, j, idx] + movup.2 swap mem_store + # => [j] + drop +end + +#! For input-note list entry `idx`: if it is unauthenticated (its note id is non-empty) and that +#! note id appears in the output-note list, marks the input entry erasure-expected and links the +#! matching output entry back to it. This is the static (order-independent) erasure determination; +#! the temporal ordering is enforced during per-transaction processing. +#! +#! If two input entries carry the same note id (impossible for real notes, whose nullifier is +#! derived from the note), the second link overwrites the first and the kernel later aborts. +#! +#! Inputs: [idx] +#! Outputs: [] +proc cross_reference_one_input + dup exec.memory::input_note_entry_ptr add.NOTE_ENTRY_VALUE_OFFSET + # => [note_id_ptr, idx] + padw movup.4 mem_loadw_le + # => [NOTE_ID, idx] + dupw exec.word::eqz + # => [is_empty, NOTE_ID, idx] + if.true + dropw drop + else + push.OUTPUT_NOTES_PTR movdn.4 + exec.memory::get_output_notes_end_ptr movdn.5 + # => [NOTE_ID, output_notes_ptr, output_end_ptr, idx] + exec.sorted_array::find_key_value + # => [is_found, key_ptr, start_ptr, end_ptr, idx] + if.true + swap drop swap drop + # => [key_ptr, idx] + exec.memory::output_entry_index_from_key_ptr + # => [j, idx] + dup.1 exec.set_input_erasure_expected + # => [j, idx] + exec.set_output_erases_input + # => [] + else + drop drop drop drop + end + end +end + +#! Cross-references the input and output note lists to determine erasure (see +#! [`cross_reference_one_input`]). +#! +#! Inputs: [] +#! Outputs: [] +proc cross_reference_erasure + exec.memory::get_num_input_notes + # => [num_input_notes] + + # Iterate from num_input_notes - 1 down to 0. + dup neq.0 + while.true + sub.1 + # => [idx] + + dup exec.cross_reference_one_input + + dup neq.0 + # => [should_loop, idx] + end + drop +end + +# PER-TRANSACTION OUTPUT NOTES +# ================================================================================================= + +#! Marks output-note list entry `j` as created, asserting it was not already created. +#! +#! Inputs: [j] +#! Outputs: [] +proc mark_output_note_created + exec.memory::output_note_flags_ptr add.OUTPUT_NOTE_IS_CREATED_OFFSET + # => [is_created_ptr] + dup mem_load + assertz.err=ERR_BATCH_OUTPUT_NOTE_CREATED_TWICE + push.1 swap mem_store +end + +#! Advances input-note list entry `idx`'s erasure flag from `INPUT_NOTE_ERASURE_EXPECTED` to +#! `INPUT_NOTE_ERASED` (its creator output note has been processed), asserting it was expected. +#! +#! Inputs: [idx] +#! Outputs: [] +proc flip_input_erasure_created + exec.memory::input_note_flags_ptr + # => [erasure_ptr] + + dup mem_load + # => [erasure, erasure_ptr] + + eq.INPUT_NOTE_ERASURE_EXPECTED assert.err=ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED + # => [erasure_ptr] + + push.INPUT_NOTE_ERASED swap mem_store +end + +#! Binds one per-transaction output note (an 8-felt `[DETAILS_COMMITMENT, METADATA_COMMITMENT]` +#! tuple at `scratch_entry_ptr`) to the note-id-sorted output-note list: derives its note id, +#! looks it up, marks it created, and — if it erases an input note — advances that input note's +#! erasure flag. +#! +#! Inputs: [scratch_entry_ptr] +#! Outputs: [] +proc bind_one_output_note + # Derive the output note's id as merge(details_commitment, metadata_commitment). + dup padw movup.4 mem_loadw_le + # => [DETAILS, scratch_entry_ptr] + dup.4 add.NOTE_ENTRY_VALUE_OFFSET padw movup.4 mem_loadw_le + # => [METADATA, DETAILS, scratch_entry_ptr] + swapw + # => [DETAILS, METADATA, scratch_entry_ptr] + exec.poseidon2::merge + # => [NOTE_ID, scratch_entry_ptr] (NoteId = merge(details, metadata)) + movup.4 drop + # => [NOTE_ID] + + # Look the note id up in the note-id-sorted output-note list and mark the entry created. + push.OUTPUT_NOTES_PTR movdn.4 + exec.memory::get_output_notes_end_ptr movdn.5 + # => [NOTE_ID, output_notes_ptr, output_end_ptr] + exec.sorted_array::find_key_value + # => [is_found, key_ptr, start_ptr, end_ptr] + assert.err=ERR_BATCH_OUTPUT_NOTE_NOT_IN_LIST + # => [key_ptr, start_ptr, end_ptr] + swap drop swap drop + # => [key_ptr] + exec.memory::output_entry_index_from_key_ptr + # => [j] + dup exec.mark_output_note_created + # => [j] + + # If this output note erases an input note (cross-referenced earlier), advance that input note's + # erasure flag from expected to erased. + dup exec.memory::output_note_flags_ptr mem_load + # => [will_be_erased, j] + if.true + exec.memory::output_note_flags_ptr add.OUTPUT_NOTE_LINKED_INPUT_OFFSET mem_load + # => [linked_input_index] + exec.flip_input_erasure_created + else + drop + end +end + +#! Binds all of the transaction's output notes staged in the scratch region (see +#! [`bind_scratch_input_notes`] for the scratch layout). +#! +#! Inputs: [num_notes] +#! Outputs: [] +proc bind_scratch_output_notes + # => [num_notes] + + # Iterate from num_notes - 1 down to 0. + dup neq.0 + while.true + sub.1 + # => [i] + + dup mul.NOTE_ENTRY_FELT_LEN add.TX_NOTES_SCRATCH_PTR + # => [scratch_entry_ptr, i] + + exec.bind_one_output_note + + dup neq.0 + # => [should_loop, i] + end + drop +end + +#! Verifies transaction `tx_index`'s `OUTPUT_NOTES_COMMITMENT_idx` against its piped +#! `(DETAILS_COMMITMENT, METADATA_COMMITMENT)` tuples, then binds each of those notes to the batch +#! output-note list (see [`bind_per_tx_notes`] for what binding proves). +#! +#! Inputs: +#! Operand stack: [tx_index] +#! Advice map: +#! OUTPUT_NOTES_COMMITMENT_idx |-> [[DETAILS_COMMITMENT_0, METADATA_COMMITMENT_0], ...] +#! Outputs: +#! Operand stack: [] +proc process_tx_output_notes + dup exec.memory::get_tx_output_notes_commitment + # => [OUTPUT_NOTES_COMMITMENT_idx, tx_index] + dupw exec.word::eqz + if.true + # Empty commitment: the transaction has no output notes, so there is nothing to bind. + dropw drop + else + # Pipe this transaction's output-note tuples into the scratch region, asserting their + # sequential hash equals OUTPUT_NOTES_COMMITMENT_idx. + push.TX_NOTES_SCRATCH_PTR movdn.4 + # => [OUTPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + + adv.push_mapvaln + # OS => [OUTPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + # AS => [tx_notes_len, [tx_note_tuples]] + + adv_push div.WORD_NUM_ELEMENTS + # OS => [num_words, OUTPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + # AS => [[tx_note_tuples]] + + dup div.NOTE_ENTRY_NUM_WORDS u32assert.err=ERR_BATCH_TX_TOO_MANY_NOTES + u32lte.MAX_NOTES_PER_BATCH assert.err=ERR_BATCH_TX_TOO_MANY_NOTES + movup.5 swap + exec.mem::pipe_preimage_to_memory + # => [end_ptr, tx_index] + # num_notes = (end_ptr - scratch base) / felts-per-entry. + sub.TX_NOTES_SCRATCH_PTR div.NOTE_ENTRY_FELT_LEN + # => [num_notes_i, tx_index] + swap drop + exec.bind_scratch_output_notes + end +end + +# PER-TRANSACTION INPUT NOTES +# ================================================================================================= + +#! Marks input-note list entry `idx` as consumed, asserting it was not already consumed. +#! +#! Inputs: [idx] +#! Outputs: [] +proc mark_input_note_consumed + exec.memory::input_note_flags_ptr add.INPUT_NOTE_CONSUMPTION_OFFSET + # => [consumption_ptr] + dup mem_load + assertz.err=ERR_BATCH_INPUT_NOTE_CONSUMED_TWICE + push.1 swap mem_store +end + +#! Asserts input-note list entry `idx` is not expected-to-be-erased at the point it is consumed: +#! if it is created in this batch, its creator has already been processed. Rejects +#! consume-before-create / circular dependencies. +#! +#! Inputs: [idx] +#! Outputs: [] +proc assert_input_not_consumed_before_created + exec.memory::input_note_flags_ptr mem_load + # => [erasure] + + neq.INPUT_NOTE_ERASURE_EXPECTED assert.err=ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED +end + +#! Binds one per-transaction input note (an 8-felt `[NULLIFIER, NOTE_ID_OR_EMPTY]` tuple at +#! `scratch_entry_ptr`) to the nullifier-sorted input-note list: looks it up by nullifier, asserts +#! it is present and that its note id matches, enforces the erasure ordering gate, then marks it +#! consumed. +#! +#! Inputs: [scratch_entry_ptr] +#! Outputs: [] +proc bind_one_input_note + # Look the per-transaction note up in the nullifier-sorted input-note list by its nullifier. + dup padw movup.4 mem_loadw_le + # => [NULLIFIER, scratch_entry_ptr] + push.INPUT_NOTES_PTR movdn.4 + exec.memory::get_input_notes_end_ptr movdn.5 + # => [NULLIFIER, start_ptr, end_ptr, scratch_entry_ptr] + exec.sorted_array::find_key_value + # => [is_found, key_ptr, start_ptr, end_ptr, scratch_entry_ptr] + assert.err=ERR_BATCH_INPUT_NOTE_NOT_IN_LIST + swap drop swap drop + # => [key_ptr, scratch_entry_ptr] + + # Assert the list entry's note id equals the per-transaction note's, binding the id as well as + # the nullifier (so an unauthenticated note cannot be matched to the wrong list entry). + dup add.NOTE_ENTRY_VALUE_OFFSET padw movup.4 mem_loadw_le + # => [LIST_NOTE_ID, key_ptr, scratch_entry_ptr] + dup.5 add.NOTE_ENTRY_VALUE_OFFSET padw movup.4 mem_loadw_le + # => [PERTX_NOTE_ID, LIST_NOTE_ID, key_ptr, scratch_entry_ptr] + assert_eqw.err=ERR_BATCH_INPUT_NOTE_ID_MISMATCH + # => [key_ptr, scratch_entry_ptr] + + # Enforce the erasure ordering gate (reject consume-before-create), then mark the entry + # consumed. + swap drop + # => [key_ptr] + exec.memory::input_entry_index_from_key_ptr + # => [idx] + dup exec.assert_input_not_consumed_before_created + exec.mark_input_note_consumed +end + +#! Binds all of the transaction's input notes staged in the scratch region. +#! +#! The scratch region ([`memory::TX_NOTES_SCRATCH_PTR`]) holds the current transaction's note tuples +#! piped from the advice provider; it is overwritten for each transaction. Entry `i` is the 8 felts +#! at `TX_NOTES_SCRATCH_PTR + i * NOTE_ENTRY_FELT_LEN`. +#! +#! Inputs: [num_notes] +#! Outputs: [] +proc bind_scratch_input_notes + # => [num_notes] + + # Iterate from num_notes - 1 down to 0. + dup neq.0 + while.true + sub.1 + # => [i] + + dup mul.NOTE_ENTRY_FELT_LEN add.TX_NOTES_SCRATCH_PTR + # => [scratch_entry_ptr, i] + + exec.bind_one_input_note + + dup neq.0 + # => [should_loop, i] + end + drop +end + +#! Verifies transaction `tx_index`'s `INPUT_NOTES_COMMITMENT_idx` against its piped +#! `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuples, then binds each of those notes to the batch input-note +#! list (see [`bind_per_tx_notes`] for what binding proves). +#! +#! Inputs: +#! Operand stack: [tx_index] +#! Advice map: +#! INPUT_NOTES_COMMITMENT_idx |-> [[NULLIFIER_0, NOTE_ID_OR_EMPTY_0], ...] +#! Outputs: +#! Operand stack: [] +proc process_tx_input_notes + dup exec.memory::get_tx_input_notes_commitment + # => [INPUT_NOTES_COMMITMENT_idx, tx_index] + dupw exec.word::eqz + if.true + # Empty commitment: the transaction has no input notes, so there is nothing to bind. + dropw drop + else + # Pipe this transaction's input-note tuples into the scratch region, asserting their + # sequential hash equals INPUT_NOTES_COMMITMENT_idx (so the tuples are exactly the committed + # ones). + push.TX_NOTES_SCRATCH_PTR movdn.4 + # => [INPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + + adv.push_mapvaln + # OS => [INPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + # AS => [tx_notes_len, [tx_note_tuples]] + + adv_push div.WORD_NUM_ELEMENTS + # OS => [num_words, INPUT_NOTES_COMMITMENT_idx, scratch_ptr, tx_index] + # AS => [[tx_note_tuples]] + + dup div.NOTE_ENTRY_NUM_WORDS u32assert.err=ERR_BATCH_TX_TOO_MANY_NOTES + u32lte.MAX_NOTES_PER_BATCH assert.err=ERR_BATCH_TX_TOO_MANY_NOTES + movup.5 swap + exec.mem::pipe_preimage_to_memory + # => [end_ptr, tx_index] + # num_notes = (end_ptr - scratch base) / felts-per-entry. + sub.TX_NOTES_SCRATCH_PTR div.NOTE_ENTRY_FELT_LEN + # => [num_notes_i, tx_index] + swap drop + exec.bind_scratch_input_notes + end +end + +#! Processes every transaction in batch order, binding its input notes then its output notes. +#! +#! Binding a per-transaction note means locating it in the batch's sorted note list — by nullifier +#! for inputs, by note id for outputs — and marking the matching entry (consumed for inputs, created +#! for outputs). Because every per-transaction note must be found and each entry may be marked at +#! most once, binding proves the host-provided list is exactly the multiset of per-transaction +#! notes: the host cannot inject, omit, or duplicate entries. +#! +#! Inputs are bound before outputs within a transaction so a note created and consumed by the same +#! transaction is rejected — at consume time its erasure flag is still 1 (its creating output has +#! not been processed), tripping the gate in [`assert_input_not_consumed_before_created`]. +#! +#! Inputs: [] +#! Outputs: [] +proc bind_per_tx_notes + exec.memory::get_num_transactions + push.0 + # => [tx_index, num_transactions] + + dup.1 dup.1 neq + # => [should_loop, tx_index, num_transactions] + while.true + dup exec.process_tx_input_notes + dup exec.process_tx_output_notes + + add.1 + dup.1 dup.1 neq + # => [should_loop, tx_index, num_transactions] + end + drop drop +end + +# NOTE TRACKING +# ================================================================================================= + +#! Tracks the batch's notes: determines erasure by cross-referencing the prepared batch input- and +#! output-note lists, then binds both lists to the verified per-transaction notes while enforcing +#! the creator-before-consumer ordering gate. Assumes the prologue has loaded and strict-sorted +#! the two sorted note lists into memory. +#! +#! Inputs: [] +#! Outputs: [] +pub proc track_notes + exec.cross_reference_erasure + exec.bind_per_tx_notes +end diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm new file mode 100644 index 0000000000..1896f82531 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -0,0 +1,263 @@ +use miden::core::mem +use miden::core::word + +use miden::batch_kernel::memory +use { + INPUT_NOTES_PTR, + MAX_NOTES_PER_BATCH, + MAX_TRANSACTIONS_PER_BATCH, + NOTE_ENTRY_FELT_LEN, + NOTE_ENTRY_NUM_WORDS, + OUTPUT_NOTES_PTR, + TX_TUPLE_NUM_WORDS, + TX_TUPLES_PTR, + WORD_NUM_ELEMENTS, +} from miden::batch_kernel::memory + +# CONSTANTS +# ================================================================================================= + +# Advice-map keys under which the sorted note lists (input notes by nullifier, output notes by +# note id) are provided. Each key is the word hash of a domain message, matching `kernel.rs`. +const INPUT_NOTE_LIST_KEY = word("miden::batch_kernel::input_note_list") +const OUTPUT_NOTE_LIST_KEY = word("miden::batch_kernel::output_note_list") + +# ERRORS +# ================================================================================================= + +const ERR_BATCH_TOO_MANY_TRANSACTIONS = + "the batch contains more transactions than the maximum allowed" + +const ERR_BATCH_NO_TRANSACTIONS = "the batch contains no transactions" + +const ERR_BATCH_NOTE_LIST_NOT_SORTED = "a batch note list is not strictly sorted by its key" + +const ERR_BATCH_NOTE_LIST_TOO_LONG = + "a batch note list contains more entries than the maximum allowed" + +# SORTED NOTE LIST LOADING +# ================================================================================================= + +#! Pipes a sorted note list (8-felt `[KEY, VALUE]` entries) from the advice map into memory and +#! returns the number of entries. The list is not hashed against a commitment; its integrity is +#! established later by binding every entry to a verified per-transaction note. +#! +#! Inputs: +#! Operand stack: [KEY, write_ptr] +#! Advice map: +#! KEY |-> [[KEY_0, VALUE_0], ..., [KEY_n, VALUE_n]] +#! Outputs: +#! Operand stack: [num_notes] +proc load_note_list + adv.push_mapvaln + # OS => [KEY, write_ptr] + # AS => [len_felts, [note_list_entries]] + + adv_push + # OS => [len_felts, KEY, write_ptr] + # AS => [[note_list_entries]] + + div.WORD_NUM_ELEMENTS + # => [num_words, KEY, write_ptr] + + dup div.NOTE_ENTRY_NUM_WORDS + # => [num_notes, num_words, KEY, write_ptr] + + # A length that is not a multiple of 8 felts wraps `num_notes` (field `div`) to a non-u32 + # felt, which the u32 assertion rejects. + dup u32assert.err=ERR_BATCH_NOTE_LIST_TOO_LONG + u32lte.MAX_NOTES_PER_BATCH assert.err=ERR_BATCH_NOTE_LIST_TOO_LONG + movdn.6 + # => [num_words, KEY, write_ptr, num_notes] + + movup.5 swap + # => [num_words, write_ptr, KEY, num_notes] + + exec.mem::pipe_words_to_memory + # => [R0, R1, C, end_ptr, KEY, num_notes] + + dropw dropw dropw drop dropw + # => [num_notes] +end + +#! Asserts a note list (8-felt entries starting at `base_ptr`) is strictly increasing by its KEY +#! word, which also proves there are no duplicate keys. +#! +#! Inputs: [base_ptr, num] +#! Outputs: [] +proc assert_list_strictly_sorted + dup.1 push.2 u32lt + # => [num<2, base_ptr, num] + if.true + drop drop + else + push.0 + # => [idx, base_ptr, num] + dup add.1 dup.3 u32lt + # => [should_loop, idx, base_ptr, num] + while.true + dup mul.NOTE_ENTRY_FELT_LEN dup.2 add + # => [ptr_i, idx, base_ptr, num] + dup add.NOTE_ENTRY_FELT_LEN + # => [ptr_i1, ptr_i, idx, base_ptr, num] + padw movup.4 mem_loadw_le + # => [KEY_i1, ptr_i, idx, base_ptr, num] + movup.4 + # => [ptr_i, KEY_i1, idx, base_ptr, num] + padw movup.4 mem_loadw_le + # => [KEY_i, KEY_i1, idx, base_ptr, num] + swapw + # => [KEY_i1, KEY_i, idx, base_ptr, num] + exec.word::lt + # => [is_strictly_increasing, idx, base_ptr, num] + assert.err=ERR_BATCH_NOTE_LIST_NOT_SORTED + add.1 + dup add.1 dup.3 u32lt + # => [should_loop, idx, base_ptr, num] + end + drop drop drop + end +end + +#! Loads and verifies the nullifier-sorted input-note list into [`memory::INPUT_NOTES_PTR`]. +#! +#! Inputs: [] +#! Outputs: [] +proc prepare_input_note_list + push.INPUT_NOTES_PTR + push.INPUT_NOTE_LIST_KEY + # => [KEY, input_notes_ptr] + exec.load_note_list + exec.memory::set_num_input_notes + exec.memory::get_num_input_notes push.INPUT_NOTES_PTR + # => [input_notes_ptr, num_input_notes] + exec.assert_list_strictly_sorted +end + +#! Loads and verifies the note-id-sorted output-note list into [`memory::OUTPUT_NOTES_PTR`]. +#! +#! Inputs: [] +#! Outputs: [] +proc prepare_output_note_list + push.OUTPUT_NOTES_PTR + push.OUTPUT_NOTE_LIST_KEY + # => [KEY, output_notes_ptr] + exec.load_note_list + exec.memory::set_num_output_notes + exec.memory::get_num_output_notes push.OUTPUT_NOTES_PTR + # => [output_notes_ptr, num_output_notes] + exec.assert_list_strictly_sorted +end + +# PROLOGUE +# ================================================================================================= + +#! Loads to memory and verifies the batch's structural commitments from the advice provider. +#! +#! Performs two steps: +#! - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by `BATCH_ID` into +#! [`memory::TX_TUPLES_PTR`], asserting that the sequential hash of the piped data matches +#! `BATCH_ID`. The number of transactions is derived from the piped length, validated to be +#! non-zero and at most [`MAX_TRANSACTIONS_PER_BATCH`], and stored in +#! [`memory::NUM_TRANSACTIONS_PTR`]. +#! - Layer 2: for each transaction, pipes its pre-image from the advice map keyed by the verified +#! `tx_id`. Each transaction's data is written into [`memory::TX_HEADERS_PTR`] at the appropriate +#! per-tx offset. +#! - Note lists: loads the input-note list (sorted by nullifier) and the output-note list (sorted +#! by note id) from the advice map and asserts each is strictly sorted by its key. Their +#! integrity is established later, when the note tracker binds every entry to a verified +#! per-transaction note. +#! +#! Inputs: +#! Operand stack: [BATCH_ID] +#! Advice map: +#! BATCH_ID |-> [(tx_id_0, account_id_0_pair), (tx_id_1, account_id_1_pair), ...] +#! For each verified tx_id_i: +#! tx_id_i |-> [INIT_i, FINAL_i, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i] +#! +#! Outputs: +#! Operand stack: [] +#! +#! Panics if: +#! - the number of transactions is zero or exceeds the maximum allowed. +#! - the `(tx_id, account_id)` tuple list piped from the advice map does not hash to `BATCH_ID`. +#! - a transaction's `(INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT)` +#! data piped from the advice map does not hash to its `tx_id`. +#! +#! TODO: verify that each transaction's reference block is contained in the chain MMR rooted at +#! BLOCK_COMMITMENT. +#! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment. +pub proc prepare_batch + # Layer 1: pipe BATCH_ID's mapped value to tx_tuples_ptr + verify. + # --------------------------------------------------------------------------------------------- + + adv.push_mapvaln + # OS => [BATCH_ID] + # AS => [tx_tuples_len, [TX_TUPLES]] + + adv_push div.WORD_NUM_ELEMENTS + # OS => [num_words, BATCH_ID] + # AS => [[TX_TUPLES]] + + # num_transactions = num_words / 2 (each tx contributes 2 words: tx_id + account_id_pair). + dup div.TX_TUPLE_NUM_WORDS + # => [num_transactions, num_words, BATCH_ID] + + dup u32assert.err=ERR_BATCH_TOO_MANY_TRANSACTIONS + u32lte.MAX_TRANSACTIONS_PER_BATCH assert.err=ERR_BATCH_TOO_MANY_TRANSACTIONS + dup neq.0 assert.err=ERR_BATCH_NO_TRANSACTIONS + exec.memory::set_num_transactions + # => [num_words, BATCH_ID] + + push.TX_TUPLES_PTR swap + # => [num_words, tx_tuples_ptr, BATCH_ID] + + # Pipe the tuples into memory, asserting their poseidon2 hash equals BATCH_ID. + exec.mem::pipe_preimage_to_memory + # => [end_ptr] + drop + + # Layer 2: for each transaction, pipe + verify its header. + # --------------------------------------------------------------------------------------------- + + exec.memory::get_num_transactions + # => [num_transactions] + + # Layer 1 asserted num_transactions > 0, so enter the loop unconditionally and iterate + # tx_index from num_transactions - 1 down to 0. + push.1 + while.true + sub.1 + # => [tx_index] + + dup exec.memory::get_tx_id + # => [TX_ID, tx_index] + + adv.push_mapvaln + # OS => [TX_ID, tx_index] + # AS => [tx_header_len, [TX_HEADER]] + + adv_push div.WORD_NUM_ELEMENTS + # OS => [num_words, TX_ID, tx_index] + # AS => [[TX_HEADER]] + + dup.5 exec.memory::tx_header_ptr swap + # => [num_words, tx_header_ptr, TX_ID, tx_index] + + # Pipe the header into memory, asserting its poseidon2 hash equals TX_ID. + exec.mem::pipe_preimage_to_memory + # => [end_ptr, tx_index] + drop + + dup neq.0 + # => [should_loop, tx_index] + end + + drop + + # Note lists: load the sorted note lists and assert each is strictly sorted by its key. + # --------------------------------------------------------------------------------------------- + + exec.prepare_input_note_list + exec.prepare_output_note_list +end diff --git a/crates/miden-protocol/asm/kernels/batch/miden-project.toml b/crates/miden-protocol/asm/kernels/batch/miden-project.toml index 96030e417a..a53836e1df 100644 --- a/crates/miden-protocol/asm/kernels/batch/miden-project.toml +++ b/crates/miden-protocol/asm/kernels/batch/miden-project.toml @@ -2,5 +2,12 @@ name = "miden-batch-kernel" version.workspace = true +[lib] +namespace = "miden::batch_kernel" +path = "lib/mod.masm" + [[bin]] path = "src/main.masm" + +[dependencies] +miden-core.workspace = true diff --git a/crates/miden-protocol/asm/kernels/batch/src/main.masm b/crates/miden-protocol/asm/kernels/batch/src/main.masm index bf9a6d5fad..33bb4ec781 100644 --- a/crates/miden-protocol/asm/kernels/batch/src/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/src/main.masm @@ -1,13 +1,24 @@ +use miden::batch_kernel::prologue +use miden::batch_kernel::note_tracker +use miden::batch_kernel::epilogue + # MAIN # ================================================================================================= -#! Batch kernel program (skeleton). +#! Batch kernel program. #! #! A transaction batch groups a set of independently-proven transactions so they can later be -#! aggregated into a block by the block kernel. This program defines the public input/output -#! contract that the batch kernel will eventually verify, but currently does not yet perform -#! any verification: it drops its inputs and exits, leaving the all-zero word output region as the -#! stack's initial padding zeros. +#! aggregated into a block by the block kernel. This program validates the per-transaction data +#! supplied via the advice provider, and computes the batch's `INPUT_NOTES_COMMITMENT`. +#! +#! Validation is done recursively by "unhashing" the layers. Each layer of advice data is keyed +#! by a hash from the previous layer's verification step, so once `BATCH_ID` is anchored to the +#! public input, every element that makes up `INPUT_NOTES_COMMITMENT` is transitively committed-to: +#! +#! `BATCH_ID` (public input) -> `(tx_id, account_id)` tuple list +#! each `tx_id` -> per-tx +#! `(INIT, FINAL, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i)` +#! each `INPUT_NOTES_COMMITMENT_i` -> `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuples #! #! Inputs: [ #! BLOCK_COMMITMENT, @@ -24,16 +35,35 @@ #! #! Where: #! - BLOCK_COMMITMENT is the commitment of the batch's reference block. -#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. -#! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note -#! commitments. In this skeleton it is the empty word. -#! - BATCH_NOTE_TREE_ROOT will be the root of the batch note tree built over every transaction's -#! output notes. In this skeleton it is the empty word. -#! - batch_expiration_block_num will be the minimum of every transaction's -#! `expiration_block_num`. In this skeleton it is zero. +#! - BATCH_ID is the batch's `BatchId`, the sequential hash of the `(tx_id, account_id)` tuples +#! committing to the transactions in the batch. +#! - INPUT_NOTES_COMMITMENT is the nullifier-sorted sequential hash over the batch's input notes, +#! excluding notes created and consumed within the batch (i.e. post-erasure). +#! - BATCH_NOTE_TREE_ROOT is emitted as the empty word (not yet computed). +#! - batch_expiration_block_num is emitted as zero (not yet computed). #! +#! TODO: authenticate unauthenticated, non-erased input notes against BLOCK_COMMITMENT's chain MMR. +#! TODO: emit BATCH_NOTE_TREE_ROOT (the batch note tree SMT root) and batch_expiration_block_num. +#! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. +#! TODO: recursively verify each transaction's `ExecutionProof`. proc main - dropw dropw + # => [BLOCK_COMMITMENT, BATCH_ID, pad(8)] + + # TODO: verify BLOCK_COMMITMENT against block header data via the pipe-and-verify pattern. + dropw + # => [BATCH_ID, pad(12)] + + exec.prologue::prepare_batch + # => [pad(16)] + + exec.note_tracker::track_notes + # => [pad(16)] + + exec.epilogue::finalize + # => [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, pad(16)] + + swapdw dropw dropw + # => [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, pad(8)] end begin diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index 3708f1a8c1..9c929c732e 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -41,9 +41,11 @@ const KERNEL_PROCEDURES_RS_FILE: &str = "procedures.rs"; const TX_EVENTS_RS_FILE: &str = "transaction_events.rs"; const TX_KERNEL_ERRORS_RS_FILE: &str = "tx_kernel_errors.rs"; const PROTOCOL_LIB_ERRORS_RS_FILE: &str = "protocol_errors.rs"; +const BATCH_KERNEL_ERRORS_RS_FILE: &str = "batch_kernel_errors.rs"; const TX_KERNEL_ERRORS_ARRAY_NAME: &str = "TX_KERNEL_ERRORS"; const PROTOCOL_LIB_ERRORS_ARRAY_NAME: &str = "PROTOCOL_LIB_ERRORS"; +const BATCH_KERNEL_ERRORS_ARRAY_NAME: &str = "BATCH_KERNEL_ERRORS"; const TX_KERNEL_ERROR_CATEGORIES: [&str; 14] = [ "KERNEL", @@ -365,6 +367,22 @@ fn generate_error_constants(asm_source_dir: &Path, build_dir: &str) -> Result<() errors, )?; + // Batch kernel errors + // ------------------------------------------ + + let batch_kernel_dir = asm_source_dir.join(ASM_BATCH_KERNEL_DIR); + let batch_kernel_errors = + extract_all_masm_errors(&batch_kernel_dir).context("failed to extract all masm errors")?; + + generate_error_file( + ErrorModule { + file_path: Path::new(build_dir).join(BATCH_KERNEL_ERRORS_RS_FILE), + array_name: BATCH_KERNEL_ERRORS_ARRAY_NAME, + is_crate_local: true, + }, + batch_kernel_errors, + )?; + Ok(()) } diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index d2ec4ca6d0..6f8d7258d5 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -1,12 +1,17 @@ +use alloc::collections::BTreeSet; use alloc::vec::Vec; use miden_core::program::KernelDescriptor; +use miden_core::utils::hash_string_to_word; use crate::batch::{BatchId, ProposedBatch}; +use crate::errors::ProvenBatchError; +use crate::note::Nullifier; +use crate::transaction::{OrderedTransactionHeaders, TransactionCommitments}; use crate::utils::serde::Deserializable; use crate::utils::sync::LazyLock; use crate::vm::{AdviceInputs, Package, Program, ProgramInfo, StackInputs}; -use crate::{Felt, Word}; +use crate::{Felt, MAX_INPUT_NOTES_PER_BATCH, MAX_OUTPUT_NOTES_PER_BATCH, Word}; // CONSTANTS // ================================================================================================ @@ -22,6 +27,13 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { .expect("batch kernel package should contain a program") }); +// Advice-map keys under which the sorted (pre-erasure) note lists are provided to the kernel. +pub static INPUT_NOTE_LIST_KEY: LazyLock = + LazyLock::new(|| hash_string_to_word("miden::batch_kernel::input_note_list")); + +pub static OUTPUT_NOTE_LIST_KEY: LazyLock = + LazyLock::new(|| hash_string_to_word("miden::batch_kernel::output_note_list")); + // BATCH KERNEL // ================================================================================================ @@ -63,6 +75,57 @@ impl BatchKernel { (stack_inputs, advice_inputs) } + /// Rejects a [`ProposedBatch`] the batch kernel cannot yet correctly prove. + /// + /// Two temporary limitations: + /// - Authenticated input notes (converted from unauthenticated via inclusion proof). The kernel + /// reconstructs their commitment from per-transaction `(NULLIFIER, NOTE_ID)` tuples, whereas + /// the batch commits `(NULLIFIER, EMPTY)` for such notes, so the two commitments diverge. + /// - Pre-erasure note union exceeding `MAX_INPUT_NOTES_PER_BATCH` / `MAX_OUTPUT_NOTES_PER_BATCH`. + /// The kernel stores the pre-erasure lists in fixed-size regions, so it rejects such batches + /// even when valid post-erasure. Tracked in + /// . + pub fn ensure_supported(proposed_batch: &ProposedBatch) -> Result<(), ProvenBatchError> { + // The pre-erasure note unions must fit the kernel's fixed-size note regions. + let num_input_notes = proposed_batch + .transactions() + .iter() + .map(|tx| usize::from(tx.input_notes().num_notes())) + .sum(); + if num_input_notes > MAX_INPUT_NOTES_PER_BATCH { + return Err(ProvenBatchError::TooManyPreErasureInputNotes(num_input_notes)); + } + let num_output_notes = proposed_batch + .transactions() + .iter() + .map(|tx| tx.output_notes().num_notes()) + .sum(); + if num_output_notes > MAX_OUTPUT_NOTES_PER_BATCH { + return Err(ProvenBatchError::TooManyPreErasureOutputNotes(num_output_notes)); + } + + // An input note that some transaction consumed as unauthenticated but which the batch + // authenticated (header erased to the `(NULLIFIER, EMPTY)` form) is not yet supported. + // Erased notes are absent from `input_notes()`, so they are not flagged. + let consumed_unauthenticated: BTreeSet = proposed_batch + .transactions() + .iter() + .flat_map(|tx| tx.input_notes().iter()) + .filter_map(|note| note.header().is_some().then_some(note.nullifier())) + .collect(); + let unsupported_note = proposed_batch + .input_notes() + .iter() + .filter(|note| note.header().is_none()) + .find(|note| consumed_unauthenticated.contains(¬e.nullifier())); + + if let Some(note) = unsupported_note { + return Err(ProvenBatchError::UnsupportedInBatchAuthenticatedNote(note.nullifier())); + } + + Ok(()) + } + /// Returns the stack with the public inputs required by the batch kernel. /// /// The initial stack is: @@ -85,10 +148,102 @@ impl BatchKernel { // ADVICE BUILDER // -------------------------------------------------------------------------------------------- - /// Builds the advice inputs (map + stack) consumed by the batch kernel. + /// Builds the advice inputs consumed by the batch kernel. + /// + /// The kernel reconstructs and verifies the batch's `INPUT_NOTES_COMMITMENT` by walking a + /// layered advice map, each layer keyed by a hash the previous layer verified: + /// - `BATCH_ID` -> the `(tx_id, account_id)` tuple list (matching + /// `OrderedTransactionHeaders::hash_input_elements`). + /// - each `tx_id` -> the transaction header felt sequence (matching + /// [`TransactionCommitments::elements`]). + /// - each per-tx `INPUT_NOTES_COMMITMENT` -> the `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuples. + /// - each per-tx `OUTPUT_NOTES_COMMITMENT` -> the `(DETAILS_COMMITMENT, METADATA_COMMITMENT)` + /// tuples (the kernel derives each output `NoteId` from these via `poseidon2::merge`). /// - /// The skeleton kernel ignores its advice inputs, so this returns the default empty value. - fn build_advice_inputs(_proposed_batch: &ProposedBatch) -> AdviceInputs { - AdviceInputs::default() + /// It also provides two sorted lists: the pre-erasure union of every transaction's input + /// notes, sorted by nullifier (keyed by [`INPUT_NOTE_LIST_KEY`]), and of their output notes, + /// sorted by note id (keyed by [`OUTPUT_NOTE_LIST_KEY`]). The kernel binds every entry of + /// these lists to the per-transaction notes above (so the host cannot inject, omit, or + /// duplicate notes), derives erasure by cross-referencing the two lists, and hashes the + /// non-erased input notes in nullifier order to reproduce + /// `ProposedBatch::input_notes().commitment()`. + fn build_advice_inputs(proposed_batch: &ProposedBatch) -> AdviceInputs { + let mut advice_inputs = AdviceInputs::default(); + + // Layer 1: BATCH_ID -> [(tx_id, account_id) tuples]. + let layer1_data = OrderedTransactionHeaders::hash_input_elements( + proposed_batch.transactions().iter().map(|tx| (tx.id(), tx.account_id())), + ); + advice_inputs.map.extend([(proposed_batch.id().as_word(), layer1_data)]); + + // Pre-erasure union of every transaction's notes, collected while walking the per-tx + // layers and sorted below: input notes by nullifier, output notes by note id, matching + // `ProposedBatch`. + let mut input_list = Vec::new(); // (nullifier, note_id_or_empty) + let mut output_list = Vec::new(); + + for tx in proposed_batch.transactions().iter() { + // Layer 2: tx_id -> the felt sequence TransactionId::new hashes. + let header_data = TransactionCommitments::from(tx.as_ref()).elements(); + advice_inputs.map.extend([(tx.id().as_word(), header_data.to_vec())]); + + // Layer 3a: per-tx INPUT_NOTES_COMMITMENT -> [NULLIFIER, NOTE_ID_OR_EMPTY] tuples. + // This must reproduce `build_input_note_commitment` exactly. + let input_notes_commitment = tx.input_notes().commitment(); + if input_notes_commitment != Word::empty() { + let mut preimage_data = + Vec::with_capacity(usize::from(tx.input_notes().num_notes()) * 8); + for note_commit in tx.input_notes().iter() { + let nullifier = note_commit.nullifier(); + let note_id_or_empty = + note_commit.header().map_or(Word::empty(), |header| header.id().as_word()); + preimage_data.extend_from_slice(nullifier.as_word().as_elements()); + preimage_data.extend_from_slice(note_id_or_empty.as_elements()); + input_list.push((nullifier, note_id_or_empty)); + } + advice_inputs.map.extend([(input_notes_commitment, preimage_data)]); + } + + // Layer 3b: per-tx OUTPUT_NOTES_COMMITMENT -> [DETAILS_COMMITMENT, + // METADATA_COMMITMENT] tuples. Mirrors `OutputNotes::commitment`; the kernel derives + // each output NoteId as `merge(details_commitment, metadata_commitment)`. + let output_notes_commitment = tx.output_notes().commitment(); + if output_notes_commitment != Word::empty() { + let mut preimage_data = Vec::with_capacity(tx.output_notes().num_notes() * 8); + for note in tx.output_notes().iter() { + preimage_data + .extend_from_slice(note.details_commitment().as_word().as_elements()); + preimage_data.extend_from_slice(note.metadata().to_commitment().as_elements()); + output_list.push(note.id()); + } + advice_inputs.map.extend([(output_notes_commitment, preimage_data)]); + } + } + + // Sort the input-note list by nullifier and the output-note list by note id, ascending. + // `Word`'s ordering compares the most-significant felt first, matching the batch kernel's + // `word::lt` strict-sort check, `sorted_array`'s lookup order, and `ProposedBatch`'s + // `InputNoteCommitment::nullifier` order. + input_list.sort_by_key(|entry| entry.0); + output_list.sort_unstable(); + + // INPUT_NOTE_LIST_KEY -> [NULLIFIER, NOTE_ID_OR_EMPTY] (8 felts per note). + let mut input_blob = Vec::with_capacity(input_list.len() * 8); + for (nullifier, note_id_or_empty) in &input_list { + input_blob.extend_from_slice(nullifier.as_word().as_elements()); + input_blob.extend_from_slice(note_id_or_empty.as_elements()); + } + advice_inputs.map.extend([(*INPUT_NOTE_LIST_KEY, input_blob)]); + + // OUTPUT_NOTE_LIST_KEY -> [NOTE_ID, 0, 0, 0, 0] (8 felts per note; the VALUE word + // is unused, present only so the entries fit `sorted_array`'s KEY+VALUE layout). + let mut output_blob = Vec::with_capacity(output_list.len() * 8); + for note_id in &output_list { + output_blob.extend_from_slice(note_id.as_word().as_elements()); + output_blob.extend_from_slice(Word::empty().as_elements()); + } + advice_inputs.map.extend([(*OUTPUT_NOTE_LIST_KEY, output_blob)]); + + advice_inputs } } diff --git a/crates/miden-protocol/src/batch/mod.rs b/crates/miden-protocol/src/batch/mod.rs index f8235397e8..4a91a51cc1 100644 --- a/crates/miden-protocol/src/batch/mod.rs +++ b/crates/miden-protocol/src/batch/mod.rs @@ -19,7 +19,7 @@ pub use ordered_batches::OrderedBatches; pub(super) mod note_tracker; mod kernel; -pub use kernel::BatchKernel; +pub use kernel::{BatchKernel, INPUT_NOTE_LIST_KEY, OUTPUT_NOTE_LIST_KEY}; mod output; pub use output::BatchOutputs; diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index 10c553707f..b6c1f37164 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -175,6 +175,19 @@ mod tests { assert_eq!(outputs.batch_expiration_block_num(), BlockNumber::from(1234u32)); } + #[test] + fn into_stack_outputs_round_trips_through_parse() { + let outputs = BatchOutputs::new( + Word::from([Felt::from(1u32), Felt::from(2u32), Felt::from(3u32), Felt::from(4u32)]), + Word::from([Felt::from(5u32), Felt::from(6u32), Felt::from(7u32), Felt::from(8u32)]), + BlockNumber::from(1234u32), + ); + + let parsed = BatchOutputs::parse(&outputs.clone().into_stack_outputs()).unwrap(); + + assert_eq!(parsed, outputs); + } + #[test] fn parse_rejects_non_zero_padding() { // A valid 9-element output followed by a non-zero felt in the padding region (>= idx 9). diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index 8b570da903..48a704138b 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -30,7 +30,7 @@ use crate::account::{ }; use crate::address::AddressType; use crate::asset::AssetClass; -use crate::batch::BatchId; +use crate::batch::{BatchId, BatchOutputs}; use crate::block::BlockNumber; use crate::note::{ NoteAssets, @@ -73,6 +73,12 @@ pub mod protocol { include!(concat!(env!("OUT_DIR"), "/protocol_errors.rs")); } +/// The errors from the MASM code of the batch kernel. +#[cfg(any(feature = "testing", test))] +pub mod batch_kernel { + include!(concat!(env!("OUT_DIR"), "/batch_kernel_errors.rs")); +} + // ACCOUNT COMPONENT TEMPLATE ERROR // ================================================================================================ @@ -1184,6 +1190,25 @@ pub enum ProvenBatchError { BatchKernelExecutionFailed(#[source] ExecutionError), #[error("batch kernel produced an invalid output stack")] BatchKernelOutputInvalid(#[source] BatchOutputError), + #[error( + "batch kernel outputs do not match the outputs expected for the proposed batch (expected {expected:?}, actual {actual:?})" + )] + BatchKernelOutputMismatch { + expected: Box, + actual: Box, + }, + #[error( + "input note {0} is authenticated within the batch, which the batch kernel does not yet support (see the note-authentication TODO in asm/kernels/batch/main.masm)" + )] + UnsupportedInBatchAuthenticatedNote(Nullifier), + #[error( + "batch has {0} pre-erasure input notes but the batch kernel temporarily supports at most {MAX_INPUT_NOTES_PER_BATCH} (see https://github.com/0xMiden/protocol/issues/3184)" + )] + TooManyPreErasureInputNotes(usize), + #[error( + "batch has {0} pre-erasure output notes but the batch kernel temporarily supports at most {MAX_OUTPUT_NOTES_PER_BATCH} (see https://github.com/0xMiden/protocol/issues/3184)" + )] + TooManyPreErasureOutputNotes(usize), } // BATCH OUTPUT ERROR diff --git a/crates/miden-protocol/src/transaction/executed_tx.rs b/crates/miden-protocol/src/transaction/executed_tx.rs index 46329a1518..ea4b3d75f4 100644 --- a/crates/miden-protocol/src/transaction/executed_tx.rs +++ b/crates/miden-protocol/src/transaction/executed_tx.rs @@ -9,6 +9,7 @@ use super::{ NoteId, RawOutputNotes, TransactionArgs, + TransactionCommitments, TransactionId, TransactionOutputs, }; @@ -64,12 +65,12 @@ impl ExecutedTransaction { // we create the id from the content, so we cannot construct the // `id` value after construction `Self {..}` without moving - let id = TransactionId::new( + let id = TransactionId::new(TransactionCommitments::new( tx_inputs.account().initial_commitment(), tx_outputs.account().to_commitment(), tx_inputs.input_notes().commitment(), tx_outputs.output_notes().commitment(), - ); + )); Self { id, diff --git a/crates/miden-protocol/src/transaction/mod.rs b/crates/miden-protocol/src/transaction/mod.rs index 07ccec9210..596c8594a8 100644 --- a/crates/miden-protocol/src/transaction/mod.rs +++ b/crates/miden-protocol/src/transaction/mod.rs @@ -36,7 +36,7 @@ pub use outputs::{ pub use partial_blockchain::PartialBlockchain; pub use proven_tx::{InputNoteCommitment, ProvenTransaction, TxAccountUpdate}; pub use script::{TRANSACTION_SCRIPT_ATTRIBUTE, TransactionScript, TransactionScriptRoot}; -pub use transaction_id::TransactionId; +pub use transaction_id::{TransactionCommitments, TransactionId}; pub use tx_args::TransactionArgs; pub use tx_header::TransactionHeader; pub use tx_summary::{TransactionSummary, TransactionSummaryUserParams}; diff --git a/crates/miden-protocol/src/transaction/ordered_transactions.rs b/crates/miden-protocol/src/transaction/ordered_transactions.rs index c0c928e7d2..d292c02750 100644 --- a/crates/miden-protocol/src/transaction/ordered_transactions.rs +++ b/crates/miden-protocol/src/transaction/ordered_transactions.rs @@ -67,6 +67,19 @@ impl OrderedTransactionHeaders { pub(crate) fn compute_commitment( transactions: impl IntoIterator, ) -> Word { + Hasher::hash_elements(&Self::hash_input_elements(transactions)) + } + + /// Returns the felt sequence that [`Self::compute_commitment`] hashes. + /// + /// The layout is, for each `(transaction_id, account_id)` pair in iteration order: + /// `[transaction_id[4], account_id_suffix, account_id_prefix, 0, 0]` + /// + /// The batch kernel pipes this same felt sequence from the advice provider to memory and + /// asserts the resulting hash matches the public input `BATCH_ID`. + pub(crate) fn hash_input_elements( + transactions: impl IntoIterator, + ) -> Vec { let mut elements = vec![]; for (transaction_id, account_id) in transactions { elements.extend_from_slice(transaction_id.as_elements()); @@ -77,8 +90,7 @@ impl OrderedTransactionHeaders { Felt::ZERO, ]); } - - Hasher::hash_elements(&elements) + elements } } diff --git a/crates/miden-protocol/src/transaction/proven_tx.rs b/crates/miden-protocol/src/transaction/proven_tx.rs index 3dc1a1548d..d671e111fb 100644 --- a/crates/miden-protocol/src/transaction/proven_tx.rs +++ b/crates/miden-protocol/src/transaction/proven_tx.rs @@ -12,6 +12,7 @@ use crate::transaction::{ Nullifier, OutputNote, OutputNotes, + TransactionCommitments, TransactionId, }; use crate::utils::serde::{ @@ -217,12 +218,12 @@ impl ProvenTransaction { } } - let id = TransactionId::new( + let id = TransactionId::new(TransactionCommitments::new( account_update.initial_state_commitment(), account_update.final_state_commitment(), input_notes.commitment(), output_notes.commitment(), - ); + )); Ok(Self { id, diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index db0e777479..c3538952e0 100644 --- a/crates/miden-protocol/src/transaction/transaction_id.rs +++ b/crates/miden-protocol/src/transaction/transaction_id.rs @@ -2,7 +2,7 @@ use core::fmt::{Debug, Display}; use miden_crypto_derive::WordWrapper; -use super::{Hasher, ProvenTransaction, WORD_SIZE, Word, ZERO}; +use super::{ExecutedTransaction, Felt, Hasher, ProvenTransaction, WORD_SIZE, Word, ZERO}; use crate::utils::serde::{ ByteReader, ByteWriter, @@ -11,6 +11,96 @@ use crate::utils::serde::{ Serializable, }; +// TRANSACTION COMMITMENTS +// ================================================================================================ + +/// The four commitments that make up the preimage of the corresponding [`TransactionId`]. +#[derive(Clone, Copy)] +pub struct TransactionCommitments { + init_account_commitment: Word, + final_account_commitment: Word, + input_notes_commitment: Word, + output_notes_commitment: Word, +} + +impl TransactionCommitments { + /// Length of the felt sequence returned by [`Self::elements`]. + pub const ELEMENTS_LEN: usize = 4 * WORD_SIZE; + + /// Returns a new [`TransactionCommitments`] from the four commitment words. + pub fn new( + init_account_commitment: Word, + final_account_commitment: Word, + input_notes_commitment: Word, + output_notes_commitment: Word, + ) -> Self { + Self { + init_account_commitment, + final_account_commitment, + input_notes_commitment, + output_notes_commitment, + } + } + + /// Returns the transaction commitments as a felt sequence. + /// + /// The layout is: + /// `[INIT[4], FINAL[4], INPUT_NOTES_COMMITMENT[4], OUTPUT_NOTES_COMMITMENT[4]]` + /// + /// The batch kernel pipes this same felt sequence from the advice provider to memory and + /// asserts the resulting hash matches a previously-verified `tx_id`. + pub fn elements(&self) -> [Felt; Self::ELEMENTS_LEN] { + let mut elements = [ZERO; Self::ELEMENTS_LEN]; + elements[..4].copy_from_slice(self.init_account_commitment.as_elements()); + elements[4..8].copy_from_slice(self.final_account_commitment.as_elements()); + elements[8..12].copy_from_slice(self.input_notes_commitment.as_elements()); + elements[12..16].copy_from_slice(self.output_notes_commitment.as_elements()); + elements + } + + /// Returns the initial account commitment. + pub fn init_account_commitment(&self) -> Word { + self.init_account_commitment + } + + /// Returns the final account commitment. + pub fn final_account_commitment(&self) -> Word { + self.final_account_commitment + } + + /// Returns the input notes commitment. + pub fn input_notes_commitment(&self) -> Word { + self.input_notes_commitment + } + + /// Returns the output notes commitment. + pub fn output_notes_commitment(&self) -> Word { + self.output_notes_commitment + } +} + +impl From<&ProvenTransaction> for TransactionCommitments { + fn from(tx: &ProvenTransaction) -> Self { + Self { + init_account_commitment: tx.account_update().initial_state_commitment(), + final_account_commitment: tx.account_update().final_state_commitment(), + input_notes_commitment: tx.input_notes().commitment(), + output_notes_commitment: tx.output_notes().commitment(), + } + } +} + +impl From<&ExecutedTransaction> for TransactionCommitments { + fn from(tx: &ExecutedTransaction) -> Self { + Self { + init_account_commitment: tx.initial_account().initial_commitment(), + final_account_commitment: tx.final_account().to_commitment(), + input_notes_commitment: tx.input_notes().commitment(), + output_notes_commitment: tx.output_notes().commitment(), + } + } +} + // TRANSACTION ID // ================================================================================================ @@ -32,19 +122,9 @@ use crate::utils::serde::{ pub struct TransactionId(Word); impl TransactionId { - /// Returns a new [TransactionId] instantiated from the provided transaction components. - pub fn new( - init_account_commitment: Word, - final_account_commitment: Word, - input_notes_commitment: Word, - output_notes_commitment: Word, - ) -> Self { - let mut elements = [ZERO; 4 * WORD_SIZE]; - elements[..4].copy_from_slice(init_account_commitment.as_elements()); - elements[4..8].copy_from_slice(final_account_commitment.as_elements()); - elements[8..12].copy_from_slice(input_notes_commitment.as_elements()); - elements[12..16].copy_from_slice(output_notes_commitment.as_elements()); - Self(Hasher::hash_elements(&elements)) + /// Returns a new [TransactionId] from the given [`TransactionCommitments`]. + pub fn new(commitments: TransactionCommitments) -> Self { + Self(Hasher::hash_elements(&commitments.elements())) } } @@ -65,12 +145,7 @@ impl Display for TransactionId { impl From<&ProvenTransaction> for TransactionId { fn from(tx: &ProvenTransaction) -> Self { - Self::new( - tx.account_update().initial_state_commitment(), - tx.account_update().final_state_commitment(), - tx.input_notes().commitment(), - tx.output_notes().commitment(), - ) + Self::new(TransactionCommitments::from(tx)) } } diff --git a/crates/miden-protocol/src/transaction/tx_header.rs b/crates/miden-protocol/src/transaction/tx_header.rs index 0a6a561281..5cd1f6b17d 100644 --- a/crates/miden-protocol/src/transaction/tx_header.rs +++ b/crates/miden-protocol/src/transaction/tx_header.rs @@ -9,6 +9,7 @@ use crate::transaction::{ InputNotes, ProvenTransaction, RawOutputNotes, + TransactionCommitments, TransactionId, }; use crate::utils::serde::{ @@ -60,12 +61,12 @@ impl TransactionHeader { let input_notes_commitment = input_notes.commitment(); let output_notes_commitment = RawOutputNotes::compute_commitment(output_notes.iter()); - let id = TransactionId::new( + let id = TransactionId::new(TransactionCommitments::new( initial_state_commitment, final_state_commitment, input_notes_commitment, output_notes_commitment, - ); + )); Self { id, diff --git a/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs b/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs index 90aeda65ec..b78a35357f 100644 --- a/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs +++ b/crates/miden-testing/src/kernel_tests/batch/batch_verifier.rs @@ -1,5 +1,6 @@ use anyhow::Context; use assert_matches::assert_matches; +use miden_protocol::vm::AdviceInputs; use miden_tx_batch::{BatchExecutor, BatchVerifier, BatchVerifierError, LocalBatchProver}; use super::proposed_batch::setup_chain; @@ -15,15 +16,20 @@ fn batch_verifier_accepts_freshly_proven_batch() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; - let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let executed = BatchExecutor::new() + .execute(batch, AdviceInputs::default()) + .context("batch execution failed")?; let proven = LocalBatchProver::new().prove(executed).context("batch proving failed")?; - let security_level = proven.proof_security_level(); + // A zero minimum always passes; `verify` returns the proof's actual security level. + let security_level = BatchVerifier::new(0) + .verify(&proven) + .context("verifying the proven batch should succeed")?; - // Requiring exactly the security level the proof provides must succeed. + // The minimum check is inclusive. BatchVerifier::new(security_level) .verify(&proven) - .context("verifying the proven batch should succeed")?; + .context("requiring exactly the provided security level should succeed")?; // Requiring even one more bit than the proof provides must fail. let err = BatchVerifier::new(security_level + 1).verify(&proven).unwrap_err(); diff --git a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs index bed7cde9de..6bfe1f30ac 100644 --- a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs +++ b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs @@ -10,7 +10,7 @@ use miden_protocol::asset::NonFungibleAsset; use miden_protocol::batch::ProposedBatch; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::merkle::MerkleError; -use miden_protocol::errors::{BatchAccountUpdateError, ProposedBatchError}; +use miden_protocol::errors::{BatchAccountUpdateError, ProposedBatchError, ProvenBatchError}; use miden_protocol::note::{ Note, NoteAssets, @@ -29,11 +29,13 @@ use miden_protocol::transaction::{ ProvenTransaction, RawOutputNote, }; +use miden_protocol::vm::AdviceInputs; use miden_standards::note::P2idNoteStorage; use miden_standards::testing::account_component::MockAccountComponent; use miden_standards::testing::note::NoteBuilder; use miden_standards::tx_script::SendNotesTransactionScript; use miden_tx::LocalTransactionProver; +use miden_tx_batch::BatchExecutor; use rand::rngs::SmallRng; use rand::{RngExt, SeedableRng}; @@ -625,6 +627,68 @@ async fn unauthenticated_note_converted_to_authenticated() -> anyhow::Result<()> Ok(()) } +/// A note authenticated within the batch (an unauthenticated note converted to authenticated via a +/// supplied inclusion proof) is not yet supported by the batch kernel: the kernel reconstructs the +/// commitment from the per-transaction `(NULLIFIER, NOTE_ID)` tuple, while the batch commits +/// `(NULLIFIER, EMPTY)`, so the two diverge. Execution must reject such a batch early rather than +/// emit an unverifiable proof. Proper support is the note-authentication TODO in +/// `asm/kernels/batch/main.masm`. +#[tokio::test] +async fn batch_kernel_rejects_in_batch_authenticated_note() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let account1 = generate_account(&mut builder); + let note1 = create_p2any_note(account1.id(), NoteType::Public, [], builder.rng_mut()); + let note2 = create_p2any_note(account1.id(), NoteType::Public, [], builder.rng_mut()); + let spawn_note = builder.add_spawn_note([¬e1, ¬e2])?; + let mut chain = builder.build()?; + + let tx = chain + .build_transaction(account1.clone()) + .authenticated_input_note(spawn_note.id()) + .expected_output_notes(vec![ + RawOutputNote::Full(note1.clone()), + RawOutputNote::Full(note2.clone()), + ]) + .build()? + .execute() + .await?; + chain.add_pending_executed_transaction(&tx)?; + + // Note2 is created in block1 and therefore provable against it. + let _block1 = chain.prove_next_block()?; + let block2 = chain.prove_next_block()?; + let block3 = chain.prove_next_block()?; + + // Consume the note as unauthenticated, then supply its inclusion proof so the batch + // authenticates it (rewriting `(NULLIFIER, NOTE_ID)` to `(NULLIFIER, EMPTY)`). + let tx1 = + MockProvenTxBuilder::with_account(account1.id(), Word::empty(), account1.to_commitment()) + .reference_block(block2.header()) + .unauthenticated_notes(vec![note2.clone()]) + .build()?; + + let input_note2 = chain.get_public_note(¬e2.id()).expect("note not found"); + let note_inclusion_proof2 = input_note2.proof().expect("note should be of type authenticated"); + + let batch = ProposedBatch::new_unverified( + [tx1].into_iter().map(Arc::new).collect(), + block3.header().clone(), + chain.latest_partial_blockchain(), + BTreeMap::from_iter([(input_note2.id(), note_inclusion_proof2.clone())]), + )?; + // The unauthenticated input note became authenticated at the batch level. + assert_eq!(batch.input_notes().num_notes(), 1); + + // `ExecutedBatch` is not `Debug`, so match on the result explicitly. + match BatchExecutor::new().execute(batch, AdviceInputs::default()) { + Err(ProvenBatchError::UnsupportedInBatchAuthenticatedNote(_)) => {}, + Ok(_) => panic!("expected the batch execution to reject the in-batch authenticated note"), + Err(other) => panic!("expected UnsupportedInBatchAuthenticatedNote, got: {other}"), + } + + Ok(()) +} + /// Test that an authenticated input note that is also created in the same batch does not error /// and instead is marked as consumed. /// - This requires a nullifier collision on the input and output note which is very unlikely in diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index f5d3fea06a..3deb317192 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -1,21 +1,43 @@ use alloc::sync::Arc; +use alloc::vec::Vec; use std::collections::BTreeMap; use anyhow::Context; -use miden_protocol::Word; -use miden_protocol::batch::ProposedBatch; +use miden_protocol::batch::{ + BatchKernel, + INPUT_NOTE_LIST_KEY, + OUTPUT_NOTE_LIST_KEY, + ProposedBatch, +}; use miden_protocol::block::BlockNumber; +use miden_protocol::errors::{MasmError, ProvenBatchError, batch_kernel}; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::vm::AdviceInputs; +use miden_protocol::{ + Felt, + MAX_INPUT_NOTES_PER_BATCH, + MAX_OUTPUT_NOTES_PER_BATCH, + WORD_SIZE, + Word, +}; use miden_tx_batch::{BatchExecutor, LocalBatchProver}; +use rstest::rstest; use super::proposed_batch::{TestSetup, mock_note, mock_output_note, setup_chain}; use super::proven_tx_builder::MockProvenTxBuilder; +/// Felts per global note-list entry: a KEY word plus a VALUE word. +const FELTS_PER_NOTE_ENTRY: usize = 2 * WORD_SIZE; + +/// Must match `MAX_TRANSACTIONS_PER_BATCH` in `asm/kernels/batch/lib/memory.masm`. +const MAX_TRANSACTIONS_PER_BATCH: usize = 1024; + // SETUP HELPERS // ================================================================================================ -/// Builds a two-transaction batch with realistic inputs and outputs. The skeleton kernel does not -/// inspect any of this data, but the batch is built end-to-end so the smoke test exercises the -/// real `prepare_inputs` path that the verification PR will eventually consume. +/// Builds a two-transaction batch: +/// - tx1 (account1): consumes one authenticated input note, produces one output note. +/// - tx2 (account2): consumes one unauthenticated input note, produces two output notes. pub(super) fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { let block1 = setup.chain.block_header(1); let block2 = setup.chain.prove_next_block()?; @@ -51,28 +73,109 @@ pub(super) fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result AdviceInputs { + let (_, advice_inputs) = BatchKernel::prepare_inputs(batch); + let mut tampered: Vec = advice_inputs + .map + .get(&key) + .expect("advice-map entry for key") + .iter() + .copied() + .collect(); + tampered[0] += Felt::from(1u32); + AdviceInputs::default().with_map([(key, tampered)]) +} + +/// Asserts that batch execution failed with the kernel raising the expected MASM assertion error. +fn assert_kernel_error(result: Result, expected: MasmError) { + match result { + Ok(_) => panic!("expected batch kernel error {expected}, but execution succeeded"), + Err(ProvenBatchError::BatchKernelExecutionFailed(execution_error)) => assert!( + expected.matches_execution_error(&execution_error), + "batch kernel error did not match:\n expected: {expected}\n actual: {execution_error}", + ), + Err(other) => panic!("expected a batch kernel execution error {expected}, got: {other}"), + } +} + +// HAPPY PATH // ================================================================================================ -/// The skeleton batch kernel drops its public inputs and exits, leaving the all-zero word output -/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, execute -/// the batch kernel via `BatchExecutor`, parse the outputs) and asserts that the contract holds: -/// the kernel runs to completion and emits the empty word shape. +/// The kernel emits `INPUT_NOTES_COMMITMENT` equal to the commitment the proposed batch derives +/// over the same notes (`two_tx_batch` has no intra-batch erasure, so the full union is +/// committed). #[test] -fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { +fn batch_kernel_emits_input_notes_commitment() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; + let expected_input_notes_commitment = batch.input_notes().commitment(); - let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let executed = BatchExecutor::new() + .execute(batch, AdviceInputs::default()) + .context("batch execution failed")?; let output = executed.batch_outputs(); - assert_eq!(output.input_notes_commitment(), Word::empty()); + assert_eq!(output.input_notes_commitment(), expected_input_notes_commitment); assert_eq!(output.batch_note_tree_root(), Word::empty()); assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); Ok(()) } +/// A note created by one transaction and consumed (unauthenticated) by a later transaction in the +/// same batch is erased: the kernel excludes it from `INPUT_NOTES_COMMITMENT`, matching the empty +/// commitment the proposed batch derives after erasure. +#[test] +fn batch_kernel_erases_note_created_and_consumed_in_batch() -> anyhow::Result<()> { + let setup = setup_chain(); + let mut chain = setup.chain; + let block1 = chain.block_header(1); + let block2 = chain.prove_next_block()?; + + let note = mock_note(40); + let tx1 = MockProvenTxBuilder::with_account( + setup.account1.id(), + Word::empty(), + setup.account1.to_commitment(), + ) + .reference_block(&block1) + .output_notes(vec![RawOutputNote::Full(note.clone()).into_output_note().unwrap()]) + .build()?; + let tx2 = MockProvenTxBuilder::with_account( + setup.account2.id(), + Word::empty(), + setup.account2.to_commitment(), + ) + .reference_block(&block1) + .unauthenticated_notes(vec![note.clone()]) + .build()?; + + let batch = ProposedBatch::new_unverified( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header().clone(), + chain.latest_partial_blockchain(), + BTreeMap::default(), + )?; + // The note is created and consumed within the batch, so the batch has no input notes. + assert_eq!(batch.input_notes().num_notes(), 0); + let expected_input_notes_commitment = batch.input_notes().commitment(); + + let executed = BatchExecutor::new() + .execute(batch, AdviceInputs::default()) + .context("batch execution failed")?; + assert_eq!( + executed.batch_outputs().input_notes_commitment(), + expected_input_notes_commitment, + ); + + Ok(()) +} + /// Executing a batch and then proving it produces a [`ProvenBatch`] carrying the kernel's proof. #[test] fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { @@ -80,10 +183,310 @@ fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { let batch = two_tx_batch(&mut setup)?; let expected_id = batch.id(); - let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let executed = BatchExecutor::new() + .execute(batch, AdviceInputs::default()) + .context("batch execution failed")?; let proven = LocalBatchProver::new().prove(executed).context("batch proving failed")?; assert_eq!(proven.id(), expected_id); Ok(()) } + +// NEGATIVE TESTS +// ================================================================================================ +// +// Each test merges a tampered advice-map entry over the advice derived from a valid +// `ProposedBatch` and asserts the kernel aborts. + +/// Tampering any preimage layer breaks its hash check inside `mem::pipe_preimage_to_memory` (a +/// bare `assert_eqw` with no named error code, so the cases only assert that execution fails). +/// Cases: Layer 1 (`BATCH_ID`), Layer 2 (`tx_id`), Layer 3a (`INPUT_NOTES_COMMITMENT`), Layer +/// 3b (`OUTPUT_NOTES_COMMITMENT`). +#[rstest] +#[case(|batch: &ProposedBatch| batch.id().as_word())] +#[case(|batch: &ProposedBatch| batch.transactions()[0].id().as_word())] +#[case(|batch: &ProposedBatch| batch.transactions()[0].input_notes().commitment())] +#[case(|batch: &ProposedBatch| batch.transactions()[0].output_notes().commitment())] +fn batch_kernel_rejects_tampered_advice( + #[case] key: fn(&ProposedBatch) -> Word, +) -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let override_advice = tampered_advice_for(&batch, key(&batch)); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert!(result.is_err(), "kernel must abort on tampered advice"); + + Ok(()) +} + +// GLOBAL NOTE LIST BINDING NEGATIVE TESTS +// ================================================================================================ +// +// These corrupt the host-provided global note lists and assert the kernel's binding rejects them. + +/// Builds the global input-note list blob the kernel expects: `(nullifier, note_id_or_empty)` per +/// note across all transactions, sorted by nullifier. +fn input_note_list_blob(batch: &ProposedBatch) -> Vec { + let mut notes: Vec<(Word, Word)> = Vec::new(); + for tx in batch.transactions() { + for commit in tx.input_notes().iter() { + let nullifier = commit.nullifier().as_word(); + let note_id_or_empty = + commit.header().map_or(Word::empty(), |header| header.id().as_word()); + notes.push((nullifier, note_id_or_empty)); + } + } + notes.sort_by_key(|entry| entry.0); + let mut blob = Vec::with_capacity(notes.len() * FELTS_PER_NOTE_ENTRY); + for (nullifier, note_id_or_empty) in ¬es { + blob.extend_from_slice(nullifier.as_elements()); + blob.extend_from_slice(note_id_or_empty.as_elements()); + } + blob +} + +/// Omitting an input note from the global list makes its per-transaction lookup fail. +#[test] +fn batch_kernel_rejects_input_note_missing_from_list() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let mut blob = input_note_list_blob(&batch); + blob.truncate(blob.len() - FELTS_PER_NOTE_ENTRY); // drop the last (highest-nullifier) note + let override_advice = AdviceInputs::default().with_map([(*INPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_INPUT_NOTE_NOT_IN_LIST); + + Ok(()) +} + +/// A duplicate entry breaks the strict-sorted (no-duplicate) invariant. +#[test] +fn batch_kernel_rejects_duplicated_input_note_list_entry() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let blob = input_note_list_blob(&batch); + // Prepend a copy of the first entry, so two equal nullifiers are adjacent. + let mut duplicated: Vec = blob[0..FELTS_PER_NOTE_ENTRY].to_vec(); + duplicated.extend_from_slice(&blob); + let override_advice = AdviceInputs::default().with_map([(*INPUT_NOTE_LIST_KEY, duplicated)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_NOTE_LIST_NOT_SORTED); + + Ok(()) +} + +/// Altering a list entry's note id (without touching its nullifier) is caught when the kernel binds +/// the entry to the per-transaction note id. +#[test] +fn batch_kernel_rejects_input_note_list_id_mismatch() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let mut blob = input_note_list_blob(&batch); + // Corrupt the first entry's note-id word only, so the entry is still found by nullifier. + blob[4] += Felt::from(1u32); + let override_advice = AdviceInputs::default().with_map([(*INPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_INPUT_NOTE_ID_MISMATCH); + + Ok(()) +} + +/// Builds the global output-note list blob the kernel expects: `(note_id, 0, 0, 0, 0)` per output +/// note across all transactions, sorted by note id. +fn output_note_list_blob(batch: &ProposedBatch) -> Vec { + let mut ids: Vec = Vec::new(); + for tx in batch.transactions() { + for note in tx.output_notes().iter() { + ids.push(note.id().as_word()); + } + } + ids.sort_unstable(); + let mut blob = Vec::with_capacity(ids.len() * FELTS_PER_NOTE_ENTRY); + for note_id in &ids { + blob.extend_from_slice(note_id.as_elements()); + blob.extend_from_slice(Word::empty().as_elements()); + } + blob +} + +/// Omitting an output note from the global list makes its per-transaction lookup fail. +#[test] +fn batch_kernel_rejects_output_note_missing_from_list() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let mut blob = output_note_list_blob(&batch); + blob.truncate(blob.len() - FELTS_PER_NOTE_ENTRY); // drop the last (highest-note-id) output note + let override_advice = AdviceInputs::default().with_map([(*OUTPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_OUTPUT_NOTE_NOT_IN_LIST); + + Ok(()) +} + +/// Adding a consumed note's id to the output list as a phantom creation (which no transaction +/// performs) leaves the note expected-to-be-erased, tripping the consume-before-create gate. +#[test] +fn batch_kernel_rejects_consume_before_create() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + // Add tx2's unauthenticated input note id to the output list as a phantom creation. + let phantom_created_id = mock_note(81).id().as_word(); + let mut ids: Vec = Vec::new(); + for tx in batch.transactions() { + for note in tx.output_notes().iter() { + ids.push(note.id().as_word()); + } + } + ids.push(phantom_created_id); + ids.sort_unstable(); + let mut blob = Vec::with_capacity(ids.len() * FELTS_PER_NOTE_ENTRY); + for note_id in &ids { + blob.extend_from_slice(note_id.as_elements()); + blob.extend_from_slice(Word::empty().as_elements()); + } + let override_advice = AdviceInputs::default().with_map([(*OUTPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_NOTE_CONSUMED_BEFORE_CREATED); + + Ok(()) +} + +/// An extra input-note list entry that no transaction consumes trips the epilogue sweep requiring +/// every entry to have been consumed (so the host cannot pad the list with phantom notes). +#[test] +fn batch_kernel_rejects_unconsumed_input_note() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + // Append an entry whose nullifier no transaction consumes, keeping the list strictly sorted. + let mut notes: Vec<(Word, Word)> = Vec::new(); + for tx in batch.transactions() { + for commit in tx.input_notes().iter() { + let nullifier = commit.nullifier().as_word(); + let note_id_or_empty = + commit.header().map_or(Word::empty(), |header| header.id().as_word()); + notes.push((nullifier, note_id_or_empty)); + } + } + notes.push((Word::from([u32::MAX; 4]), Word::empty())); + notes.sort_by_key(|entry| entry.0); + let mut blob = Vec::with_capacity(notes.len() * FELTS_PER_NOTE_ENTRY); + for (nullifier, note_id_or_empty) in ¬es { + blob.extend_from_slice(nullifier.as_elements()); + blob.extend_from_slice(note_id_or_empty.as_elements()); + } + let override_advice = AdviceInputs::default().with_map([(*INPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_INPUT_NOTE_NOT_CONSUMED); + + Ok(()) +} + +/// An extra output-note list entry that no transaction creates trips the epilogue sweep requiring +/// every entry to have been created (the injected id matches no input note, so it is not linked +/// for erasure). +#[test] +fn batch_kernel_rejects_uncreated_output_note() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let mut ids: Vec = Vec::new(); + for tx in batch.transactions() { + for note in tx.output_notes().iter() { + ids.push(note.id().as_word()); + } + } + ids.push(Word::from([u32::MAX; 4])); + ids.sort_unstable(); + let mut blob = Vec::with_capacity(ids.len() * FELTS_PER_NOTE_ENTRY); + for note_id in &ids { + blob.extend_from_slice(note_id.as_elements()); + blob.extend_from_slice(Word::empty().as_elements()); + } + let override_advice = AdviceInputs::default().with_map([(*OUTPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_OUTPUT_NOTE_NOT_CREATED); + + Ok(()) +} + +/// A global input-note list longer than the maximum is rejected during load, before the (here +/// all-zero) entries are inspected. +#[test] +fn batch_kernel_rejects_oversized_input_note_list() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let blob = vec![Felt::from(0u32); (MAX_INPUT_NOTES_PER_BATCH + 1) * FELTS_PER_NOTE_ENTRY]; + let override_advice = AdviceInputs::default().with_map([(*INPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_NOTE_LIST_TOO_LONG); + + Ok(()) +} + +/// Same as above for the output-note list. +#[test] +fn batch_kernel_rejects_oversized_output_note_list() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let blob = vec![Felt::from(0u32); (MAX_OUTPUT_NOTES_PER_BATCH + 1) * FELTS_PER_NOTE_ENTRY]; + let override_advice = AdviceInputs::default().with_map([(*OUTPUT_NOTE_LIST_KEY, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_NOTE_LIST_TOO_LONG); + + Ok(()) +} + +/// A layer-1 tuple list longer than `MAX_TRANSACTIONS_PER_BATCH` is rejected before it is piped +/// and hash-checked against `BATCH_ID`. +#[test] +fn batch_kernel_rejects_too_many_transactions() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + // A layer-1 `(tx_id, account_id)` tuple is 8 felts. + const FELTS_PER_TX_TUPLE: usize = 2 * WORD_SIZE; + let blob = vec![Felt::from(0u32); (MAX_TRANSACTIONS_PER_BATCH + 1) * FELTS_PER_TX_TUPLE]; + let override_advice = AdviceInputs::default().with_map([(batch.id().as_word(), blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_TOO_MANY_TRANSACTIONS); + + Ok(()) +} + +/// A per-transaction note set longer than the maximum is rejected before it is piped and +/// hash-checked against the transaction's `INPUT_NOTES_COMMITMENT`. +#[test] +fn batch_kernel_rejects_too_many_notes_per_transaction() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let key = batch.transactions()[0].input_notes().commitment(); + let blob = vec![Felt::from(0u32); (MAX_INPUT_NOTES_PER_BATCH + 1) * FELTS_PER_NOTE_ENTRY]; + let override_advice = AdviceInputs::default().with_map([(key, blob)]); + + let result = BatchExecutor::new().execute(batch, override_advice); + assert_kernel_error(result, batch_kernel::ERR_BATCH_TX_TOO_MANY_NOTES); + + Ok(()) +} diff --git a/crates/miden-tx-batch/src/batch_executor.rs b/crates/miden-tx-batch/src/batch_executor.rs index 218bfcbcb8..7aff9ebf16 100644 --- a/crates/miden-tx-batch/src/batch_executor.rs +++ b/crates/miden-tx-batch/src/batch_executor.rs @@ -1,6 +1,11 @@ +use alloc::boxed::Box; + use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcessor}; use miden_protocol::batch::{BatchKernel, BatchOutputs, ProposedBatch}; +use miden_protocol::block::BlockNumber; use miden_protocol::errors::ProvenBatchError; +use miden_protocol::vm::AdviceInputs; +use miden_protocol::{CoreLibrary, Word}; use crate::ExecutedBatch; @@ -20,35 +25,62 @@ impl BatchExecutor { /// Runs the batch kernel over the [`ProposedBatch`], returning an [`ExecutedBatch`] that can be /// passed to [`LocalBatchProver::prove`](crate::LocalBatchProver::prove). /// + /// The provided advice inputs are merged onto those derived from the proposed batch, + /// overriding matching advice-map keys. + /// /// # Errors /// /// Returns an error if: + /// - the batch contains a feature the kernel does not yet support (an input note authenticated + /// within the batch, or a pre-erasure note union exceeding the kernel's fixed-size regions); /// - the batch kernel program fails to execute; - /// - the kernel output stack fails to parse. + /// - the kernel output stack fails to parse; + /// - the kernel outputs do not match the outputs expected for the proposed batch. pub fn execute( &self, proposed_batch: ProposedBatch, + advice_inputs: AdviceInputs, ) -> Result { - let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + BatchKernel::ensure_supported(&proposed_batch)?; + + let (stack_inputs, mut batch_advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + batch_advice_inputs.extend(advice_inputs); let processor = FastProcessor::new_with_options( stack_inputs, - advice_inputs, + batch_advice_inputs, ExecutionOptions::default(), ) .map_err(ExecutionError::advice_error_no_context) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + // Load the core library so the host has the `miden::core` procedures and the `sorted_array` + // event handlers the batch kernel relies on. + let mut host = DefaultHost::default(); + host.load_library(&CoreLibrary::default()) + .expect("loading the core library into the host should succeed"); + let trace_inputs = processor - .execute_trace_inputs_sync(&BatchKernel::main(), &mut DefaultHost::default()) + .execute_trace_inputs_sync(&BatchKernel::main(), &mut host) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; - // Parse and validate the output stack shape (padding cells are zero and the expiration - // fits in u32); the actual output values themselves are not checked until the kernel - // verifies them. + // Parse and validate the output stack shape (zero padding, u32 expiration). let batch_outputs = BatchOutputs::parse(trace_inputs.stack_outputs()) .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; + // Reject if the kernel's outputs do not match the proposed batch, so drift is caught early. + let expected_outputs = BatchOutputs::new( + proposed_batch.input_notes().commitment(), + Word::empty(), + BlockNumber::from(0u32), + ); + if batch_outputs != expected_outputs { + return Err(ProvenBatchError::BatchKernelOutputMismatch { + expected: Box::new(expected_outputs), + actual: Box::new(batch_outputs), + }); + } + Ok(ExecutedBatch::new(proposed_batch, trace_inputs, batch_outputs)) } } diff --git a/crates/miden-tx-batch/src/verifier.rs b/crates/miden-tx-batch/src/verifier.rs index 15c61e3272..92e93ec39d 100644 --- a/crates/miden-tx-batch/src/verifier.rs +++ b/crates/miden-tx-batch/src/verifier.rs @@ -17,58 +17,68 @@ use crate::BatchVerifierError; /// /// # Warning /// -/// The current batch kernel is a skeleton that drops its inputs and emits an all-zero output -/// region, so a successful [`verify`](BatchVerifier::verify) attests only that the kernel program -/// ran over the batch's `[BLOCK_COMMITMENT, BATCH_ID]` public inputs. It does **not** yet bind the -/// batch's notes, account updates, or expiration: those values are not part of what the proof -/// commits to, so a `ProvenBatch` whose contents were mutated would still verify. This verifier -/// must therefore not be relied on at a trust boundary until the kernel verification logic that -/// emits and binds the real commitments lands. +/// The batch kernel binds only the batch's `INPUT_NOTES_COMMITMENT`. The batch note tree root and +/// expiration are emitted as the empty word and zero, and input notes are not authenticated against +/// the chain MMR. A successful [`verify`](BatchVerifier::verify) therefore attests that the kernel +/// ran over the batch's `[BLOCK_COMMITMENT, BATCH_ID]` inputs and produced +/// `batch.input_notes().commitment()`, but does **not** bind the batch's output notes, account +/// updates, or expiration. The `BATCH_ID` and reference block commitment fed to the kernel are +/// taken from the [`ProvenBatch`]'s own fields and are not recomputed from its transactions, so +/// verification binds nothing that an entity constructing the [`ProvenBatch`] could not also forge. +/// This verifier must not be relied on at a trust boundary. pub struct BatchVerifier { batch_program_info: ProgramInfo, - proof_security_level: u32, + min_proof_security_level: u32, } impl BatchVerifier { /// Returns a new [`BatchVerifier`] instantiated with the specified minimum security level. - pub fn new(proof_security_level: u32) -> Self { + pub fn new(min_proof_security_level: u32) -> Self { let batch_program_info = BatchKernel::program_info(); - Self { batch_program_info, proof_security_level } + Self { + batch_program_info, + min_proof_security_level, + } } /// Verifies the provided [`ProvenBatch`]'s execution proof against the batch kernel. /// + /// On success, returns the security level (in bits) of the verified proof. See the + /// [type-level warning](BatchVerifier#warning): a successful result must not be relied on at a + /// trust boundary while the kernel binds only `INPUT_NOTES_COMMITMENT`. + /// /// # Errors /// Returns an error if: /// - Batch proof verification fails. - /// - The security level of the verified proof is insufficient. - pub fn verify(&self, batch: &ProvenBatch) -> Result<(), BatchVerifierError> { + /// - The security level of the verified proof is below the configured minimum. + pub fn verify(&self, batch: &ProvenBatch) -> Result { let stack_inputs = BatchKernel::build_input_stack(batch.reference_block_commitment(), batch.id()); - // The skeleton kernel drops its inputs and emits the all-zero output region, so the proof - // attests to empty outputs. Once the kernel computes the real commitments, these empty - // values become `batch.input_notes().commitment()`, the batch note tree root and - // `batch.batch_expiration_block_num()`. - let stack_outputs = - BatchOutputs::new(Word::empty(), Word::empty(), BlockNumber::from(0u32)) - .into_stack_outputs(); + // The kernel binds the batch's INPUT_NOTES_COMMITMENT but not the batch note tree root or + // expiration, so those are passed as the empty word and zero. + let stack_outputs = BatchOutputs::new( + batch.input_notes().commitment(), + Word::empty(), + BlockNumber::from(0u32), + ) + .into_stack_outputs(); let claim = ExecutionClaim::from_program_info( self.batch_program_info.clone(), stack_inputs, stack_outputs, ); - let proof_security_level = verify(batch.proof().clone(), claim) + let verified_security_level = verify(batch.proof().clone(), claim) .map_err(BatchVerifierError::BatchVerificationFailed)?; - if proof_security_level < self.proof_security_level { + if verified_security_level < self.min_proof_security_level { return Err(BatchVerifierError::InsufficientProofSecurityLevel { - actual: proof_security_level, - expected_minimum: self.proof_security_level, + actual: verified_security_level, + expected_minimum: self.min_proof_security_level, }); } - Ok(()) + Ok(verified_security_level) } }