From b7a86ad9f907bde87297e833513aba19f8f43eae Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 07:44:36 +0000 Subject: [PATCH 01/14] feat: add minimal batch kernel with recursive unhashing chain Implements the public-input/output interface for issue #1122 plus the minimal MASM that verifies every advice-provider input back to the public TRANSACTIONS_COMMITMENT. Stack interface (per #919): Inputs: [BLOCK_HASH, TRANSACTIONS_COMMITMENT] Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] The kernel never trusts advice data. Each layer is loaded via adv.push_mapvaln keyed by the previously-verified hash, piped to memory while sequentially hashed, and assert_eqw'd against the key: Layer 1 (TRANSACTIONS_COMMITMENT) -> (tx_id, account_id) tuples Layer 2 (each tx_id) -> per-tx header (init/final, per-tx note commitments, fee asset) Layer 3 (per-tx INPUT_NOTES_C) -> (nullifier, commit_or_zero) tuples Layer 3' (per-tx OUTPUT_NOTES_C) -> (note_id, metadata_commit) tuples Module split mirrors ProposedBatch::new sections (prologue, note_tracker) so future PRs can lift each TODO check 1:1 from Rust. Also: - Extracts BatchId::tuple_elements / TransactionId::input_elements as pub(crate) helpers shared between the Rust hashers and the kernel advice builder (single source of truth for felt layouts). - Adds proof: ExecutionProof to ProvenBatch; LocalBatchProver::prove now runs the kernel via miden_prover::prove and sanity-checks the parsed batch_expiration_block_num against the proposed batch. - prove_dummy retained for tests that don't want proof-generation cost. - BLOCK_HASH consumed as public input but not yet verified; see TODO list in main.masm. Tests: 5 unit tests in batch::kernel (happy path + four negative cases covering each ERR_BATCH_*). Full nextest suite passes (993 tests, prove-tests filtered). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + .../asm/kernels/batch/lib/memory.masm | 233 ++++++++ .../asm/kernels/batch/lib/note_tracker.masm | 272 +++++++++ .../asm/kernels/batch/lib/prologue.masm | 170 ++++++ .../asm/kernels/batch/main.masm | 97 +++ crates/miden-protocol/build.rs | 33 +- crates/miden-protocol/src/batch/batch_id.rs | 17 +- crates/miden-protocol/src/batch/kernel.rs | 553 ++++++++++++++++++ crates/miden-protocol/src/batch/mod.rs | 3 + .../src/batch/proposed_batch.rs | 5 + .../miden-protocol/src/batch/proven_batch.rs | 17 +- crates/miden-protocol/src/errors/mod.rs | 13 + .../src/transaction/transaction_id.rs | 30 +- crates/miden-tx-batch-prover/Cargo.toml | 8 +- .../src/local_batch_prover.rs | 93 ++- 15 files changed, 1517 insertions(+), 29 deletions(-) create mode 100644 crates/miden-protocol/asm/kernels/batch/lib/memory.masm create mode 100644 crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm create mode 100644 crates/miden-protocol/asm/kernels/batch/lib/prologue.masm create mode 100644 crates/miden-protocol/asm/kernels/batch/main.masm create mode 100644 crates/miden-protocol/src/batch/kernel.rs diff --git a/Cargo.lock b/Cargo.lock index 86f4fada84..a34e2823b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1978,7 +1978,9 @@ dependencies = [ name = "miden-tx-batch-prover" version = "0.15.0" dependencies = [ + "miden-processor", "miden-protocol", + "miden-prover", "miden-tx", ] 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..0bc6faa404 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm @@ -0,0 +1,233 @@ +# MEMORY LAYOUT +# ================================================================================================= +# +# Below is the memory layout used by the batch kernel: +# +# +-------------------+-------------------------+----------------------------------+ +# | Address range | Constant | Contents | +# +-------------------+-------------------------+----------------------------------+ +# | 0 | NUM_TRANSACTIONS_PTR | num_transactions (1 felt). | +# | 1 | BATCH_EXPIRATION_PTR | batch_expiration_block_num. | +# | 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 hasher. | +# | 12..16 | BATCH_HASHER_CAP_PTR | CAPACITY of the batch-level | +# | | | hasher. | +# | 16 | SCRATCH_WORDS_COUNT_PTR | num_words piped into | +# | | | TX_NOTES_SCRATCH_PTR for the | +# | | | current transaction. | +# | 17 | SCRATCH_WORD_INDEX_PTR | current iteration cursor for the | +# | | | scratch absorption loop. | +# | 20..8212 | TX_TUPLES_PTR | Layer 1 piped data: per | +# | | | transaction `[tx_id[4], | +# | | | account_id_prefix, | +# | | | account_id_suffix, 0, 0]` | +# | | | (8 felts each, sized for up to | +# | | | 1024 transactions). | +# | 8212..32788 | TX_HEADERS_PTR | Layer 2 piped data: per | +# | | | transaction the felt sequence | +# | | | TransactionId::new hashes | +# | | | (24 felts each, sized for up to | +# | | | 1024 transactions). | +# | 32788..40980 | TX_NOTES_SCRATCH_PTR | Per-transaction scratch space | +# | | | for Layer 3 / Layer 3' note | +# | | | data (overwritten each tx). | +# +-------------------+-------------------------+----------------------------------+ + +# BOOK KEEPING +# ================================================================================================= + +#! Single-felt slot holding `num_transactions` after Layer 1 verification. +const NUM_TRANSACTIONS_PTR=0 + +#! Single-felt slot holding the running min of all transactions' +#! `expiration_block_num`, initialised to `u32::MAX`. +const BATCH_EXPIRATION_PTR=1 + +# 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 + +# SCRATCH BOOKKEEPING +# ================================================================================================= + +#! Number of words piped into TX_NOTES_SCRATCH_PTR for the transaction whose Layer 3 / 3' is +#! currently being absorbed. +const SCRATCH_WORDS_COUNT_PTR=16 + +#! Iteration cursor (index in words) into TX_NOTES_SCRATCH_PTR for the absorption loop. +const SCRATCH_WORD_INDEX_PTR=17 + +# PIPED DATA REGIONS +# ================================================================================================= + +#! Base of the Layer 1 piped data region. Per transaction, 8 felts: +#! `[tx_id[4], account_id_prefix, account_id_suffix, 0, 0]`. +const TX_TUPLES_PTR=20 + +#! Number of felts each transaction occupies in TX_TUPLES_PTR. +const TX_TUPLE_FELT_LEN=8 + +#! Base of the Layer 2 piped data region. Per transaction, 24 felts: +#! `[INIT[4], FINAL[4], INPUT_NOTES_COMMITMENT[4], OUTPUT_NOTES_COMMITMENT[4], FEE_ASSET[8]]`. +#! 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=24 + +#! 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 3 / 3' note data, overwritten between iterations. +const TX_NOTES_SCRATCH_PTR=32788 + +# 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 EXPIRATION BLOCK NUM +# ================================================================================================= + +#! Stores `batch_expiration_block_num`. +#! +#! Inputs: [batch_expiration_block_num] +#! Outputs: [] +pub proc set_batch_expiration_block_num + mem_store.BATCH_EXPIRATION_PTR +end + +#! Returns `batch_expiration_block_num`. +#! +#! Inputs: [] +#! Outputs: [batch_expiration_block_num] +pub proc get_batch_expiration_block_num + mem_load.BATCH_EXPIRATION_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 + +# SCRATCH BOOKKEEPING +# ================================================================================================= + +#! Stores the count (in words) of data piped into the per-transaction scratch. +#! +#! Inputs: [num_words] +#! Outputs: [] +pub proc set_scratch_words_count + mem_store.SCRATCH_WORDS_COUNT_PTR +end + +#! Returns the count (in words) of data piped into the per-transaction scratch. +#! +#! Inputs: [] +#! Outputs: [num_words] +pub proc get_scratch_words_count + mem_load.SCRATCH_WORDS_COUNT_PTR +end + +#! Stores the absorption iteration cursor (index in words). +#! +#! Inputs: [word_index] +#! Outputs: [] +pub proc set_scratch_word_index + mem_store.SCRATCH_WORD_INDEX_PTR +end + +#! Returns the absorption iteration cursor (index in words). +#! +#! Inputs: [] +#! Outputs: [word_index] +pub proc get_scratch_word_index + mem_load.SCRATCH_WORD_INDEX_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 padw movup.4 mem_loadw_le +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_i] +pub proc get_tx_input_notes_commitment + exec.tx_header_ptr add.TX_HEADER_INPUT_NOTES_OFFSET padw movup.4 mem_loadw_le +end + +#! Returns the verified per-transaction OUTPUT_NOTES_COMMITMENT for transaction `tx_index`. +#! +#! Inputs: [tx_index] +#! Outputs: [OUTPUT_NOTES_COMMITMENT_i] +pub proc get_tx_output_notes_commitment + exec.tx_header_ptr add.TX_HEADER_OUTPUT_NOTES_OFFSET padw movup.4 mem_loadw_le +end 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..a770028d44 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -0,0 +1,272 @@ +use miden::core::mem +use miden::core::crypto::hashes::poseidon2 +use miden::core::word + +use miden::batch_kernel::memory +use miden::batch_kernel::memory::TX_NOTES_SCRATCH_PTR + +# ERRORS +# ================================================================================================= + +const ERR_BATCH_INPUT_NOTES_MISMATCH="per-transaction input notes data piped from the advice map does not match its INPUT_NOTES_COMMITMENT" + +const ERR_BATCH_OUTPUT_NOTES_MISMATCH="per-transaction output notes data piped from the advice map does not match its OUTPUT_NOTES_COMMITMENT" + +# ABSORPTION HELPER +# ================================================================================================= + +#! Absorbs the contents of [`memory::TX_NOTES_SCRATCH_PTR`] into the batch hasher state held in +#! [`memory::BATCH_HASHER_*_PTR`] memory slots. +#! +#! The scratch region is read as `(NULL_OR_NOTE_ID, COMMIT_OR_METADATA)` 8-felt tuples, in the +#! order they were piped (which matches `build_input_note_commitment` / +#! `OutputNotes::compute_commitment` in Rust). +#! +#! The number of words to absorb is read from [`memory::SCRATCH_WORDS_COUNT_PTR`]. +#! +#! Inputs: [] +#! Outputs: [] +proc absorb_scratch_into_batch_hasher + # Reset the iteration cursor. + push.0 exec.memory::set_scratch_word_index + + # Hoist the batch hasher state onto the operand stack; we'll save it back at the end. + exec.memory::load_batch_hasher_state + # Stack: [RATE0, RATE1, CAPACITY] + + # Loop while word_index < num_words. + exec.memory::get_scratch_word_index + exec.memory::get_scratch_words_count + u32lt + # Stack: [should_loop, RATE0, RATE1, CAPACITY] + + while.true + # Compute the address of the next 2-word tuple. + exec.memory::get_scratch_word_index + mul.4 add.TX_NOTES_SCRATCH_PTR + # Stack: [scratch_ptr, RATE0, RATE1, CAPACITY] + + # Load the first word (DATA1 = NULL or NOTE_ID). + padw dup.4 mem_loadw_le + # Stack: [DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + + # Load the second word (DATA2 = COMMIT_OR_ZERO or METADATA_COMMITMENT). + padw dup.8 add.4 mem_loadw_le + # Stack: [DATA2, DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + + # Drop scratch_ptr. + movup.8 drop + # Stack: [DATA2, DATA1, RATE0, RATE1, CAPACITY] + + # Replace RATE0 + RATE1 with DATA1 + DATA2 (in the absorbing order DATA1 first, DATA2 + # second), then permute. Same pattern as + # crates/miden-protocol/asm/kernels/transaction/lib/note.masm:213-221. + swapdw + # Stack: [RATE0, RATE1, DATA2, DATA1, CAPACITY] + dropw dropw + # Stack: [DATA2, DATA1, CAPACITY] + swapw + # Stack: [DATA1, DATA2, CAPACITY] + exec.poseidon2::permute + # Stack: [RATE0', RATE1', CAPACITY'] + + # Advance the cursor by 2 words and re-evaluate the loop condition. + exec.memory::get_scratch_word_index add.2 exec.memory::set_scratch_word_index + exec.memory::get_scratch_word_index + exec.memory::get_scratch_words_count + u32lt + # Stack: [should_loop, RATE0, RATE1, CAPACITY] + end + # Stack: [RATE0, RATE1, CAPACITY] + + # Save the updated state back to memory. + exec.memory::save_batch_hasher_state +end + +# COMMON SHAPE +# ================================================================================================= +# +# Both `compute_input_notes_commitment` and `compute_output_notes_commitment` follow the same +# four-step pattern, parametrised by which per-tx commitment the caller fetches from +# `TX_HEADERS_PTR`: +# +# 1. Init the batch hasher state in memory. +# 2. For each transaction: +# - Read the per-tx commitment from the verified TX_HEADERS region. +# - If it is `Word::empty()`, skip (the per-tx note list is empty). +# - Otherwise, pipe the advice-map value keyed by that commitment into TX_NOTES_SCRATCH_PTR +# and `assert_eqw` against the commitment. +# - Absorb the verified scratch contents into the batch hasher state. +# 3. Squeeze the final digest out of the batch hasher state. +# 4. Return the digest on the operand stack. + +# INPUT NOTES COMMITMENT +# ================================================================================================= + +#! Computes the batch's INPUT_NOTES_COMMITMENT. +#! +#! For each transaction in transaction order, verifies the per-transaction +#! `INPUT_NOTES_COMMITMENT_i` against its advice-map value (the `(NULLIFIER, EMPTY_OR_COMMITMENT)` +#! tuple list), then absorbs that verified data into the batch-level sequential hasher. +#! +#! Inputs: [] +#! Outputs: [INPUT_NOTES_COMMITMENT] +#! +#! Errors: +#! - ERR_BATCH_INPUT_NOTES_MISMATCH: a transaction's piped input-note data does not hash to its +#! verified per-tx INPUT_NOTES_COMMITMENT_i. +#! +#! TODO: erase intra-batch unauthenticated notes from the absorbed sequence (mirrors +#! `InputOutputNoteTracker::from_transactions`). +#! TODO: re-sort + dedupe by nullifier so the result equals +#! `proposed_batch.input_notes().commitment()`. +#! TODO: authenticate unauthenticated input notes against `BLOCK_HASH`'s chain MMR. +#! TODO: enforce `MAX_INPUT_NOTES_PER_BATCH`. +pub proc compute_input_notes_commitment + # Stack: [] + + # Init the batch hasher state in memory. + exec.poseidon2::init_no_padding + exec.memory::save_batch_hasher_state + + exec.memory::get_num_transactions + push.0 + # Stack: [tx_index, num_transactions] + + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + + while.true + # Read INPUT_NOTES_COMMITMENT_i. + dup exec.memory::get_tx_input_notes_commitment + # Stack: [INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + # Skip if empty (no map entry needed; the empty list contributes nothing). + dupw exec.word::eqz + # Stack: [is_empty, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + if.true + dropw + # Stack: [tx_index, num_transactions] + else + # Pipe + verify Layer 3 to TX_NOTES_SCRATCH_PTR. + push.TX_NOTES_SCRATCH_PTR movdn.4 + # Stack: [INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] + + adv.push_mapvaln + adv_push.1 div.4 + # Stack: [num_words, INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] + + # Save num_words for the absorption iterator. + dup exec.memory::set_scratch_words_count + # Stack: [num_words, INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] + + movup.5 swap + # Stack: [num_words, TX_NOTES_SCRATCH_PTR, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + exec.mem::pipe_words_to_memory + exec.poseidon2::squeeze_digest + movup.4 drop + # Stack: [DIGEST, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + assert_eqw.err=ERR_BATCH_INPUT_NOTES_MISMATCH + # Stack: [tx_index, num_transactions] + + # Absorb the verified scratch contents into the batch hasher. + exec.absorb_scratch_into_batch_hasher + # Stack: [tx_index, num_transactions] + end + + add.1 + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + end + # Stack: [tx_index, num_transactions] + + drop drop + # Stack: [] + + # Squeeze the final digest. `squeeze_digest` consumes the 12-felt hasher state and produces + # the 4-felt digest, so the stack now holds just `[INPUT_NOTES_COMMITMENT]`. + exec.memory::load_batch_hasher_state + exec.poseidon2::squeeze_digest + # Stack: [INPUT_NOTES_COMMITMENT] +end + +# OUTPUT NOTES COMMITMENT +# ================================================================================================= + +#! Computes the batch's OUTPUT_NOTES_COMMITMENT. +#! +#! For each transaction in transaction order, verifies the per-transaction +#! `OUTPUT_NOTES_COMMITMENT_i` against its advice-map value (the `(NOTE_ID, METADATA_COMMITMENT)` +#! tuple list), then absorbs that verified data into the batch-level sequential hasher. +#! +#! Inputs: [] +#! Outputs: [OUTPUT_NOTES_COMMITMENT] +#! +#! Errors: +#! - ERR_BATCH_OUTPUT_NOTES_MISMATCH: a transaction's piped output-note data does not hash to its +#! verified per-tx OUTPUT_NOTES_COMMITMENT_i. +#! +#! TODO: erase intra-batch unauthenticated notes from the absorbed sequence. +#! TODO: switch the output to `BatchNoteTree::root` (an SMT root rather than this sequential +#! hash). +#! TODO: enforce `MAX_OUTPUT_NOTES_PER_BATCH`. +pub proc compute_output_notes_commitment + # Stack: [] + + exec.poseidon2::init_no_padding + exec.memory::save_batch_hasher_state + + exec.memory::get_num_transactions + push.0 + # Stack: [tx_index, num_transactions] + + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + + while.true + dup exec.memory::get_tx_output_notes_commitment + # Stack: [OUTPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + dupw exec.word::eqz + # Stack: [is_empty, OUTPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + if.true + dropw + else + push.TX_NOTES_SCRATCH_PTR movdn.4 + # Stack: [OUTPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] + + adv.push_mapvaln + adv_push.1 div.4 + # Stack: [num_words, OUTPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] + + dup exec.memory::set_scratch_words_count + + movup.5 swap + # Stack: [num_words, TX_NOTES_SCRATCH_PTR, OUTPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + exec.mem::pipe_words_to_memory + exec.poseidon2::squeeze_digest + movup.4 drop + # Stack: [DIGEST, OUTPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + assert_eqw.err=ERR_BATCH_OUTPUT_NOTES_MISMATCH + # Stack: [tx_index, num_transactions] + + exec.absorb_scratch_into_batch_hasher + end + + add.1 + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + end + + drop drop + + exec.memory::load_batch_hasher_state + exec.poseidon2::squeeze_digest + # Stack: [OUTPUT_NOTES_COMMITMENT] +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..ca59d63604 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -0,0 +1,170 @@ +use miden::core::mem +use miden::core::crypto::hashes::poseidon2 + +use miden::batch_kernel::memory +use miden::batch_kernel::memory::TX_TUPLES_PTR + +# CONSTS +# ================================================================================================= + +#! Maximum value of `batch_expiration_block_num`, used as the running-min initialiser. +const MAX_BLOCK_NUM=0xFFFFFFFF + +# ERRORS +# ================================================================================================= + +const ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH="batch transactions commitment piped from the advice map does not match the public input" + +const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from the advice map does not match its tx_id" + +# PROLOGUE +# ================================================================================================= + +#! Loads and verifies the batch's structural commitments and accumulates the batch expiration min. +#! +#! Runs the equivalent of the leading sections of `ProposedBatch::new`: +#! - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by +#! `TRANSACTIONS_COMMITMENT` into [`memory::TX_TUPLES_PTR`], asserting that the sequential hash of +#! the piped data matches `TRANSACTIONS_COMMITMENT`. The number of transactions is derived from +#! the piped length and stored in [`memory::NUM_TRANSACTIONS_PTR`]. +#! - Layer 2: for each transaction, pipes the felt sequence hashed by `TransactionId::new` from the +#! advice map keyed by the verified `tx_id`, asserting that the sequential hash matches the +#! `tx_id`. Each transaction's data is written into [`memory::TX_HEADERS_PTR`] at the appropriate +#! per-tx offset. +#! - For each transaction, pops the `expiration_block_num` from the advice stack and updates the +#! batch-level min stored in [`memory::BATCH_EXPIRATION_PTR`]. +#! +#! Inputs: +#! Operand stack: [TRANSACTIONS_COMMITMENT] +#! Advice map: +#! TRANSACTIONS_COMMITMENT |-> Layer 1 tuple data +#! For each verified tx_id_i: tx_id_i |-> Layer 2 transaction header data +#! Advice stack: [expiration_block_num_0, expiration_block_num_1, ...] +#! +#! Outputs: +#! Operand stack: [] +#! +#! Errors: +#! - ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH: Layer 1 piped data does not hash to +#! TRANSACTIONS_COMMITMENT. +#! - ERR_BATCH_TRANSACTION_HEADER_MISMATCH: Layer 2 piped data for a transaction does not hash to +#! that transaction's tx_id. +#! +#! TODO: verify that each transaction's reference block is contained in the chain MMR rooted at +#! BLOCK_HASH (mirrors `ProposedBatch::new` lines 184-193). +#! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment +#! (mirrors `ProposedBatch::new` lines 145-161). +#! TODO: assert each `expiration_block_num_i > reference_block_num` (mirrors +#! `ProposedBatch::new` lines 225-242). +#! TODO: verify each `expiration_block_num_i` is part of tx_i's verified ExecutionProof public +#! outputs once recursive proof verification ships. +pub proc prepare_batch + # Layer 1: pipe TRANSACTIONS_COMMITMENT's mapped value to TX_TUPLES_PTR + verify. + # --------------------------------------------------------------------------------------------- + + # Stack: [TRANSACTIONS_COMMITMENT] + push.TX_TUPLES_PTR movdn.4 + # Stack: [TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] + + # Push the mapped value's length + data onto the advice stack, keyed by + # TRANSACTIONS_COMMITMENT (top-of-operand-stack word). + adv.push_mapvaln + # Stack: [TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] + # AS: [len_felts, data...] + + # Pop length, divide by 4 to get number of words. + adv_push.1 div.4 + # Stack: [num_words, TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] + + # num_transactions = num_words / 2 (each tx contributes 2 words: tx_id + account_id_pair). + dup div.2 exec.memory::set_num_transactions + # Stack: [num_words, TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] + + # Bring TX_TUPLES_PTR up so pipe_words_to_memory has [num_words, write_ptr, ...]. + movup.5 swap + # Stack: [num_words, TX_TUPLES_PTR, TRANSACTIONS_COMMITMENT] + + exec.mem::pipe_words_to_memory + # Stack: [C, B, A, end_ptr, TRANSACTIONS_COMMITMENT] + + exec.poseidon2::squeeze_digest + # Stack: [DIGEST, end_ptr, TRANSACTIONS_COMMITMENT] + + movup.4 drop + # Stack: [DIGEST, TRANSACTIONS_COMMITMENT] + + assert_eqw.err=ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH + # Stack: [] + + # Initialise batch_expiration_block_num to u32::MAX. + # --------------------------------------------------------------------------------------------- + + push.MAX_BLOCK_NUM exec.memory::set_batch_expiration_block_num + + # Layer 2: for each transaction, pipe + verify its header, accumulate expiration min. + # --------------------------------------------------------------------------------------------- + + exec.memory::get_num_transactions + push.0 + # Stack: [tx_index, num_transactions] + + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + + while.true + # Get tx_id (4 felts) for this transaction. + dup exec.memory::get_tx_id + # Stack: [TX_ID, tx_index, num_transactions] + + # Push the destination memory pointer (TX_HEADERS_PTR + TX_HEADER_FELT_LEN * tx_index). + dup.4 exec.memory::tx_header_ptr movdn.4 + # Stack: [TX_ID, tx_header_ptr, tx_index, num_transactions] + + # Pipe + verify Layer 2. + adv.push_mapvaln + adv_push.1 div.4 + # Stack: [num_words, TX_ID, tx_header_ptr, tx_index, num_transactions] + + movup.5 swap + # Stack: [num_words, tx_header_ptr, TX_ID, tx_index, num_transactions] + + exec.mem::pipe_words_to_memory + exec.poseidon2::squeeze_digest + movup.4 drop + # Stack: [DIGEST, TX_ID, tx_index, num_transactions] + + assert_eqw.err=ERR_BATCH_TRANSACTION_HEADER_MISMATCH + # Stack: [tx_index, num_transactions] + + # Pop expiration_block_num from the advice stack and fold it into the batch min. + adv_push.1 + # Stack: [expiration, tx_index, num_transactions] + + dup exec.memory::get_batch_expiration_block_num + # Stack: [current_min, expiration, expiration, tx_index, num_transactions] + + u32lt + # `u32lt` pops `[a (top), b]` and pushes `1` if `b < a`. Here `a = current_min` and + # `b = expiration`, so the bool is `1` iff `expiration < current_min` — i.e. the candidate + # is the new minimum. + # Stack: [is_new_min, expiration, tx_index, num_transactions] + + if.true + # `expiration < current_min`: install the candidate as the new min. + exec.memory::set_batch_expiration_block_num + else + # `expiration >= current_min`: keep the current min and drop the candidate. + drop + end + # Stack: [tx_index, num_transactions] + + # Increment tx_index and re-evaluate the loop condition. + add.1 + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + end + # Stack: [tx_index, num_transactions] + + drop drop + # Stack: [] +end diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm new file mode 100644 index 0000000000..2930670fbb --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -0,0 +1,97 @@ +use miden::batch_kernel::memory +use miden::batch_kernel::note_tracker +use miden::batch_kernel::prologue + +# MAIN +# ================================================================================================= + +#! Minimal batch kernel program. +#! +#! Verifies the unhashing chain rooted at `TRANSACTIONS_COMMITMENT` and emits the three batch +#! commitments. The verification chain is: +#! +#! `TRANSACTIONS_COMMITMENT` (public input) -> `(tx_id, account_id)` tuple list +#! each `tx_id` -> per-tx +#! `(INIT, FINAL, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i, FEE_ASSET)` +#! each `INPUT_NOTES_COMMITMENT_i` -> `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples +#! each `OUTPUT_NOTES_COMMITMENT_i` -> `(NOTE_ID, METADATA_COMMITMENT)` tuples +#! +#! Each layer is loaded via `adv.push_mapvaln` keyed by the previously-verified hash and asserted +#! against that hash via `assert_eqw`. Only verified data feeds into the batch outputs. +#! +#! Inputs: [BLOCK_HASH, TRANSACTIONS_COMMITMENT, pad(8)] +#! +#! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad(7)] +#! +#! Where: +#! - BLOCK_HASH is the commitment of the batch's reference block. Currently consumed and dropped; +#! verifying it against the chain MMR is a TODO. +#! - TRANSACTIONS_COMMITMENT is the sequential hash of the `(tx_id, account_id)` tuples committing +#! to the transactions in the batch (i.e. the `BatchId` value). +#! - INPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified +#! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples in transaction order. It does not yet match +#! `proposed_batch.input_notes().commitment()` for batches with intra-batch erasure or +#! re-sorted nullifiers (TODO in `note_tracker.masm`). +#! - OUTPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified +#! `(NOTE_ID, METADATA_COMMITMENT)` tuples. Switching to the `BatchNoteTree` SMT root is a TODO. +#! - batch_expiration_block_num is the running min over each transaction's +#! `expiration_block_num`, which is currently supplied unverified on the advice stack. Anchoring +#! this in a verified commitment is a TODO that becomes possible once recursive transaction +#! proof verification ships (the per-tx `expiration_block_num` is a public output of the tx +#! kernel). +#! +#! TODOs (each will be lifted in a follow-up issue without changing this stack interface): +#! - Verify BLOCK_HASH against block header data via the same pipe-and-verify pattern. +#! - Authenticate unauthenticated input notes against BLOCK_HASH's chain MMR. +#! - Verify each transaction's reference block is contained in the chain MMR rooted at BLOCK_HASH. +#! - Aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output (mirrors +#! `BatchAccountUpdate` and `ProposedBatch::new` lines 195-223). The Layer 1 tuple list already +#! has `(tx_id, account_id)` per transaction in memory at TX_TUPLES_PTR, ready for this. +#! - Erase intra-batch unauthenticated notes from INPUT_NOTES_COMMITMENT and the output set. +#! - Recursively verify each transaction's `ExecutionProof`. +#! - Switch OUTPUT_NOTES_COMMITMENT to the `BatchNoteTree` SMT root. +#! - Assert `batch_expiration_block_num > reference_block_num`. +#! - Enforce `MAX_INPUT_NOTES_PER_BATCH`, `MAX_OUTPUT_NOTES_PER_BATCH`, `MAX_ACCOUNTS_PER_BATCH`. +proc main + # Bring TRANSACTIONS_COMMITMENT to the top so the prologue can consume it. + swapw + # Stack: [TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)] + + # Layer 1 + Layer 2 verification, plus expiration min accumulation. + exec.prologue::prepare_batch + # Stack: [BLOCK_HASH, pad(8)] + + # TODO: verify BLOCK_HASH against block header data via the pipe-and-verify pattern. + dropw + # Stack: [pad(8)] + + # Compute the batch INPUT_NOTES_COMMITMENT (Layer 3 verification + sequential absorption). + exec.note_tracker::compute_input_notes_commitment + # Stack: [INPUT_NOTES_COMMITMENT, pad(8)] + + # Compute the batch OUTPUT_NOTES_COMMITMENT (Layer 3' verification + sequential absorption). + exec.note_tracker::compute_output_notes_commitment + # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, pad(8)] + + # Read the accumulated batch_expiration_block_num. + exec.memory::get_batch_expiration_block_num + # Stack: [batch_expiration_block_num, + # OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, pad...] + + # Push the expiration felt down past both commitment words so the words become word-aligned. + movdn.8 + # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad...] + + swapw + # Stack: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad...] + + # Truncate the stack to the canonical 16-element output: drop the bottom-most words that came + # from the depth-floor auto-fill during the kernel's earlier `consume`-only operations. + # See `crates/miden-protocol/asm/kernels/transaction/main.masm:182` for the equivalent + # truncate in the transaction kernel. + repeat.3 movupw.3 dropw end +end + +begin + exec.main +end diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index f116980634..c804dca594 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -20,6 +20,9 @@ const ASM_PROTOCOL_DIR: &str = "protocol"; const SHARED_UTILS_DIR: &str = "shared_utils"; const SHARED_MODULES_DIR: &str = "shared_modules"; const ASM_TX_KERNEL_DIR: &str = "kernels/transaction"; +const ASM_BATCH_KERNEL_DIR: &str = "kernels/batch"; + +const BATCH_KERNEL_NAMESPACE: &str = "miden::batch_kernel"; const PROTOCOL_LIB_NAMESPACE: &str = "miden::protocol"; @@ -30,7 +33,7 @@ const PROTOCOL_LIB_ERRORS_RS_FILE: &str = "protocol_errors.rs"; const TX_KERNEL_ERRORS_ARRAY_NAME: &str = "TX_KERNEL_ERRORS"; const PROTOCOL_LIB_ERRORS_ARRAY_NAME: &str = "PROTOCOL_LIB_ERRORS"; -const TX_KERNEL_ERROR_CATEGORIES: [&str; 14] = [ +const TX_KERNEL_ERROR_CATEGORIES: [&str; 15] = [ "KERNEL", "PROLOGUE", "EPILOGUE", @@ -45,6 +48,7 @@ const TX_KERNEL_ERROR_CATEGORIES: [&str; 14] = [ "LINK_MAP", "INPUT_NOTE", "OUTPUT_NOTE", + "BATCH", ]; // PRE-PROCESSING @@ -85,6 +89,9 @@ fn main() -> Result<()> { let protocol_lib = compile_protocol_lib(&source_dir, &target_dir, assembler.clone())?; assembler.link_dynamic_library(protocol_lib)?; + // compile batch kernel + compile_batch_kernel(&source_dir, &target_dir.join("kernels"))?; + generate_error_constants(&source_dir, &build_dir)?; generate_event_constants(&source_dir, &target_dir)?; @@ -190,6 +197,30 @@ fn compile_tx_script_main( tx_script_main.write_to_file(masb_file_path).into_diagnostic() } +// COMPILE BATCH KERNEL +// ================================================================================================ + +/// Reads the batch kernel MASM source from the `source_dir`, compiles it, and saves the result to +/// the `target_dir` as a `batch_kernel.masb` binary file. +/// +/// Unlike the transaction kernel, the batch kernel does not expose syscalls, so there is no +/// `KernelLibrary` to build — only a single executable program assembled from +/// `kernels/batch/main.masm` with the modules under `kernels/batch/lib/` statically linked under +/// the `$batch` namespace. +fn compile_batch_kernel(source_dir: &Path, target_dir: &Path) -> Result<()> { + let batch_kernel_dir = source_dir.join(ASM_BATCH_KERNEL_DIR); + let lib_dir = batch_kernel_dir.join("lib"); + let main_file_path = batch_kernel_dir.join("main.masm"); + + let mut assembler = build_assembler(None)?; + assembler.compile_and_statically_link_from_dir(&lib_dir, BATCH_KERNEL_NAMESPACE)?; + + let batch_main = assembler.assemble_program(main_file_path)?; + + let masb_file_path = target_dir.join("batch_kernel.masb"); + batch_main.write_to_file(masb_file_path).into_diagnostic() +} + /// Generates kernel `procedures.rs` file based on the kernel library. /// /// The file is written to `{build_dir}/procedures.rs` and included via `include!` in the source. diff --git a/crates/miden-protocol/src/batch/batch_id.rs b/crates/miden-protocol/src/batch/batch_id.rs index 39cfe42255..e4a452524c 100644 --- a/crates/miden-protocol/src/batch/batch_id.rs +++ b/crates/miden-protocol/src/batch/batch_id.rs @@ -37,14 +37,27 @@ impl BatchId { /// Calculates a batch ID from the given transaction ID and account ID tuple. pub fn from_ids(iter: impl IntoIterator) -> Self { + Self(Hasher::hash_elements(&Self::tuple_elements(iter))) + } + + /// Returns the felt sequence that [`Self::from_ids`] hashes to produce a [`BatchId`]. + /// + /// The layout is, for each `(transaction_id, account_id)` pair in iteration order: + /// `[transaction_id[4], account_id_prefix, account_id_suffix, 0, 0]` + /// + /// Exposed for use by the batch kernel which pipes this same felt sequence from the advice + /// provider to memory and asserts the resulting hash matches the public input + /// `TRANSACTIONS_COMMITMENT`. + pub(crate) fn tuple_elements( + iter: impl IntoIterator, + ) -> Vec { let mut elements: Vec = Vec::new(); for (tx_id, account_id) in iter { elements.extend_from_slice(tx_id.as_elements()); let [account_id_prefix, account_id_suffix] = <[Felt; 2]>::from(account_id); elements.extend_from_slice(&[account_id_prefix, account_id_suffix, ZERO, ZERO]); } - - Self(Hasher::hash_elements(&elements)) + elements } } diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs new file mode 100644 index 0000000000..c7ae677275 --- /dev/null +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -0,0 +1,553 @@ +use alloc::vec::Vec; + +use miden_core::program::Kernel; + +use crate::batch::{BatchId, ProposedBatch}; +use crate::block::BlockNumber; +use crate::errors::BatchOutputError; +use crate::transaction::{ToInputNoteCommitments, TransactionId}; +use crate::utils::serde::Deserializable; +use crate::utils::sync::LazyLock; +use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs, StackOutputs}; +use crate::{Felt, Word}; + +// CONSTANTS +// ================================================================================================ + +static KERNEL_MAIN: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/kernels/batch_kernel.masb")); + Program::read_from_bytes(bytes).expect("failed to deserialize batch kernel runtime") +}); + +// Output stack indices, kept in sync with the lay-out at the end of `main.masm::main`. These are +// felt offsets, not word indices: `get_word(N)` returns the four felts at positions `N..N+4`. +const INPUT_NOTES_COMMITMENT_WORD_IDX: usize = 0; +const OUTPUT_NOTES_COMMITMENT_WORD_IDX: usize = 4; +const BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX: usize = 8; +// The word containing `batch_expiration_block_num` plus three padding zeros. +const EXPIRATION_PAD_WORD_FELT_IDX: usize = 8; +const EXPIRATION_PAD_WORD_INNER_OFFSET: usize = 1; +// The trailing word at felt indices 12..16 must be all zero. +const TRAILING_PAD_WORD_FELT_IDX: usize = 12; + +// BATCH KERNEL +// ================================================================================================ + +/// The batch kernel program: an executable Miden program that proves a batch of transactions. +/// +/// The kernel takes `[BLOCK_HASH, TRANSACTIONS_COMMITMENT]` as public inputs and emits +/// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. +/// See [`crate::batch::ProposedBatch`] / `asm/kernels/batch/main.masm` for the verification +/// chain and the list of TODOs the minimal kernel intentionally elides. +pub struct BatchKernel; + +impl BatchKernel { + // KERNEL SOURCE CODE + // -------------------------------------------------------------------------------------------- + + /// Returns the executable batch kernel program loaded from the build's `OUT_DIR`. + pub fn main() -> Program { + KERNEL_MAIN.clone() + } + + /// Returns [`ProgramInfo`] for the batch kernel program. + /// + /// The batch kernel does not expose syscalls, so the associated [`Kernel`] is empty. + pub fn program_info() -> ProgramInfo { + ProgramInfo::new(Self::main().hash(), Kernel::default()) + } + + // INPUT BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Transforms the provided [`ProposedBatch`] into the stack and advice inputs needed to + /// execute the batch kernel. + pub fn prepare_inputs(proposed_batch: &ProposedBatch) -> (StackInputs, AdviceInputs) { + let block_hash = proposed_batch.reference_block_header().commitment(); + let transactions_commitment = proposed_batch.id().as_word(); + + let stack_inputs = Self::build_input_stack(block_hash, transactions_commitment); + let advice_inputs = Self::build_advice_inputs(proposed_batch); + + (stack_inputs, advice_inputs) + } + + /// Returns the stack with the public inputs required by the batch kernel. + /// + /// The initial stack is defined as: + /// + /// ```text + /// [BLOCK_HASH, TRANSACTIONS_COMMITMENT] + /// ``` + /// + /// Where: + /// - `BLOCK_HASH` is the commitment of the batch's reference block. + /// - `TRANSACTIONS_COMMITMENT` is the value [`BatchId`] computes — a sequential hash of + /// `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` over all + /// transactions in the batch. + /// + /// Note: `main.masm` immediately performs a `swapw`, so by the time `prologue::prepare_batch` + /// runs the kernel-side stack is `[TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)]`. Any + /// refactor that drops the leading `swapw` must update this builder to match. + pub fn build_input_stack(block_hash: Word, transactions_commitment: Word) -> StackInputs { + let mut inputs: Vec = Vec::with_capacity(8); + inputs.extend_from_slice(block_hash.as_elements()); + inputs.extend_from_slice(transactions_commitment.as_elements()); + + StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") + } + + /// Builds the stack with the expected batch kernel outputs. + /// + /// The output stack is defined as: + /// + /// ```text + /// [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] + /// ``` + pub fn build_output_stack( + input_notes_commitment: Word, + output_notes_commitment: Word, + batch_expiration_block_num: BlockNumber, + ) -> StackOutputs { + let mut outputs: Vec = Vec::with_capacity(9); + outputs.extend_from_slice(input_notes_commitment.as_elements()); + outputs.extend_from_slice(output_notes_commitment.as_elements()); + outputs.push(Felt::from(batch_expiration_block_num)); + + StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") + } + + /// Extracts batch output data from the provided stack outputs. + /// + /// # Errors + /// + /// Returns an error if: + /// - The padding cells (positions 9..16) are not all zero. + /// - `batch_expiration_block_num` does not fit into a `u32`. + pub fn parse_output_stack( + stack: &StackOutputs, + ) -> Result<(Word, Word, BlockNumber), BatchOutputError> { + let input_notes_commitment = stack + .get_word(INPUT_NOTES_COMMITMENT_WORD_IDX) + .expect("input_notes_commitment word missing"); + let output_notes_commitment = stack + .get_word(OUTPUT_NOTES_COMMITMENT_WORD_IDX) + .expect("output_notes_commitment word missing"); + + let expiration_felt = stack + .get_element(BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) + .expect("batch_expiration_block_num missing"); + + // The word at felt indices 8..12 contains [batch_expiration_block_num, 0, 0, 0]. Indices + // 9..12 of the output stack must be zero. + let pad_word = stack + .get_word(EXPIRATION_PAD_WORD_FELT_IDX) + .expect("expiration pad word missing"); + if pad_word.as_elements()[EXPIRATION_PAD_WORD_INNER_OFFSET..] + != Word::empty().as_elements()[1..] + { + return Err(BatchOutputError::OutputStackInvalid( + "batch_expiration_block_num must be followed by zero padding".into(), + )); + } + + // Felts 12..16 (the trailing word) must also be zero. + let trailing_word = + stack.get_word(TRAILING_PAD_WORD_FELT_IDX).expect("trailing word missing"); + if trailing_word != Word::empty() { + return Err(BatchOutputError::OutputStackInvalid( + "trailing output stack cells must be zero".into(), + )); + } + + let batch_expiration_block_num = u32::try_from(expiration_felt.as_canonical_u64()) + .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? + .into(); + + Ok((input_notes_commitment, output_notes_commitment, batch_expiration_block_num)) + } + + // ADVICE BUILDER + // -------------------------------------------------------------------------------------------- + + /// Builds the advice inputs (map + stack) consumed by the batch kernel. + /// + /// See `asm/kernels/batch/main.masm` for the layered map structure. + fn build_advice_inputs(proposed_batch: &ProposedBatch) -> AdviceInputs { + let mut advice_inputs = AdviceInputs::default(); + + // Layer 1: TRANSACTIONS_COMMITMENT |-> [(tx_id, account_id_pair) tuples]. + let layer1_data = BatchId::tuple_elements( + proposed_batch.transactions().iter().map(|tx| (tx.id(), tx.account_id())), + ); + advice_inputs.map.extend([(proposed_batch.id().as_word(), layer1_data)]); + + for tx in proposed_batch.transactions().iter() { + // Layer 2: tx_id |-> [INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, + // FEE_ASSET]. + let header_data = TransactionId::input_elements( + tx.account_update().initial_state_commitment(), + tx.account_update().final_state_commitment(), + tx.input_notes().commitment(), + tx.output_notes().commitment(), + tx.fee(), + ); + advice_inputs.map.extend([(tx.id().as_word(), header_data.to_vec())]); + + // Layer 3: per-tx INPUT_NOTES_COMMITMENT |-> [(NULLIFIER, EMPTY_OR_COMMITMENT) tuples]. + let input_notes_commitment = tx.input_notes().commitment(); + if input_notes_commitment != Word::empty() { + let mut data: Vec = + Vec::with_capacity(usize::from(tx.input_notes().num_notes()) * 8); + for note_commit in tx.input_notes().iter() { + data.extend_from_slice(note_commit.nullifier().as_word().as_elements()); + let commit_or_zero = note_commit.note_commitment().unwrap_or(Word::empty()); + data.extend_from_slice(commit_or_zero.as_elements()); + } + advice_inputs.map.extend([(input_notes_commitment, data)]); + } + + // Layer 3': per-tx OUTPUT_NOTES_COMMITMENT |-> [(NOTE_ID, METADATA_COMMITMENT) tuples]. + let output_notes_commitment = tx.output_notes().commitment(); + if output_notes_commitment != Word::empty() { + let mut data: Vec = Vec::with_capacity(tx.output_notes().num_notes() * 8); + for note in tx.output_notes().iter() { + data.extend_from_slice(note.id().as_word().as_elements()); + data.extend_from_slice(note.metadata().to_commitment().as_elements()); + } + advice_inputs.map.extend([(output_notes_commitment, data)]); + } + } + + // Advice stack: per-tx expiration_block_num in transaction order. + // The advice stack is FIFO from the prover's perspective — first pushed = first popped. + for tx in proposed_batch.transactions().iter() { + advice_inputs.stack.push(Felt::from(tx.expiration_block_num())); + } + + advice_inputs + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + use alloc::vec; + use alloc::vec::Vec; + + use anyhow::Context; + use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; + + use super::*; + use crate::Hasher; + use crate::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; + use crate::asset::FungibleAsset; + use crate::note::{NoteId, Nullifier}; + + /// One synthetic transaction's worth of input data the kernel will read out of the advice + /// provider during a test run. + struct SynthTransaction { + account_id: AccountId, + initial_state: Word, + final_state: Word, + // (NULLIFIER, EMPTY_OR_NOTE_COMMITMENT) tuples — verbatim what the per-tx + // INPUT_NOTES_COMMITMENT hashes. + input_notes: Vec<(Nullifier, Word)>, + // (NOTE_ID, METADATA_COMMITMENT) tuples — verbatim what the per-tx + // OUTPUT_NOTES_COMMITMENT hashes. + output_notes: Vec<(NoteId, Word)>, + fee: FungibleAsset, + expiration: BlockNumber, + } + + impl SynthTransaction { + fn input_notes_commitment(&self) -> Word { + input_notes_commitment(&self.input_notes) + } + + fn output_notes_commitment(&self) -> Word { + output_notes_commitment(&self.output_notes) + } + + fn tx_id(&self) -> TransactionId { + TransactionId::from_raw(Hasher::hash_elements(&TransactionId::input_elements( + self.initial_state, + self.final_state, + self.input_notes_commitment(), + self.output_notes_commitment(), + self.fee, + ))) + } + + fn input_notes_advice(&self) -> Vec { + let mut data = Vec::with_capacity(self.input_notes.len() * 8); + for (nullifier, commit) in &self.input_notes { + data.extend_from_slice(nullifier.as_word().as_elements()); + data.extend_from_slice(commit.as_elements()); + } + data + } + + fn output_notes_advice(&self) -> Vec { + let mut data = Vec::with_capacity(self.output_notes.len() * 8); + for (note_id, metadata) in &self.output_notes { + data.extend_from_slice(note_id.as_word().as_elements()); + data.extend_from_slice(metadata.as_elements()); + } + data + } + } + + fn input_notes_commitment(notes: &[(Nullifier, Word)]) -> Word { + if notes.is_empty() { + return Word::empty(); + } + let mut elements = Vec::with_capacity(notes.len() * 8); + for (nullifier, commit) in notes { + elements.extend_from_slice(nullifier.as_word().as_elements()); + elements.extend_from_slice(commit.as_elements()); + } + Hasher::hash_elements(&elements) + } + + fn output_notes_commitment(notes: &[(NoteId, Word)]) -> Word { + if notes.is_empty() { + return Word::empty(); + } + let mut elements = Vec::with_capacity(notes.len() * 8); + for (note_id, metadata) in notes { + elements.extend_from_slice(note_id.as_word().as_elements()); + elements.extend_from_slice(metadata.as_elements()); + } + Hasher::hash_elements(&elements) + } + + fn batch_input_notes_commitment(txs: &[SynthTransaction]) -> Word { + let combined: Vec<_> = txs.iter().flat_map(|tx| tx.input_notes.clone()).collect(); + input_notes_commitment(&combined) + } + + fn batch_output_notes_commitment(txs: &[SynthTransaction]) -> Word { + let combined: Vec<_> = txs.iter().flat_map(|tx| tx.output_notes.clone()).collect(); + output_notes_commitment(&combined) + } + + fn batch_expiration(txs: &[SynthTransaction]) -> BlockNumber { + txs.iter().map(|tx| tx.expiration).min().expect("non-empty") + } + + fn transactions_commitment(txs: &[SynthTransaction]) -> Word { + Hasher::hash_elements(&BatchId::tuple_elements( + txs.iter().map(|tx| (tx.tx_id(), tx.account_id)), + )) + } + + fn build_advice_inputs(txs: &[SynthTransaction]) -> AdviceInputs { + let mut advice_inputs = AdviceInputs::default(); + + // Layer 1: TRANSACTIONS_COMMITMENT |-> [(tx_id, account_id_pair) tuples]. + let layer1 = BatchId::tuple_elements(txs.iter().map(|tx| (tx.tx_id(), tx.account_id))); + advice_inputs.map.extend([(transactions_commitment(txs), layer1)]); + + for tx in txs { + let header = TransactionId::input_elements( + tx.initial_state, + tx.final_state, + tx.input_notes_commitment(), + tx.output_notes_commitment(), + tx.fee, + ); + advice_inputs.map.extend([(tx.tx_id().as_word(), header.to_vec())]); + + let input_notes_commitment = tx.input_notes_commitment(); + if input_notes_commitment != Word::empty() { + advice_inputs.map.extend([(input_notes_commitment, tx.input_notes_advice())]); + } + + let output_notes_commitment = tx.output_notes_commitment(); + if output_notes_commitment != Word::empty() { + advice_inputs.map.extend([(output_notes_commitment, tx.output_notes_advice())]); + } + + advice_inputs.stack.push(Felt::from(tx.expiration)); + } + + advice_inputs + } + + fn dummy_account_id(n: u8) -> AccountId { + AccountId::dummy( + [n; 15], + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Private, + ) + } + + fn synth_batch() -> Vec { + // Transaction 0: 2 input notes, 1 output note. + let tx0 = SynthTransaction { + account_id: dummy_account_id(1), + initial_state: [10u32; 4].into(), + final_state: [11u32; 4].into(), + input_notes: vec![ + (Nullifier::dummy(101), [21u32; 4].into()), + (Nullifier::dummy(102), Word::empty()), + ], + output_notes: vec![(NoteId::from_raw([31u32; 4].into()), [32u32; 4].into())], + fee: FungibleAsset::mock(100).unwrap_fungible(), + expiration: BlockNumber::from(1234u32), + }; + + // Transaction 1: 1 input note, 2 output notes, smaller expiration. + let tx1 = SynthTransaction { + account_id: dummy_account_id(2), + initial_state: [40u32; 4].into(), + final_state: [41u32; 4].into(), + input_notes: vec![(Nullifier::dummy(201), [51u32; 4].into())], + output_notes: vec![ + (NoteId::from_raw([61u32; 4].into()), [62u32; 4].into()), + (NoteId::from_raw([63u32; 4].into()), [64u32; 4].into()), + ], + fee: FungibleAsset::mock(200).unwrap_fungible(), + expiration: BlockNumber::from(800u32), + }; + + vec![tx0, tx1] + } + + fn run_kernel( + stack_inputs: StackInputs, + advice_inputs: AdviceInputs, + ) -> Result { + use miden_core_lib::CoreLibrary; + + let mut host = DefaultHost::default(); + let core_lib = CoreLibrary::default(); + host.load_library(core_lib.mast_forest()) + .expect("failed to load core library into the test host"); + + let processor = FastProcessor::new_with_options( + stack_inputs, + advice_inputs, + ExecutionOptions::default(), + ) + .with_debugging(true); + let output = processor.execute_sync(&BatchKernel::main(), &mut host)?; + Ok(output.stack) + } + + #[test] + fn batch_kernel_happy_path() -> anyhow::Result<()> { + let txs = synth_batch(); + let block_hash = Word::from([99u32; 4]); + let stack_inputs = + BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); + let advice_inputs = build_advice_inputs(&txs); + + let stack_outputs = + run_kernel(stack_inputs, advice_inputs).context("kernel execution failed")?; + let (input_notes_comm, output_notes_comm, expiration) = + BatchKernel::parse_output_stack(&stack_outputs).context("parse output failed")?; + + assert_eq!(input_notes_comm, batch_input_notes_commitment(&txs)); + assert_eq!(output_notes_comm, batch_output_notes_commitment(&txs)); + assert_eq!(expiration, batch_expiration(&txs)); + Ok(()) + } + + #[test] + fn batch_kernel_rejects_wrong_transactions_commitment() { + let txs = synth_batch(); + let block_hash = Word::from([99u32; 4]); + // Use a TRANSACTIONS_COMMITMENT the advice map does not provide. + let bogus_commitment = Word::from([12345u32; 4]); + let stack_inputs = BatchKernel::build_input_stack(block_hash, bogus_commitment); + let advice_inputs = build_advice_inputs(&txs); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + // The advice-map lookup for the bogus commitment will produce no value, which surfaces as + // an `AdviceMapKeyNotFound` (or similar) error from `adv.push_mapvaln`. We don't depend on + // the exact error variant; failing is enough. + let msg = err.to_string(); + assert!( + msg.contains("advice map") || msg.contains("not found") || msg.contains("missing"), + "unexpected error: {msg}", + ); + } + + #[test] + fn batch_kernel_rejects_tampered_layer_2() -> anyhow::Result<()> { + let txs = synth_batch(); + let block_hash = Word::from([99u32; 4]); + let stack_inputs = + BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); + let mut advice_inputs = build_advice_inputs(&txs); + + // Flip a bit in the Layer 2 entry for tx0 — the entry's hash will no longer equal tx0's + // tx_id and the kernel must abort with ERR_BATCH_TRANSACTION_HEADER_MISMATCH. + let tx0_id_word = txs[0].tx_id().as_word(); + let entry = advice_inputs.map.get(&tx0_id_word).expect("tx0 layer 2 entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(tx0_id_word, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("transaction header data piped from the advice map"), + "unexpected error: {msg}", + ); + Ok(()) + } + + #[test] + fn batch_kernel_rejects_tampered_input_notes() -> anyhow::Result<()> { + let txs = synth_batch(); + let block_hash = Word::from([99u32; 4]); + let stack_inputs = + BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); + let mut advice_inputs = build_advice_inputs(&txs); + + // Flip a felt of the input-notes data for tx0. + let key = txs[0].input_notes_commitment(); + let entry = advice_inputs.map.get(&key).expect("layer 3 entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(key, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("per-transaction input notes data piped"), + "unexpected error: {msg}", + ); + Ok(()) + } + + #[test] + fn batch_kernel_rejects_tampered_output_notes() -> anyhow::Result<()> { + let txs = synth_batch(); + let block_hash = Word::from([99u32; 4]); + let stack_inputs = + BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); + let mut advice_inputs = build_advice_inputs(&txs); + + let key = txs[0].output_notes_commitment(); + let entry = advice_inputs.map.get(&key).expect("layer 3' entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(key, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("per-transaction output notes data piped"), + "unexpected error: {msg}", + ); + Ok(()) + } +} diff --git a/crates/miden-protocol/src/batch/mod.rs b/crates/miden-protocol/src/batch/mod.rs index 1cef432dd3..a16ee4d083 100644 --- a/crates/miden-protocol/src/batch/mod.rs +++ b/crates/miden-protocol/src/batch/mod.rs @@ -18,3 +18,6 @@ pub use ordered_batches::OrderedBatches; mod input_output_note_tracker; pub(crate) use input_output_note_tracker::InputOutputNoteTracker; + +mod kernel; +pub use kernel::BatchKernel; diff --git a/crates/miden-protocol/src/batch/proposed_batch.rs b/crates/miden-protocol/src/batch/proposed_batch.rs index b0a96439ba..e46d7289b0 100644 --- a/crates/miden-protocol/src/batch/proposed_batch.rs +++ b/crates/miden-protocol/src/batch/proposed_batch.rs @@ -341,6 +341,11 @@ impl ProposedBatch { self.id } + /// Returns the header of the reference block this batch is proposed for. + pub fn reference_block_header(&self) -> &BlockHeader { + &self.reference_block_header + } + /// Returns the block number at which the batch will expire. pub fn batch_expiration_block_num(&self) -> BlockNumber { self.batch_expiration_block_num diff --git a/crates/miden-protocol/src/batch/proven_batch.rs b/crates/miden-protocol/src/batch/proven_batch.rs index 32a0bf9d18..f1f5f0d7a3 100644 --- a/crates/miden-protocol/src/batch/proven_batch.rs +++ b/crates/miden-protocol/src/batch/proven_batch.rs @@ -15,11 +15,10 @@ use crate::utils::serde::{ DeserializationError, Serializable, }; +use crate::vm::ExecutionProof; use crate::{MIN_PROOF_SECURITY_LEVEL, Word}; -/// A transaction batch with an execution proof. -/// Currently, there is no proof attached. Future versions will extend this structure to include -/// a proof artifact once recursive proving is implemented. +/// A transaction batch with an execution proof produced by the batch kernel. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProvenBatch { id: BatchId, @@ -30,6 +29,7 @@ pub struct ProvenBatch { output_notes: Vec, batch_expiration_block_num: BlockNumber, transactions: OrderedTransactionHeaders, + proof: ExecutionProof, } impl ProvenBatch { @@ -45,6 +45,7 @@ impl ProvenBatch { /// /// Returns an error if the batch expiration block number is not greater than the reference /// block number. + #[allow(clippy::too_many_arguments)] pub fn new_unchecked( id: BatchId, reference_block_commitment: Word, @@ -54,6 +55,7 @@ impl ProvenBatch { output_notes: Vec, batch_expiration_block_num: BlockNumber, transactions: OrderedTransactionHeaders, + proof: ExecutionProof, ) -> Result { // Check that the batch expiration block number is greater than the reference block number. if batch_expiration_block_num <= reference_block_num { @@ -72,6 +74,7 @@ impl ProvenBatch { output_notes, batch_expiration_block_num, transactions, + proof, }) } @@ -144,6 +147,11 @@ impl ProvenBatch { &self.transactions } + /// Returns the [`ExecutionProof`] attached to this batch. + pub fn proof(&self) -> &ExecutionProof { + &self.proof + } + // MUTATORS // -------------------------------------------------------------------------------------------- @@ -166,6 +174,7 @@ impl Serializable for ProvenBatch { self.output_notes.write_into(target); self.batch_expiration_block_num.write_into(target); self.transactions.write_into(target); + self.proof.write_into(target); } } @@ -179,6 +188,7 @@ impl Deserializable for ProvenBatch { let output_notes = Vec::::read_from(source)?; let batch_expiration_block_num = BlockNumber::read_from(source)?; let transactions = OrderedTransactionHeaders::read_from(source)?; + let proof = ExecutionProof::read_from(source)?; Self::new_unchecked( id, @@ -189,6 +199,7 @@ impl Deserializable for ProvenBatch { output_notes, batch_expiration_block_num, transactions, + proof, ) .map_err(|e| DeserializationError::UnknownError(e.to_string())) } diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index d89e98fc9f..fc63c1281d 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -1058,6 +1058,19 @@ pub enum ProvenBatchError { batch_expiration_block_num: BlockNumber, reference_block_num: BlockNumber, }, + #[error("batch kernel execution failed: {0}")] + BatchKernelExecutionFailed(String), +} + +// BATCH OUTPUT ERROR +// ================================================================================================ + +#[derive(Debug, Error)] +pub enum BatchOutputError { + #[error("batch kernel output stack is invalid: {0}")] + OutputStackInvalid(String), + #[error("batch expiration block number {0} does not fit into a u32")] + ExpirationBlockNumberTooLarge(Felt), } // PROPOSED BLOCK ERROR diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index 4313e6c465..97a709feca 100644 --- a/crates/miden-protocol/src/transaction/transaction_id.rs +++ b/crates/miden-protocol/src/transaction/transaction_id.rs @@ -35,6 +35,9 @@ use crate::utils::serde::{ pub struct TransactionId(Word); impl TransactionId { + /// Length of the felt sequence hashed by [`Self::new`] / [`Self::input_elements`]. + pub(crate) const INPUT_ELEMENTS_LEN: usize = 6 * WORD_SIZE; + /// Returns a new [TransactionId] instantiated from the provided transaction components. pub fn new( init_account_commitment: Word, @@ -43,13 +46,36 @@ impl TransactionId { output_notes_commitment: Word, fee_asset: FungibleAsset, ) -> Self { - let mut elements = [ZERO; 6 * WORD_SIZE]; + Self(Hasher::hash_elements(&Self::input_elements( + init_account_commitment, + final_account_commitment, + input_notes_commitment, + output_notes_commitment, + fee_asset, + ))) + } + + /// Returns the felt sequence that [`Self::new`] hashes to produce a [`TransactionId`]. + /// + /// The layout is: + /// `[INIT[4], FINAL[4], INPUT_NOTES_COMMITMENT[4], OUTPUT_NOTES_COMMITMENT[4], FEE_ASSET[8]]` + /// + /// Exposed for use by the batch kernel which pipes this same felt sequence from the advice + /// provider to memory and asserts the resulting hash matches a previously-verified `tx_id`. + pub(crate) fn input_elements( + init_account_commitment: Word, + final_account_commitment: Word, + input_notes_commitment: Word, + output_notes_commitment: Word, + fee_asset: FungibleAsset, + ) -> [Felt; Self::INPUT_ELEMENTS_LEN] { + let mut elements = [ZERO; Self::INPUT_ELEMENTS_LEN]; 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()); elements[16..].copy_from_slice(&Asset::from(fee_asset).as_elements()); - Self(Hasher::hash_elements(&elements)) + elements } } diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml index df138ae0e2..a4bfdc2095 100644 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -18,9 +18,11 @@ doctest = false [features] default = ["std"] -std = ["miden-protocol/std", "miden-tx/std"] +std = ["miden-processor/std", "miden-protocol/std", "miden-prover/std", "miden-tx/std"] testing = [] [dependencies] -miden-protocol = { workspace = true } -miden-tx = { workspace = true } +miden-processor = { workspace = true } +miden-protocol = { workspace = true } +miden-prover = { workspace = true } +miden-tx = { workspace = true } diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs index 09a5dc3fce..53c85268fc 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -1,36 +1,66 @@ use alloc::boxed::Box; +use alloc::format; +use alloc::string::ToString; -use miden_protocol::batch::{ProposedBatch, ProvenBatch}; +use miden_processor::DefaultHost; +use miden_protocol::batch::{BatchKernel, ProposedBatch, ProvenBatch}; use miden_protocol::errors::ProvenBatchError; +use miden_prover::{ExecutionProof, ProvingOptions, prove}; use miden_tx::TransactionVerifier; // LOCAL BATCH PROVER // ================================================================================================ -/// A local prover for transaction batches, proving the transactions in a [`ProposedBatch`] and -/// returning a [`ProvenBatch`]. +/// A local prover for transaction batches. +/// +/// Verifies each transaction's `ExecutionProof` natively, then runs the batch kernel program in +/// `miden_prover::prove` to produce an [`ExecutionProof`] over the batch's public commitments. #[derive(Clone)] pub struct LocalBatchProver { proof_security_level: u32, + proving_options: ProvingOptions, } impl LocalBatchProver { /// Creates a new [`LocalBatchProver`] instance. pub fn new(proof_security_level: u32) -> Self { - Self { proof_security_level } + Self { + proof_security_level, + proving_options: ProvingOptions::default(), + } + } + + /// Returns this prover's configured proof security level. + pub fn proof_security_level(&self) -> u32 { + self.proof_security_level } /// Attempts to prove the [`ProposedBatch`] into a [`ProvenBatch`]. /// - /// Currently we don't perform any recursive proving. For now, this function runs a native - /// verifier for each transaction separately, and outputs a `ProvenBatch` object if all of the - /// individual proofs verify. + /// Verifies each transaction's `ExecutionProof` natively first, then runs the batch kernel via + /// `miden_prover::prove` and attaches the resulting proof to the returned [`ProvenBatch`]. + /// + /// `prove` is `async` because the underlying [`miden_prover::prove`] is `async`. + /// + /// After proof generation, the kernel's parsed `batch_expiration_block_num` output is + /// sanity-checked against `proposed_batch.batch_expiration_block_num()`. The two batch note + /// commitments produced by the kernel are *not* checked here because the minimal kernel + /// computes a raw, un-erased sequential hash that intentionally diverges from + /// `proposed_batch.input_notes().commitment()` whenever the batch contains intra-batch + /// unauthenticated-note erasure. Reconciling them is part of the erasure TODO. /// /// # Errors /// /// Returns an error if: - /// - a proof of any transaction in the batch fails to verify. - pub fn prove(&self, proposed_batch: ProposedBatch) -> Result { + /// - any transaction's proof in the batch fails to verify; + /// - the batch kernel program fails to execute or produce a proof; + /// - the kernel output stack fails to parse; + /// - the kernel's `batch_expiration_block_num` does not match + /// `proposed_batch.batch_expiration_block_num()` (indicates a kernel/advice-builder bug). + pub async fn prove( + &self, + proposed_batch: ProposedBatch, + ) -> Result { let verifier = TransactionVerifier::new(self.proof_security_level); for tx in proposed_batch.transactions() { @@ -42,25 +72,51 @@ impl LocalBatchProver { })?; } - self.prove_inner(proposed_batch) + let expected_expiration = proposed_batch.batch_expiration_block_num(); + let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + let mut host = DefaultHost::default(); + + let (stack_outputs, proof) = prove( + &BatchKernel::main(), + stack_inputs, + advice_inputs, + &mut host, + self.proving_options.clone(), + ) + .await + .map_err(|err| ProvenBatchError::BatchKernelExecutionFailed(err.to_string()))?; + + let (_input_notes_commitment, _output_notes_commitment, kernel_expiration) = + BatchKernel::parse_output_stack(&stack_outputs) + .map_err(|err| ProvenBatchError::BatchKernelExecutionFailed(err.to_string()))?; + + if kernel_expiration != expected_expiration { + return Err(ProvenBatchError::BatchKernelExecutionFailed(format!( + "kernel batch_expiration_block_num {kernel_expiration} does not match the proposed batch's {expected_expiration}", + ))); + } + + Self::build_proven_batch(proposed_batch, proof) } - /// Proves the provided [`ProposedBatch`] into a [`ProvenBatch`], **without verifying batches - /// and proving the block**. + /// Proves the provided [`ProposedBatch`] into a [`ProvenBatch`] **without running the batch + /// kernel**, attaching a dummy [`ExecutionProof`] instead. /// - /// This is exposed for testing purposes. + /// Exposed for tests that want a `ProvenBatch` without paying the cost of proof generation. #[cfg(any(feature = "testing", test))] pub fn prove_dummy( &self, proposed_batch: ProposedBatch, ) -> Result { - self.prove_inner(proposed_batch) + Self::build_proven_batch(proposed_batch, ExecutionProof::new_dummy()) } - /// Converts a proposed batch into a proven batch. - /// - /// For now, this doesn't do anything interesting. - fn prove_inner(&self, proposed_batch: ProposedBatch) -> Result { + /// Combines the parts of a [`ProposedBatch`] with a freshly-produced [`ExecutionProof`] into a + /// [`ProvenBatch`]. + fn build_proven_batch( + proposed_batch: ProposedBatch, + proof: ExecutionProof, + ) -> Result { let tx_headers = proposed_batch.transaction_headers(); let ( _transactions, @@ -83,6 +139,7 @@ impl LocalBatchProver { output_notes, batch_expiration_block_num, tx_headers, + proof, ) } } From 482ca6c1b4f7ce9f8171c11039021559e8c3e9fc Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:36:25 +0000 Subject: [PATCH 02/14] chore: track meaningful stack elements explicitly in batch main.masm Remove the pad(N) and pad... shorthands; comment only the elements that participate in the kernel's logic. The depth-floor zero-fill is implicit and doesn't need to be repeated in every stack annotation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/main.masm | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 2930670fbb..afc901e178 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -19,9 +19,9 @@ use miden::batch_kernel::prologue #! Each layer is loaded via `adv.push_mapvaln` keyed by the previously-verified hash and asserted #! against that hash via `assert_eqw`. Only verified data feeds into the batch outputs. #! -#! Inputs: [BLOCK_HASH, TRANSACTIONS_COMMITMENT, pad(8)] +#! Inputs: [BLOCK_HASH, TRANSACTIONS_COMMITMENT] #! -#! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad(7)] +#! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] #! #! Where: #! - BLOCK_HASH is the commitment of the batch's reference block. Currently consumed and dropped; @@ -55,35 +55,34 @@ use miden::batch_kernel::prologue proc main # Bring TRANSACTIONS_COMMITMENT to the top so the prologue can consume it. swapw - # Stack: [TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)] + # Stack: [TRANSACTIONS_COMMITMENT, BLOCK_HASH] # Layer 1 + Layer 2 verification, plus expiration min accumulation. exec.prologue::prepare_batch - # Stack: [BLOCK_HASH, pad(8)] + # Stack: [BLOCK_HASH] # TODO: verify BLOCK_HASH against block header data via the pipe-and-verify pattern. dropw - # Stack: [pad(8)] + # Stack: [] # Compute the batch INPUT_NOTES_COMMITMENT (Layer 3 verification + sequential absorption). exec.note_tracker::compute_input_notes_commitment - # Stack: [INPUT_NOTES_COMMITMENT, pad(8)] + # Stack: [INPUT_NOTES_COMMITMENT] # Compute the batch OUTPUT_NOTES_COMMITMENT (Layer 3' verification + sequential absorption). exec.note_tracker::compute_output_notes_commitment - # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, pad(8)] + # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT] # Read the accumulated batch_expiration_block_num. exec.memory::get_batch_expiration_block_num - # Stack: [batch_expiration_block_num, - # OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, pad...] + # Stack: [batch_expiration_block_num, OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT] # Push the expiration felt down past both commitment words so the words become word-aligned. movdn.8 - # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad...] + # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, batch_expiration_block_num] swapw - # Stack: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad...] + # Stack: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] # Truncate the stack to the canonical 16-element output: drop the bottom-most words that came # from the depth-floor auto-fill during the kernel's earlier `consume`-only operations. From 9275af56428deb169ad0d29a4eb660f155c52479 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:37:14 +0000 Subject: [PATCH 03/14] docs: explain what the batch kernel does before describing the layer chain The previous main.masm header dove straight into the unhashing chain without first explaining why a batch kernel exists. Add a two-step high-level description (reconstruct per-tx data, aggregate into batch-wide commitments) so readers landing here cold can orient themselves before parsing the layer-by-layer details. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/asm/kernels/batch/main.masm | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index afc901e178..e1e8b6c62d 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -7,8 +7,19 @@ use miden::batch_kernel::prologue #! Minimal batch kernel program. #! -#! Verifies the unhashing chain rooted at `TRANSACTIONS_COMMITMENT` and emits the three batch -#! commitments. The verification chain is: +#! 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: +#! +#! 1. Reconstructs the per-transaction data (account state transitions, input/output notes, fee, +#! expiration) from the advice provider, anchoring the entire reconstruction in the public +#! `TRANSACTIONS_COMMITMENT`. +#! 2. Aggregates the per-transaction data into batch-wide commitments — one over the consumed +#! input notes, one over the produced output notes, plus the batch's effective expiration +#! block number — that the block kernel will later consume. +#! +#! Reconstruction is a recursive unhashing chain. Each layer of advice data is keyed by a hash +#! that the previous layer's verification produced, so once `TRANSACTIONS_COMMITMENT` is anchored +#! to the public input, every byte that feeds the batch outputs is transitively committed-to: #! #! `TRANSACTIONS_COMMITMENT` (public input) -> `(tx_id, account_id)` tuple list #! each `tx_id` -> per-tx From b336025ec01ab51361acbd9f3af3c45b919a70a7 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:38:26 +0000 Subject: [PATCH 04/14] docs: drop project-internal language from main.masm header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Where:" block leaked implementation history ("currently consumed and dropped", "does not yet match…") and project-management framing ("each will be lifted in a follow-up issue") that aren't useful to external readers. Trim each entry to what the value is, and rewrite the follow-up list as plain TODO comments. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/main.masm | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index e1e8b6c62d..e19c70a2be 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -35,34 +35,28 @@ use miden::batch_kernel::prologue #! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] #! #! Where: -#! - BLOCK_HASH is the commitment of the batch's reference block. Currently consumed and dropped; -#! verifying it against the chain MMR is a TODO. +#! - BLOCK_HASH is the commitment of the batch's reference block. #! - TRANSACTIONS_COMMITMENT is the sequential hash of the `(tx_id, account_id)` tuples committing #! to the transactions in the batch (i.e. the `BatchId` value). #! - INPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified -#! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples in transaction order. It does not yet match -#! `proposed_batch.input_notes().commitment()` for batches with intra-batch erasure or -#! re-sorted nullifiers (TODO in `note_tracker.masm`). +#! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples in transaction order. #! - OUTPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified -#! `(NOTE_ID, METADATA_COMMITMENT)` tuples. Switching to the `BatchNoteTree` SMT root is a TODO. -#! - batch_expiration_block_num is the running min over each transaction's -#! `expiration_block_num`, which is currently supplied unverified on the advice stack. Anchoring -#! this in a verified commitment is a TODO that becomes possible once recursive transaction -#! proof verification ships (the per-tx `expiration_block_num` is a public output of the tx -#! kernel). +#! `(NOTE_ID, METADATA_COMMITMENT)` tuples in transaction order. +#! - batch_expiration_block_num is the minimum of every transaction's `expiration_block_num`. #! -#! TODOs (each will be lifted in a follow-up issue without changing this stack interface): -#! - Verify BLOCK_HASH against block header data via the same pipe-and-verify pattern. -#! - Authenticate unauthenticated input notes against BLOCK_HASH's chain MMR. -#! - Verify each transaction's reference block is contained in the chain MMR rooted at BLOCK_HASH. -#! - Aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output (mirrors -#! `BatchAccountUpdate` and `ProposedBatch::new` lines 195-223). The Layer 1 tuple list already -#! has `(tx_id, account_id)` per transaction in memory at TX_TUPLES_PTR, ready for this. -#! - Erase intra-batch unauthenticated notes from INPUT_NOTES_COMMITMENT and the output set. -#! - Recursively verify each transaction's `ExecutionProof`. -#! - Switch OUTPUT_NOTES_COMMITMENT to the `BatchNoteTree` SMT root. -#! - Assert `batch_expiration_block_num > reference_block_num`. -#! - Enforce `MAX_INPUT_NOTES_PER_BATCH`, `MAX_OUTPUT_NOTES_PER_BATCH`, `MAX_ACCOUNTS_PER_BATCH`. +#! TODO: verify BLOCK_HASH against block header data via the same pipe-and-verify pattern. +#! TODO: authenticate unauthenticated input notes against BLOCK_HASH's chain MMR. +#! TODO: verify each transaction's reference block is contained in the chain MMR rooted at +#! BLOCK_HASH. +#! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. +#! TODO: erase intra-batch unauthenticated notes from INPUT_NOTES_COMMITMENT and the output set. +#! TODO: recursively verify each transaction's `ExecutionProof`. +#! TODO: switch OUTPUT_NOTES_COMMITMENT to the `BatchNoteTree` SMT root. +#! TODO: assert `batch_expiration_block_num > reference_block_num`. +#! TODO: enforce `MAX_INPUT_NOTES_PER_BATCH`, `MAX_OUTPUT_NOTES_PER_BATCH`, +#! `MAX_ACCOUNTS_PER_BATCH`. +#! TODO: derive `batch_expiration_block_num` from data committed-to in the verified chain rather +#! than from the unverified advice stack. proc main # Bring TRANSACTIONS_COMMITMENT to the top so the prologue can consume it. swapw From 5d4e95cb9acb4b9aa6cce1e447fd709af241b34e Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:40:46 +0000 Subject: [PATCH 05/14] chore: drop comments that just narrate the next instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove inline comments that restate what the operation right below them already says: "Bring TRANSACTIONS_COMMITMENT to the top so the prologue can consume it" before a `swapw`, "Compute the batch INPUT_NOTES_COMMITMENT" before `exec.compute_input_notes_commitment`, "Truncate the stack to the canonical 16-element output…" before the truncate idiom, etc. Stack-state comments (`# Stack: [...]`) and notes that explain non-obvious choices (e.g. the `u32lt` argument ordering) are kept. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/lib/note_tracker.masm | 34 ++----------------- .../asm/kernels/batch/lib/prologue.masm | 27 ++------------- .../asm/kernels/batch/main.masm | 13 +------ 3 files changed, 6 insertions(+), 68 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm index a770028d44..679945b227 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -27,59 +27,44 @@ const ERR_BATCH_OUTPUT_NOTES_MISMATCH="per-transaction output notes data piped f #! Inputs: [] #! Outputs: [] proc absorb_scratch_into_batch_hasher - # Reset the iteration cursor. push.0 exec.memory::set_scratch_word_index - # Hoist the batch hasher state onto the operand stack; we'll save it back at the end. exec.memory::load_batch_hasher_state # Stack: [RATE0, RATE1, CAPACITY] - # Loop while word_index < num_words. exec.memory::get_scratch_word_index exec.memory::get_scratch_words_count u32lt # Stack: [should_loop, RATE0, RATE1, CAPACITY] while.true - # Compute the address of the next 2-word tuple. exec.memory::get_scratch_word_index mul.4 add.TX_NOTES_SCRATCH_PTR # Stack: [scratch_ptr, RATE0, RATE1, CAPACITY] - # Load the first word (DATA1 = NULL or NOTE_ID). padw dup.4 mem_loadw_le # Stack: [DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] - # Load the second word (DATA2 = COMMIT_OR_ZERO or METADATA_COMMITMENT). padw dup.8 add.4 mem_loadw_le # Stack: [DATA2, DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] - # Drop scratch_ptr. movup.8 drop # Stack: [DATA2, DATA1, RATE0, RATE1, CAPACITY] - # Replace RATE0 + RATE1 with DATA1 + DATA2 (in the absorbing order DATA1 first, DATA2 - # second), then permute. Same pattern as - # crates/miden-protocol/asm/kernels/transaction/lib/note.masm:213-221. + # Replace RATE0 + RATE1 with DATA1 + DATA2 (DATA1 first, DATA2 second), then permute. swapdw - # Stack: [RATE0, RATE1, DATA2, DATA1, CAPACITY] dropw dropw - # Stack: [DATA2, DATA1, CAPACITY] swapw - # Stack: [DATA1, DATA2, CAPACITY] exec.poseidon2::permute # Stack: [RATE0', RATE1', CAPACITY'] - # Advance the cursor by 2 words and re-evaluate the loop condition. exec.memory::get_scratch_word_index add.2 exec.memory::set_scratch_word_index exec.memory::get_scratch_word_index exec.memory::get_scratch_words_count u32lt # Stack: [should_loop, RATE0, RATE1, CAPACITY] end - # Stack: [RATE0, RATE1, CAPACITY] - # Save the updated state back to memory. exec.memory::save_batch_hasher_state end @@ -123,9 +108,6 @@ end #! TODO: authenticate unauthenticated input notes against `BLOCK_HASH`'s chain MMR. #! TODO: enforce `MAX_INPUT_NOTES_PER_BATCH`. pub proc compute_input_notes_commitment - # Stack: [] - - # Init the batch hasher state in memory. exec.poseidon2::init_no_padding exec.memory::save_batch_hasher_state @@ -137,19 +119,15 @@ pub proc compute_input_notes_commitment # Stack: [should_loop, tx_index, num_transactions] while.true - # Read INPUT_NOTES_COMMITMENT_i. dup exec.memory::get_tx_input_notes_commitment # Stack: [INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] - # Skip if empty (no map entry needed; the empty list contributes nothing). dupw exec.word::eqz # Stack: [is_empty, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] if.true dropw - # Stack: [tx_index, num_transactions] else - # Pipe + verify Layer 3 to TX_NOTES_SCRATCH_PTR. push.TX_NOTES_SCRATCH_PTR movdn.4 # Stack: [INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] @@ -157,9 +135,7 @@ pub proc compute_input_notes_commitment adv_push.1 div.4 # Stack: [num_words, INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] - # Save num_words for the absorption iterator. dup exec.memory::set_scratch_words_count - # Stack: [num_words, INPUT_NOTES_COMMITMENT_i, TX_NOTES_SCRATCH_PTR, tx_index, num_transactions] movup.5 swap # Stack: [num_words, TX_NOTES_SCRATCH_PTR, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] @@ -170,24 +146,18 @@ pub proc compute_input_notes_commitment # Stack: [DIGEST, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] assert_eqw.err=ERR_BATCH_INPUT_NOTES_MISMATCH - # Stack: [tx_index, num_transactions] - # Absorb the verified scratch contents into the batch hasher. exec.absorb_scratch_into_batch_hasher - # Stack: [tx_index, num_transactions] end + # Stack: [tx_index, num_transactions] add.1 dup.1 dup.1 neq # Stack: [should_loop, tx_index, num_transactions] end - # Stack: [tx_index, num_transactions] drop drop - # Stack: [] - # Squeeze the final digest. `squeeze_digest` consumes the 12-felt hasher state and produces - # the 4-felt digest, so the stack now holds just `[INPUT_NOTES_COMMITMENT]`. exec.memory::load_batch_hasher_state exec.poseidon2::squeeze_digest # Stack: [INPUT_NOTES_COMMITMENT] diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm index ca59d63604..90cb247004 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -66,21 +66,15 @@ pub proc prepare_batch push.TX_TUPLES_PTR movdn.4 # Stack: [TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] - # Push the mapped value's length + data onto the advice stack, keyed by - # TRANSACTIONS_COMMITMENT (top-of-operand-stack word). adv.push_mapvaln - # Stack: [TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] - # AS: [len_felts, data...] + # AS: [len_felts, data...] - # Pop length, divide by 4 to get number of words. adv_push.1 div.4 # Stack: [num_words, TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] # num_transactions = num_words / 2 (each tx contributes 2 words: tx_id + account_id_pair). dup div.2 exec.memory::set_num_transactions - # Stack: [num_words, TRANSACTIONS_COMMITMENT, TX_TUPLES_PTR] - # Bring TX_TUPLES_PTR up so pipe_words_to_memory has [num_words, write_ptr, ...]. movup.5 swap # Stack: [num_words, TX_TUPLES_PTR, TRANSACTIONS_COMMITMENT] @@ -88,13 +82,10 @@ pub proc prepare_batch # Stack: [C, B, A, end_ptr, TRANSACTIONS_COMMITMENT] exec.poseidon2::squeeze_digest - # Stack: [DIGEST, end_ptr, TRANSACTIONS_COMMITMENT] - movup.4 drop # Stack: [DIGEST, TRANSACTIONS_COMMITMENT] assert_eqw.err=ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH - # Stack: [] # Initialise batch_expiration_block_num to u32::MAX. # --------------------------------------------------------------------------------------------- @@ -112,15 +103,12 @@ pub proc prepare_batch # Stack: [should_loop, tx_index, num_transactions] while.true - # Get tx_id (4 felts) for this transaction. dup exec.memory::get_tx_id # Stack: [TX_ID, tx_index, num_transactions] - # Push the destination memory pointer (TX_HEADERS_PTR + TX_HEADER_FELT_LEN * tx_index). dup.4 exec.memory::tx_header_ptr movdn.4 # Stack: [TX_ID, tx_header_ptr, tx_index, num_transactions] - # Pipe + verify Layer 2. adv.push_mapvaln adv_push.1 div.4 # Stack: [num_words, TX_ID, tx_header_ptr, tx_index, num_transactions] @@ -134,37 +122,28 @@ pub proc prepare_batch # Stack: [DIGEST, TX_ID, tx_index, num_transactions] assert_eqw.err=ERR_BATCH_TRANSACTION_HEADER_MISMATCH - # Stack: [tx_index, num_transactions] - # Pop expiration_block_num from the advice stack and fold it into the batch min. adv_push.1 # Stack: [expiration, tx_index, num_transactions] dup exec.memory::get_batch_expiration_block_num # Stack: [current_min, expiration, expiration, tx_index, num_transactions] + # `u32lt` pops `[a (top), b]` and pushes `1` if `b < a`, so this is true iff + # `expiration < current_min`. u32lt - # `u32lt` pops `[a (top), b]` and pushes `1` if `b < a`. Here `a = current_min` and - # `b = expiration`, so the bool is `1` iff `expiration < current_min` — i.e. the candidate - # is the new minimum. - # Stack: [is_new_min, expiration, tx_index, num_transactions] if.true - # `expiration < current_min`: install the candidate as the new min. exec.memory::set_batch_expiration_block_num else - # `expiration >= current_min`: keep the current min and drop the candidate. drop end # Stack: [tx_index, num_transactions] - # Increment tx_index and re-evaluate the loop condition. add.1 dup.1 dup.1 neq # Stack: [should_loop, tx_index, num_transactions] end - # Stack: [tx_index, num_transactions] drop drop - # Stack: [] end diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index e19c70a2be..6f6ba442ae 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -58,11 +58,9 @@ use miden::batch_kernel::prologue #! TODO: derive `batch_expiration_block_num` from data committed-to in the verified chain rather #! than from the unverified advice stack. proc main - # Bring TRANSACTIONS_COMMITMENT to the top so the prologue can consume it. swapw # Stack: [TRANSACTIONS_COMMITMENT, BLOCK_HASH] - # Layer 1 + Layer 2 verification, plus expiration min accumulation. exec.prologue::prepare_batch # Stack: [BLOCK_HASH] @@ -70,29 +68,20 @@ proc main dropw # Stack: [] - # Compute the batch INPUT_NOTES_COMMITMENT (Layer 3 verification + sequential absorption). exec.note_tracker::compute_input_notes_commitment # Stack: [INPUT_NOTES_COMMITMENT] - # Compute the batch OUTPUT_NOTES_COMMITMENT (Layer 3' verification + sequential absorption). exec.note_tracker::compute_output_notes_commitment # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT] - # Read the accumulated batch_expiration_block_num. exec.memory::get_batch_expiration_block_num # Stack: [batch_expiration_block_num, OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT] - # Push the expiration felt down past both commitment words so the words become word-aligned. + # Push the expiration felt past both commitment words so the words land word-aligned. movdn.8 - # Stack: [OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, batch_expiration_block_num] - swapw # Stack: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] - # Truncate the stack to the canonical 16-element output: drop the bottom-most words that came - # from the depth-floor auto-fill during the kernel's earlier `consume`-only operations. - # See `crates/miden-protocol/asm/kernels/transaction/main.masm:182` for the equivalent - # truncate in the transaction kernel. repeat.3 movupw.3 dropw end end From 11c5946b39568f41c8e726f501b826370c8b4b0c Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:41:39 +0000 Subject: [PATCH 06/14] docs: drop line-number citations from MASM TODOs Pinning citations to specific Rust line ranges (e.g. "ProposedBatch::new lines 184-193") rots as soon as the Rust file is edited. The TODO text itself names what the eventual check needs to do; readers who want the Rust counterpart can grep for the type / function name. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/asm/kernels/batch/lib/prologue.masm | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm index 90cb247004..f999787e2e 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -51,11 +51,9 @@ const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from #! that transaction's tx_id. #! #! TODO: verify that each transaction's reference block is contained in the chain MMR rooted at -#! BLOCK_HASH (mirrors `ProposedBatch::new` lines 184-193). -#! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment -#! (mirrors `ProposedBatch::new` lines 145-161). -#! TODO: assert each `expiration_block_num_i > reference_block_num` (mirrors -#! `ProposedBatch::new` lines 225-242). +#! BLOCK_HASH. +#! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment. +#! TODO: assert each `expiration_block_num_i > reference_block_num`. #! TODO: verify each `expiration_block_num_i` is part of tx_i's verified ExecutionProof public #! outputs once recursive proof verification ships. pub proc prepare_batch From ad23260c2adf495f2d1b2032a89ea183ba80bcbf Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:42:28 +0000 Subject: [PATCH 07/14] refactor: rename BatchId::tuple_elements to hash_input_elements The previous name described the *shape* of the returned value (a flat sequence of `(tx_id, account_id_pair)` tuples) but not its *purpose*. The function returns the felt sequence that `from_ids` hashes; readers shouldn't have to chase the call site to learn that. Aligns with the existing `TransactionId::input_elements` convention elsewhere in the crate. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/src/batch/batch_id.rs | 4 ++-- crates/miden-protocol/src/batch/kernel.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/miden-protocol/src/batch/batch_id.rs b/crates/miden-protocol/src/batch/batch_id.rs index e4a452524c..7df7014446 100644 --- a/crates/miden-protocol/src/batch/batch_id.rs +++ b/crates/miden-protocol/src/batch/batch_id.rs @@ -37,7 +37,7 @@ impl BatchId { /// Calculates a batch ID from the given transaction ID and account ID tuple. pub fn from_ids(iter: impl IntoIterator) -> Self { - Self(Hasher::hash_elements(&Self::tuple_elements(iter))) + Self(Hasher::hash_elements(&Self::hash_input_elements(iter))) } /// Returns the felt sequence that [`Self::from_ids`] hashes to produce a [`BatchId`]. @@ -48,7 +48,7 @@ impl BatchId { /// Exposed for use by the batch kernel which pipes this same felt sequence from the advice /// provider to memory and asserts the resulting hash matches the public input /// `TRANSACTIONS_COMMITMENT`. - pub(crate) fn tuple_elements( + pub(crate) fn hash_input_elements( iter: impl IntoIterator, ) -> Vec { let mut elements: Vec = Vec::new(); diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index c7ae677275..5f91c44f3e 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -177,7 +177,7 @@ impl BatchKernel { let mut advice_inputs = AdviceInputs::default(); // Layer 1: TRANSACTIONS_COMMITMENT |-> [(tx_id, account_id_pair) tuples]. - let layer1_data = BatchId::tuple_elements( + let layer1_data = BatchId::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)]); @@ -340,7 +340,7 @@ mod tests { } fn transactions_commitment(txs: &[SynthTransaction]) -> Word { - Hasher::hash_elements(&BatchId::tuple_elements( + Hasher::hash_elements(&BatchId::hash_input_elements( txs.iter().map(|tx| (tx.tx_id(), tx.account_id)), )) } @@ -349,7 +349,7 @@ mod tests { let mut advice_inputs = AdviceInputs::default(); // Layer 1: TRANSACTIONS_COMMITMENT |-> [(tx_id, account_id_pair) tuples]. - let layer1 = BatchId::tuple_elements(txs.iter().map(|tx| (tx.tx_id(), tx.account_id))); + let layer1 = BatchId::hash_input_elements(txs.iter().map(|tx| (tx.tx_id(), tx.account_id))); advice_inputs.map.extend([(transactions_commitment(txs), layer1)]); for tx in txs { From bf269485157fbe1b827ec2150c816407e611325c Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:43:34 +0000 Subject: [PATCH 08/14] docs: drop "minimal kernel" framing from MASM and prover docs Keep one mention on the `BatchKernel` Rust struct (the type readers land on first when looking up the kernel from a downstream crate) and drop it everywhere else. The MASM source describes what the program *does* without editorializing on its scope, and the prover docstring explains the divergence between kernel-side and Rust-side note commitments without labelling the kernel itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/asm/kernels/batch/main.masm | 2 +- crates/miden-protocol/src/batch/kernel.rs | 10 +++++++--- crates/miden-tx-batch-prover/src/local_batch_prover.rs | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 6f6ba442ae..69d757a5ae 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -5,7 +5,7 @@ use miden::batch_kernel::prologue # MAIN # ================================================================================================= -#! Minimal batch kernel program. +#! 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: diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 5f91c44f3e..977272da96 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -36,9 +36,13 @@ const TRAILING_PAD_WORD_FELT_IDX: usize = 12; /// The batch kernel program: an executable Miden program that proves a batch of transactions. /// /// The kernel takes `[BLOCK_HASH, TRANSACTIONS_COMMITMENT]` as public inputs and emits -/// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. -/// See [`crate::batch::ProposedBatch`] / `asm/kernels/batch/main.masm` for the verification -/// chain and the list of TODOs the minimal kernel intentionally elides. +/// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See +/// `asm/kernels/batch/main.masm` for the verification chain. +/// +/// This is the initial, minimal implementation: it verifies the unhashing chain rooted at +/// `TRANSACTIONS_COMMITMENT` but defers a number of checks (block-MMR-based note authentication, +/// account-update aggregation, intra-batch note erasure, recursive transaction proof +/// verification, batch-size limits) — see the `TODO` markers in the MASM source. pub struct BatchKernel; impl BatchKernel { diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs index 53c85268fc..56f77d5ef5 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -44,10 +44,10 @@ impl LocalBatchProver { /// /// After proof generation, the kernel's parsed `batch_expiration_block_num` output is /// sanity-checked against `proposed_batch.batch_expiration_block_num()`. The two batch note - /// commitments produced by the kernel are *not* checked here because the minimal kernel - /// computes a raw, un-erased sequential hash that intentionally diverges from + /// commitments produced by the kernel are *not* checked here because the kernel computes a + /// raw, un-erased sequential hash that intentionally diverges from /// `proposed_batch.input_notes().commitment()` whenever the batch contains intra-batch - /// unauthenticated-note erasure. Reconciling them is part of the erasure TODO. + /// unauthenticated-note erasure. /// /// # Errors /// From a89c83fc5507465987a9d109893e1901d2f1c09e Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 08:49:57 +0000 Subject: [PATCH 09/14] test: drive batch kernel tests through MockChain instead of synthetic data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier tests in `miden-protocol::batch::kernel` constructed felt sequences by hand and asserted the kernel produced the same hash — which only proved the test's expected-value helper agreed with the kernel, not that either matched real-batch semantics. Move the kernel tests next to the existing `proposed_batch` tests in `miden-testing::kernel_tests::batch` and drive them through the same `MockChain` + `MockProvenTxBuilder` pipeline used by the rest of the batch tests: - Two real accounts created from the genesis block. - One real authenticated input note (P2ID), consumed by tx1. - One synthesised unauthenticated input note, consumed by tx2. - Three synthesised output notes (one in tx1, two in tx2). - Two distinct expirations so the min reduction has something to do. The expected note commitments are still computed in Rust for comparison (the kernel emits the raw, un-erased aggregation), but they are derived from the real `ProposedBatch`'s transactions rather than fabricated tuples. Negative tests retained: corrupting `TRANSACTIONS_COMMITMENT`, the Layer 2 entry, and either Layer 3 entry must each abort the kernel with the corresponding `ERR_BATCH_*`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/miden-protocol/src/batch/kernel.rs | 323 ------------------ .../src/kernel_tests/batch/batch_kernel.rs | 263 ++++++++++++++ .../src/kernel_tests/batch/mod.rs | 1 + 3 files changed, 264 insertions(+), 323 deletions(-) create mode 100644 crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 977272da96..f0013c2a7d 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -232,326 +232,3 @@ impl BatchKernel { advice_inputs } } - -// TESTS -// ================================================================================================ - -#[cfg(test)] -mod tests { - use alloc::string::ToString; - use alloc::vec; - use alloc::vec::Vec; - - use anyhow::Context; - use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; - - use super::*; - use crate::Hasher; - use crate::account::{AccountId, AccountIdVersion, AccountStorageMode, AccountType}; - use crate::asset::FungibleAsset; - use crate::note::{NoteId, Nullifier}; - - /// One synthetic transaction's worth of input data the kernel will read out of the advice - /// provider during a test run. - struct SynthTransaction { - account_id: AccountId, - initial_state: Word, - final_state: Word, - // (NULLIFIER, EMPTY_OR_NOTE_COMMITMENT) tuples — verbatim what the per-tx - // INPUT_NOTES_COMMITMENT hashes. - input_notes: Vec<(Nullifier, Word)>, - // (NOTE_ID, METADATA_COMMITMENT) tuples — verbatim what the per-tx - // OUTPUT_NOTES_COMMITMENT hashes. - output_notes: Vec<(NoteId, Word)>, - fee: FungibleAsset, - expiration: BlockNumber, - } - - impl SynthTransaction { - fn input_notes_commitment(&self) -> Word { - input_notes_commitment(&self.input_notes) - } - - fn output_notes_commitment(&self) -> Word { - output_notes_commitment(&self.output_notes) - } - - fn tx_id(&self) -> TransactionId { - TransactionId::from_raw(Hasher::hash_elements(&TransactionId::input_elements( - self.initial_state, - self.final_state, - self.input_notes_commitment(), - self.output_notes_commitment(), - self.fee, - ))) - } - - fn input_notes_advice(&self) -> Vec { - let mut data = Vec::with_capacity(self.input_notes.len() * 8); - for (nullifier, commit) in &self.input_notes { - data.extend_from_slice(nullifier.as_word().as_elements()); - data.extend_from_slice(commit.as_elements()); - } - data - } - - fn output_notes_advice(&self) -> Vec { - let mut data = Vec::with_capacity(self.output_notes.len() * 8); - for (note_id, metadata) in &self.output_notes { - data.extend_from_slice(note_id.as_word().as_elements()); - data.extend_from_slice(metadata.as_elements()); - } - data - } - } - - fn input_notes_commitment(notes: &[(Nullifier, Word)]) -> Word { - if notes.is_empty() { - return Word::empty(); - } - let mut elements = Vec::with_capacity(notes.len() * 8); - for (nullifier, commit) in notes { - elements.extend_from_slice(nullifier.as_word().as_elements()); - elements.extend_from_slice(commit.as_elements()); - } - Hasher::hash_elements(&elements) - } - - fn output_notes_commitment(notes: &[(NoteId, Word)]) -> Word { - if notes.is_empty() { - return Word::empty(); - } - let mut elements = Vec::with_capacity(notes.len() * 8); - for (note_id, metadata) in notes { - elements.extend_from_slice(note_id.as_word().as_elements()); - elements.extend_from_slice(metadata.as_elements()); - } - Hasher::hash_elements(&elements) - } - - fn batch_input_notes_commitment(txs: &[SynthTransaction]) -> Word { - let combined: Vec<_> = txs.iter().flat_map(|tx| tx.input_notes.clone()).collect(); - input_notes_commitment(&combined) - } - - fn batch_output_notes_commitment(txs: &[SynthTransaction]) -> Word { - let combined: Vec<_> = txs.iter().flat_map(|tx| tx.output_notes.clone()).collect(); - output_notes_commitment(&combined) - } - - fn batch_expiration(txs: &[SynthTransaction]) -> BlockNumber { - txs.iter().map(|tx| tx.expiration).min().expect("non-empty") - } - - fn transactions_commitment(txs: &[SynthTransaction]) -> Word { - Hasher::hash_elements(&BatchId::hash_input_elements( - txs.iter().map(|tx| (tx.tx_id(), tx.account_id)), - )) - } - - fn build_advice_inputs(txs: &[SynthTransaction]) -> AdviceInputs { - let mut advice_inputs = AdviceInputs::default(); - - // Layer 1: TRANSACTIONS_COMMITMENT |-> [(tx_id, account_id_pair) tuples]. - let layer1 = BatchId::hash_input_elements(txs.iter().map(|tx| (tx.tx_id(), tx.account_id))); - advice_inputs.map.extend([(transactions_commitment(txs), layer1)]); - - for tx in txs { - let header = TransactionId::input_elements( - tx.initial_state, - tx.final_state, - tx.input_notes_commitment(), - tx.output_notes_commitment(), - tx.fee, - ); - advice_inputs.map.extend([(tx.tx_id().as_word(), header.to_vec())]); - - let input_notes_commitment = tx.input_notes_commitment(); - if input_notes_commitment != Word::empty() { - advice_inputs.map.extend([(input_notes_commitment, tx.input_notes_advice())]); - } - - let output_notes_commitment = tx.output_notes_commitment(); - if output_notes_commitment != Word::empty() { - advice_inputs.map.extend([(output_notes_commitment, tx.output_notes_advice())]); - } - - advice_inputs.stack.push(Felt::from(tx.expiration)); - } - - advice_inputs - } - - fn dummy_account_id(n: u8) -> AccountId { - AccountId::dummy( - [n; 15], - AccountIdVersion::Version0, - AccountType::FungibleFaucet, - AccountStorageMode::Private, - ) - } - - fn synth_batch() -> Vec { - // Transaction 0: 2 input notes, 1 output note. - let tx0 = SynthTransaction { - account_id: dummy_account_id(1), - initial_state: [10u32; 4].into(), - final_state: [11u32; 4].into(), - input_notes: vec![ - (Nullifier::dummy(101), [21u32; 4].into()), - (Nullifier::dummy(102), Word::empty()), - ], - output_notes: vec![(NoteId::from_raw([31u32; 4].into()), [32u32; 4].into())], - fee: FungibleAsset::mock(100).unwrap_fungible(), - expiration: BlockNumber::from(1234u32), - }; - - // Transaction 1: 1 input note, 2 output notes, smaller expiration. - let tx1 = SynthTransaction { - account_id: dummy_account_id(2), - initial_state: [40u32; 4].into(), - final_state: [41u32; 4].into(), - input_notes: vec![(Nullifier::dummy(201), [51u32; 4].into())], - output_notes: vec![ - (NoteId::from_raw([61u32; 4].into()), [62u32; 4].into()), - (NoteId::from_raw([63u32; 4].into()), [64u32; 4].into()), - ], - fee: FungibleAsset::mock(200).unwrap_fungible(), - expiration: BlockNumber::from(800u32), - }; - - vec![tx0, tx1] - } - - fn run_kernel( - stack_inputs: StackInputs, - advice_inputs: AdviceInputs, - ) -> Result { - use miden_core_lib::CoreLibrary; - - let mut host = DefaultHost::default(); - let core_lib = CoreLibrary::default(); - host.load_library(core_lib.mast_forest()) - .expect("failed to load core library into the test host"); - - let processor = FastProcessor::new_with_options( - stack_inputs, - advice_inputs, - ExecutionOptions::default(), - ) - .with_debugging(true); - let output = processor.execute_sync(&BatchKernel::main(), &mut host)?; - Ok(output.stack) - } - - #[test] - fn batch_kernel_happy_path() -> anyhow::Result<()> { - let txs = synth_batch(); - let block_hash = Word::from([99u32; 4]); - let stack_inputs = - BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); - let advice_inputs = build_advice_inputs(&txs); - - let stack_outputs = - run_kernel(stack_inputs, advice_inputs).context("kernel execution failed")?; - let (input_notes_comm, output_notes_comm, expiration) = - BatchKernel::parse_output_stack(&stack_outputs).context("parse output failed")?; - - assert_eq!(input_notes_comm, batch_input_notes_commitment(&txs)); - assert_eq!(output_notes_comm, batch_output_notes_commitment(&txs)); - assert_eq!(expiration, batch_expiration(&txs)); - Ok(()) - } - - #[test] - fn batch_kernel_rejects_wrong_transactions_commitment() { - let txs = synth_batch(); - let block_hash = Word::from([99u32; 4]); - // Use a TRANSACTIONS_COMMITMENT the advice map does not provide. - let bogus_commitment = Word::from([12345u32; 4]); - let stack_inputs = BatchKernel::build_input_stack(block_hash, bogus_commitment); - let advice_inputs = build_advice_inputs(&txs); - - let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); - // The advice-map lookup for the bogus commitment will produce no value, which surfaces as - // an `AdviceMapKeyNotFound` (or similar) error from `adv.push_mapvaln`. We don't depend on - // the exact error variant; failing is enough. - let msg = err.to_string(); - assert!( - msg.contains("advice map") || msg.contains("not found") || msg.contains("missing"), - "unexpected error: {msg}", - ); - } - - #[test] - fn batch_kernel_rejects_tampered_layer_2() -> anyhow::Result<()> { - let txs = synth_batch(); - let block_hash = Word::from([99u32; 4]); - let stack_inputs = - BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); - let mut advice_inputs = build_advice_inputs(&txs); - - // Flip a bit in the Layer 2 entry for tx0 — the entry's hash will no longer equal tx0's - // tx_id and the kernel must abort with ERR_BATCH_TRANSACTION_HEADER_MISMATCH. - let tx0_id_word = txs[0].tx_id().as_word(); - let entry = advice_inputs.map.get(&tx0_id_word).expect("tx0 layer 2 entry"); - let mut tampered: Vec = entry.iter().copied().collect(); - tampered[0] += Felt::new(1); - advice_inputs.map.extend([(tx0_id_word, tampered)]); - - let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); - let msg = err.to_string(); - assert!( - msg.contains("transaction header data piped from the advice map"), - "unexpected error: {msg}", - ); - Ok(()) - } - - #[test] - fn batch_kernel_rejects_tampered_input_notes() -> anyhow::Result<()> { - let txs = synth_batch(); - let block_hash = Word::from([99u32; 4]); - let stack_inputs = - BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); - let mut advice_inputs = build_advice_inputs(&txs); - - // Flip a felt of the input-notes data for tx0. - let key = txs[0].input_notes_commitment(); - let entry = advice_inputs.map.get(&key).expect("layer 3 entry"); - let mut tampered: Vec = entry.iter().copied().collect(); - tampered[0] += Felt::new(1); - advice_inputs.map.extend([(key, tampered)]); - - let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); - let msg = err.to_string(); - assert!( - msg.contains("per-transaction input notes data piped"), - "unexpected error: {msg}", - ); - Ok(()) - } - - #[test] - fn batch_kernel_rejects_tampered_output_notes() -> anyhow::Result<()> { - let txs = synth_batch(); - let block_hash = Word::from([99u32; 4]); - let stack_inputs = - BatchKernel::build_input_stack(block_hash, transactions_commitment(&txs)); - let mut advice_inputs = build_advice_inputs(&txs); - - let key = txs[0].output_notes_commitment(); - let entry = advice_inputs.map.get(&key).expect("layer 3' entry"); - let mut tampered: Vec = entry.iter().copied().collect(); - tampered[0] += Felt::new(1); - advice_inputs.map.extend([(key, tampered)]); - - let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); - let msg = err.to_string(); - assert!( - msg.contains("per-transaction output notes data piped"), - "unexpected error: {msg}", - ); - Ok(()) - } -} diff --git a/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs new file mode 100644 index 0000000000..9c3e34f6a6 --- /dev/null +++ b/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs @@ -0,0 +1,263 @@ +use alloc::string::ToString; +use alloc::sync::Arc; +use alloc::vec::Vec; +use std::collections::BTreeMap; + +use anyhow::Context; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; +use miden_protocol::account::{Account, AccountStorageMode}; +use miden_protocol::batch::{BatchKernel, ProposedBatch}; +use miden_protocol::block::BlockNumber; +use miden_protocol::note::{Note, NoteType}; +use miden_protocol::transaction::ToInputNoteCommitments; +use miden_protocol::vm::{AdviceInputs, StackInputs, StackOutputs}; +use miden_protocol::{Felt, Hasher, Word}; +use miden_standards::testing::account_component::MockAccountComponent; +use rand::Rng; + +use super::proposed_batch::{mock_note, mock_output_note}; +use super::proven_tx_builder::MockProvenTxBuilder; +use crate::{AccountState, Auth, MockChain, MockChainBuilder}; + +// SETUP HELPERS +// ================================================================================================ + +struct TestSetup { + chain: MockChain, + account1: Account, + account2: Account, + auth_note: Note, +} + +fn setup() -> TestSetup { + let mut builder = MockChain::builder(); + let account1 = generate_account(&mut builder); + let account2 = generate_account(&mut builder); + let auth_note = builder + .add_p2id_note(account1.id(), account2.id(), &[], NoteType::Public) + .expect("adding p2id note should work"); + let mut chain = builder.build().expect("genesis should be valid"); + chain.prove_next_block().expect("first block should prove"); + TestSetup { chain, account1, account2, auth_note } +} + +fn generate_account(chain: &mut MockChainBuilder) -> Account { + let account_builder = Account::builder(rand::rng().random()) + .storage_mode(AccountStorageMode::Private) + .with_component(MockAccountComponent::with_empty_slots()); + chain + .add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) + .expect("failed to add pending account from builder") +} + +/// Builds a two-transaction batch: +/// - tx1 (account1): consumes one authenticated input note, produces one output note, expiration = +/// 1234. +/// - tx2 (account2): consumes one unauthenticated input note, produces two output notes, expiration +/// = 800. +fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { + let block1 = setup.chain.block_header(1); + let block2 = setup.chain.prove_next_block()?; + + let tx1 = MockProvenTxBuilder::with_account( + setup.account1.id(), + Word::empty(), + setup.account1.to_commitment(), + ) + .ref_block_commitment(block1.commitment()) + .authenticated_notes(vec![setup.auth_note.clone()]) + .output_notes(vec![mock_output_note(80)]) + .expiration_block_num(BlockNumber::from(1234u32)) + .build()?; + + let tx2_input = mock_note(81); + let tx2 = MockProvenTxBuilder::with_account( + setup.account2.id(), + Word::empty(), + setup.account2.to_commitment(), + ) + .ref_block_commitment(block1.commitment()) + .unauthenticated_notes(vec![tx2_input]) + .output_notes(vec![mock_output_note(82), mock_output_note(83)]) + .expiration_block_num(BlockNumber::from(800u32)) + .build()?; + + Ok(ProposedBatch::new( + [tx1, tx2].into_iter().map(Arc::new).collect(), + block2.header().clone(), + setup.chain.latest_partial_blockchain(), + BTreeMap::default(), + )?) +} + +// EXPECTED-VALUE HELPERS +// ================================================================================================ + +/// Sequential hash over `(NULLIFIER, EMPTY_OR_NOTE_COMMITMENT)` tuples for every input note in +/// every transaction in iteration order. Mirrors the kernel's per-tx absorption (no erasure). +fn expected_input_notes_commitment(batch: &ProposedBatch) -> Word { + let mut elements: Vec = Vec::new(); + for tx in batch.transactions() { + for commit in tx.input_notes().iter() { + elements.extend_from_slice(commit.nullifier().as_word().as_elements()); + let note_or_zero = commit.note_commitment().unwrap_or(Word::empty()); + elements.extend_from_slice(note_or_zero.as_elements()); + } + } + if elements.is_empty() { + Word::empty() + } else { + Hasher::hash_elements(&elements) + } +} + +/// Sequential hash over `(NOTE_ID, METADATA_COMMITMENT)` tuples for every output note in every +/// transaction in iteration order. +fn expected_output_notes_commitment(batch: &ProposedBatch) -> Word { + let mut elements: Vec = Vec::new(); + for tx in batch.transactions() { + for note in tx.output_notes().iter() { + elements.extend_from_slice(note.id().as_word().as_elements()); + elements.extend_from_slice(note.metadata().to_commitment().as_elements()); + } + } + if elements.is_empty() { + Word::empty() + } else { + Hasher::hash_elements(&elements) + } +} + +// EXECUTION HELPERS +// ================================================================================================ + +fn run_kernel( + stack_inputs: StackInputs, + advice_inputs: AdviceInputs, +) -> Result { + let mut host = DefaultHost::default(); + host.load_library(CoreLibrary::default().mast_forest()) + .expect("loading the core library into the test host should succeed"); + + let processor = + FastProcessor::new_with_options(stack_inputs, advice_inputs, ExecutionOptions::default()) + .with_debugging(true); + let output = processor.execute_sync(&BatchKernel::main(), &mut host)?; + Ok(output.stack) +} + +// HAPPY PATH +// ================================================================================================ + +#[test] +fn batch_kernel_happy_path() -> anyhow::Result<()> { + let mut setup = setup(); + let batch = two_tx_batch(&mut setup)?; + + let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&batch); + let stack_outputs = + run_kernel(stack_inputs, advice_inputs).context("kernel execution failed")?; + let (input_notes_commitment, output_notes_commitment, expiration) = + BatchKernel::parse_output_stack(&stack_outputs).context("parse output stack failed")?; + + assert_eq!(input_notes_commitment, expected_input_notes_commitment(&batch)); + assert_eq!(output_notes_commitment, expected_output_notes_commitment(&batch)); + assert_eq!(expiration, batch.batch_expiration_block_num()); + assert_eq!(expiration, BlockNumber::from(800u32)); + + Ok(()) +} + +// NEGATIVE TESTS +// ================================================================================================ + +/// Corrupting `TRANSACTIONS_COMMITMENT` on the input stack makes Layer 1 unloadable from the +/// advice map, so the kernel must abort. +#[test] +fn batch_kernel_rejects_wrong_transactions_commitment() -> anyhow::Result<()> { + let mut setup = setup(); + let batch = two_tx_batch(&mut setup)?; + + let block_hash = batch.reference_block_header().commitment(); + let bogus_commitment = Word::from([12345u32; 4]); + let stack_inputs = BatchKernel::build_input_stack(block_hash, bogus_commitment); + let (_, advice_inputs) = BatchKernel::prepare_inputs(&batch); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("advice map") || msg.contains("not found") || msg.contains("missing"), + "unexpected error: {msg}", + ); + Ok(()) +} + +/// Tampering a verified `tx_id`'s Layer 2 advice-map entry breaks the per-tx hash check. +#[test] +fn batch_kernel_rejects_tampered_layer_2() -> anyhow::Result<()> { + let mut setup = setup(); + let batch = two_tx_batch(&mut setup)?; + + let (stack_inputs, mut advice_inputs) = BatchKernel::prepare_inputs(&batch); + + let tx0_id = batch.transactions()[0].id().as_word(); + let entry = advice_inputs.map.get(&tx0_id).expect("tx0 layer 2 entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(tx0_id, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("transaction header data piped from the advice map"), + "unexpected error: {msg}", + ); + Ok(()) +} + +/// Tampering the per-tx input-notes Layer 3 entry breaks the input-note hash check. +#[test] +fn batch_kernel_rejects_tampered_input_notes() -> anyhow::Result<()> { + let mut setup = setup(); + let batch = two_tx_batch(&mut setup)?; + + let (stack_inputs, mut advice_inputs) = BatchKernel::prepare_inputs(&batch); + + let key = batch.transactions()[0].input_notes().commitment(); + let entry = advice_inputs.map.get(&key).expect("layer 3 entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(key, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("per-transaction input notes data piped"), + "unexpected error: {msg}", + ); + Ok(()) +} + +/// Tampering the per-tx output-notes Layer 3' entry breaks the output-note hash check. +#[test] +fn batch_kernel_rejects_tampered_output_notes() -> anyhow::Result<()> { + let mut setup = setup(); + let batch = two_tx_batch(&mut setup)?; + + let (stack_inputs, mut advice_inputs) = BatchKernel::prepare_inputs(&batch); + + let key = batch.transactions()[0].output_notes().commitment(); + let entry = advice_inputs.map.get(&key).expect("layer 3' entry"); + let mut tampered: Vec = entry.iter().copied().collect(); + tampered[0] += Felt::new(1); + advice_inputs.map.extend([(key, tampered)]); + + let err = run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort"); + let msg = err.to_string(); + assert!( + msg.contains("per-transaction output notes data piped"), + "unexpected error: {msg}", + ); + Ok(()) +} diff --git a/crates/miden-testing/src/kernel_tests/batch/mod.rs b/crates/miden-testing/src/kernel_tests/batch/mod.rs index b7dcf5b03d..987752b629 100644 --- a/crates/miden-testing/src/kernel_tests/batch/mod.rs +++ b/crates/miden-testing/src/kernel_tests/batch/mod.rs @@ -1,2 +1,3 @@ +mod batch_kernel; mod proposed_batch; mod proven_tx_builder; From 1204ba26145f803a1be078904504fe08e6165ccb Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 09:40:16 +0000 Subject: [PATCH 10/14] docs: strip remaining project-internal language from PR docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pass over the PR after the earlier main.masm cleanup. Drops: - "initial, minimal implementation … defers a number of checks" from the BatchKernel rustdoc; the TODO list in main.masm already covers what is and isn't enforced. - "Any refactor that drops the leading `swapw` must update this builder to match" — addresses future maintainers; replaced with a fact-stated cross-reference to main.masm. Drops the leftover `pad(8)` notation along with it. - "Exposed for use by the batch kernel which …" on `BatchId::hash_input_elements` and `TransactionId::input_elements`; the docstring should describe what the function returns, not justify the visibility. - "`prove` is `async` because the underlying `miden_prover::prove` is `async`" — the signature already says it's async; the reason adds no value to a downstream caller. - "sanity-checked" / "intentionally diverges" / "(indicates a kernel/advice-builder bug)" / "Exposed for tests that want a ProvenBatch without paying the cost of proof generation" — defensive / scope-cut / project-internal phrasing throughout `LocalBatchProver` docs. - "currently being absorbed" → "being absorbed" in memory.masm. - "Runs the equivalent of the leading sections of `ProposedBatch::new`" → "Performs three steps" in prologue.masm. - "once recursive proof verification ships" — project-management framing on a TODO; dropped. - "(mirrors `InputOutputNoteTracker::from_transactions`)" → "(see `InputOutputNoteTracker::from_transactions`)" — keeps the useful pointer, drops the "mirrors" framing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/lib/memory.masm | 2 +- .../asm/kernels/batch/lib/note_tracker.masm | 2 +- .../asm/kernels/batch/lib/prologue.masm | 4 ++-- crates/miden-protocol/src/batch/batch_id.rs | 5 ++--- crates/miden-protocol/src/batch/kernel.rs | 14 +++++--------- .../src/transaction/transaction_id.rs | 4 ++-- .../src/local_batch_prover.rs | 17 ++++++----------- 7 files changed, 19 insertions(+), 29 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/lib/memory.masm b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm index 0bc6faa404..6c68c1d3b7 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/memory.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm @@ -60,7 +60,7 @@ const BATCH_HASHER_CAP_PTR=12 # ================================================================================================= #! Number of words piped into TX_NOTES_SCRATCH_PTR for the transaction whose Layer 3 / 3' is -#! currently being absorbed. +#! being absorbed. const SCRATCH_WORDS_COUNT_PTR=16 #! Iteration cursor (index in words) into TX_NOTES_SCRATCH_PTR for the absorption loop. diff --git a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm index 679945b227..d1a371222d 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -101,7 +101,7 @@ end #! - ERR_BATCH_INPUT_NOTES_MISMATCH: a transaction's piped input-note data does not hash to its #! verified per-tx INPUT_NOTES_COMMITMENT_i. #! -#! TODO: erase intra-batch unauthenticated notes from the absorbed sequence (mirrors +#! TODO: erase intra-batch unauthenticated notes from the absorbed sequence (see #! `InputOutputNoteTracker::from_transactions`). #! TODO: re-sort + dedupe by nullifier so the result equals #! `proposed_batch.input_notes().commitment()`. diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm index f999787e2e..79b45d9d2d 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -22,7 +22,7 @@ const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from #! Loads and verifies the batch's structural commitments and accumulates the batch expiration min. #! -#! Runs the equivalent of the leading sections of `ProposedBatch::new`: +#! Performs three steps: #! - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by #! `TRANSACTIONS_COMMITMENT` into [`memory::TX_TUPLES_PTR`], asserting that the sequential hash of #! the piped data matches `TRANSACTIONS_COMMITMENT`. The number of transactions is derived from @@ -55,7 +55,7 @@ const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from #! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment. #! TODO: assert each `expiration_block_num_i > reference_block_num`. #! TODO: verify each `expiration_block_num_i` is part of tx_i's verified ExecutionProof public -#! outputs once recursive proof verification ships. +#! outputs. pub proc prepare_batch # Layer 1: pipe TRANSACTIONS_COMMITMENT's mapped value to TX_TUPLES_PTR + verify. # --------------------------------------------------------------------------------------------- diff --git a/crates/miden-protocol/src/batch/batch_id.rs b/crates/miden-protocol/src/batch/batch_id.rs index 7df7014446..4c4f78839f 100644 --- a/crates/miden-protocol/src/batch/batch_id.rs +++ b/crates/miden-protocol/src/batch/batch_id.rs @@ -45,9 +45,8 @@ impl BatchId { /// The layout is, for each `(transaction_id, account_id)` pair in iteration order: /// `[transaction_id[4], account_id_prefix, account_id_suffix, 0, 0]` /// - /// Exposed for use by the batch kernel which pipes this same felt sequence from the advice - /// provider to memory and asserts the resulting hash matches the public input - /// `TRANSACTIONS_COMMITMENT`. + /// The batch kernel pipes this same felt sequence from the advice provider to memory and + /// asserts the resulting hash matches the public input `TRANSACTIONS_COMMITMENT`. pub(crate) fn hash_input_elements( iter: impl IntoIterator, ) -> Vec { diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index f0013c2a7d..fe0127579b 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -37,12 +37,8 @@ const TRAILING_PAD_WORD_FELT_IDX: usize = 12; /// /// The kernel takes `[BLOCK_HASH, TRANSACTIONS_COMMITMENT]` as public inputs and emits /// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See -/// `asm/kernels/batch/main.masm` for the verification chain. -/// -/// This is the initial, minimal implementation: it verifies the unhashing chain rooted at -/// `TRANSACTIONS_COMMITMENT` but defers a number of checks (block-MMR-based note authentication, -/// account-update aggregation, intra-batch note erasure, recursive transaction proof -/// verification, batch-size limits) — see the `TODO` markers in the MASM source. +/// `asm/kernels/batch/main.masm` for the verification chain and the `TODO` markers listing +/// checks that the kernel does not yet enforce. pub struct BatchKernel; impl BatchKernel { @@ -90,9 +86,9 @@ impl BatchKernel { /// `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` over all /// transactions in the batch. /// - /// Note: `main.masm` immediately performs a `swapw`, so by the time `prologue::prepare_batch` - /// runs the kernel-side stack is `[TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)]`. Any - /// refactor that drops the leading `swapw` must update this builder to match. + /// The element order is kept in sync with the leading `swapw` in `main.masm`, which moves + /// `TRANSACTIONS_COMMITMENT` to the top of the kernel-side stack before the prologue + /// consumes it. pub fn build_input_stack(block_hash: Word, transactions_commitment: Word) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); inputs.extend_from_slice(block_hash.as_elements()); diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index 97a709feca..14b5f538ee 100644 --- a/crates/miden-protocol/src/transaction/transaction_id.rs +++ b/crates/miden-protocol/src/transaction/transaction_id.rs @@ -60,8 +60,8 @@ impl TransactionId { /// The layout is: /// `[INIT[4], FINAL[4], INPUT_NOTES_COMMITMENT[4], OUTPUT_NOTES_COMMITMENT[4], FEE_ASSET[8]]` /// - /// Exposed for use by the batch kernel which pipes this same felt sequence from the advice - /// provider to memory and asserts the resulting hash matches a previously-verified `tx_id`. + /// 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(crate) fn input_elements( init_account_commitment: Word, final_account_commitment: Word, diff --git a/crates/miden-tx-batch-prover/src/local_batch_prover.rs b/crates/miden-tx-batch-prover/src/local_batch_prover.rs index 56f77d5ef5..425b1197ea 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -40,14 +40,11 @@ impl LocalBatchProver { /// Verifies each transaction's `ExecutionProof` natively first, then runs the batch kernel via /// `miden_prover::prove` and attaches the resulting proof to the returned [`ProvenBatch`]. /// - /// `prove` is `async` because the underlying [`miden_prover::prove`] is `async`. - /// /// After proof generation, the kernel's parsed `batch_expiration_block_num` output is - /// sanity-checked against `proposed_batch.batch_expiration_block_num()`. The two batch note + /// checked against `proposed_batch.batch_expiration_block_num()`. The two batch note /// commitments produced by the kernel are *not* checked here because the kernel computes a - /// raw, un-erased sequential hash that intentionally diverges from - /// `proposed_batch.input_notes().commitment()` whenever the batch contains intra-batch - /// unauthenticated-note erasure. + /// raw sequential hash that does not match `proposed_batch.input_notes().commitment()` for + /// batches with intra-batch unauthenticated-note erasure. /// /// # Errors /// @@ -56,7 +53,7 @@ impl LocalBatchProver { /// - the batch kernel program fails to execute or produce a proof; /// - the kernel output stack fails to parse; /// - the kernel's `batch_expiration_block_num` does not match - /// `proposed_batch.batch_expiration_block_num()` (indicates a kernel/advice-builder bug). + /// `proposed_batch.batch_expiration_block_num()`. pub async fn prove( &self, proposed_batch: ProposedBatch, @@ -99,10 +96,8 @@ impl LocalBatchProver { Self::build_proven_batch(proposed_batch, proof) } - /// Proves the provided [`ProposedBatch`] into a [`ProvenBatch`] **without running the batch - /// kernel**, attaching a dummy [`ExecutionProof`] instead. - /// - /// Exposed for tests that want a `ProvenBatch` without paying the cost of proof generation. + /// Returns a [`ProvenBatch`] built from the proposed batch with a dummy [`ExecutionProof`] + /// attached, without running the batch kernel. #[cfg(any(feature = "testing", test))] pub fn prove_dummy( &self, From 10adc73799aed3b1385c235377695cc73008c657 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 09:46:13 +0000 Subject: [PATCH 11/14] refactor: drop BLOCK_HASH at the start of main Flip the input order so `TRANSACTIONS_COMMITMENT` is at the top and `BLOCK_HASH` is below. The first thing main does is `swapw dropw` to discard `BLOCK_HASH` (which the kernel does not yet verify), leaving `TRANSACTIONS_COMMITMENT` ready for the prologue to consume. Side effects of the rearrangement: - The input/output type signatures in main.masm now show the trailing `pad(N)` like the transaction kernel header. - The closing `repeat.3 movupw.3 dropw end` truncate carries a comment explaining why it's needed (depth-floor invariant means `consume`-only operations don't shrink the stack below 16, so the depth has grown above 16 by the end and needs to be brought back down). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/main.masm | 27 ++++++++++++------- crates/miden-protocol/src/batch/kernel.rs | 12 +++------ 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 69d757a5ae..70bcb7e5df 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -30,14 +30,23 @@ use miden::batch_kernel::prologue #! Each layer is loaded via `adv.push_mapvaln` keyed by the previously-verified hash and asserted #! against that hash via `assert_eqw`. Only verified data feeds into the batch outputs. #! -#! Inputs: [BLOCK_HASH, TRANSACTIONS_COMMITMENT] +#! Inputs: [ +#! TRANSACTIONS_COMMITMENT, +#! BLOCK_HASH, +#! pad(8), +#! ] #! -#! Outputs: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] +#! Outputs: [ +#! INPUT_NOTES_COMMITMENT, +#! OUTPUT_NOTES_COMMITMENT, +#! batch_expiration_block_num, +#! pad(7), +#! ] #! #! Where: -#! - BLOCK_HASH is the commitment of the batch's reference block. #! - TRANSACTIONS_COMMITMENT is the sequential hash of the `(tx_id, account_id)` tuples committing #! to the transactions in the batch (i.e. the `BatchId` value). +#! - BLOCK_HASH is the commitment of the batch's reference block. #! - INPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified #! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples in transaction order. #! - OUTPUT_NOTES_COMMITMENT is the sequential hash over every transaction's verified @@ -58,14 +67,11 @@ use miden::batch_kernel::prologue #! TODO: derive `batch_expiration_block_num` from data committed-to in the verified chain rather #! than from the unverified advice stack. proc main - swapw - # Stack: [TRANSACTIONS_COMMITMENT, BLOCK_HASH] + # TODO: verify BLOCK_HASH against block header data via the pipe-and-verify pattern. + swapw dropw + # Stack: [TRANSACTIONS_COMMITMENT] exec.prologue::prepare_batch - # Stack: [BLOCK_HASH] - - # TODO: verify BLOCK_HASH against block header data via the pipe-and-verify pattern. - dropw # Stack: [] exec.note_tracker::compute_input_notes_commitment @@ -82,6 +88,9 @@ proc main swapw # Stack: [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] + # Drop the leftover words below the output region. The kernel's earlier `consume`-only + # operations don't reduce stack depth below the 16-felt floor, so by this point the depth + # has grown above 16 and we need to bring it back down. repeat.3 movupw.3 dropw end end diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index fe0127579b..0f73a49121 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -74,25 +74,21 @@ impl BatchKernel { /// Returns the stack with the public inputs required by the batch kernel. /// - /// The initial stack is defined as: + /// The initial stack is: /// /// ```text - /// [BLOCK_HASH, TRANSACTIONS_COMMITMENT] + /// [TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)] /// ``` /// /// Where: - /// - `BLOCK_HASH` is the commitment of the batch's reference block. /// - `TRANSACTIONS_COMMITMENT` is the value [`BatchId`] computes — a sequential hash of /// `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` over all /// transactions in the batch. - /// - /// The element order is kept in sync with the leading `swapw` in `main.masm`, which moves - /// `TRANSACTIONS_COMMITMENT` to the top of the kernel-side stack before the prologue - /// consumes it. + /// - `BLOCK_HASH` is the commitment of the batch's reference block. pub fn build_input_stack(block_hash: Word, transactions_commitment: Word) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); - inputs.extend_from_slice(block_hash.as_elements()); inputs.extend_from_slice(transactions_commitment.as_elements()); + inputs.extend_from_slice(block_hash.as_elements()); StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") } From 303c20e40f14e8a07d6dc03c58721690c95319ac Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 09:49:15 +0000 Subject: [PATCH 12/14] docs: name advice-map values explicitly in prologue / note_tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rephrase the prologue's running-min description from "accumulates the batch expiration min" to "tracks the smallest expiration_block_num across all transactions". - Replace "Layer 1 / Layer 2 / Layer 3 / Layer 3' tuple data" in the prologue and note_tracker error and input descriptions with the actual shape of the data — e.g. "the (tx_id, account_id) tuple list", "(INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, FEE_ASSET) data", "(NULLIFIER, EMPTY_OR_COMMITMENT) tuple list" — so readers who haven't internalised the layer numbering can still tell what the kernel is checking against. - Drop the inline `u32lt` semantics-explainer comment in prologue.masm. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/kernels/batch/lib/note_tracker.masm | 10 +++++---- .../asm/kernels/batch/lib/prologue.masm | 22 ++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm index d1a371222d..4e6ca8b86b 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -98,8 +98,9 @@ end #! Outputs: [INPUT_NOTES_COMMITMENT] #! #! Errors: -#! - ERR_BATCH_INPUT_NOTES_MISMATCH: a transaction's piped input-note data does not hash to its -#! verified per-tx INPUT_NOTES_COMMITMENT_i. +#! - ERR_BATCH_INPUT_NOTES_MISMATCH: a transaction's `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuple +#! list piped from the advice map does not hash to its verified per-tx +#! `INPUT_NOTES_COMMITMENT_i`. #! #! TODO: erase intra-batch unauthenticated notes from the absorbed sequence (see #! `InputOutputNoteTracker::from_transactions`). @@ -176,8 +177,9 @@ end #! Outputs: [OUTPUT_NOTES_COMMITMENT] #! #! Errors: -#! - ERR_BATCH_OUTPUT_NOTES_MISMATCH: a transaction's piped output-note data does not hash to its -#! verified per-tx OUTPUT_NOTES_COMMITMENT_i. +#! - ERR_BATCH_OUTPUT_NOTES_MISMATCH: a transaction's `(NOTE_ID, METADATA_COMMITMENT)` tuple +#! list piped from the advice map does not hash to its verified per-tx +#! `OUTPUT_NOTES_COMMITMENT_i`. #! #! TODO: erase intra-batch unauthenticated notes from the absorbed sequence. #! TODO: switch the output to `BatchNoteTree::root` (an SMT root rather than this sequential diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm index 79b45d9d2d..af1f330146 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -20,7 +20,8 @@ const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from # PROLOGUE # ================================================================================================= -#! Loads and verifies the batch's structural commitments and accumulates the batch expiration min. +#! Loads and verifies the batch's structural commitments and tracks the smallest +#! `expiration_block_num` across all transactions in [`memory::BATCH_EXPIRATION_PTR`]. #! #! Performs three steps: #! - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by @@ -32,23 +33,26 @@ const ERR_BATCH_TRANSACTION_HEADER_MISMATCH="transaction header data piped from #! `tx_id`. Each transaction's data is written into [`memory::TX_HEADERS_PTR`] at the appropriate #! per-tx offset. #! - For each transaction, pops the `expiration_block_num` from the advice stack and updates the -#! batch-level min stored in [`memory::BATCH_EXPIRATION_PTR`]. +#! running minimum in [`memory::BATCH_EXPIRATION_PTR`]. #! #! Inputs: #! Operand stack: [TRANSACTIONS_COMMITMENT] #! Advice map: -#! TRANSACTIONS_COMMITMENT |-> Layer 1 tuple data -#! For each verified tx_id_i: tx_id_i |-> Layer 2 transaction header data +#! TRANSACTIONS_COMMITMENT |-> [(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, +#! FEE_ASSET_i] #! Advice stack: [expiration_block_num_0, expiration_block_num_1, ...] #! #! Outputs: #! Operand stack: [] #! #! Errors: -#! - ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH: Layer 1 piped data does not hash to -#! TRANSACTIONS_COMMITMENT. -#! - ERR_BATCH_TRANSACTION_HEADER_MISMATCH: Layer 2 piped data for a transaction does not hash to -#! that transaction's tx_id. +#! - ERR_BATCH_TRANSACTIONS_COMMITMENT_MISMATCH: the `(tx_id, account_id)` tuple list piped from +#! the advice map does not hash to `TRANSACTIONS_COMMITMENT`. +#! - ERR_BATCH_TRANSACTION_HEADER_MISMATCH: a transaction's +#! `(INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, FEE_ASSET)` 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_HASH. @@ -127,8 +131,6 @@ pub proc prepare_batch dup exec.memory::get_batch_expiration_block_num # Stack: [current_min, expiration, expiration, tx_index, num_transactions] - # `u32lt` pops `[a (top), b]` and pushes `1` if `b < a`, so this is true iff - # `expiration < current_min`. u32lt if.true From 6a395b805b68502288bca76fcab07dbf7969d5cd Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 15:19:23 +0000 Subject: [PATCH 13/14] fix(ci): wire testing feature so prove_dummy compiles under --all-features, add changelog entry --- CHANGELOG.md | 1 + crates/miden-tx-batch-prover/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 742d51b666..e45edb0ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Added `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). - [BREAKING] Added `NoteScriptRoot` newtype wrapping note script roots ([#2851](https://github.com/0xMiden/protocol/pull/2851)). - Re-exported `MIN_STACK_DEPTH` from `miden-processor` ([#2856](https://github.com/0xMiden/protocol/pull/2856)). +- Added a batch kernel that verifies the unhashing chain rooted at `TRANSACTIONS_COMMITMENT` and emits the batch's note commitments and expiration block number; wired `LocalBatchProver::prove` to run it via `miden_prover::prove` and attach the resulting proof to `ProvenBatch` ([#2884](https://github.com/0xMiden/protocol/pull/2884)). ### Fixes diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml index a4bfdc2095..40508bc424 100644 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -19,7 +19,7 @@ doctest = false [features] default = ["std"] std = ["miden-processor/std", "miden-protocol/std", "miden-prover/std", "miden-tx/std"] -testing = [] +testing = ["miden-processor/testing", "miden-protocol/testing"] [dependencies] miden-processor = { workspace = true } From 31d4a35b672e0f6ec1132c339253e8fb7c3da368 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 7 May 2026 15:54:07 +0000 Subject: [PATCH 14/14] fix(ci): activate miden-protocol/testing as a dev-dependency so prove_dummy compiles in test builds with --no-default-features --- crates/miden-tx-batch-prover/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml index 40508bc424..158c16ef40 100644 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -26,3 +26,6 @@ miden-processor = { workspace = true } miden-protocol = { workspace = true } miden-prover = { workspace = true } miden-tx = { workspace = true } + +[dev-dependencies] +miden-protocol = { features = ["testing"], workspace = true }