From 1cbd5f9e6ca444d2bf022068fd701edcec3be09b Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Tue, 12 May 2026 08:18:10 +0000 Subject: [PATCH 01/29] feat: add skeleton batch kernel + ProvenBatch proof field Establishes the public input/output contract for the batch kernel (#1122) plus the Rust plumbing that surrounds it, without any verification logic. - main.masm drops TRANSACTIONS_COMMITMENT + BLOCK_HASH and exits; the VM's depth >= 16 invariant leaves the all-zero 16-felt output region in place. - BatchKernel struct exposes prepare_inputs / build_input_stack / build_output_stack / parse_output_stack; build_advice_inputs returns the default empty AdviceInputs since the skeleton ignores advice data. - ProvenBatch carries a proof: ExecutionProof field through new_unchecked and serde. - LocalBatchProver::prove now runs the kernel via miden_prover::prove and attaches the proof to the returned ProvenBatch. The kernel's public outputs are not yet cross-checked against the proposed batch; that lands with the verification logic. - prove_dummy retained for tests that don't want proof generation. Smoke test exercises the full plumbing: builds a realistic two-transaction ProposedBatch, runs the kernel via FastProcessor, asserts the parsed outputs are empty / zero. TODO list in main.masm enumerates the checks the verification PR will introduce. --- CHANGELOG.md | 1 + Cargo.lock | 2 + .../asm/kernels/batch/main.masm | 53 ++++++ crates/miden-protocol/build.rs | 27 ++- crates/miden-protocol/src/batch/kernel.rs | 177 ++++++++++++++++++ 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/kernel_tests/batch/batch_kernel.rs | 129 +++++++++++++ .../src/kernel_tests/batch/mod.rs | 1 + crates/miden-tx-batch-prover/Cargo.toml | 13 +- .../src/local_batch_prover.rs | 80 ++++++-- 13 files changed, 494 insertions(+), 27 deletions(-) create mode 100644 crates/miden-protocol/asm/kernels/batch/main.masm create mode 100644 crates/miden-protocol/src/batch/kernel.rs create mode 100644 crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c360ebb25a..aef584236b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - [BREAKING] Renamed account ID version 0 to version 1 and made encoded version 0 invalid ([#2842](https://github.com/0xMiden/protocol/issues/2842)). - [BREAKING] Changed note metadata version 1 to encode as `1`, leaving encoded version `0` invalid. - Documented the `miden::protocol::account_id` module in the protocol library docs ([#2607](https://github.com/0xMiden/protocol/issues/2607)). +- Added a skeleton batch kernel program with the public input/output contract from issue [#1122](https://github.com/0xMiden/protocol/issues/1122), wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. The kernel does not yet perform any verification; the verification chain that fills in the real outputs will land in a follow-up PR. ### Fixes 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/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm new file mode 100644 index 0000000000..89e70417be --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -0,0 +1,53 @@ +# MAIN +# ================================================================================================= + +#! Batch kernel program (skeleton). +#! +#! A transaction batch groups a set of independently-proven transactions so they can later be +#! aggregated into a block by the block kernel. This program defines the public input/output +#! contract that the batch kernel will eventually verify, but does not yet perform any +#! verification: it drops its inputs and exits, leaving the all-zero word output region as the +#! stack's initial padding zeros. +#! +#! Splitting the kernel into "shape only" and "verification" lets downstream Rust tooling +#! (`BatchKernel`'s input/advice/output builders, `LocalBatchProver::prove`, the `ProvenBatch` +#! proof field) be built and reviewed against a stable interface before the verification logic +#! lands. The verification chain that fills in the real outputs is added in a follow-up PR. +#! +#! Inputs: [ +#! TRANSACTIONS_COMMITMENT, +#! BLOCK_HASH, +#! pad(8), +#! ] +#! +#! Outputs: [ +#! INPUT_NOTES_COMMITMENT, +#! OUTPUT_NOTES_COMMITMENT, +#! batch_expiration_block_num, +#! pad(7), +#! ] +#! +#! Where: +#! - 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 will be the sequential hash over every transaction's input note +#! commitments. In this skeleton it is the empty word. +#! - OUTPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's output note +#! commitments. In this skeleton it is the empty word. +#! - batch_expiration_block_num will be the minimum of every transaction's +#! `expiration_block_num`. In this skeleton it is zero. +#! +#! TODO: replace this skeleton with the verification chain that reconstructs every transaction +#! from the advice provider, anchors the reconstruction in `TRANSACTIONS_COMMITMENT`, and +#! emits the real batch commitments and expiration. +proc main + # Drop TRANSACTIONS_COMMITMENT and BLOCK_HASH. The VM keeps the stack at depth >= 16, so the + # remaining 8 felts (the explicit `pad(8)` inputs) plus 8 zeros auto-filled below them form + # the all-zero 16-felt output region. + dropw dropw +end + +begin + exec.main +end diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index f116980634..6d45f6cdec 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -20,6 +20,7 @@ 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 PROTOCOL_LIB_NAMESPACE: &str = "miden::protocol"; @@ -30,7 +31,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 +46,7 @@ const TX_KERNEL_ERROR_CATEGORIES: [&str; 14] = [ "LINK_MAP", "INPUT_NOTE", "OUTPUT_NOTE", + "BATCH", ]; // PRE-PROCESSING @@ -85,6 +87,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)?; @@ -92,6 +97,26 @@ fn main() -> Result<()> { Ok(()) } +// 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`. +fn compile_batch_kernel(source_dir: &Path, target_dir: &Path) -> Result<()> { + let batch_kernel_dir = source_dir.join(ASM_BATCH_KERNEL_DIR); + let main_file_path = batch_kernel_dir.join("main.masm"); + + let assembler = build_assembler(None)?; + 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() +} + // COMPILE TRANSACTION KERNEL // ================================================================================================ diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs new file mode 100644 index 0000000000..29c5ad4fa8 --- /dev/null +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -0,0 +1,177 @@ +use alloc::vec::Vec; + +use miden_core::program::Kernel; + +use crate::batch::ProposedBatch; +use crate::block::BlockNumber; +use crate::errors::BatchOutputError; +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 layout 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 `[TRANSACTIONS_COMMITMENT, BLOCK_HASH]` as public inputs and emits +/// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See +/// `asm/kernels/batch/main.masm` for the input/output contract and the `TODO` listing checks +/// the kernel does not yet enforce. +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: + /// + /// ```text + /// [TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)] + /// ``` + /// + /// Where: + /// - `TRANSACTIONS_COMMITMENT` is the value [`BatchId`](crate::batch::BatchId) computes — a + /// sequential hash of `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` + /// over all transactions in the batch. + /// - `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(transactions_commitment.as_elements()); + inputs.extend_from_slice(block_hash.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. + /// + /// The skeleton kernel ignores its advice inputs, so this returns the default empty value. + /// The follow-up PR that adds the verification chain will populate the advice map with the + /// `(tx_id, account_id)` tuple list keyed by `TRANSACTIONS_COMMITMENT` and the per-tx headers + /// and note tuples. + fn build_advice_inputs(_proposed_batch: &ProposedBatch) -> AdviceInputs { + AdviceInputs::default() + } +} 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 72c9eebfd0..8bf4ab6d51 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..48c4ec8286 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 over the batch kernel's public commitments. #[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 execution proof 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-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..6964990410 --- /dev/null +++ b/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs @@ -0,0 +1,129 @@ +use alloc::sync::Arc; +use std::collections::BTreeMap; + +use anyhow::Context; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; +use miden_protocol::Word; +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::vm::{AdviceInputs, Program, StackInputs, StackOutputs}; +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 with realistic inputs and outputs. The skeleton kernel does not +/// inspect any of this data, but the batch is built end-to-end so the smoke test exercises the +/// real `prepare_inputs` path that the verification PR will eventually consume. +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(), + )?) +} + +fn run_kernel( + program: &Program, + 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(program, &mut host)?; + Ok(output.stack) +} + +// SMOKE TEST +// ================================================================================================ + +/// The skeleton batch kernel drops its public inputs and exits, leaving the all-zero word output +/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, derive +/// stack and advice inputs via `BatchKernel::prepare_inputs`, run the kernel, parse the outputs) +/// and asserts that the contract holds: the kernel runs to completion and emits the empty word +/// shape. +#[test] +fn batch_kernel_skeleton_emits_empty_outputs() -> 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(&BatchKernel::main(), 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, Word::empty()); + assert_eq!(output_notes_commitment, Word::empty()); + assert_eq!(expiration, BlockNumber::from(0u32)); + + 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; diff --git a/crates/miden-tx-batch-prover/Cargo.toml b/crates/miden-tx-batch-prover/Cargo.toml index df138ae0e2..158c16ef40 100644 --- a/crates/miden-tx-batch-prover/Cargo.toml +++ b/crates/miden-tx-batch-prover/Cargo.toml @@ -18,9 +18,14 @@ doctest = false [features] default = ["std"] -std = ["miden-protocol/std", "miden-tx/std"] -testing = [] +std = ["miden-processor/std", "miden-protocol/std", "miden-prover/std", "miden-tx/std"] +testing = ["miden-processor/testing", "miden-protocol/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 } + +[dev-dependencies] +miden-protocol = { features = ["testing"], 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..fdd70bf6cb 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,58 @@ use alloc::boxed::Box; +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 +/// via `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`]. The kernel's public outputs are not yet cross-checked against the + /// proposed batch's expected values; that check is added together with the kernel's + /// verification logic in a follow-up PR. /// /// # 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. + 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 +64,44 @@ impl LocalBatchProver { })?; } - self.prove_inner(proposed_batch) + 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()))?; + + // Validate the output stack shape (padding cells are zero and the expiration fits in + // u32); the actual output values themselves are not checked until the kernel verifies + // them. + BatchKernel::parse_output_stack(&stack_outputs) + .map_err(|err| ProvenBatchError::BatchKernelExecutionFailed(err.to_string()))?; + + Self::build_proven_batch(proposed_batch, proof) } - /// Proves the provided [`ProposedBatch`] into a [`ProvenBatch`], **without verifying batches - /// and proving the block**. - /// - /// This is exposed for testing purposes. + /// 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, 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 the 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 +124,7 @@ impl LocalBatchProver { output_notes, batch_expiration_block_num, tx_headers, + proof, ) } } From 0411259f46bfa9a63a99c3adb0e4a8eb38668f9a Mon Sep 17 00:00:00 2001 From: Marti Date: Wed, 27 May 2026 16:38:58 +0200 Subject: [PATCH 02/29] Apply suggestions from code review Co-authored-by: Marti --- crates/miden-protocol/asm/kernels/batch/main.masm | 15 ++------------- crates/miden-protocol/build.rs | 4 ---- crates/miden-protocol/src/batch/kernel.rs | 6 +----- crates/miden-protocol/src/batch/proven_batch.rs | 3 ++- .../src/local_batch_prover.rs | 3 +-- 5 files changed, 6 insertions(+), 25 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 89e70417be..2771540ac9 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -5,15 +5,10 @@ #! #! A transaction batch groups a set of independently-proven transactions so they can later be #! aggregated into a block by the block kernel. This program defines the public input/output -#! contract that the batch kernel will eventually verify, but does not yet perform any -#! verification: it drops its inputs and exits, leaving the all-zero word output region as the +#! contract that the batch kernel will eventually verify, but currently does not yet perform +#! any verification: it drops its inputs and exits, leaving the all-zero word output region as the #! stack's initial padding zeros. #! -#! Splitting the kernel into "shape only" and "verification" lets downstream Rust tooling -#! (`BatchKernel`'s input/advice/output builders, `LocalBatchProver::prove`, the `ProvenBatch` -#! proof field) be built and reviewed against a stable interface before the verification logic -#! lands. The verification chain that fills in the real outputs is added in a follow-up PR. -#! #! Inputs: [ #! TRANSACTIONS_COMMITMENT, #! BLOCK_HASH, @@ -38,13 +33,7 @@ #! - batch_expiration_block_num will be the minimum of every transaction's #! `expiration_block_num`. In this skeleton it is zero. #! -#! TODO: replace this skeleton with the verification chain that reconstructs every transaction -#! from the advice provider, anchors the reconstruction in `TRANSACTIONS_COMMITMENT`, and -#! emits the real batch commitments and expiration. proc main - # Drop TRANSACTIONS_COMMITMENT and BLOCK_HASH. The VM keeps the stack at depth >= 16, so the - # remaining 8 felts (the explicit `pad(8)` inputs) plus 8 zeros auto-filled below them form - # the all-zero 16-felt output region. dropw dropw end diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index 6d45f6cdec..39bba72407 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -102,10 +102,6 @@ fn main() -> Result<()> { /// 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`. fn compile_batch_kernel(source_dir: &Path, target_dir: &Path) -> Result<()> { let batch_kernel_dir = source_dir.join(ASM_BATCH_KERNEL_DIR); let main_file_path = batch_kernel_dir.join("main.masm"); diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 29c5ad4fa8..74e6c50c0b 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -18,8 +18,7 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { Program::read_from_bytes(bytes).expect("failed to deserialize batch kernel runtime") }); -// Output stack indices, kept in sync with the layout 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`. +// Output stack indices (layout at the end of `batch/main.masm::main`). 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; @@ -168,9 +167,6 @@ impl BatchKernel { /// Builds the advice inputs (map + stack) consumed by the batch kernel. /// /// The skeleton kernel ignores its advice inputs, so this returns the default empty value. - /// The follow-up PR that adds the verification chain will populate the advice map with the - /// `(tx_id, account_id)` tuple list keyed by `TRANSACTIONS_COMMITMENT` and the per-tx headers - /// and note tuples. fn build_advice_inputs(_proposed_batch: &ProposedBatch) -> AdviceInputs { AdviceInputs::default() } diff --git a/crates/miden-protocol/src/batch/proven_batch.rs b/crates/miden-protocol/src/batch/proven_batch.rs index 48c4ec8286..753ceaf67a 100644 --- a/crates/miden-protocol/src/batch/proven_batch.rs +++ b/crates/miden-protocol/src/batch/proven_batch.rs @@ -18,7 +18,8 @@ use crate::utils::serde::{ use crate::vm::ExecutionProof; use crate::{MIN_PROOF_SECURITY_LEVEL, Word}; -/// A transaction batch with an execution proof over the batch kernel's public commitments. +/// A transaction batch with an execution proof. +/// Currently, this only carries a skeleton proof which does not attest to anything meaningful. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProvenBatch { id: BatchId, 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 fdd70bf6cb..5a69181288 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -40,8 +40,7 @@ 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`]. The kernel's public outputs are not yet cross-checked against the - /// proposed batch's expected values; that check is added together with the kernel's - /// verification logic in a follow-up PR. + /// proposed batch's expected values. /// /// # Errors /// From ee2df534f4e5af34ba11fd6e98fec4c591853ea6 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:50:48 +0000 Subject: [PATCH 03/29] chore(protocol): drop premature BATCH error category The batch kernel is compiled separately and its MASM errors are not extracted by generate_error_constants (which only scans the transaction kernel dir), so the BATCH entry in TX_KERNEL_ERROR_CATEGORIES is inert. Batch error handling will be added properly alongside the verification logic. --- crates/miden-protocol/build.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index 39bba72407..a252bb7527 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -31,7 +31,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; 15] = [ +const TX_KERNEL_ERROR_CATEGORIES: [&str; 14] = [ "KERNEL", "PROLOGUE", "EPILOGUE", @@ -46,7 +46,6 @@ const TX_KERNEL_ERROR_CATEGORIES: [&str; 15] = [ "LINK_MAP", "INPUT_NOTE", "OUTPUT_NOTE", - "BATCH", ]; // PRE-PROCESSING From c5087401f0dc5d0915f678ab89c4f940888b3033 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:51:07 +0000 Subject: [PATCH 04/29] refactor(protocol): order build_input_stack params to match stack layout Pass transactions_commitment before block_hash so the parameter order matches the documented input stack [TRANSACTIONS_COMMITMENT, BLOCK_HASH]. Also drop the now-stale reference to the main.masm TODO from the BatchKernel doc comment. --- crates/miden-protocol/src/batch/kernel.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 74e6c50c0b..39cc00f11c 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -35,8 +35,7 @@ const TRAILING_PAD_WORD_FELT_IDX: usize = 12; /// /// The kernel takes `[TRANSACTIONS_COMMITMENT, BLOCK_HASH]` as public inputs and emits /// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See -/// `asm/kernels/batch/main.masm` for the input/output contract and the `TODO` listing checks -/// the kernel does not yet enforce. +/// `asm/kernels/batch/main.masm` for the input/output contract. pub struct BatchKernel; impl BatchKernel { @@ -64,7 +63,7 @@ impl BatchKernel { 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 stack_inputs = Self::build_input_stack(transactions_commitment, block_hash); let advice_inputs = Self::build_advice_inputs(proposed_batch); (stack_inputs, advice_inputs) @@ -83,7 +82,7 @@ impl BatchKernel { /// sequential hash of `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` /// over all transactions in the batch. /// - `BLOCK_HASH` is the commitment of the batch's reference block. - pub fn build_input_stack(block_hash: Word, transactions_commitment: Word) -> StackInputs { + pub fn build_input_stack(transactions_commitment: Word, block_hash: Word) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); inputs.extend_from_slice(transactions_commitment.as_elements()); inputs.extend_from_slice(block_hash.as_elements()); From 9ed96f7d757dfb054d469becbe7319180404fca9 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:51:19 +0000 Subject: [PATCH 05/29] refactor(batch-prover): remove unused proof_security_level accessor The getter had no callers. --- crates/miden-tx-batch-prover/src/local_batch_prover.rs | 5 ----- 1 file changed, 5 deletions(-) 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 5a69181288..32b49eea68 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -30,11 +30,6 @@ impl LocalBatchProver { } } - /// 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`]. /// /// Verifies each transaction's `ExecutionProof` natively first, then runs the batch kernel From 52adb85ed5da35ea34ffaaa155cdf2b7ed757054 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:52:49 +0000 Subject: [PATCH 06/29] refactor(protocol): simplify batch output padding check Replace the two separate pad-word checks with a single scan over every cell after batch_expiration_block_num, dropping the EXPIRATION_PAD_WORD_* and TRAILING_PAD_WORD_FELT_IDX constants. --- crates/miden-protocol/src/batch/kernel.rs | 27 +++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 39cc00f11c..b2121cdc51 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -8,7 +8,7 @@ use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; use crate::utils::sync::LazyLock; use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs, StackOutputs}; -use crate::{Felt, Word}; +use crate::{Felt, Word, ZERO}; // CONSTANTS // ================================================================================================ @@ -22,11 +22,6 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { 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 // ================================================================================================ @@ -131,28 +126,16 @@ impl BatchKernel { .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..] + // Every cell after batch_expiration_block_num must be zero padding. + if stack[BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] + .iter() + .any(|&felt| felt != ZERO) { 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(); From ee3f559c3fe617b727e56b493436de1bd94bfc80 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:55:21 +0000 Subject: [PATCH 07/29] test(batch): reuse shared chain setup helpers Expose TestSetup and setup_chain from the proposed_batch test module and reuse them in the batch kernel smoke test instead of re-implementing the identical TestSetup/setup/generate_account fixtures. --- .../src/kernel_tests/batch/batch_kernel.rs | 39 ++----------------- .../src/kernel_tests/batch/proposed_batch.rs | 12 +++--- 2 files changed, 9 insertions(+), 42 deletions(-) diff --git a/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs index 6964990410..602f163471 100644 --- a/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs @@ -5,49 +5,16 @@ use anyhow::Context; use miden_core_lib::CoreLibrary; use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; use miden_protocol::Word; -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::vm::{AdviceInputs, Program, StackInputs, StackOutputs}; -use miden_standards::testing::account_component::MockAccountComponent; -use rand::Rng; -use super::proposed_batch::{mock_note, mock_output_note}; +use super::proposed_batch::{TestSetup, mock_note, mock_output_note, setup_chain}; 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 with realistic inputs and outputs. The skeleton kernel does not /// inspect any of this data, but the batch is built end-to-end so the smoke test exercises the /// real `prepare_inputs` path that the verification PR will eventually consume. @@ -61,7 +28,7 @@ fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { setup.account1.to_commitment(), ) .ref_block_commitment(block1.commitment()) - .authenticated_notes(vec![setup.auth_note.clone()]) + .authenticated_notes(vec![setup.note1.clone()]) .output_notes(vec![mock_output_note(80)]) .expiration_block_num(BlockNumber::from(1234u32)) .build()?; @@ -112,7 +79,7 @@ fn run_kernel( /// shape. #[test] fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { - let mut setup = setup(); + let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&batch); diff --git a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs index 254af358f1..c185887e64 100644 --- a/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs +++ b/crates/miden-testing/src/kernel_tests/batch/proposed_batch.rs @@ -40,14 +40,14 @@ pub fn mock_output_note(num: u8) -> OutputNote { RawOutputNote::Full(mock_note(num)).into_output_note().unwrap() } -struct TestSetup { - chain: MockChain, - account1: Account, - account2: Account, - note1: Note, +pub struct TestSetup { + pub chain: MockChain, + pub account1: Account, + pub account2: Account, + pub note1: Note, } -fn setup_chain() -> TestSetup { +pub fn setup_chain() -> TestSetup { let mut builder = MockChain::builder(); let account1 = generate_account(&mut builder); let account2 = generate_account(&mut builder); From e3d15c579e9879716e0ec449af0350344c4ff46e Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 27 May 2026 14:55:34 +0000 Subject: [PATCH 08/29] docs: trim verbose batch kernel CHANGELOG entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef584236b..bdc3fab8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ - [BREAKING] Renamed account ID version 0 to version 1 and made encoded version 0 invalid ([#2842](https://github.com/0xMiden/protocol/issues/2842)). - [BREAKING] Changed note metadata version 1 to encode as `1`, leaving encoded version `0` invalid. - Documented the `miden::protocol::account_id` module in the protocol library docs ([#2607](https://github.com/0xMiden/protocol/issues/2607)). -- Added a skeleton batch kernel program with the public input/output contract from issue [#1122](https://github.com/0xMiden/protocol/issues/1122), wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. The kernel does not yet perform any verification; the verification chain that fills in the real outputs will land in a follow-up PR. +- Added a skeleton batch kernel ([#1122](https://github.com/0xMiden/protocol/issues/1122)) wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. It does not yet perform any verification. ### Fixes From 11f0760aeae18ae656140b3f2422ea3ec2fff655 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 12:31:41 +0000 Subject: [PATCH 09/29] refactor(protocol): name batch kernel inputs BATCH_ID and BLOCK_COMMITMENT Rename the batch kernel's public inputs from TRANSACTIONS_COMMITMENT to BATCH_ID (consistent with the `BatchId` type the value comes from) and from BLOCK_HASH to BLOCK_COMMITMENT (we no longer use "hash"). Trim the over-detailed BATCH_ID doc comment down to just referencing `BatchId`. Addresses review comments on PR #2904. --- .../asm/kernels/batch/main.masm | 9 ++++---- crates/miden-protocol/src/batch/kernel.rs | 22 +++++++++---------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 2771540ac9..866e5f93a8 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -10,8 +10,8 @@ #! stack's initial padding zeros. #! #! Inputs: [ -#! TRANSACTIONS_COMMITMENT, -#! BLOCK_HASH, +#! BATCH_ID, +#! BLOCK_COMMITMENT, #! pad(8), #! ] #! @@ -23,9 +23,8 @@ #! ] #! #! Where: -#! - 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. +#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. +#! - BLOCK_COMMITMENT is the commitment of the batch's reference block. #! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note #! commitments. In this skeleton it is the empty word. #! - OUTPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's output note diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index b2121cdc51..b66637928d 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -28,7 +28,7 @@ const BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX: usize = 8; /// The batch kernel program: an executable Miden program that proves a batch of transactions. /// -/// The kernel takes `[TRANSACTIONS_COMMITMENT, BLOCK_HASH]` as public inputs and emits +/// The kernel takes `[BATCH_ID, BLOCK_COMMITMENT]` as public inputs and emits /// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See /// `asm/kernels/batch/main.masm` for the input/output contract. pub struct BatchKernel; @@ -55,10 +55,10 @@ impl BatchKernel { /// 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 block_commitment = proposed_batch.reference_block_header().commitment(); + let batch_id = proposed_batch.id().as_word(); - let stack_inputs = Self::build_input_stack(transactions_commitment, block_hash); + let stack_inputs = Self::build_input_stack(batch_id, block_commitment); let advice_inputs = Self::build_advice_inputs(proposed_batch); (stack_inputs, advice_inputs) @@ -69,18 +69,16 @@ impl BatchKernel { /// The initial stack is: /// /// ```text - /// [TRANSACTIONS_COMMITMENT, BLOCK_HASH, pad(8)] + /// [BATCH_ID, BLOCK_COMMITMENT, pad(8)] /// ``` /// /// Where: - /// - `TRANSACTIONS_COMMITMENT` is the value [`BatchId`](crate::batch::BatchId) computes — a - /// sequential hash of `(transaction_id || account_id_prefix || account_id_suffix || 0 || 0)` - /// over all transactions in the batch. - /// - `BLOCK_HASH` is the commitment of the batch's reference block. - pub fn build_input_stack(transactions_commitment: Word, block_hash: Word) -> StackInputs { + /// - `BATCH_ID` is the batch's [`BatchId`](crate::batch::BatchId). + /// - `BLOCK_COMMITMENT` is the commitment of the batch's reference block. + pub fn build_input_stack(batch_id: Word, block_commitment: Word) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); - inputs.extend_from_slice(transactions_commitment.as_elements()); - inputs.extend_from_slice(block_hash.as_elements()); + inputs.extend_from_slice(batch_id.as_elements()); + inputs.extend_from_slice(block_commitment.as_elements()); StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") } From 7a4afcbab0bf3fda05c1875c52ae81d973668105 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 12:32:03 +0000 Subject: [PATCH 10/29] refactor(protocol): use Felt::ZERO associated constant Prefer the associated constant `Felt::ZERO` over the bare `ZERO` import in the batch output padding check. Addresses a review comment on PR #2904. --- crates/miden-protocol/src/batch/kernel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index b66637928d..53bd6766a3 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -8,7 +8,7 @@ use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; use crate::utils::sync::LazyLock; use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs, StackOutputs}; -use crate::{Felt, Word, ZERO}; +use crate::{Felt, Word}; // CONSTANTS // ================================================================================================ @@ -127,7 +127,7 @@ impl BatchKernel { // Every cell after batch_expiration_block_num must be zero padding. if stack[BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] .iter() - .any(|&felt| felt != ZERO) + .any(|&felt| felt != Felt::ZERO) { return Err(BatchOutputError::OutputStackInvalid( "batch_expiration_block_num must be followed by zero padding".into(), From 26c044b5a6fccaa0ae6f162c6fefa77f588cd3a4 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 12:34:35 +0000 Subject: [PATCH 11/29] refactor(protocol): type batch kernel errors instead of stringifying Stringifying the kernel error via `to_string` discarded the source error chain. Wrap the real source instead: `BatchKernelExecutionFailed` now carries `#[source] ExecutionError`, and a dedicated `BatchKernelOutputInvalid(#[source] BatchOutputError)` variant covers output-stack parsing failures. The batch prover maps these directly. Addresses a review comment on PR #2904. --- crates/miden-protocol/src/errors/mod.rs | 7 +++++-- crates/miden-tx-batch-prover/src/local_batch_prover.rs | 6 ++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index 2a962f0634..8117f96c66 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -9,6 +9,7 @@ use miden_core::mast::MastForestError; use miden_crypto::merkle::mmr::MmrError; use miden_crypto::merkle::smt::{SmtLeafError, SmtProofError}; use miden_crypto::utils::HexParseError; +use miden_processor::ExecutionError; use miden_verifier::VerificationError; use thiserror::Error; @@ -1089,8 +1090,10 @@ pub enum ProvenBatchError { batch_expiration_block_num: BlockNumber, reference_block_num: BlockNumber, }, - #[error("batch kernel execution failed: {0}")] - BatchKernelExecutionFailed(String), + #[error("batch kernel execution failed")] + BatchKernelExecutionFailed(#[source] ExecutionError), + #[error("batch kernel produced an invalid output stack")] + BatchKernelOutputInvalid(#[source] BatchOutputError), } // BATCH OUTPUT ERROR 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 aecbf86419..99f672e2f8 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -1,5 +1,3 @@ -use alloc::string::ToString; - use miden_processor::{DefaultHost, ExecutionOptions}; use miden_protocol::batch::{BatchKernel, ProposedBatch, ProvenBatch}; use miden_protocol::errors::ProvenBatchError; @@ -51,13 +49,13 @@ impl LocalBatchProver { self.proving_options.clone(), ) .await - .map_err(|err| ProvenBatchError::BatchKernelExecutionFailed(err.to_string()))?; + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; // Validate the output stack shape (padding cells are zero and the expiration fits in // u32); the actual output values themselves are not checked until the kernel verifies // them. BatchKernel::parse_output_stack(&stack_outputs) - .map_err(|err| ProvenBatchError::BatchKernelExecutionFailed(err.to_string()))?; + .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; Self::build_proven_batch(proposed_batch, proof) } From 7c726756358a793649b7b5fb1a6e27cbe790bdf6 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 12:36:06 +0000 Subject: [PATCH 12/29] test(batch): rename batch_kernel module to test_batch_kernel Adopt the `test_*` naming used by `kernel_tests/tx/test_*.rs` so the `kernel_tests::batch::batch_kernel` path is no longer redundant and future per-feature batch kernel test modules slot in consistently. Addresses a review comment on PR #2904. --- crates/miden-testing/src/kernel_tests/batch/mod.rs | 2 +- .../batch/{batch_kernel.rs => test_batch_kernel.rs} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename crates/miden-testing/src/kernel_tests/batch/{batch_kernel.rs => test_batch_kernel.rs} (100%) diff --git a/crates/miden-testing/src/kernel_tests/batch/mod.rs b/crates/miden-testing/src/kernel_tests/batch/mod.rs index 1fedd8e4cf..0dd9aaa47b 100644 --- a/crates/miden-testing/src/kernel_tests/batch/mod.rs +++ b/crates/miden-testing/src/kernel_tests/batch/mod.rs @@ -1,3 +1,3 @@ -mod batch_kernel; pub(super) mod proposed_batch; mod proven_tx_builder; +mod test_batch_kernel; diff --git a/crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs similarity index 100% rename from crates/miden-testing/src/kernel_tests/batch/batch_kernel.rs rename to crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs From b84ea284d6f56d4c5cb90d76109a0e6d38959874 Mon Sep 17 00:00:00 2001 From: Marti Date: Fri, 29 May 2026 14:10:42 +0000 Subject: [PATCH 13/29] chore: shorten comment --- crates/miden-tx-batch-prover/src/local_batch_prover.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 99f672e2f8..3bc233dae2 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -8,9 +8,8 @@ use miden_prover::{ExecutionProof, ProvingOptions, prove}; /// A local prover for transaction batches. /// -/// Runs the batch kernel program via `miden_prover::prove` to produce an [`ExecutionProof`] over -/// the batch's public commitments. The batch's transactions are verified when the -/// [`ProposedBatch`] is constructed, so the prover does not re-verify them. +/// Runs the batch kernel program to produce an [`ExecutionProof`] over the batch's public +/// commitments. #[derive(Clone, Default)] pub struct LocalBatchProver { proving_options: ProvingOptions, From 5e25d941f9dec1d50104f1c401ff7f72052eb3ee Mon Sep 17 00:00:00 2001 From: Marti Date: Fri, 29 May 2026 14:12:42 +0000 Subject: [PATCH 14/29] chore: fmt --- crates/miden-tx-batch-prover/src/local_batch_prover.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3bc233dae2..e101f3823c 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -9,7 +9,7 @@ use miden_prover::{ExecutionProof, ProvingOptions, prove}; /// A local prover for transaction batches. /// /// Runs the batch kernel program to produce an [`ExecutionProof`] over the batch's public -/// commitments. +/// commitments. #[derive(Clone, Default)] pub struct LocalBatchProver { proving_options: ProvingOptions, From d812d4dd48f23cb4063bf40cdfc1409a07fcfdac Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 14:29:44 +0000 Subject: [PATCH 15/29] refactor(batch-prover): split batch execution and proving Separate batch kernel execution from proof generation, mirroring the transaction flow's ExecutedTransaction: ProposedBatch -> BatchExecutor::execute -> ExecutedBatch ExecutedBatch -> LocalBatchProver::prove -> ProvenBatch `BatchExecutor::execute` runs the batch kernel (via the synchronous trace APIs, `execute_trace_inputs_sync`), validates the output stack shape, and returns an `ExecutedBatch` holding the trace inputs. `LocalBatchProver` now consumes an `ExecutedBatch` and only builds the trace + proof (via `prove_from_trace_sync`), so the flow is fully synchronous. Transaction proofs are already verified in `ProposedBatch::new`, so the executor does not re-verify them. This lets tests exercise raw batch execution without re-proving. The batch kernel smoke test now goes through `BatchExecutor`, removing the inline FastProcessor setup, and a new test covers the execute -> prove path. Addresses review comment T19 on PR #2904. --- .../kernel_tests/batch/test_batch_kernel.rs | 52 ++++++++--------- .../src/batch_executor.rs | 56 +++++++++++++++++++ .../src/executed_batch.rs | 39 +++++++++++++ crates/miden-tx-batch-prover/src/lib.rs | 6 ++ .../src/local_batch_prover.rs | 48 ++++++---------- 5 files changed, 140 insertions(+), 61 deletions(-) create mode 100644 crates/miden-tx-batch-prover/src/batch_executor.rs create mode 100644 crates/miden-tx-batch-prover/src/executed_batch.rs diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index b10d27cbd3..76dd75713b 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -2,12 +2,10 @@ use alloc::sync::Arc; use std::collections::BTreeMap; use anyhow::Context; -use miden_core_lib::CoreLibrary; -use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; use miden_protocol::Word; use miden_protocol::batch::{BatchKernel, ProposedBatch}; use miden_protocol::block::BlockNumber; -use miden_protocol::vm::{AdviceInputs, Program, StackInputs, StackOutputs}; +use miden_tx_batch_prover::{BatchExecutor, LocalBatchProver}; use super::proposed_batch::{TestSetup, mock_note, mock_output_note, setup_chain}; use super::proven_tx_builder::MockProvenTxBuilder; @@ -53,41 +51,22 @@ fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { )?) } -fn run_kernel( - program: &Program, - 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()) - .expect("failed to create processor") - .with_debugging(true); - let output = processor.execute_sync(program, &mut host)?; - Ok(output.stack) -} - -// SMOKE TEST +// TESTS // ================================================================================================ /// The skeleton batch kernel drops its public inputs and exits, leaving the all-zero word output -/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, derive -/// stack and advice inputs via `BatchKernel::prepare_inputs`, run the kernel, parse the outputs) -/// and asserts that the contract holds: the kernel runs to completion and emits the empty word -/// shape. +/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, execute +/// the batch kernel via `BatchExecutor`, parse the outputs) and asserts that the contract holds: +/// the kernel runs to completion and emits the empty word shape. #[test] fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; - let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&batch); - let stack_outputs = run_kernel(&BatchKernel::main(), stack_inputs, advice_inputs) - .context("kernel execution failed")?; + let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; let (input_notes_commitment, output_notes_commitment, expiration) = - BatchKernel::parse_output_stack(&stack_outputs).context("parse output stack failed")?; + BatchKernel::parse_output_stack(executed.stack_outputs()) + .context("parse output stack failed")?; assert_eq!(input_notes_commitment, Word::empty()); assert_eq!(output_notes_commitment, Word::empty()); @@ -95,3 +74,18 @@ fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { Ok(()) } + +/// Executing a batch and then proving it produces a [`ProvenBatch`] carrying the kernel's proof. +#[test] +fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + let expected_id = batch.id(); + + let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; + let proven = LocalBatchProver::new().prove(executed).context("batch proving failed")?; + + assert_eq!(proven.id(), expected_id); + + Ok(()) +} diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs new file mode 100644 index 0000000000..3f5a0c7637 --- /dev/null +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -0,0 +1,56 @@ +use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcessor}; +use miden_protocol::batch::{BatchKernel, ProposedBatch}; +use miden_protocol::errors::ProvenBatchError; + +use crate::ExecutedBatch; + +// BATCH EXECUTOR +// ================================================================================================ + +/// Executes the batch kernel over a [`ProposedBatch`], producing an [`ExecutedBatch`]. +/// +/// The proposed batch's transactions are verified when the [`ProposedBatch`] is constructed, so the +/// executor only runs the batch kernel program; it does not re-verify the transactions. +#[derive(Clone, Default)] +pub struct BatchExecutor; + +impl BatchExecutor { + /// Creates a new [`BatchExecutor`] instance. + pub fn new() -> Self { + Self + } + + /// Runs the batch kernel over the [`ProposedBatch`], returning an [`ExecutedBatch`] that can be + /// passed to [`LocalBatchProver::prove`](crate::LocalBatchProver::prove). + /// + /// # Errors + /// + /// Returns an error if: + /// - the batch kernel program fails to execute; + /// - the kernel output stack fails to parse. + pub fn execute( + &self, + proposed_batch: ProposedBatch, + ) -> Result { + let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + + let processor = FastProcessor::new_with_options( + stack_inputs, + advice_inputs, + ExecutionOptions::default(), + ) + .map_err(ExecutionError::advice_error_no_context) + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + + let trace_inputs = processor + .execute_trace_inputs_sync(&BatchKernel::main(), &mut DefaultHost::default()) + .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + + // Validate the output stack shape (padding cells are zero and the expiration fits in u32); + // the actual output values themselves are not checked until the kernel verifies them. + BatchKernel::parse_output_stack(trace_inputs.stack_outputs()) + .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; + + Ok(ExecutedBatch::new(proposed_batch, trace_inputs)) + } +} diff --git a/crates/miden-tx-batch-prover/src/executed_batch.rs b/crates/miden-tx-batch-prover/src/executed_batch.rs new file mode 100644 index 0000000000..8ac9e8d21b --- /dev/null +++ b/crates/miden-tx-batch-prover/src/executed_batch.rs @@ -0,0 +1,39 @@ +use miden_processor::{StackOutputs, TraceBuildInputs}; +use miden_protocol::batch::ProposedBatch; + +// EXECUTED BATCH +// ================================================================================================ + +/// A [`ProposedBatch`] whose batch kernel has been executed, but not yet proven. +/// +/// Produced by [`BatchExecutor::execute`](crate::BatchExecutor::execute) and consumed by +/// [`LocalBatchProver::prove`](crate::LocalBatchProver::prove). It carries the executed batch's +/// trace inputs so that proving only needs to build the trace and generate the proof. +pub struct ExecutedBatch { + proposed_batch: ProposedBatch, + trace_inputs: TraceBuildInputs, +} + +impl ExecutedBatch { + /// Creates a new [`ExecutedBatch`] from the proposed batch and the trace inputs produced by + /// executing the batch kernel over it. + pub(crate) fn new(proposed_batch: ProposedBatch, trace_inputs: TraceBuildInputs) -> Self { + Self { proposed_batch, trace_inputs } + } + + /// Returns the [`ProposedBatch`] this batch was executed from. + pub fn proposed_batch(&self) -> &ProposedBatch { + &self.proposed_batch + } + + /// Returns the public outputs the batch kernel left on the stack. + pub fn stack_outputs(&self) -> &StackOutputs { + self.trace_inputs.stack_outputs() + } + + /// Consumes the executed batch, returning the proposed batch and the trace inputs needed to + /// prove it. + pub(crate) fn into_parts(self) -> (ProposedBatch, TraceBuildInputs) { + (self.proposed_batch, self.trace_inputs) + } +} diff --git a/crates/miden-tx-batch-prover/src/lib.rs b/crates/miden-tx-batch-prover/src/lib.rs index 368056917b..fc966fcb90 100644 --- a/crates/miden-tx-batch-prover/src/lib.rs +++ b/crates/miden-tx-batch-prover/src/lib.rs @@ -5,5 +5,11 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +mod batch_executor; +pub use batch_executor::BatchExecutor; + +mod executed_batch; +pub use executed_batch::ExecutedBatch; + mod local_batch_prover; pub use local_batch_prover::LocalBatchProver; 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 e101f3823c..8eec60af3b 100644 --- a/crates/miden-tx-batch-prover/src/local_batch_prover.rs +++ b/crates/miden-tx-batch-prover/src/local_batch_prover.rs @@ -1,15 +1,16 @@ -use miden_processor::{DefaultHost, ExecutionOptions}; -use miden_protocol::batch::{BatchKernel, ProposedBatch, ProvenBatch}; +use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_protocol::errors::ProvenBatchError; -use miden_prover::{ExecutionProof, ProvingOptions, prove}; +use miden_prover::{ExecutionProof, ProvingOptions, TraceProvingInputs, prove_from_trace_sync}; + +use crate::ExecutedBatch; // LOCAL BATCH PROVER // ================================================================================================ /// A local prover for transaction batches. /// -/// Runs the batch kernel program to produce an [`ExecutionProof`] over the batch's public -/// commitments. +/// Proves an [`ExecutedBatch`] (produced by [`BatchExecutor`](crate::BatchExecutor)) into a +/// [`ProvenBatch`] carrying an [`ExecutionProof`] over the batch's public commitments. #[derive(Clone, Default)] pub struct LocalBatchProver { proving_options: ProvingOptions, @@ -21,41 +22,24 @@ impl LocalBatchProver { Self::default() } - /// Proves the [`ProposedBatch`] into a [`ProvenBatch`]. + /// Proves the [`ExecutedBatch`] into a [`ProvenBatch`]. /// - /// Runs the batch kernel via `miden_prover::prove` and attaches the resulting proof to the - /// returned [`ProvenBatch`]. The kernel's public outputs are not yet cross-checked against the - /// proposed batch's expected values. + /// Builds the execution trace from the executed batch and generates the proof, attaching it to + /// the returned [`ProvenBatch`]. The kernel's public outputs are not yet cross-checked against + /// the proposed batch's expected values. /// /// # Errors /// - /// Returns an error if: - /// - the batch kernel program fails to execute or produce a proof; - /// - the kernel output stack fails to parse. - pub async fn prove( - &self, - proposed_batch: ProposedBatch, - ) -> Result { - let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); - let mut host = DefaultHost::default(); + /// Returns an error if proof generation fails. + pub fn prove(&self, executed_batch: ExecutedBatch) -> Result { + let (proposed_batch, trace_inputs) = executed_batch.into_parts(); - let (stack_outputs, proof) = prove( - &BatchKernel::main(), - stack_inputs, - advice_inputs, - &mut host, - ExecutionOptions::default(), + let (_stack_outputs, proof) = prove_from_trace_sync(TraceProvingInputs::new( + trace_inputs, self.proving_options.clone(), - ) - .await + )) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; - // Validate the output stack shape (padding cells are zero and the expiration fits in - // u32); the actual output values themselves are not checked until the kernel verifies - // them. - BatchKernel::parse_output_stack(&stack_outputs) - .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; - Self::build_proven_batch(proposed_batch, proof) } From fc7d855c87d272d752e3b45a9a902b5cdf230d2a Mon Sep 17 00:00:00 2001 From: Marti Date: Fri, 29 May 2026 16:44:46 +0200 Subject: [PATCH 16/29] Update crates/miden-tx-batch-prover/src/batch_executor.rs --- crates/miden-tx-batch-prover/src/batch_executor.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 3f5a0c7637..1198500bbd 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -8,9 +8,6 @@ use crate::ExecutedBatch; // ================================================================================================ /// Executes the batch kernel over a [`ProposedBatch`], producing an [`ExecutedBatch`]. -/// -/// The proposed batch's transactions are verified when the [`ProposedBatch`] is constructed, so the -/// executor only runs the batch kernel program; it does not re-verify the transactions. #[derive(Clone, Default)] pub struct BatchExecutor; From 79165e9d46807662cfd921eb6d021a88d523872d Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Fri, 29 May 2026 14:53:49 +0000 Subject: [PATCH 17/29] refactor(protocol): return a BatchOutput from parse_output_stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a `BatchOutput` type (mirroring `TransactionOutputs`) that encapsulates the batch kernel's parsed outputs — input/output note commitments and the batch expiration block number — instead of returning a raw `(Word, Word, BlockNumber)` tuple. The output-stack layout indices move onto `BatchOutput` as associated constants. Addresses review comment T15 on PR #2904. --- crates/miden-protocol/src/batch/kernel.rs | 27 ++++---- crates/miden-protocol/src/batch/mod.rs | 3 + crates/miden-protocol/src/batch/output.rs | 66 +++++++++++++++++++ .../kernel_tests/batch/test_batch_kernel.rs | 11 ++-- 4 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 crates/miden-protocol/src/batch/output.rs diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 53bd6766a3..9769549c00 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -2,7 +2,7 @@ use alloc::vec::Vec; use miden_core::program::Kernel; -use crate::batch::ProposedBatch; +use crate::batch::{BatchOutput, ProposedBatch}; use crate::block::BlockNumber; use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; @@ -18,11 +18,6 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { Program::read_from_bytes(bytes).expect("failed to deserialize batch kernel runtime") }); -// Output stack indices (layout at the end of `batch/main.masm::main`). -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; - // BATCH KERNEL // ================================================================================================ @@ -103,29 +98,27 @@ impl BatchKernel { StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") } - /// Extracts batch output data from the provided stack outputs. + /// Extracts the [`BatchOutput`] 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> { + pub fn parse_output_stack(stack: &StackOutputs) -> Result { let input_notes_commitment = stack - .get_word(INPUT_NOTES_COMMITMENT_WORD_IDX) + .get_word(BatchOutput::INPUT_NOTES_COMMITMENT_WORD_IDX) .expect("input_notes_commitment word missing"); let output_notes_commitment = stack - .get_word(OUTPUT_NOTES_COMMITMENT_WORD_IDX) + .get_word(BatchOutput::OUTPUT_NOTES_COMMITMENT_WORD_IDX) .expect("output_notes_commitment word missing"); let expiration_felt = stack - .get_element(BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) + .get_element(BatchOutput::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) .expect("batch_expiration_block_num missing"); // Every cell after batch_expiration_block_num must be zero padding. - if stack[BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] + if stack[BatchOutput::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] .iter() .any(|&felt| felt != Felt::ZERO) { @@ -138,7 +131,11 @@ impl BatchKernel { .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? .into(); - Ok((input_notes_commitment, output_notes_commitment, batch_expiration_block_num)) + Ok(BatchOutput::new( + input_notes_commitment, + output_notes_commitment, + batch_expiration_block_num, + )) } // ADVICE BUILDER diff --git a/crates/miden-protocol/src/batch/mod.rs b/crates/miden-protocol/src/batch/mod.rs index d7921e6f8a..f23bce8cd0 100644 --- a/crates/miden-protocol/src/batch/mod.rs +++ b/crates/miden-protocol/src/batch/mod.rs @@ -20,3 +20,6 @@ pub(super) mod note_tracker; mod kernel; pub use kernel::BatchKernel; + +mod output; +pub use output::BatchOutput; diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs new file mode 100644 index 0000000000..cfab4a7e6e --- /dev/null +++ b/crates/miden-protocol/src/batch/output.rs @@ -0,0 +1,66 @@ +use crate::Word; +use crate::block::BlockNumber; + +// BATCH OUTPUT +// ================================================================================================ + +/// The public outputs produced by the batch kernel. +/// +/// This is the parsed, typed form of the kernel's output stack (see +/// [`BatchKernel::parse_output_stack`](crate::batch::BatchKernel::parse_output_stack)), mirroring +/// [`TransactionOutputs`](crate::transaction::TransactionOutputs) for transactions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchOutput { + /// The commitment to the batch's input notes. + input_notes_commitment: Word, + /// The commitment to the batch's output notes. + output_notes_commitment: Word, + /// The block number at which the batch expires. + batch_expiration_block_num: BlockNumber, +} + +impl BatchOutput { + // OUTPUT STACK LAYOUT + // -------------------------------------------------------------------------------------------- + + /// The element index at which the input notes commitment word starts on the output stack. + pub const INPUT_NOTES_COMMITMENT_WORD_IDX: usize = 0; + /// The element index at which the output notes commitment word starts on the output stack. + pub const OUTPUT_NOTES_COMMITMENT_WORD_IDX: usize = 4; + /// The element index at which the batch expiration block number is stored on the output stack. + pub const BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX: usize = 8; + + // CONSTRUCTOR + // -------------------------------------------------------------------------------------------- + + /// Returns a new [`BatchOutput`] instantiated from the provided data. + pub fn new( + input_notes_commitment: Word, + output_notes_commitment: Word, + batch_expiration_block_num: BlockNumber, + ) -> Self { + Self { + input_notes_commitment, + output_notes_commitment, + batch_expiration_block_num, + } + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the commitment to the batch's input notes. + pub fn input_notes_commitment(&self) -> Word { + self.input_notes_commitment + } + + /// Returns the commitment to the batch's output notes. + pub fn output_notes_commitment(&self) -> Word { + self.output_notes_commitment + } + + /// Returns the block number at which the batch expires. + pub fn batch_expiration_block_num(&self) -> BlockNumber { + self.batch_expiration_block_num + } +} diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index 76dd75713b..d3a16442d1 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -64,13 +64,12 @@ fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { let batch = two_tx_batch(&mut setup)?; let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; - let (input_notes_commitment, output_notes_commitment, expiration) = - BatchKernel::parse_output_stack(executed.stack_outputs()) - .context("parse output stack failed")?; + let output = BatchKernel::parse_output_stack(executed.stack_outputs()) + .context("parse output stack failed")?; - assert_eq!(input_notes_commitment, Word::empty()); - assert_eq!(output_notes_commitment, Word::empty()); - assert_eq!(expiration, BlockNumber::from(0u32)); + assert_eq!(output.input_notes_commitment(), Word::empty()); + assert_eq!(output.output_notes_commitment(), Word::empty()); + assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); Ok(()) } From e9c34d315f3c80536774dbfe08910d5bc4291324 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 09:21:38 +0000 Subject: [PATCH 18/29] refactor(protocol): put BLOCK_COMMITMENT on top of the batch input stack Order the batch kernel's public inputs as [BLOCK_COMMITMENT, BATCH_ID] to stay consistent with the transaction kernel's input layout. Addresses a review comment on PR #2904. --- crates/miden-protocol/asm/kernels/batch/main.masm | 4 ++-- crates/miden-protocol/src/batch/kernel.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 866e5f93a8..fbcdcd3d8d 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -10,8 +10,8 @@ #! stack's initial padding zeros. #! #! Inputs: [ -#! BATCH_ID, #! BLOCK_COMMITMENT, +#! BATCH_ID, #! pad(8), #! ] #! @@ -23,8 +23,8 @@ #! ] #! #! Where: -#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. #! - BLOCK_COMMITMENT is the commitment of the batch's reference block. +#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. #! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note #! commitments. In this skeleton it is the empty word. #! - OUTPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's output note diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 9769549c00..d6b8110826 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -23,7 +23,7 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { /// The batch kernel program: an executable Miden program that proves a batch of transactions. /// -/// The kernel takes `[BATCH_ID, BLOCK_COMMITMENT]` as public inputs and emits +/// The kernel takes `[BLOCK_COMMITMENT, BATCH_ID]` as public inputs and emits /// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See /// `asm/kernels/batch/main.masm` for the input/output contract. pub struct BatchKernel; @@ -53,7 +53,7 @@ impl BatchKernel { let block_commitment = proposed_batch.reference_block_header().commitment(); let batch_id = proposed_batch.id().as_word(); - let stack_inputs = Self::build_input_stack(batch_id, block_commitment); + let stack_inputs = Self::build_input_stack(block_commitment, batch_id); let advice_inputs = Self::build_advice_inputs(proposed_batch); (stack_inputs, advice_inputs) @@ -64,16 +64,16 @@ impl BatchKernel { /// The initial stack is: /// /// ```text - /// [BATCH_ID, BLOCK_COMMITMENT, pad(8)] + /// [BLOCK_COMMITMENT, BATCH_ID, pad(8)] /// ``` /// /// Where: - /// - `BATCH_ID` is the batch's [`BatchId`](crate::batch::BatchId). /// - `BLOCK_COMMITMENT` is the commitment of the batch's reference block. - pub fn build_input_stack(batch_id: Word, block_commitment: Word) -> StackInputs { + /// - `BATCH_ID` is the batch's [`BatchId`](crate::batch::BatchId). + pub fn build_input_stack(block_commitment: Word, batch_id: Word) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); - inputs.extend_from_slice(batch_id.as_elements()); inputs.extend_from_slice(block_commitment.as_elements()); + inputs.extend_from_slice(batch_id.as_elements()); StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") } From 763a6a7c4b99734c7b72521d12d218d725b501e6 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 09:25:41 +0000 Subject: [PATCH 19/29] refactor(protocol): name the batch output BATCH_NOTE_TREE_ROOT Rename the batch kernel's second output from OUTPUT_NOTES_COMMITMENT to BATCH_NOTE_TREE_ROOT (and the `BatchOutput` field/accessor/layout const accordingly). The end-goal output is the root of the batch's note tree, which lets the block kernel aggregate per-batch note trees instead of a flat output-notes commitment; renaming now fixes the contract name even though the value is still wired up in a follow-up. Addresses a review comment on PR #2904. --- .../asm/kernels/batch/main.masm | 6 +++--- crates/miden-protocol/src/batch/kernel.rs | 16 ++++++++-------- crates/miden-protocol/src/batch/output.rs | 19 ++++++++++--------- .../kernel_tests/batch/test_batch_kernel.rs | 2 +- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index fbcdcd3d8d..bf9a6d5fad 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -17,7 +17,7 @@ #! #! Outputs: [ #! INPUT_NOTES_COMMITMENT, -#! OUTPUT_NOTES_COMMITMENT, +#! BATCH_NOTE_TREE_ROOT, #! batch_expiration_block_num, #! pad(7), #! ] @@ -27,8 +27,8 @@ #! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. #! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note #! commitments. In this skeleton it is the empty word. -#! - OUTPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's output note -#! commitments. In this skeleton it is the empty word. +#! - BATCH_NOTE_TREE_ROOT will be the root of the batch note tree built over every transaction's +#! output notes. In this skeleton it is the empty word. #! - batch_expiration_block_num will be the minimum of every transaction's #! `expiration_block_num`. In this skeleton it is zero. #! diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index d6b8110826..2e65b62bdf 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -24,7 +24,7 @@ static KERNEL_MAIN: LazyLock = LazyLock::new(|| { /// The batch kernel program: an executable Miden program that proves a batch of transactions. /// /// The kernel takes `[BLOCK_COMMITMENT, BATCH_ID]` as public inputs and emits -/// `[INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num]`. See +/// `[INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num]`. See /// `asm/kernels/batch/main.masm` for the input/output contract. pub struct BatchKernel; @@ -83,16 +83,16 @@ impl BatchKernel { /// The output stack is defined as: /// /// ```text - /// [INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, batch_expiration_block_num] + /// [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num] /// ``` pub fn build_output_stack( input_notes_commitment: Word, - output_notes_commitment: Word, + batch_note_tree_root: 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.extend_from_slice(batch_note_tree_root.as_elements()); outputs.push(Felt::from(batch_expiration_block_num)); StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") @@ -109,9 +109,9 @@ impl BatchKernel { let input_notes_commitment = stack .get_word(BatchOutput::INPUT_NOTES_COMMITMENT_WORD_IDX) .expect("input_notes_commitment word missing"); - let output_notes_commitment = stack - .get_word(BatchOutput::OUTPUT_NOTES_COMMITMENT_WORD_IDX) - .expect("output_notes_commitment word missing"); + let batch_note_tree_root = stack + .get_word(BatchOutput::BATCH_NOTE_TREE_ROOT_WORD_IDX) + .expect("batch_note_tree_root word missing"); let expiration_felt = stack .get_element(BatchOutput::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) @@ -133,7 +133,7 @@ impl BatchKernel { Ok(BatchOutput::new( input_notes_commitment, - output_notes_commitment, + batch_note_tree_root, batch_expiration_block_num, )) } diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index cfab4a7e6e..6814b66fb5 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -13,8 +13,9 @@ use crate::block::BlockNumber; pub struct BatchOutput { /// The commitment to the batch's input notes. input_notes_commitment: Word, - /// The commitment to the batch's output notes. - output_notes_commitment: Word, + /// The root of the batch's note tree (the [`BatchNoteTree`](crate::batch::BatchNoteTree)) over + /// the batch's output notes. + batch_note_tree_root: Word, /// The block number at which the batch expires. batch_expiration_block_num: BlockNumber, } @@ -25,8 +26,8 @@ impl BatchOutput { /// The element index at which the input notes commitment word starts on the output stack. pub const INPUT_NOTES_COMMITMENT_WORD_IDX: usize = 0; - /// The element index at which the output notes commitment word starts on the output stack. - pub const OUTPUT_NOTES_COMMITMENT_WORD_IDX: usize = 4; + /// The element index at which the batch note tree root word starts on the output stack. + pub const BATCH_NOTE_TREE_ROOT_WORD_IDX: usize = 4; /// The element index at which the batch expiration block number is stored on the output stack. pub const BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX: usize = 8; @@ -36,12 +37,12 @@ impl BatchOutput { /// Returns a new [`BatchOutput`] instantiated from the provided data. pub fn new( input_notes_commitment: Word, - output_notes_commitment: Word, + batch_note_tree_root: Word, batch_expiration_block_num: BlockNumber, ) -> Self { Self { input_notes_commitment, - output_notes_commitment, + batch_note_tree_root, batch_expiration_block_num, } } @@ -54,9 +55,9 @@ impl BatchOutput { self.input_notes_commitment } - /// Returns the commitment to the batch's output notes. - pub fn output_notes_commitment(&self) -> Word { - self.output_notes_commitment + /// Returns the root of the batch's note tree. + pub fn batch_note_tree_root(&self) -> Word { + self.batch_note_tree_root } /// Returns the block number at which the batch expires. diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index d3a16442d1..39504b49f1 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -68,7 +68,7 @@ fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { .context("parse output stack failed")?; assert_eq!(output.input_notes_commitment(), Word::empty()); - assert_eq!(output.output_notes_commitment(), Word::empty()); + assert_eq!(output.batch_note_tree_root(), Word::empty()); assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); Ok(()) From 9c91b7025c40c609ac6e3aff686468f0f327c364 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:11:13 +0000 Subject: [PATCH 20/29] refactor(batch): strong-type build_input_stack batch_id param Take `BatchId` instead of a raw `Word` so the input contract is expressed through the domain type. --- crates/miden-protocol/src/batch/kernel.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index 2e65b62bdf..e0ca918582 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -2,7 +2,7 @@ use alloc::vec::Vec; use miden_core::program::Kernel; -use crate::batch::{BatchOutput, ProposedBatch}; +use crate::batch::{BatchId, BatchOutput, ProposedBatch}; use crate::block::BlockNumber; use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; @@ -51,7 +51,7 @@ impl BatchKernel { /// execute the batch kernel. pub fn prepare_inputs(proposed_batch: &ProposedBatch) -> (StackInputs, AdviceInputs) { let block_commitment = proposed_batch.reference_block_header().commitment(); - let batch_id = proposed_batch.id().as_word(); + let batch_id = proposed_batch.id(); let stack_inputs = Self::build_input_stack(block_commitment, batch_id); let advice_inputs = Self::build_advice_inputs(proposed_batch); @@ -69,11 +69,11 @@ impl BatchKernel { /// /// Where: /// - `BLOCK_COMMITMENT` is the commitment of the batch's reference block. - /// - `BATCH_ID` is the batch's [`BatchId`](crate::batch::BatchId). - pub fn build_input_stack(block_commitment: Word, batch_id: Word) -> StackInputs { + /// - `BATCH_ID` is the batch's [`BatchId`]. + pub fn build_input_stack(block_commitment: Word, batch_id: BatchId) -> StackInputs { let mut inputs: Vec = Vec::with_capacity(8); inputs.extend_from_slice(block_commitment.as_elements()); - inputs.extend_from_slice(batch_id.as_elements()); + inputs.extend_from_slice(batch_id.as_word().as_elements()); StackInputs::new(&inputs).expect("number of stack inputs should be <= 16") } From 727ee9b9b2514396965b7b80d919357ddc004c99 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:12:53 +0000 Subject: [PATCH 21/29] refactor(batch): rename BatchOutput to BatchOutputs The type holds multiple outputs; rename it for consistency with `TransactionOutputs`. --- crates/miden-protocol/src/batch/kernel.rs | 16 ++++++++-------- crates/miden-protocol/src/batch/mod.rs | 2 +- crates/miden-protocol/src/batch/output.rs | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index e0ca918582..d813e1f85d 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -2,7 +2,7 @@ use alloc::vec::Vec; use miden_core::program::Kernel; -use crate::batch::{BatchId, BatchOutput, ProposedBatch}; +use crate::batch::{BatchId, BatchOutputs, ProposedBatch}; use crate::block::BlockNumber; use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; @@ -98,27 +98,27 @@ impl BatchKernel { StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") } - /// Extracts the [`BatchOutput`] from the provided stack outputs. + /// Extracts the [`BatchOutputs`] 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 { + pub fn parse_output_stack(stack: &StackOutputs) -> Result { let input_notes_commitment = stack - .get_word(BatchOutput::INPUT_NOTES_COMMITMENT_WORD_IDX) + .get_word(BatchOutputs::INPUT_NOTES_COMMITMENT_WORD_IDX) .expect("input_notes_commitment word missing"); let batch_note_tree_root = stack - .get_word(BatchOutput::BATCH_NOTE_TREE_ROOT_WORD_IDX) + .get_word(BatchOutputs::BATCH_NOTE_TREE_ROOT_WORD_IDX) .expect("batch_note_tree_root word missing"); let expiration_felt = stack - .get_element(BatchOutput::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) + .get_element(BatchOutputs::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) .expect("batch_expiration_block_num missing"); // Every cell after batch_expiration_block_num must be zero padding. - if stack[BatchOutput::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] + if stack[BatchOutputs::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] .iter() .any(|&felt| felt != Felt::ZERO) { @@ -131,7 +131,7 @@ impl BatchKernel { .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? .into(); - Ok(BatchOutput::new( + Ok(BatchOutputs::new( input_notes_commitment, batch_note_tree_root, batch_expiration_block_num, diff --git a/crates/miden-protocol/src/batch/mod.rs b/crates/miden-protocol/src/batch/mod.rs index f23bce8cd0..f8235397e8 100644 --- a/crates/miden-protocol/src/batch/mod.rs +++ b/crates/miden-protocol/src/batch/mod.rs @@ -22,4 +22,4 @@ mod kernel; pub use kernel::BatchKernel; mod output; -pub use output::BatchOutput; +pub use output::BatchOutputs; diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index 6814b66fb5..7beefe9f9d 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -1,7 +1,7 @@ use crate::Word; use crate::block::BlockNumber; -// BATCH OUTPUT +// BATCH OUTPUTS // ================================================================================================ /// The public outputs produced by the batch kernel. @@ -10,7 +10,7 @@ use crate::block::BlockNumber; /// [`BatchKernel::parse_output_stack`](crate::batch::BatchKernel::parse_output_stack)), mirroring /// [`TransactionOutputs`](crate::transaction::TransactionOutputs) for transactions. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BatchOutput { +pub struct BatchOutputs { /// The commitment to the batch's input notes. input_notes_commitment: Word, /// The root of the batch's note tree (the [`BatchNoteTree`](crate::batch::BatchNoteTree)) over @@ -20,7 +20,7 @@ pub struct BatchOutput { batch_expiration_block_num: BlockNumber, } -impl BatchOutput { +impl BatchOutputs { // OUTPUT STACK LAYOUT // -------------------------------------------------------------------------------------------- @@ -34,7 +34,7 @@ impl BatchOutput { // CONSTRUCTOR // -------------------------------------------------------------------------------------------- - /// Returns a new [`BatchOutput`] instantiated from the provided data. + /// Returns a new [`BatchOutputs`] instantiated from the provided data. pub fn new( input_notes_commitment: Word, batch_note_tree_root: Word, From eaa71d4d244d910a9119326f15a081ce4895b9b6 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:14:37 +0000 Subject: [PATCH 22/29] refactor(batch): move output parsing onto BatchOutputs and return errors Replace `BatchKernel::parse_output_stack` with `BatchOutputs::parse`, and return `BatchOutputError::OutputStackInvalid` instead of panicking when a required output word or element is missing from the stack. --- crates/miden-protocol/src/batch/kernel.rs | 43 +------------ crates/miden-protocol/src/batch/output.rs | 64 +++++++++++++++++-- .../kernel_tests/batch/test_batch_kernel.rs | 6 +- .../src/batch_executor.rs | 9 +-- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index d813e1f85d..fc89e9abed 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -2,9 +2,8 @@ use alloc::vec::Vec; use miden_core::program::Kernel; -use crate::batch::{BatchId, BatchOutputs, ProposedBatch}; +use crate::batch::{BatchId, ProposedBatch}; use crate::block::BlockNumber; -use crate::errors::BatchOutputError; use crate::utils::serde::Deserializable; use crate::utils::sync::LazyLock; use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs, StackOutputs}; @@ -98,46 +97,6 @@ impl BatchKernel { StackOutputs::new(&outputs).expect("number of stack outputs should be <= 16") } - /// Extracts the [`BatchOutputs`] 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 { - let input_notes_commitment = stack - .get_word(BatchOutputs::INPUT_NOTES_COMMITMENT_WORD_IDX) - .expect("input_notes_commitment word missing"); - let batch_note_tree_root = stack - .get_word(BatchOutputs::BATCH_NOTE_TREE_ROOT_WORD_IDX) - .expect("batch_note_tree_root word missing"); - - let expiration_felt = stack - .get_element(BatchOutputs::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX) - .expect("batch_expiration_block_num missing"); - - // Every cell after batch_expiration_block_num must be zero padding. - if stack[BatchOutputs::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] - .iter() - .any(|&felt| felt != Felt::ZERO) - { - return Err(BatchOutputError::OutputStackInvalid( - "batch_expiration_block_num must be followed by zero padding".into(), - )); - } - - let batch_expiration_block_num = u32::try_from(expiration_felt.as_canonical_u64()) - .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? - .into(); - - Ok(BatchOutputs::new( - input_notes_commitment, - batch_note_tree_root, - batch_expiration_block_num, - )) - } - // ADVICE BUILDER // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index 7beefe9f9d..e3bb4b165e 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -1,14 +1,15 @@ -use crate::Word; use crate::block::BlockNumber; +use crate::errors::BatchOutputError; +use crate::vm::StackOutputs; +use crate::{Felt, Word}; // BATCH OUTPUTS // ================================================================================================ /// The public outputs produced by the batch kernel. /// -/// This is the parsed, typed form of the kernel's output stack (see -/// [`BatchKernel::parse_output_stack`](crate::batch::BatchKernel::parse_output_stack)), mirroring -/// [`TransactionOutputs`](crate::transaction::TransactionOutputs) for transactions. +/// This is the parsed, typed form of the kernel's output stack (see [`BatchOutputs::parse`]), +/// mirroring [`TransactionOutputs`](crate::transaction::TransactionOutputs) for transactions. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BatchOutputs { /// The commitment to the batch's input notes. @@ -47,6 +48,61 @@ impl BatchOutputs { } } + // PARSER + // -------------------------------------------------------------------------------------------- + + /// Parses the batch kernel's output stack into a [`BatchOutputs`]. + /// + /// # Errors + /// + /// Returns [`BatchOutputError::OutputStackInvalid`] if: + /// - a required output word or element is missing from the stack; + /// - the cells following `batch_expiration_block_num` (positions 9..16) are not all zero. + /// + /// Returns [`BatchOutputError::ExpirationBlockNumberTooLarge`] if `batch_expiration_block_num` + /// does not fit into a `u32`. + pub fn parse(stack: &StackOutputs) -> Result { + let input_notes_commitment = + stack.get_word(Self::INPUT_NOTES_COMMITMENT_WORD_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "input notes commitment word missing from output stack".into(), + ) + })?; + let batch_note_tree_root = + stack.get_word(Self::BATCH_NOTE_TREE_ROOT_WORD_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "batch note tree root word missing from output stack".into(), + ) + })?; + + let expiration_felt = + stack.get_element(Self::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX).ok_or_else(|| { + BatchOutputError::OutputStackInvalid( + "batch expiration block number missing from output stack".into(), + ) + })?; + + // Every cell after batch_expiration_block_num must be zero padding. + if stack[Self::BATCH_EXPIRATION_BLOCK_NUM_ELEMENT_IDX + 1..] + .iter() + .any(|&felt| felt != Felt::ZERO) + { + return Err(BatchOutputError::OutputStackInvalid( + "batch_expiration_block_num must be followed by zero padding".into(), + )); + } + + let batch_expiration_block_num = u32::try_from(expiration_felt.as_canonical_u64()) + .map_err(|_| BatchOutputError::ExpirationBlockNumberTooLarge(expiration_felt))? + .into(); + + Ok(Self::new( + input_notes_commitment, + batch_note_tree_root, + batch_expiration_block_num, + )) + } + // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index 39504b49f1..ff8e7f64a4 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use anyhow::Context; use miden_protocol::Word; -use miden_protocol::batch::{BatchKernel, ProposedBatch}; +use miden_protocol::batch::{BatchOutputs, ProposedBatch}; use miden_protocol::block::BlockNumber; use miden_tx_batch_prover::{BatchExecutor, LocalBatchProver}; @@ -64,8 +64,8 @@ fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { let batch = two_tx_batch(&mut setup)?; let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; - let output = BatchKernel::parse_output_stack(executed.stack_outputs()) - .context("parse output stack failed")?; + let output = + BatchOutputs::parse(executed.stack_outputs()).context("parse output stack failed")?; assert_eq!(output.input_notes_commitment(), Word::empty()); assert_eq!(output.batch_note_tree_root(), Word::empty()); diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 1198500bbd..210db86305 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -1,5 +1,5 @@ use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcessor}; -use miden_protocol::batch::{BatchKernel, ProposedBatch}; +use miden_protocol::batch::{BatchKernel, BatchOutputs, ProposedBatch}; use miden_protocol::errors::ProvenBatchError; use crate::ExecutedBatch; @@ -43,9 +43,10 @@ impl BatchExecutor { .execute_trace_inputs_sync(&BatchKernel::main(), &mut DefaultHost::default()) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; - // Validate the output stack shape (padding cells are zero and the expiration fits in u32); - // the actual output values themselves are not checked until the kernel verifies them. - BatchKernel::parse_output_stack(trace_inputs.stack_outputs()) + // Parse and validate the output stack shape (padding cells are zero and the expiration + // fits in u32); the actual output values themselves are not checked until the kernel + // verifies them. + BatchOutputs::parse(trace_inputs.stack_outputs()) .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; Ok(ExecutedBatch::new(proposed_batch, trace_inputs)) From 473d20a52725b204fbd61b40d6329ffbc6903f34 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:15:24 +0000 Subject: [PATCH 23/29] refactor(batch): expose typed BatchOutputs from ExecutedBatch Store the parsed `BatchOutputs` on `ExecutedBatch` and expose it via a typed `batch_outputs()` accessor, replacing the raw `stack_outputs()`. --- .../kernel_tests/batch/test_batch_kernel.rs | 5 ++-- .../src/batch_executor.rs | 4 +-- .../src/executed_batch.rs | 27 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index ff8e7f64a4..7f4a798661 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use anyhow::Context; use miden_protocol::Word; -use miden_protocol::batch::{BatchOutputs, ProposedBatch}; +use miden_protocol::batch::ProposedBatch; use miden_protocol::block::BlockNumber; use miden_tx_batch_prover::{BatchExecutor, LocalBatchProver}; @@ -64,8 +64,7 @@ fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { let batch = two_tx_batch(&mut setup)?; let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; - let output = - BatchOutputs::parse(executed.stack_outputs()).context("parse output stack failed")?; + let output = executed.batch_outputs(); assert_eq!(output.input_notes_commitment(), Word::empty()); assert_eq!(output.batch_note_tree_root(), Word::empty()); diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 210db86305..218bfcbcb8 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -46,9 +46,9 @@ impl BatchExecutor { // Parse and validate the output stack shape (padding cells are zero and the expiration // fits in u32); the actual output values themselves are not checked until the kernel // verifies them. - BatchOutputs::parse(trace_inputs.stack_outputs()) + let batch_outputs = BatchOutputs::parse(trace_inputs.stack_outputs()) .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; - Ok(ExecutedBatch::new(proposed_batch, trace_inputs)) + Ok(ExecutedBatch::new(proposed_batch, trace_inputs, batch_outputs)) } } diff --git a/crates/miden-tx-batch-prover/src/executed_batch.rs b/crates/miden-tx-batch-prover/src/executed_batch.rs index 8ac9e8d21b..7bbf365060 100644 --- a/crates/miden-tx-batch-prover/src/executed_batch.rs +++ b/crates/miden-tx-batch-prover/src/executed_batch.rs @@ -1,5 +1,5 @@ -use miden_processor::{StackOutputs, TraceBuildInputs}; -use miden_protocol::batch::ProposedBatch; +use miden_processor::TraceBuildInputs; +use miden_protocol::batch::{BatchOutputs, ProposedBatch}; // EXECUTED BATCH // ================================================================================================ @@ -12,13 +12,22 @@ use miden_protocol::batch::ProposedBatch; pub struct ExecutedBatch { proposed_batch: ProposedBatch, trace_inputs: TraceBuildInputs, + batch_outputs: BatchOutputs, } impl ExecutedBatch { - /// Creates a new [`ExecutedBatch`] from the proposed batch and the trace inputs produced by - /// executing the batch kernel over it. - pub(crate) fn new(proposed_batch: ProposedBatch, trace_inputs: TraceBuildInputs) -> Self { - Self { proposed_batch, trace_inputs } + /// Creates a new [`ExecutedBatch`] from the proposed batch, the trace inputs and the public + /// outputs produced by executing the batch kernel over it. + pub(crate) fn new( + proposed_batch: ProposedBatch, + trace_inputs: TraceBuildInputs, + batch_outputs: BatchOutputs, + ) -> Self { + Self { + proposed_batch, + trace_inputs, + batch_outputs, + } } /// Returns the [`ProposedBatch`] this batch was executed from. @@ -26,9 +35,9 @@ impl ExecutedBatch { &self.proposed_batch } - /// Returns the public outputs the batch kernel left on the stack. - pub fn stack_outputs(&self) -> &StackOutputs { - self.trace_inputs.stack_outputs() + /// Returns the public outputs produced by the batch kernel. + pub fn batch_outputs(&self) -> &BatchOutputs { + &self.batch_outputs } /// Consumes the executed batch, returning the proposed batch and the trace inputs needed to From 5d1b7d74a76df62603a5b5a31f61c55c6a6a24f6 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:32:15 +0000 Subject: [PATCH 24/29] test(batch): cover BatchOutputs::parse error branches Add unit tests for the well-formed stack, non-zero padding rejection, and oversized expiration block number rejection. --- crates/miden-protocol/src/batch/output.rs | 80 +++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index e3bb4b165e..50518e3352 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -121,3 +121,83 @@ impl BatchOutputs { self.batch_expiration_block_num } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_returns_outputs_for_well_formed_stack() { + let input_notes_commitment = + Word::from([Felt::from(1u32), Felt::from(2u32), Felt::from(3u32), Felt::from(4u32)]); + let batch_note_tree_root = + Word::from([Felt::from(5u32), Felt::from(6u32), Felt::from(7u32), Felt::from(8u32)]); + let elements = [ + Felt::from(1u32), + Felt::from(2u32), + Felt::from(3u32), + Felt::from(4u32), + Felt::from(5u32), + Felt::from(6u32), + Felt::from(7u32), + Felt::from(8u32), + Felt::from(1234u32), + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + let outputs = BatchOutputs::parse(&stack).unwrap(); + + assert_eq!(outputs.input_notes_commitment(), input_notes_commitment); + assert_eq!(outputs.batch_note_tree_root(), batch_note_tree_root); + assert_eq!(outputs.batch_expiration_block_num(), BlockNumber::from(1234u32)); + } + + #[test] + fn parse_rejects_non_zero_padding() { + // A valid 9-element output followed by a non-zero felt in the padding region (>= idx 9). + let elements = [ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::from(7u32), + Felt::from(1u32), + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + assert!(matches!( + BatchOutputs::parse(&stack), + Err(BatchOutputError::OutputStackInvalid(_)) + )); + } + + #[test] + fn parse_rejects_oversized_expiration() { + // An expiration value that does not fit into a u32. + let oversized = Felt::from(u32::MAX) + Felt::from(1u32); + let elements = [ + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + Felt::ZERO, + oversized, + ]; + let stack = StackOutputs::new(&elements).unwrap(); + + assert!(matches!( + BatchOutputs::parse(&stack), + Err(BatchOutputError::ExpirationBlockNumberTooLarge(_)) + )); + } +} From 30fb74a235be22bec32bfc10c7a155768cfec25b Mon Sep 17 00:00:00 2001 From: Marti Date: Mon, 1 Jun 2026 13:28:13 +0200 Subject: [PATCH 25/29] Apply suggestion from @mmagician --- crates/miden-protocol/src/batch/output.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/miden-protocol/src/batch/output.rs b/crates/miden-protocol/src/batch/output.rs index 50518e3352..7759472b80 100644 --- a/crates/miden-protocol/src/batch/output.rs +++ b/crates/miden-protocol/src/batch/output.rs @@ -8,8 +8,7 @@ use crate::{Felt, Word}; /// The public outputs produced by the batch kernel. /// -/// This is the parsed, typed form of the kernel's output stack (see [`BatchOutputs::parse`]), -/// mirroring [`TransactionOutputs`](crate::transaction::TransactionOutputs) for transactions. +/// This is the parsed, typed form of the kernel's output stack. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BatchOutputs { /// The commitment to the batch's input notes. From 59c2efb8c6d57668d25a900a533e561ee8d03103 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:10:55 +0000 Subject: [PATCH 26/29] feat: verify batch tx list, tx headers, and input-notes commitment Fill in the batch kernel to verify the batch's transaction list against its BATCH_ID (Layer 1), verify each transaction header (Layer 2), and recompute the batch INPUT_NOTES_COMMITMENT from the verified per-tx input notes (Layer 3). Output-notes (BATCH_NOTE_TREE_ROOT) and the expiration running-min stay as zero placeholders, wired up in follow-up PRs. --- CHANGELOG.md | 1 + .../asm/kernels/batch/lib/memory.masm | 209 ++++++++++++++++++ .../asm/kernels/batch/lib/note_tracker.masm | 137 ++++++++++++ .../asm/kernels/batch/lib/prologue.masm | 103 +++++++++ .../asm/kernels/batch/main.masm | 62 ++++-- crates/miden-protocol/build.rs | 9 +- crates/miden-protocol/src/batch/batch_id.rs | 16 +- crates/miden-protocol/src/batch/kernel.rs | 57 ++++- .../src/transaction/transaction_id.rs | 30 ++- .../kernel_tests/batch/test_batch_kernel.rs | 147 ++++++++++-- .../src/batch_executor.rs | 9 +- 11 files changed, 744 insertions(+), 36 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6d2e6a51..d13a5986d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes - Added a skeleton batch kernel ([#1122](https://github.com/0xMiden/protocol/issues/1122)) wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. It does not yet perform any verification. +- The batch kernel now verifies the batch's transaction list against its `BATCH_ID`, verifies each transaction header, and computes the batch `INPUT_NOTES_COMMITMENT` ([#2905](https://github.com/0xMiden/protocol/pull/2905)). - [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)). - [BREAKING] Extracted `NullifierTreeBackendReader` and `AccountTreeBackendReader` traits from existing `NullifierTreeBackend` and `AccountTreeBackend` traits ([#2755](https://github.com/0xMiden/protocol/pull/2755)). 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..08acac7d41 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm @@ -0,0 +1,209 @@ +# MEMORY LAYOUT +# ================================================================================================= +# +# Below is the memory layout used by the batch kernel: +# +# +-------------------+-------------------------+----------------------------------+ +# | Address range | Constant | Contents | +# +-------------------+-------------------------+----------------------------------+ +# | 0 | NUM_TRANSACTIONS_PTR | num_transactions (1 felt). | +# | 4..8 | BATCH_HASHER_RATE0_PTR | RATE0 of the batch-level | +# | | | poseidon2 hasher state. | +# | 8..12 | BATCH_HASHER_RATE1_PTR | RATE1 of the batch-level 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 + +# 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 +#! 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]`. +pub 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 note data, overwritten between iterations. +pub 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 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..2603562851 --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -0,0 +1,137 @@ +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 + +# 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 `(NULLIFIER, EMPTY_OR_NOTE_ID)` 8-felt tuples, in the order they +#! were piped (which matches `build_input_note_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 + push.0 exec.memory::set_scratch_word_index + + exec.memory::load_batch_hasher_state + # Stack: [RATE0, RATE1, CAPACITY] + + exec.memory::get_scratch_word_index + exec.memory::get_scratch_words_count + u32lt + # Stack: [should_loop, RATE0, RATE1, CAPACITY] + + while.true + exec.memory::get_scratch_word_index + mul.4 add.TX_NOTES_SCRATCH_PTR + # Stack: [scratch_ptr, RATE0, RATE1, CAPACITY] + + padw dup.4 mem_loadw_le + # Stack: [DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + + padw dup.8 add.4 mem_loadw_le + # Stack: [DATA2, DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + + movup.8 drop + # Stack: [DATA2, DATA1, RATE0, RATE1, CAPACITY] + + # Replace RATE0 + RATE1 with DATA1 + DATA2 (DATA1 first, DATA2 second), then permute. + swapdw + dropw dropw + swapw + exec.poseidon2::permute + # Stack: [RATE0', RATE1', CAPACITY'] + + 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 + + exec.memory::save_batch_hasher_state +end + +# 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] +#! +#! Panics if 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`). +#! 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 + 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_input_notes_commitment + # Stack: [INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + dupw exec.word::eqz + # Stack: [is_empty, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + if.true + dropw + else + 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 div.4 + # Stack: [num_words, INPUT_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, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + + # Pipe the note data into scratch, asserting its poseidon2 hash equals + # INPUT_NOTES_COMMITMENT_i. + exec.mem::pipe_preimage_to_memory + # Stack: [end_ptr, tx_index, num_transactions] + drop + + exec.absorb_scratch_into_batch_hasher + end + # Stack: [tx_index, num_transactions] + + 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: [INPUT_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..f14df2597b --- /dev/null +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -0,0 +1,103 @@ +use miden::core::mem + +use miden::batch_kernel::memory +use miden::batch_kernel::memory::TX_TUPLES_PTR + +# PROLOGUE +# ================================================================================================= + +#! Loads and verifies the batch's structural commitments from the advice provider. +#! +#! Performs two steps: +#! - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by `BATCH_ID` into +#! [`memory::TX_TUPLES_PTR`], asserting that the sequential hash of the piped data matches +#! `BATCH_ID`. The number of transactions is derived from the piped length 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. +#! +#! `BATCH_ID` is the batch's `BatchId` value, i.e. the sequential hash of the `(tx_id, account_id)` +#! tuples committing to the transactions in the batch. +#! +#! Inputs: +#! Operand stack: [BATCH_ID] +#! Advice map: +#! BATCH_ID |-> [(tx_id_0, account_id_0_pair), (tx_id_1, account_id_1_pair), ...] +#! For each verified tx_id_i: +#! tx_id_i |-> [INIT_i, FINAL_i, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i, +#! FEE_ASSET_i] +#! +#! Outputs: +#! Operand stack: [] +#! +#! Panics if: +#! - the `(tx_id, account_id)` tuple list piped from the advice map does not hash to `BATCH_ID`. +#! - a transaction's `(INIT, FINAL, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, 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_COMMITMENT. +#! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment. +pub proc prepare_batch + # Layer 1: pipe BATCH_ID's mapped value to tx_tuples_ptr + verify. + # --------------------------------------------------------------------------------------------- + + # Stack: [BATCH_ID] + push.TX_TUPLES_PTR movdn.4 + # Stack: [BATCH_ID, tx_tuples_ptr] + + adv.push_mapvaln + # AS: [len_felts, data...] + + adv_push div.4 + # Stack: [num_words, BATCH_ID, 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 + + movup.5 swap + # Stack: [num_words, tx_tuples_ptr, BATCH_ID] + + # Pipe the tuples into memory, asserting their poseidon2 hash equals BATCH_ID. + exec.mem::pipe_preimage_to_memory + # Stack: [end_ptr] + drop + + # Layer 2: for each transaction, pipe + verify its header. + # --------------------------------------------------------------------------------------------- + + 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_id + # Stack: [TX_ID, tx_index, num_transactions] + + dup.4 exec.memory::tx_header_ptr movdn.4 + # Stack: [TX_ID, tx_header_ptr, tx_index, num_transactions] + + adv.push_mapvaln + adv_push 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] + + # Pipe the header into memory, asserting its poseidon2 hash equals TX_ID. + exec.mem::pipe_preimage_to_memory + # Stack: [end_ptr, tx_index, num_transactions] + drop + + add.1 + dup.1 dup.1 neq + # Stack: [should_loop, tx_index, num_transactions] + end + + drop drop +end diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index bf9a6d5fad..67f8b8129e 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -1,13 +1,27 @@ +use miden::batch_kernel::note_tracker +use miden::batch_kernel::prologue + # MAIN # ================================================================================================= -#! Batch kernel program (skeleton). +#! Batch kernel program. #! #! A transaction batch groups a set of independently-proven transactions so they can later be -#! aggregated into a block by the block kernel. This program defines the public input/output -#! contract that the batch kernel will eventually verify, but currently does not yet perform -#! any verification: it drops its inputs and exits, leaving the all-zero word output region as the -#! stack's initial padding zeros. +#! aggregated into a block by the block kernel. This program reconstructs the per-transaction data +#! from the advice provider, anchoring the reconstruction in the public `BATCH_ID`, and emits the +#! batch's `INPUT_NOTES_COMMITMENT`. +#! +#! 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 `BATCH_ID` is anchored to the public input, +#! every felt that feeds `INPUT_NOTES_COMMITMENT` is transitively committed-to: +#! +#! `BATCH_ID` (public input) -> `(tx_id, account_id)` tuple list +#! each `tx_id` -> per-tx +#! `(INIT, FINAL, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i, FEE_ASSET)` +#! each `INPUT_NOTES_COMMITMENT_i` -> `(NULLIFIER, EMPTY_OR_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 output. #! #! Inputs: [ #! BLOCK_COMMITMENT, @@ -24,16 +38,38 @@ #! #! Where: #! - BLOCK_COMMITMENT is the commitment of the batch's reference block. -#! - BATCH_ID is the batch's `BatchId`, the commitment to its transactions. -#! - INPUT_NOTES_COMMITMENT will be the sequential hash over every transaction's input note -#! commitments. In this skeleton it is the empty word. -#! - BATCH_NOTE_TREE_ROOT will be the root of the batch note tree built over every transaction's -#! output notes. In this skeleton it is the empty word. -#! - batch_expiration_block_num will be the minimum of every transaction's -#! `expiration_block_num`. In this skeleton it is zero. +#! - BATCH_ID is the batch's `BatchId`, the sequential hash of the `(tx_id, account_id)` tuples +#! committing to the transactions in the batch. +#! - INPUT_NOTES_COMMITMENT is the sequential hash over every transaction's +#! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples. +#! - BATCH_NOTE_TREE_ROOT is currently not wired up (empty word). +#! - batch_expiration_block_num is currently not wired-up (zero). #! +#! TODO: verify BLOCK_COMMITMENT against block header data via the same pipe-and-verify pattern. +#! TODO: authenticate unauthenticated input notes against BLOCK_COMMITMENT's chain MMR. +#! TODO: erase intra-batch unauthenticated notes from INPUT_NOTES_COMMITMENT. +#! TODO: emit BATCH_NOTE_TREE_ROOT (the batch note tree SMT root) and batch_expiration_block_num. +#! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. +#! TODO: recursively verify each transaction's `ExecutionProof`. proc main - dropw dropw + # Stack: [BLOCK_COMMITMENT, BATCH_ID, pad(8)] + + # TODO: verify BLOCK_COMMITMENT against block header data via the pipe-and-verify pattern. + dropw + # Stack: [BATCH_ID, pad(12)] + + exec.prologue::prepare_batch + # Stack: [pad(16)] + + exec.note_tracker::compute_input_notes_commitment + # Stack: [INPUT_NOTES_COMMITMENT, pad(16)] + + # Drop the surplus padding word so the stack holds exactly the 16-felt output region + # [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num, pad(7)]. The + # BATCH_NOTE_TREE_ROOT and batch_expiration_block_num cells stay zero (wired up in follow-up + # PRs). + movupw.3 dropw + # Stack: [INPUT_NOTES_COMMITMENT, pad(12)] end begin diff --git a/crates/miden-protocol/build.rs b/crates/miden-protocol/build.rs index a252bb7527..f1ff5283b7 100644 --- a/crates/miden-protocol/build.rs +++ b/crates/miden-protocol/build.rs @@ -23,6 +23,7 @@ const ASM_TX_KERNEL_DIR: &str = "kernels/transaction"; const ASM_BATCH_KERNEL_DIR: &str = "kernels/batch"; const PROTOCOL_LIB_NAMESPACE: &str = "miden::protocol"; +const BATCH_KERNEL_NAMESPACE: &str = "miden::batch_kernel"; const KERNEL_PROCEDURES_RS_FILE: &str = "procedures.rs"; const TX_KERNEL_ERRORS_RS_FILE: &str = "tx_kernel_errors.rs"; @@ -101,11 +102,17 @@ fn main() -> Result<()> { /// 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. +/// +/// The modules under `kernels/batch/lib/` are statically linked under the `miden::batch_kernel` +/// namespace so `main.masm` can `use` them. 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 assembler = build_assembler(None)?; + 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"); diff --git a/crates/miden-protocol/src/batch/batch_id.rs b/crates/miden-protocol/src/batch/batch_id.rs index 39cfe42255..b3f7c10762 100644 --- a/crates/miden-protocol/src/batch/batch_id.rs +++ b/crates/miden-protocol/src/batch/batch_id.rs @@ -37,14 +37,26 @@ 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::hash_input_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]` + /// + /// The batch kernel pipes this same felt sequence from the advice provider to memory and + /// asserts the resulting hash matches the public input `BATCH_ID`. + pub(crate) fn hash_input_elements( + 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 index fc89e9abed..fd8aa63286 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -4,6 +4,7 @@ use miden_core::program::Kernel; use crate::batch::{BatchId, ProposedBatch}; use crate::block::BlockNumber; +use crate::transaction::TransactionId; use crate::utils::serde::Deserializable; use crate::utils::sync::LazyLock; use crate::vm::{AdviceInputs, Program, ProgramInfo, StackInputs, StackOutputs}; @@ -100,10 +101,58 @@ impl BatchKernel { // ADVICE BUILDER // -------------------------------------------------------------------------------------------- - /// Builds the advice inputs (map + stack) consumed by the batch kernel. + /// Builds the advice inputs consumed by the batch kernel. /// - /// The skeleton kernel ignores its advice inputs, so this returns the default empty value. - fn build_advice_inputs(_proposed_batch: &ProposedBatch) -> AdviceInputs { - AdviceInputs::default() + /// The kernel reconstructs and verifies the batch's `INPUT_NOTES_COMMITMENT` by walking a + /// layered advice map, each layer keyed by a hash the previous layer verified: + /// - `BATCH_ID` -> the `(tx_id, account_id)` tuple list (matching + /// `BatchId::hash_input_elements`). + /// - each `tx_id` -> the transaction header felt sequence (matching + /// `TransactionId::input_elements`). + /// - each per-tx `INPUT_NOTES_COMMITMENT` -> the `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples. + /// + /// The per-tx output-notes layer and the expiration data are wired up in follow-up PRs. + fn build_advice_inputs(proposed_batch: &ProposedBatch) -> AdviceInputs { + let mut advice_inputs = AdviceInputs::default(); + + // Layer 1: BATCH_ID -> [(tx_id, account_id) tuples]. + let layer1_data = 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)]); + + for tx in proposed_batch.transactions().iter() { + // Layer 2: tx_id -> the felt sequence TransactionId::new hashes. + 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_NOTE_ID) tuples]. + // This must reproduce `build_input_note_commitment` exactly: per note, the nullifier + // followed by the note ID (or the empty word for authenticated notes). + let input_notes_commitment = tx.input_notes().commitment(); + if input_notes_commitment != Word::empty() { + let mut notes_commitment_preimage_data: Vec = + Vec::with_capacity(usize::from(tx.input_notes().num_notes()) * 8); + for note_commit in tx.input_notes().iter() { + notes_commitment_preimage_data + .extend_from_slice(note_commit.nullifier().as_word().as_elements()); + let note_id_or_empty = + note_commit.header().map_or(Word::empty(), |header| header.id().as_word()); + notes_commitment_preimage_data + .extend_from_slice(note_id_or_empty.as_elements()); + } + advice_inputs + .map + .extend([(input_notes_commitment, notes_commitment_preimage_data)]); + } + } + + advice_inputs } } diff --git a/crates/miden-protocol/src/transaction/transaction_id.rs b/crates/miden-protocol/src/transaction/transaction_id.rs index 4313e6c465..14b5f538ee 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]]` + /// + /// 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, + 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-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index 7f4a798661..530991aa52 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -1,10 +1,14 @@ use alloc::sync::Arc; +use alloc::vec::Vec; use std::collections::BTreeMap; use anyhow::Context; -use miden_protocol::Word; -use miden_protocol::batch::ProposedBatch; +use miden_core_lib::CoreLibrary; +use miden_processor::{DefaultHost, ExecutionOptions, FastProcessor}; +use miden_protocol::batch::{BatchId, BatchKernel, ProposedBatch}; use miden_protocol::block::BlockNumber; +use miden_protocol::vm::{AdviceInputs, StackInputs, StackOutputs}; +use miden_protocol::{Felt, Hasher, Word}; use miden_tx_batch_prover::{BatchExecutor, LocalBatchProver}; use super::proposed_batch::{TestSetup, mock_note, mock_output_note, setup_chain}; @@ -13,10 +17,10 @@ use super::proven_tx_builder::MockProvenTxBuilder; // SETUP HELPERS // ================================================================================================ -/// Builds a two-transaction batch with realistic inputs and outputs. The skeleton kernel does not -/// inspect any of this data, but the batch is built end-to-end so the smoke test exercises the -/// real `prepare_inputs` path that the verification PR will eventually consume. -fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { +/// Builds a two-transaction batch: +/// - tx1 (account1): consumes one authenticated input note, produces one output note. +/// - tx2 (account2): consumes one unauthenticated input note, produces two output notes. +pub(super) fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { let block1 = setup.chain.block_header(1); let block2 = setup.chain.prove_next_block()?; @@ -51,22 +55,76 @@ fn two_tx_batch(setup: &mut TestSetup) -> anyhow::Result { )?) } -// TESTS +// EXPECTED-VALUE HELPERS // ================================================================================================ -/// The skeleton batch kernel drops its public inputs and exits, leaving the all-zero word output -/// region. This test exercises the full plumbing path (build a realistic `ProposedBatch`, execute -/// the batch kernel via `BatchExecutor`, parse the outputs) and asserts that the contract holds: -/// the kernel runs to completion and emits the empty word shape. +/// Sequential hash over `(NULLIFIER, EMPTY_OR_NOTE_ID)` tuples for every input note in every +/// transaction in transaction order, mirroring the kernel's per-tx absorption. +/// +/// We cannot compare against `ProposedBatch::input_notes().commitment()` yet: that commitment is +/// over the batch-level input notes re-sorted and deduped by nullifier, whereas the kernel +/// currently absorbs notes in transaction order without that re-sort/dedupe (tracked as a TODO in +/// `note_tracker.masm`). The two coincide only when transaction order already matches nullifier +/// order; this helper reproduces exactly what the kernel computes today. +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_id_or_empty = + commit.header().map_or(Word::empty(), |header| header.id().as_word()); + elements.extend_from_slice(note_id_or_empty.as_elements()); + } + } + if elements.is_empty() { + Word::empty() + } else { + Hasher::hash_elements(&elements) + } +} + +// EXECUTION HELPERS +// ================================================================================================ + +/// Runs the batch kernel directly over the given inputs, returning its output stack. +/// +/// Used by the tampering tests, which corrupt the advice inputs before execution. `BatchExecutor` +/// builds the advice internally from a (valid) `ProposedBatch` and offers no injection point, so it +/// cannot exercise the kernel's rejection paths. This mirrors how the transaction-kernel tests +/// inject tampered advice and run kernel code directly (see `tx_context.execute_code` with +/// `extend_advice_inputs` in `test_prologue.rs`). +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()) + .expect("failed to create processor") + .with_debugging(true); + let output = processor.execute_sync(&BatchKernel::main(), &mut host)?; + Ok(output.stack) +} + +// HAPPY PATH +// ================================================================================================ + +/// The batch kernel reconstructs every transaction's input notes from the advice provider, anchors +/// them in `BATCH_ID`, and emits the batch's `INPUT_NOTES_COMMITMENT`. The batch note tree root and +/// expiration outputs are not wired up yet, so they remain empty / zero. #[test] -fn batch_kernel_skeleton_emits_empty_outputs() -> anyhow::Result<()> { +fn batch_kernel_emits_input_notes_commitment() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; + let expected_input_notes_commitment = expected_input_notes_commitment(&batch); let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; let output = executed.batch_outputs(); - assert_eq!(output.input_notes_commitment(), Word::empty()); + assert_eq!(output.input_notes_commitment(), expected_input_notes_commitment); assert_eq!(output.batch_note_tree_root(), Word::empty()); assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); @@ -87,3 +145,66 @@ fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { Ok(()) } + +// NEGATIVE TESTS +// ================================================================================================ + +/// Corrupting `BATCH_ID` on the input stack makes Layer 1 unloadable from the advice map, so the +/// kernel must abort. +#[test] +fn batch_kernel_rejects_wrong_batch_id() -> anyhow::Result<()> { + let mut setup = setup_chain(); + let batch = two_tx_batch(&mut setup)?; + + let block_commitment = batch.reference_block_header().commitment(); + // A BatchId over a one-transaction subset differs from the real (two-tx) batch id, so the + // kernel cannot find its Layer 1 tuples in the advice map. + let bogus_tx = &batch.transactions()[0]; + let bogus_batch_id = BatchId::from_ids([(bogus_tx.id(), bogus_tx.account_id())]); + let stack_inputs = BatchKernel::build_input_stack(block_commitment, bogus_batch_id); + let (_, advice_inputs) = BatchKernel::prepare_inputs(&batch); + + run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort on an unknown BATCH_ID"); + + Ok(()) +} + +/// Tampering a verified `tx_id`'s Layer 2 advice-map entry breaks the per-tx header hash check. +#[test] +fn batch_kernel_rejects_tampered_layer_2() -> anyhow::Result<()> { + let mut setup = setup_chain(); + 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::from(1u32); + advice_inputs.map.extend([(tx0_id, tampered)]); + + run_kernel(stack_inputs, advice_inputs) + .expect_err("kernel must abort on a tampered transaction header"); + + 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_chain(); + 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::from(1u32); + advice_inputs.map.extend([(key, tampered)]); + + run_kernel(stack_inputs, advice_inputs) + .expect_err("kernel must abort on tampered input notes data"); + + Ok(()) +} diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 218bfcbcb8..491e47d48f 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -1,4 +1,5 @@ use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcessor}; +use miden_protocol::CoreLibrary; use miden_protocol::batch::{BatchKernel, BatchOutputs, ProposedBatch}; use miden_protocol::errors::ProvenBatchError; @@ -39,8 +40,14 @@ impl BatchExecutor { .map_err(ExecutionError::advice_error_no_context) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; + // The batch kernel calls `miden::core` procedures (poseidon2, mem, ...), so the core + // library must be available to the host at runtime. + let mut host = DefaultHost::default(); + host.load_library(CoreLibrary::default().mast_forest()) + .expect("loading the core library into the host should succeed"); + let trace_inputs = processor - .execute_trace_inputs_sync(&BatchKernel::main(), &mut DefaultHost::default()) + .execute_trace_inputs_sync(&BatchKernel::main(), &mut host) .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; // Parse and validate the output stack shape (padding cells are zero and the expiration From be707bcfe4d49b18a3b01837ca70ad91c1066650 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 14:20:23 +0000 Subject: [PATCH 27/29] test: drive batch-kernel rejection tests through BatchExecutor advice injection Add BatchExecutor::extend_advice_inputs (mirroring TransactionContextBuilder) so the kernel's rejection paths can be exercised through the normal executor with tampered advice, and drop the low-level run_kernel test helper. The three negative tests now corrupt the Layer 1/2/3 advice-map entries via the executor. --- .../kernel_tests/batch/test_batch_kernel.rs | 90 ++++++++----------- .../src/batch_executor.rs | 23 ++++- 2 files changed, 55 insertions(+), 58 deletions(-) diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index 530991aa52..68e5a97a84 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -3,11 +3,9 @@ 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::batch::{BatchId, BatchKernel, ProposedBatch}; +use miden_protocol::batch::{BatchKernel, ProposedBatch}; use miden_protocol::block::BlockNumber; -use miden_protocol::vm::{AdviceInputs, StackInputs, StackOutputs}; +use miden_protocol::vm::AdviceInputs; use miden_protocol::{Felt, Hasher, Word}; use miden_tx_batch_prover::{BatchExecutor, LocalBatchProver}; @@ -83,30 +81,24 @@ fn expected_input_notes_commitment(batch: &ProposedBatch) -> Word { } } -// EXECUTION HELPERS +// TAMPERING HELPERS // ================================================================================================ -/// Runs the batch kernel directly over the given inputs, returning its output stack. -/// -/// Used by the tampering tests, which corrupt the advice inputs before execution. `BatchExecutor` -/// builds the advice internally from a (valid) `ProposedBatch` and offers no injection point, so it -/// cannot exercise the kernel's rejection paths. This mirrors how the transaction-kernel tests -/// inject tampered advice and run kernel code directly (see `tx_context.execute_code` with -/// `extend_advice_inputs` in `test_prologue.rs`). -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()) - .expect("failed to create processor") - .with_debugging(true); - let output = processor.execute_sync(&BatchKernel::main(), &mut host)?; - Ok(output.stack) +/// Builds an advice-inputs override that corrupts the advice-map entry stored under `key`, so the +/// kernel's hash check against `key` fails. Fed to [`BatchExecutor::extend_advice_inputs`] to drive +/// the kernel's rejection paths through the normal executor (mirroring how the transaction-kernel +/// tests inject tampered advice via `extend_advice_inputs`; see `test_prologue.rs`). +fn tampered_advice_for(batch: &ProposedBatch, key: Word) -> AdviceInputs { + let (_, advice_inputs) = BatchKernel::prepare_inputs(batch); + let mut tampered: Vec = advice_inputs + .map + .get(&key) + .expect("advice-map entry for key") + .iter() + .copied() + .collect(); + tampered[0] += Felt::from(1u32); + AdviceInputs::default().with_map([(key, tampered)]) } // HAPPY PATH @@ -148,63 +140,51 @@ fn batch_executor_then_prover_produces_proven_batch() -> anyhow::Result<()> { // NEGATIVE TESTS // ================================================================================================ +// +// Each test injects a tampered advice-map entry through `BatchExecutor::extend_advice_inputs` and +// asserts the kernel aborts. The executor builds consistent advice from the (valid) `ProposedBatch` +// and the override then corrupts a single layer's entry, breaking that layer's hash check. -/// Corrupting `BATCH_ID` on the input stack makes Layer 1 unloadable from the advice map, so the -/// kernel must abort. +/// Tampering the `BATCH_ID` -> `(tx_id, account_id)` tuples breaks the Layer 1 hash check. #[test] -fn batch_kernel_rejects_wrong_batch_id() -> anyhow::Result<()> { +fn batch_kernel_rejects_tampered_layer_1() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; - let block_commitment = batch.reference_block_header().commitment(); - // A BatchId over a one-transaction subset differs from the real (two-tx) batch id, so the - // kernel cannot find its Layer 1 tuples in the advice map. - let bogus_tx = &batch.transactions()[0]; - let bogus_batch_id = BatchId::from_ids([(bogus_tx.id(), bogus_tx.account_id())]); - let stack_inputs = BatchKernel::build_input_stack(block_commitment, bogus_batch_id); - let (_, advice_inputs) = BatchKernel::prepare_inputs(&batch); + let override_advice = tampered_advice_for(&batch, batch.id().as_word()); - run_kernel(stack_inputs, advice_inputs).expect_err("kernel must abort on an unknown BATCH_ID"); + let result = BatchExecutor::new().extend_advice_inputs(override_advice).execute(batch); + assert!(result.is_err(), "kernel must abort on a tampered transaction list"); Ok(()) } -/// Tampering a verified `tx_id`'s Layer 2 advice-map entry breaks the per-tx header hash check. +/// Tampering a verified `tx_id`'s header data breaks the Layer 2 hash check. #[test] fn batch_kernel_rejects_tampered_layer_2() -> anyhow::Result<()> { let mut setup = setup_chain(); 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::from(1u32); - advice_inputs.map.extend([(tx0_id, tampered)]); + let override_advice = tampered_advice_for(&batch, tx0_id); - run_kernel(stack_inputs, advice_inputs) - .expect_err("kernel must abort on a tampered transaction header"); + let result = BatchExecutor::new().extend_advice_inputs(override_advice).execute(batch); + assert!(result.is_err(), "kernel must abort on a tampered transaction header"); Ok(()) } -/// Tampering the per-tx input-notes Layer 3 entry breaks the input-note hash check. +/// Tampering a transaction's input-notes data breaks the Layer 3 (input-notes commitment) check. #[test] fn batch_kernel_rejects_tampered_input_notes() -> anyhow::Result<()> { let mut setup = setup_chain(); 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::from(1u32); - advice_inputs.map.extend([(key, tampered)]); + let override_advice = tampered_advice_for(&batch, key); - run_kernel(stack_inputs, advice_inputs) - .expect_err("kernel must abort on tampered input notes data"); + let result = BatchExecutor::new().extend_advice_inputs(override_advice).execute(batch); + assert!(result.is_err(), "kernel must abort on tampered input-notes data"); Ok(()) } diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 491e47d48f..6d90516eba 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -2,6 +2,7 @@ use miden_processor::{DefaultHost, ExecutionError, ExecutionOptions, FastProcess use miden_protocol::CoreLibrary; use miden_protocol::batch::{BatchKernel, BatchOutputs, ProposedBatch}; use miden_protocol::errors::ProvenBatchError; +use miden_protocol::vm::AdviceInputs; use crate::ExecutedBatch; @@ -10,12 +11,26 @@ use crate::ExecutedBatch; /// Executes the batch kernel over a [`ProposedBatch`], producing an [`ExecutedBatch`]. #[derive(Clone, Default)] -pub struct BatchExecutor; +pub struct BatchExecutor { + /// Extra advice inputs merged onto those derived from the proposed batch before execution. + advice_inputs: AdviceInputs, +} impl BatchExecutor { /// Creates a new [`BatchExecutor`] instance. pub fn new() -> Self { - Self + Self::default() + } + + /// Extends the advice inputs merged onto those derived from the proposed batch before + /// execution. Entries provided here override matching keys from the derived advice. + /// + /// This is primarily a testing hook for exercising the batch kernel's rejection paths by + /// injecting tampered advice, mirroring + /// [`TransactionContextBuilder::extend_advice_inputs`](https://docs.rs/miden-testing). + pub fn extend_advice_inputs(mut self, advice_inputs: AdviceInputs) -> Self { + self.advice_inputs.extend(advice_inputs); + self } /// Runs the batch kernel over the [`ProposedBatch`], returning an [`ExecutedBatch`] that can be @@ -30,7 +45,9 @@ impl BatchExecutor { &self, proposed_batch: ProposedBatch, ) -> Result { - let (stack_inputs, advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + let (stack_inputs, mut advice_inputs) = BatchKernel::prepare_inputs(&proposed_batch); + // Merge any caller-provided advice, overriding matching keys from the derived advice. + advice_inputs.extend(self.advice_inputs.clone()); let processor = FastProcessor::new_with_options( stack_inputs, From 093ac8e14ec7accef405660f437dea689d82e707 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 14:20:23 +0000 Subject: [PATCH 28/29] style: use the '# =>' stack-comment convention in the batch kernel masm Match the operand-stack (# =>) and advice-stack (# AS =>) comment style used throughout the rest of the protocol assembly. --- .../asm/kernels/batch/lib/note_tracker.masm | 38 +++++++++---------- .../asm/kernels/batch/lib/prologue.masm | 28 +++++++------- .../asm/kernels/batch/main.masm | 10 ++--- 3 files changed, 38 insertions(+), 38 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 2603562851..1b5f4853a6 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/note_tracker.masm @@ -22,39 +22,39 @@ proc absorb_scratch_into_batch_hasher push.0 exec.memory::set_scratch_word_index exec.memory::load_batch_hasher_state - # Stack: [RATE0, RATE1, CAPACITY] + # => [RATE0, RATE1, CAPACITY] exec.memory::get_scratch_word_index exec.memory::get_scratch_words_count u32lt - # Stack: [should_loop, RATE0, RATE1, CAPACITY] + # => [should_loop, RATE0, RATE1, CAPACITY] while.true exec.memory::get_scratch_word_index mul.4 add.TX_NOTES_SCRATCH_PTR - # Stack: [scratch_ptr, RATE0, RATE1, CAPACITY] + # => [scratch_ptr, RATE0, RATE1, CAPACITY] padw dup.4 mem_loadw_le - # Stack: [DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + # => [DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] padw dup.8 add.4 mem_loadw_le - # Stack: [DATA2, DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] + # => [DATA2, DATA1, scratch_ptr, RATE0, RATE1, CAPACITY] movup.8 drop - # Stack: [DATA2, DATA1, RATE0, RATE1, CAPACITY] + # => [DATA2, DATA1, RATE0, RATE1, CAPACITY] # Replace RATE0 + RATE1 with DATA1 + DATA2 (DATA1 first, DATA2 second), then permute. swapdw dropw dropw swapw exec.poseidon2::permute - # Stack: [RATE0', RATE1', CAPACITY'] + # => [RATE0', RATE1', CAPACITY'] 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] + # => [should_loop, RATE0, RATE1, CAPACITY] end exec.memory::save_batch_hasher_state @@ -87,51 +87,51 @@ pub proc compute_input_notes_commitment exec.memory::get_num_transactions push.0 - # Stack: [tx_index, num_transactions] + # => [tx_index, num_transactions] dup.1 dup.1 neq - # Stack: [should_loop, tx_index, num_transactions] + # => [should_loop, tx_index, num_transactions] while.true dup exec.memory::get_tx_input_notes_commitment - # Stack: [INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + # => [INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] dupw exec.word::eqz - # Stack: [is_empty, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + # => [is_empty, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] if.true dropw else push.TX_NOTES_SCRATCH_PTR movdn.4 - # Stack: [INPUT_NOTES_COMMITMENT_i, tx_notes_scratch_ptr, tx_index, num_transactions] + # => [INPUT_NOTES_COMMITMENT_i, tx_notes_scratch_ptr, tx_index, num_transactions] adv.push_mapvaln adv_push div.4 - # Stack: [num_words, INPUT_NOTES_COMMITMENT_i, tx_notes_scratch_ptr, tx_index, num_transactions] + # => [num_words, INPUT_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, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] + # => [num_words, tx_notes_scratch_ptr, INPUT_NOTES_COMMITMENT_i, tx_index, num_transactions] # Pipe the note data into scratch, asserting its poseidon2 hash equals # INPUT_NOTES_COMMITMENT_i. exec.mem::pipe_preimage_to_memory - # Stack: [end_ptr, tx_index, num_transactions] + # => [end_ptr, tx_index, num_transactions] drop exec.absorb_scratch_into_batch_hasher end - # Stack: [tx_index, num_transactions] + # => [tx_index, num_transactions] add.1 dup.1 dup.1 neq - # Stack: [should_loop, tx_index, num_transactions] + # => [should_loop, tx_index, num_transactions] end drop drop exec.memory::load_batch_hasher_state exec.poseidon2::squeeze_digest - # Stack: [INPUT_NOTES_COMMITMENT] + # => [INPUT_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 index f14df2597b..1eb38aa924 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -44,25 +44,25 @@ pub proc prepare_batch # Layer 1: pipe BATCH_ID's mapped value to tx_tuples_ptr + verify. # --------------------------------------------------------------------------------------------- - # Stack: [BATCH_ID] + # => [BATCH_ID] push.TX_TUPLES_PTR movdn.4 - # Stack: [BATCH_ID, tx_tuples_ptr] + # => [BATCH_ID, tx_tuples_ptr] adv.push_mapvaln - # AS: [len_felts, data...] + # AS => [len_felts, data...] adv_push div.4 - # Stack: [num_words, BATCH_ID, tx_tuples_ptr] + # => [num_words, BATCH_ID, 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 movup.5 swap - # Stack: [num_words, tx_tuples_ptr, BATCH_ID] + # => [num_words, tx_tuples_ptr, BATCH_ID] # Pipe the tuples into memory, asserting their poseidon2 hash equals BATCH_ID. exec.mem::pipe_preimage_to_memory - # Stack: [end_ptr] + # => [end_ptr] drop # Layer 2: for each transaction, pipe + verify its header. @@ -70,33 +70,33 @@ pub proc prepare_batch exec.memory::get_num_transactions push.0 - # Stack: [tx_index, num_transactions] + # => [tx_index, num_transactions] dup.1 dup.1 neq - # Stack: [should_loop, tx_index, num_transactions] + # => [should_loop, tx_index, num_transactions] while.true dup exec.memory::get_tx_id - # Stack: [TX_ID, tx_index, num_transactions] + # => [TX_ID, tx_index, num_transactions] dup.4 exec.memory::tx_header_ptr movdn.4 - # Stack: [TX_ID, tx_header_ptr, tx_index, num_transactions] + # => [TX_ID, tx_header_ptr, tx_index, num_transactions] adv.push_mapvaln adv_push div.4 - # Stack: [num_words, TX_ID, tx_header_ptr, tx_index, num_transactions] + # => [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] + # => [num_words, tx_header_ptr, TX_ID, tx_index, num_transactions] # Pipe the header into memory, asserting its poseidon2 hash equals TX_ID. exec.mem::pipe_preimage_to_memory - # Stack: [end_ptr, tx_index, num_transactions] + # => [end_ptr, tx_index, num_transactions] drop add.1 dup.1 dup.1 neq - # Stack: [should_loop, tx_index, num_transactions] + # => [should_loop, tx_index, num_transactions] end drop drop diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index 67f8b8129e..ff5d008344 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -52,24 +52,24 @@ use miden::batch_kernel::prologue #! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. #! TODO: recursively verify each transaction's `ExecutionProof`. proc main - # Stack: [BLOCK_COMMITMENT, BATCH_ID, pad(8)] + # => [BLOCK_COMMITMENT, BATCH_ID, pad(8)] # TODO: verify BLOCK_COMMITMENT against block header data via the pipe-and-verify pattern. dropw - # Stack: [BATCH_ID, pad(12)] + # => [BATCH_ID, pad(12)] exec.prologue::prepare_batch - # Stack: [pad(16)] + # => [pad(16)] exec.note_tracker::compute_input_notes_commitment - # Stack: [INPUT_NOTES_COMMITMENT, pad(16)] + # => [INPUT_NOTES_COMMITMENT, pad(16)] # Drop the surplus padding word so the stack holds exactly the 16-felt output region # [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num, pad(7)]. The # BATCH_NOTE_TREE_ROOT and batch_expiration_block_num cells stay zero (wired up in follow-up # PRs). movupw.3 dropw - # Stack: [INPUT_NOTES_COMMITMENT, pad(12)] + # => [INPUT_NOTES_COMMITMENT, pad(12)] end begin From b46a277a6db889563632247d17748a8d7d51640d Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Mon, 1 Jun 2026 10:24:25 +0000 Subject: [PATCH 29/29] feat: emit batch_expiration_block_num as the running-min over transactions The batch kernel accumulates the minimum expiration_block_num across all transactions (popped per-tx from the advice stack during Layer 2 header verification) and emits it as the batch_expiration_block_num output. BatchExecutor cross-checks the kernel's value against the expiration the ProposedBatch independently derives, erroring with BatchExpirationMismatch on disagreement. --- CHANGELOG.md | 1 + .../asm/kernels/batch/lib/memory.masm | 25 +++++++++++++ .../asm/kernels/batch/lib/prologue.masm | 37 ++++++++++++++++++- .../asm/kernels/batch/main.masm | 36 +++++++++++++----- crates/miden-protocol/src/batch/kernel.rs | 12 +++++- crates/miden-protocol/src/errors/mod.rs | 7 ++++ .../kernel_tests/batch/test_batch_kernel.rs | 13 +++++-- .../src/batch_executor.rs | 14 ++++++- 8 files changed, 127 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13a5986d0..aca0a5e54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Added a skeleton batch kernel ([#1122](https://github.com/0xMiden/protocol/issues/1122)) wired through `LocalBatchProver::prove` and attached to `ProvenBatch` as an `ExecutionProof`. It does not yet perform any verification. - The batch kernel now verifies the batch's transaction list against its `BATCH_ID`, verifies each transaction header, and computes the batch `INPUT_NOTES_COMMITMENT` ([#2905](https://github.com/0xMiden/protocol/pull/2905)). +- The batch kernel now emits `batch_expiration_block_num`, the running minimum of its transactions' expiration block numbers, cross-checked against the proposed batch ([#3019](https://github.com/0xMiden/protocol/pull/3019)). - [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)). - [BREAKING] Extracted `NullifierTreeBackendReader` and `AccountTreeBackendReader` traits from existing `NullifierTreeBackend` and `AccountTreeBackend` traits ([#2755](https://github.com/0xMiden/protocol/pull/2755)). diff --git a/crates/miden-protocol/asm/kernels/batch/lib/memory.masm b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm index 08acac7d41..0f0ad7846a 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/memory.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/memory.masm @@ -7,6 +7,8 @@ # | Address range | Constant | Contents | # +-------------------+-------------------------+----------------------------------+ # | 0 | NUM_TRANSACTIONS_PTR | num_transactions (1 felt). | +# | 1 | BATCH_EXPIRATION_PTR | batch_expiration_block_num, | +# | | | the running min over all txs. | # | 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. | @@ -39,6 +41,10 @@ #! Single-felt slot holding `num_transactions` after Layer 1 verification. const NUM_TRANSACTIONS_PTR=0 +#! Single-felt slot holding `batch_expiration_block_num`: the running minimum of every +#! transaction's `expiration_block_num`, initialised to `u32::MAX`. +const BATCH_EXPIRATION_PTR=1 + # BATCH HASHER STATE # ================================================================================================= @@ -107,6 +113,25 @@ 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 # ================================================================================================= diff --git a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm index 1eb38aa924..b2350fd4f5 100644 --- a/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm +++ b/crates/miden-protocol/asm/kernels/batch/lib/prologue.masm @@ -3,6 +3,12 @@ use miden::core::mem use miden::batch_kernel::memory use miden::batch_kernel::memory::TX_TUPLES_PTR +# CONSTANTS +# ================================================================================================= + +#! Maximum value of `batch_expiration_block_num`, used as the running-min initialiser. +const MAX_BLOCK_NUM=0xFFFFFFFF + # PROLOGUE # ================================================================================================= @@ -16,7 +22,8 @@ use miden::batch_kernel::memory::TX_TUPLES_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. +#! per-tx offset. For each transaction, pops its `expiration_block_num` from the advice stack and +#! updates the running minimum in [`memory::BATCH_EXPIRATION_PTR`]. #! #! `BATCH_ID` is the batch's `BatchId` value, i.e. the sequential hash of the `(tx_id, account_id)` #! tuples committing to the transactions in the batch. @@ -28,6 +35,7 @@ use miden::batch_kernel::memory::TX_TUPLES_PTR #! 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: [] @@ -40,6 +48,9 @@ use miden::batch_kernel::memory::TX_TUPLES_PTR #! TODO: verify that each transaction's reference block is contained in the chain MMR rooted at #! BLOCK_COMMITMENT. #! TODO: verify that the partial-blockchain peaks hash matches the block header's chain commitment. +#! TODO: assert each `expiration_block_num_i > reference_block_num`. +#! TODO: derive each `expiration_block_num_i` from data committed-to in the verified transaction +#! header rather than from the unverified advice stack. pub proc prepare_batch # Layer 1: pipe BATCH_ID's mapped value to tx_tuples_ptr + verify. # --------------------------------------------------------------------------------------------- @@ -65,7 +76,12 @@ pub proc prepare_batch # => [end_ptr] drop - # Layer 2: for each transaction, pipe + verify its header. + # 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 @@ -94,6 +110,23 @@ pub proc prepare_batch # => [end_ptr, tx_index, num_transactions] drop + # Accumulate the expiration running-min from the advice stack. + adv_push + # => [expiration, tx_index, num_transactions] + + dup exec.memory::get_batch_expiration_block_num + # => [current_min, expiration, expiration, tx_index, num_transactions] + + u32lt + # => [is_new_min, expiration, tx_index, num_transactions] + + if.true + exec.memory::set_batch_expiration_block_num + else + drop + end + # => [tx_index, num_transactions] + add.1 dup.1 dup.1 neq # => [should_loop, tx_index, num_transactions] diff --git a/crates/miden-protocol/asm/kernels/batch/main.masm b/crates/miden-protocol/asm/kernels/batch/main.masm index ff5d008344..b751b733fc 100644 --- a/crates/miden-protocol/asm/kernels/batch/main.masm +++ b/crates/miden-protocol/asm/kernels/batch/main.masm @@ -1,3 +1,6 @@ +use miden::core::sys + +use miden::batch_kernel::memory use miden::batch_kernel::note_tracker use miden::batch_kernel::prologue @@ -9,7 +12,7 @@ use miden::batch_kernel::prologue #! 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 reconstructs the per-transaction data #! from the advice provider, anchoring the reconstruction in the public `BATCH_ID`, and emits the -#! batch's `INPUT_NOTES_COMMITMENT`. +#! batch's `INPUT_NOTES_COMMITMENT` and its effective `batch_expiration_block_num`. #! #! 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 `BATCH_ID` is anchored to the public input, @@ -43,12 +46,14 @@ use miden::batch_kernel::prologue #! - INPUT_NOTES_COMMITMENT is the sequential hash over every transaction's #! `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples. #! - BATCH_NOTE_TREE_ROOT is currently not wired up (empty word). -#! - batch_expiration_block_num is currently not wired-up (zero). +#! - batch_expiration_block_num is the minimum of every transaction's `expiration_block_num`. #! #! TODO: verify BLOCK_COMMITMENT against block header data via the same pipe-and-verify pattern. #! TODO: authenticate unauthenticated input notes against BLOCK_COMMITMENT's chain MMR. #! TODO: erase intra-batch unauthenticated notes from INPUT_NOTES_COMMITMENT. -#! TODO: emit BATCH_NOTE_TREE_ROOT (the batch note tree SMT root) and batch_expiration_block_num. +#! TODO: emit BATCH_NOTE_TREE_ROOT (the batch note tree SMT root). +#! TODO: derive batch_expiration_block_num from verified data rather than the unverified advice +#! stack (see `prologue::prepare_batch`). #! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. #! TODO: recursively verify each transaction's `ExecutionProof`. proc main @@ -64,12 +69,25 @@ proc main exec.note_tracker::compute_input_notes_commitment # => [INPUT_NOTES_COMMITMENT, pad(16)] - # Drop the surplus padding word so the stack holds exactly the 16-felt output region - # [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num, pad(7)]. The - # BATCH_NOTE_TREE_ROOT and batch_expiration_block_num cells stay zero (wired up in follow-up - # PRs). - movupw.3 dropw - # => [INPUT_NOTES_COMMITMENT, pad(12)] + # Assemble the output region [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, expiration]. The + # BATCH_NOTE_TREE_ROOT word stays empty for now (wired up in a follow-up PR). + padw + # => [BATCH_NOTE_TREE_ROOT, INPUT_NOTES_COMMITMENT, pad(16)] + + exec.memory::get_batch_expiration_block_num + # => [batch_expiration_block_num, BATCH_NOTE_TREE_ROOT, INPUT_NOTES_COMMITMENT, pad(16)] + + movdn.8 + # => [BATCH_NOTE_TREE_ROOT, INPUT_NOTES_COMMITMENT, batch_expiration_block_num, pad(16)] + + swapw + # => [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, batch_expiration_block_num, pad(16)] + + # batch_expiration_block_num is a single felt, so the output region (9 felts) is not + # word-aligned and the surplus padding cannot be dropped in whole words; truncate the operand + # stack to the 16-felt output [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, + # batch_expiration_block_num, pad(7)]. + exec.sys::truncate_stack end begin diff --git a/crates/miden-protocol/src/batch/kernel.rs b/crates/miden-protocol/src/batch/kernel.rs index fd8aa63286..81a5c7cc2d 100644 --- a/crates/miden-protocol/src/batch/kernel.rs +++ b/crates/miden-protocol/src/batch/kernel.rs @@ -111,7 +111,10 @@ impl BatchKernel { /// `TransactionId::input_elements`). /// - each per-tx `INPUT_NOTES_COMMITMENT` -> the `(NULLIFIER, EMPTY_OR_COMMITMENT)` tuples. /// - /// The per-tx output-notes layer and the expiration data are wired up in follow-up PRs. + /// It also pushes each transaction's `expiration_block_num` onto the advice stack, from which + /// the kernel accumulates the batch-wide running minimum. + /// + /// The per-tx output-notes layer is wired up in a follow-up PR. fn build_advice_inputs(proposed_batch: &ProposedBatch) -> AdviceInputs { let mut advice_inputs = AdviceInputs::default(); @@ -153,6 +156,13 @@ impl BatchKernel { } } + // Advice stack: each transaction's expiration_block_num, in transaction order. The kernel + // pops one per transaction and accumulates the running minimum; order is irrelevant to the + // resulting minimum. + for tx in proposed_batch.transactions().iter() { + advice_inputs.stack.push(Felt::from(tx.expiration_block_num())); + } + advice_inputs } } diff --git a/crates/miden-protocol/src/errors/mod.rs b/crates/miden-protocol/src/errors/mod.rs index 5bb698ec0b..59e0f0d67c 100644 --- a/crates/miden-protocol/src/errors/mod.rs +++ b/crates/miden-protocol/src/errors/mod.rs @@ -1096,6 +1096,13 @@ pub enum ProvenBatchError { BatchKernelExecutionFailed(#[source] ExecutionError), #[error("batch kernel produced an invalid output stack")] BatchKernelOutputInvalid(#[source] BatchOutputError), + #[error( + "batch kernel computed expiration block number {actual} but the proposed batch expected {expected}" + )] + BatchExpirationMismatch { + actual: BlockNumber, + expected: BlockNumber, + }, } // BATCH OUTPUT ERROR diff --git a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs index 68e5a97a84..05e6188c79 100644 --- a/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs +++ b/crates/miden-testing/src/kernel_tests/batch/test_batch_kernel.rs @@ -105,20 +105,25 @@ fn tampered_advice_for(batch: &ProposedBatch, key: Word) -> AdviceInputs { // ================================================================================================ /// The batch kernel reconstructs every transaction's input notes from the advice provider, anchors -/// them in `BATCH_ID`, and emits the batch's `INPUT_NOTES_COMMITMENT`. The batch note tree root and -/// expiration outputs are not wired up yet, so they remain empty / zero. +/// them in `BATCH_ID`, and emits the batch's `INPUT_NOTES_COMMITMENT` and the running-min +/// `batch_expiration_block_num`. The batch note tree root is not wired up yet, so it remains empty. #[test] -fn batch_kernel_emits_input_notes_commitment() -> anyhow::Result<()> { +fn batch_kernel_emits_input_notes_commitment_and_expiration() -> anyhow::Result<()> { let mut setup = setup_chain(); let batch = two_tx_batch(&mut setup)?; let expected_input_notes_commitment = expected_input_notes_commitment(&batch); + // The expected expiration is the minimum over the batch's transactions, which the proposed + // batch derives independently of the kernel. + let expected_expiration = batch.batch_expiration_block_num(); let executed = BatchExecutor::new().execute(batch).context("batch execution failed")?; let output = executed.batch_outputs(); assert_eq!(output.input_notes_commitment(), expected_input_notes_commitment); assert_eq!(output.batch_note_tree_root(), Word::empty()); - assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(0u32)); + assert_eq!(output.batch_expiration_block_num(), expected_expiration); + // Sanity check: the min of the two transactions' expirations (1234 and 800). + assert_eq!(output.batch_expiration_block_num(), BlockNumber::from(800u32)); Ok(()) } diff --git a/crates/miden-tx-batch-prover/src/batch_executor.rs b/crates/miden-tx-batch-prover/src/batch_executor.rs index 6d90516eba..a37a5fb76c 100644 --- a/crates/miden-tx-batch-prover/src/batch_executor.rs +++ b/crates/miden-tx-batch-prover/src/batch_executor.rs @@ -68,11 +68,21 @@ impl BatchExecutor { .map_err(ProvenBatchError::BatchKernelExecutionFailed)?; // Parse and validate the output stack shape (padding cells are zero and the expiration - // fits in u32); the actual output values themselves are not checked until the kernel - // verifies them. + // fits in u32). let batch_outputs = BatchOutputs::parse(trace_inputs.stack_outputs()) .map_err(ProvenBatchError::BatchKernelOutputInvalid)?; + // Cross-check the kernel's running-min expiration against the value the proposed batch + // independently derived from its transactions. + let expected_expiration = proposed_batch.batch_expiration_block_num(); + let actual_expiration = batch_outputs.batch_expiration_block_num(); + if actual_expiration != expected_expiration { + return Err(ProvenBatchError::BatchExpirationMismatch { + actual: actual_expiration, + expected: expected_expiration, + }); + } + Ok(ExecutedBatch::new(proposed_batch, trace_inputs, batch_outputs)) } }