diff --git a/tests/fixtures/batch-kernel/.cargo/config.toml b/tests/fixtures/batch-kernel/.cargo/config.toml new file mode 100644 index 000000000..ff10b8f32 --- /dev/null +++ b/tests/fixtures/batch-kernel/.cargo/config.toml @@ -0,0 +1,8 @@ +# This fixture is intended to be built as Wasm for the Miden VM. + +[build] +target = "wasm32-wasip2" + +[target.wasm32-wasip2] +# Force-enable `cfg(miden)` for Miden-VM-targeted builds (including editor/LSP workflows). +rustflags = ["--cfg", "miden"] diff --git a/tests/fixtures/batch-kernel/.gitignore b/tests/fixtures/batch-kernel/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/tests/fixtures/batch-kernel/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tests/fixtures/batch-kernel/Cargo.toml b/tests/fixtures/batch-kernel/Cargo.toml new file mode 100644 index 000000000..25eb46bc1 --- /dev/null +++ b/tests/fixtures/batch-kernel/Cargo.toml @@ -0,0 +1,22 @@ +cargo-features = ["trim-paths"] + +[package] +name = "batch-kernel" +version = "0.1.0" +edition = "2024" + +[lib] +# Build this crate as a self-contained, C-style dynamic library +# This is required to emit the proper Wasm module type +crate-type = ["cdylib"] + +[dependencies] +miden-sdk-alloc = { path = "../../../sdk/alloc" } +miden-stdlib-sys = { path = "../../../sdk/stdlib-sys" } + +[profile.release] +panic = "abort" +# optimize for size +opt-level = "z" +debug = false +trim-paths = ["diagnostics", "object"] diff --git a/tests/fixtures/batch-kernel/miden-project.toml b/tests/fixtures/batch-kernel/miden-project.toml new file mode 100644 index 000000000..42fd7055a --- /dev/null +++ b/tests/fixtures/batch-kernel/miden-project.toml @@ -0,0 +1,10 @@ +[package] +name = "batch-kernel" +version = "0.1.0" + +[[bin]] +name = "batch-kernel" +path = "src/lib.rs" + +[dependencies] +miden-core = "*" diff --git a/tests/fixtures/batch-kernel/src/epilogue.rs b/tests/fixtures/batch-kernel/src/epilogue.rs new file mode 100644 index 000000000..2b2cd6418 --- /dev/null +++ b/tests/fixtures/batch-kernel/src/epilogue.rs @@ -0,0 +1,131 @@ +//! Batch kernel epilogue: verifies the note-tracking results and computes the batch's note +//! commitments. +//! +//! Mirrors `asm/kernels/batch/lib/epilogue.masm` from the protocol batch kernel +//! (0xMiden/protocol#2905). + +extern crate alloc; +use alloc::vec::Vec; + +use miden_stdlib_sys::{Felt, Word, felt, hash_elements}; + +use crate::memory::{ + BatchMemory, INPUT_FLAGS_STRIDE, NOTE_ENTRY_FELT_LEN, OUTPUT_FLAGS_STRIDE, erasure_erased, + erasure_expected, +}; + +// ASSERTIONS +// ================================================================================================= + +/// Asserts every output-note list entry was created by exactly one transaction (with the per-tx +/// binding, this proves the list is exactly the union of the per-transaction output notes, so a +/// host cannot fabricate an erasure with a note no transaction creates). +/// +/// The matching input-note checks (every entry consumed exactly once, none left pending-erasure) +/// are folded into the single pass in [`compute_input_notes_commitment`]. +#[inline(always)] +fn assert_all_output_notes_created(memory: &BatchMemory) { + for flags in memory.output_note_flags().chunks_exact(OUTPUT_FLAGS_STRIDE) { + assert!( + flags[1] != felt!(0), + "an output-note list entry was not created by any transaction" + ); + } +} + +// INPUT NOTES COMMITMENT +// ================================================================================================= + +/// Computes `INPUT_NOTES_COMMITMENT` as the sequential hash of the non-erased +/// `(NULLIFIER, NOTE_ID_OR_EMPTY)` entries of the nullifier-sorted input-note list (entries with +/// erasure flag `Erased` are skipped). +/// +/// The pass over the input entries also enforces the epilogue invariants on each entry: it was +/// consumed exactly once and is not left expected-to-be-erased (i.e. any created-and-consumed +/// note had its creator processed). +/// +/// The MASM kernel absorbs the entries into an incremental hasher state persisted in memory; +/// here the sorted note list buffer is handed to `hash_elements` whole in the common no-erasure +/// case (which is why this takes `BatchMemory` by value), and only batches with erased notes +/// collect the surviving entries into a fresh buffer first. Both produce the same sequential +/// hash (matching `Hasher::hash_elements`). +#[inline(always)] +fn compute_input_notes_commitment(memory: BatchMemory) -> Word { + let num_notes = memory.num_input_notes(); + let mut num_erased = 0; + + for flags in memory.input_note_flags().chunks_exact(INPUT_FLAGS_STRIDE) { + // Assert this entry was consumed exactly once and is not left expected-to-be-erased. + assert!( + flags[1] != felt!(0), + "an input-note list entry was not consumed by any transaction" + ); + let erasure = flags[0]; + assert!( + erasure != erasure_expected(), + "an erased input note was consumed before the transaction that creates it" + ); + if erasure == erasure_erased() { + num_erased += 1; + } + } + + // With no non-erased entries the commitment is the empty word, matching the early return in + // `build_input_note_commitment` (this is not the hash of zero elements). + if num_erased == num_notes { + return Word::empty(); + } + + // Common case: nothing erased, hash the sorted note list buffer as-is. + if num_erased == 0 { + return Word::from(hash_elements(memory.input_notes)); + } + + // Some entries were erased: collect the surviving entries and hash those. + let mut elements: Vec = + Vec::with_capacity((num_notes - num_erased) * NOTE_ENTRY_FELT_LEN); + for (entry, flags) in memory + .input_notes + .chunks_exact(NOTE_ENTRY_FELT_LEN) + .zip(memory.input_note_flags().chunks_exact(INPUT_FLAGS_STRIDE)) + { + if flags[0] != erasure_erased() { + elements.extend_from_slice(entry); + } + } + Word::from(hash_elements(elements)) +} + +// OUTPUT NOTES COMMITMENT +// ================================================================================================= + +/// Computes the batch's output-notes commitment (the batch note tree root). +/// +/// Placeholder: returns the empty word until the batch note tree is wired up. +/// +/// TODO: hash the batch's output notes into the batch note tree (SMT) root. +#[inline(always)] +fn compute_output_notes_commitment(_memory: &BatchMemory) -> Word { + Word::empty() +} + +// EPILOGUE +// ================================================================================================= + +/// Verifies the note-tracking results and computes the batch's note commitments, returned as +/// `(INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT)`. Consumes the batch state (the input-note +/// buffer becomes the hash input in the no-erasure case). +/// +/// Asserts every output-note list entry was created, then computes the input- and output-notes +/// commitments. The per-input-entry invariants (consumed exactly once, no pending erasure) are +/// enforced inside the input-commitment pass. +/// +/// TODO: authenticate unauthenticated, non-erased input notes against BLOCK_COMMITMENT's chain +/// MMR. +#[inline(always)] +pub fn finalize(memory: BatchMemory) -> (Word, Word) { + assert_all_output_notes_created(&memory); + let output_notes_commitment = compute_output_notes_commitment(&memory); + let input_notes_commitment = compute_input_notes_commitment(memory); + (input_notes_commitment, output_notes_commitment) +} diff --git a/tests/fixtures/batch-kernel/src/lib.rs b/tests/fixtures/batch-kernel/src/lib.rs new file mode 100644 index 000000000..2af417283 --- /dev/null +++ b/tests/fixtures/batch-kernel/src/lib.rs @@ -0,0 +1,118 @@ +//! Rust implementation of the Miden protocol batch kernel, compiled to Miden Assembly by the +//! Miden compiler. +//! +//! 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 validates the per-transaction data +//! supplied via the advice provider, and computes the batch's `INPUT_NOTES_COMMITMENT` and its +//! effective `batch_expiration_block_num`. +//! +//! The implementation mirrors the MASM batch kernel of the protocol repository — the phase +//! structure and checks follow `asm/kernels/batch/{main.masm,lib/*.masm}` of +//! 0xMiden/protocol#2905 (with the expiration running-minimum of 0xMiden/protocol#3019): +//! +//! - [`prologue`]: "unhashes" the layered advice data anchored at the public `BATCH_ID`. Each +//! layer of advice data is keyed by a hash the previous layer verified, so every element that +//! makes up `INPUT_NOTES_COMMITMENT` is transitively committed-to: +//! +//! - `BATCH_ID` (public input) -> `(tx_id, account_id)` tuple list +//! - each `tx_id` -> per-tx `(INIT, FINAL, INPUT_NOTES_COMMITMENT_i, +//! OUTPUT_NOTES_COMMITMENT_i)` +//! - each `INPUT_NOTES_COMMITMENT_i` -> `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuples +//! +//! - [`note_tracker`]: determines intra-batch note erasure and binds the host-provided sorted +//! note lists to the verified per-transaction notes. +//! - [`epilogue`]: enforces the tracking invariants and computes the output commitments. +//! +//! # Inputs +//! +//! - operand stack: `BLOCK_COMMITMENT` and `BATCH_ID`, one felt per parameter (the MASM kernel's +//! `[BLOCK_COMMITMENT, BATCH_ID, pad(8)]` public inputs), plus the output pointer. +//! - advice map and stack: see [`prologue::prepare_batch`]. +//! +//! # Outputs +//! +//! The MASM kernel's output stack is `[INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT, +//! batch_expiration_block_num, pad(7)]`. Here the two commitment words are written to `out_ptr` +//! and `batch_expiration_block_num` is the return value: +//! +//! - `INPUT_NOTES_COMMITMENT` is the nullifier-sorted sequential hash over the batch's input +//! notes, excluding notes created and consumed within the batch (i.e. post-erasure). +//! - `BATCH_NOTE_TREE_ROOT` is emitted as the empty word (not yet computed). +//! - `batch_expiration_block_num` is the minimum of every transaction's `expiration_block_num`. +//! +//! TODO: verify BLOCK_COMMITMENT against block header data. +//! TODO: authenticate unauthenticated, non-erased input notes against BLOCK_COMMITMENT's chain +//! MMR. +//! TODO: emit BATCH_NOTE_TREE_ROOT (the batch note tree SMT root). +//! TODO: aggregate per-account updates and emit a separate ACCOUNT_UPDATES_COMMITMENT output. +//! TODO: recursively verify each transaction's `ExecutionProof`. + +#![no_std] +#![no_main] +#![feature(alloc_error_handler)] + +extern crate alloc; + +#[global_allocator] +static ALLOC: miden_sdk_alloc::BumpAlloc = miden_sdk_alloc::BumpAlloc::new(); + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +#[alloc_error_handler] +fn alloc_error(_layout: core::alloc::Layout) -> ! { + core::arch::wasm32::unreachable() +} + +mod epilogue; +mod memory; +mod note_tracker; +mod prologue; + +use miden_stdlib_sys::{Felt, Word}; + +/// Batch kernel program. +/// +/// See the crate documentation for the input/output contract. +// The entrypoint is invoked by the VM with its arguments on the operand stack, so `unsafe fn` +// would not communicate anything to its caller; `out_ptr` validity is the executing host's +// responsibility. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[unsafe(no_mangle)] +#[allow(improper_ctypes_definitions)] +pub extern "C" fn entrypoint( + out_ptr: *mut Felt, + _block_commitment0: Felt, + _block_commitment1: Felt, + _block_commitment2: Felt, + _block_commitment3: Felt, + batch_id0: Felt, + batch_id1: Felt, + batch_id2: Felt, + batch_id3: Felt, +) -> Felt { + // TODO: verify BLOCK_COMMITMENT against block header data via the pipe-and-verify pattern + // (the MASM kernel drops it the same way). + let batch_id = Word::from([batch_id0, batch_id1, batch_id2, batch_id3]); + + let mut memory = prologue::prepare_batch(batch_id); + + note_tracker::track_notes(&mut memory); + + let batch_expiration_block_num = memory.batch_expiration_block_num; + let (input_notes_commitment, batch_note_tree_root) = epilogue::finalize(memory); + + // Lay the output words out at `out_ptr`: [INPUT_NOTES_COMMITMENT, BATCH_NOTE_TREE_ROOT]. + for (offset, felt) in input_notes_commitment + .as_elements() + .iter() + .chain(batch_note_tree_root.as_elements().iter()) + .enumerate() + { + unsafe { out_ptr.add(offset).write(*felt) }; + } + + batch_expiration_block_num +} diff --git a/tests/fixtures/batch-kernel/src/memory.rs b/tests/fixtures/batch-kernel/src/memory.rs new file mode 100644 index 000000000..ec24b348a --- /dev/null +++ b/tests/fixtures/batch-kernel/src/memory.rs @@ -0,0 +1,255 @@ +//! Batch state shared between the kernel phases. +//! +//! Mirrors `asm/kernels/batch/lib/memory.masm` from the protocol batch kernel +//! (0xMiden/protocol#2905). The MASM module lays the batch state out as flat felt regions +//! (transaction tuples, transaction headers, sorted note lists and their parallel flag arrays) +//! and exposes accessors over them; the Rust equivalent keeps the same flat felt layout in +//! [`BatchMemory`]'s buffers — the advice data is stored exactly as piped, with word accessors +//! over it — which avoids re-decoding the piped data into per-entry structures. The +//! per-transaction note scratch region has no counterpart here: each transaction's notes are +//! decoded into a temporary that is dropped when the transaction has been processed. + +extern crate alloc; +use alloc::vec::Vec; + +use miden_stdlib_sys::{Felt, Word, felt}; + +// CAPACITY LIMITS +// ================================================================================================= + +/// Maximum number of transactions in a batch. +pub const MAX_TRANSACTIONS_PER_BATCH: usize = 1024; + +/// Maximum number of entries in a sorted note list and in a single transaction's note set. +/// Equals `MAX_INPUT_NOTES_PER_BATCH` = `MAX_OUTPUT_NOTES_PER_BATCH` in the protocol. +pub const MAX_NOTES_PER_BATCH: usize = 1024; + +/// Number of felts each transaction tuple occupies in the Layer 1 piped data. +pub const TX_TUPLE_FELT_LEN: usize = 8; + +/// Number of felts each transaction header occupies in the Layer 2 piped data. This must match +/// the felt-sequence layout of `TransactionId::new`. +pub const TX_HEADER_FELT_LEN: usize = 16; + +/// Number of felts each note occupies in a sorted note list entry (KEY word + VALUE word). +pub const NOTE_ENTRY_FELT_LEN: usize = 8; + +/// Stride of the parallel input-note flag array: `[erasure, consumed]` per entry. +pub const INPUT_FLAGS_STRIDE: usize = 2; + +/// Stride of the parallel output-note flag array: `[will_be_erased, is_created, +/// linked_input_index]` per entry. +pub const OUTPUT_FLAGS_STRIDE: usize = 3; + +// ERASURE FLAG VALUES +// ================================================================================================= + +// The flags are `Felt`-valued like the felts of the MASM flag words. This also keeps every flag +// test a VM felt comparison: integer-typed flags invite LLVM to merge the flag tests into a +// branch table, a shape the Miden backend does not currently lower correctly. + +/// `erasure` flag value: not erased / external. +#[inline] +pub fn erasure_not_erased() -> Felt { + felt!(0) +} + +/// `erasure` flag value: expected to be erased: the input note's id matches an output note +/// created by another transaction within this batch -- its "creator" -- which has not been +/// processed yet. +#[inline] +pub fn erasure_expected() -> Felt { + felt!(1) +} + +/// `erasure` flag value: erased: the creator output note has been processed. +#[inline] +pub fn erasure_erased() -> Felt { + felt!(2) +} + +// FLAT LIST ACCESSORS +// ================================================================================================= + +/// Returns the KEY word (the first 4 felts) of entry `index` of a flat note list. +#[inline(always)] +pub fn note_key(list: &[Felt], index: usize) -> &[Felt; 4] { + word_at(list, index * NOTE_ENTRY_FELT_LEN) +} + +/// Returns the VALUE word (the last 4 felts) of entry `index` of a flat note list. +#[inline(always)] +pub fn note_value(list: &[Felt], index: usize) -> &[Felt; 4] { + word_at(list, index * NOTE_ENTRY_FELT_LEN + 4) +} + +/// Returns the 4-felt word at felt offset `start` of a flat felt buffer. +#[inline(always)] +pub fn word_at(buffer: &[Felt], start: usize) -> &[Felt; 4] { + (&buffer[start..start + 4]).try_into().unwrap() +} + +/// Copies the 4-felt word at felt offset `start` of a flat felt buffer into a [`Word`] (needed +/// where an intrinsic takes a `Word` by value). +#[inline(always)] +pub fn load_word(buffer: &[Felt], start: usize) -> Word { + let felts = word_at(buffer, start); + Word::from([felts[0], felts[1], felts[2], felts[3]]) +} + +/// Returns whether the 4-felt word `word` is the empty word. +#[inline(always)] +pub fn is_empty_word(word: &[Felt; 4]) -> bool { + word[0] == felt!(0) && word[1] == felt!(0) && word[2] == felt!(0) && word[3] == felt!(0) +} + +// BATCH STATE +// ================================================================================================= + +/// The batch state produced by the prologue and updated by the note tracker. +/// +/// Each buffer corresponds to a memory region of `memory.masm` and holds the felts exactly as +/// piped from the advice provider; the region's entry count (`num_transactions`, +/// `num_input_notes`, `num_output_notes`) is the buffer length divided by the entry stride. +pub struct BatchMemory { + /// Layer 1 piped data: the verified `(tx_id, account_id)` tuples, in batch order + /// ([`TX_TUPLE_FELT_LEN`] felts per transaction). The account pair is verified as part of + /// the Layer 1 pre-image but not otherwise used until account updates are aggregated. + pub tx_tuples: Vec, + /// Layer 2 piped data: the verified per-transaction headers, in batch order + /// ([`TX_HEADER_FELT_LEN`] felts per transaction: `[INIT, FINAL, INPUT_NOTES_COMMITMENT, + /// OUTPUT_NOTES_COMMITMENT]`). The account commitments are verified as part of the pre-image + /// but not otherwise used until account updates are aggregated. + pub tx_headers: Vec, + /// The nullifier-sorted input-note list: 8-felt `[KEY, VALUE]` entries where KEY is the + /// nullifier and VALUE is the note id (for unauthenticated notes) or the empty word. The + /// VALUE word doubles as the second half of the `(nullifier, note_id_or_empty)` tuple hashed + /// into `INPUT_NOTES_COMMITMENT`. + pub input_notes: Vec, + /// The note-id-sorted output-note list: 8-felt `[KEY, VALUE]` entries where KEY is the note + /// id; the VALUE word is unused. + pub output_notes: Vec, + /// The parallel note flags, in one buffer: the input-note flags + /// ([`INPUT_FLAGS_STRIDE`] felts per entry) followed by the output-note flags + /// ([`OUTPUT_FLAGS_STRIDE`] felts per entry) starting at `num_input_notes * INPUT_FLAGS_STRIDE`. + pub note_flags: Vec, + /// Felt offset at which the output-note flags begin in [`Self::note_flags`]. + pub(crate) output_flags_base: usize, + /// The running minimum of the transactions' expiration block numbers. + pub batch_expiration_block_num: Felt, +} + +impl BatchMemory { + /// Returns `num_transactions`. + #[inline(always)] + pub fn num_transactions(&self) -> usize { + self.tx_tuples.len() / TX_TUPLE_FELT_LEN + } + + /// Returns the number of entries in the nullifier-sorted input-note list. + #[inline(always)] + pub fn num_input_notes(&self) -> usize { + self.input_notes.len() / NOTE_ENTRY_FELT_LEN + } + + /// Returns the verified `TransactionId` of transaction `tx_index`. + #[inline(always)] + pub fn tx_id(&self, tx_index: usize) -> Word { + load_word(&self.tx_tuples, tx_index * TX_TUPLE_FELT_LEN) + } + + /// Returns the verified per-transaction `INPUT_NOTES_COMMITMENT` for transaction `tx_index`. + #[inline(always)] + pub fn tx_input_notes_commitment(&self, tx_index: usize) -> &[Felt; 4] { + word_at(&self.tx_headers, tx_index * TX_HEADER_FELT_LEN + 8) + } + + /// Returns the verified per-transaction `OUTPUT_NOTES_COMMITMENT` for transaction `tx_index`. + #[inline(always)] + pub fn tx_output_notes_commitment(&self, tx_index: usize) -> &[Felt; 4] { + word_at(&self.tx_headers, tx_index * TX_HEADER_FELT_LEN + 12) + } + + /// Returns the offset of the output-note flags within the shared flag buffer. + #[inline(always)] + fn output_flags_base(&self) -> usize { + self.output_flags_base + } + + /// Returns the input-note portion of the shared flag buffer. + #[inline(always)] + pub fn input_note_flags(&self) -> &[Felt] { + &self.note_flags[..self.output_flags_base()] + } + + /// Returns the output-note portion of the shared flag buffer. + #[inline(always)] + pub fn output_note_flags(&self) -> &[Felt] { + &self.note_flags[self.output_flags_base()..] + } + + /// Returns input-note entry `index`'s `erasure` flag. + #[inline(always)] + pub fn input_note_erasure(&self, index: usize) -> Felt { + self.note_flags[index * INPUT_FLAGS_STRIDE] + } + + /// Sets input-note entry `index`'s `erasure` flag. + #[inline(always)] + pub fn set_input_note_erasure(&mut self, index: usize, value: Felt) { + self.note_flags[index * INPUT_FLAGS_STRIDE] = value; + } + + /// Returns input-note entry `index`'s `consumed` flag. + #[inline(always)] + pub fn input_note_consumed(&self, index: usize) -> Felt { + self.note_flags[index * INPUT_FLAGS_STRIDE + 1] + } + + /// Sets input-note entry `index`'s `consumed` flag. + #[inline(always)] + pub fn set_input_note_consumed(&mut self, index: usize, value: Felt) { + self.note_flags[index * INPUT_FLAGS_STRIDE + 1] = value; + } + + /// Returns output-note entry `index`'s `will_be_erased` flag. + #[inline(always)] + pub fn output_note_will_be_erased(&self, index: usize) -> Felt { + self.note_flags[self.output_flags_base() + index * OUTPUT_FLAGS_STRIDE] + } + + /// Sets output-note entry `index`'s `will_be_erased` flag. + #[inline(always)] + pub fn set_output_note_will_be_erased(&mut self, index: usize, value: Felt) { + let base = self.output_flags_base(); + self.note_flags[base + index * OUTPUT_FLAGS_STRIDE] = value; + } + + /// Returns output-note entry `index`'s `is_created` flag. + #[inline(always)] + pub fn output_note_created(&self, index: usize) -> Felt { + self.note_flags[self.output_flags_base() + index * OUTPUT_FLAGS_STRIDE + 1] + } + + /// Sets output-note entry `index`'s `is_created` flag. + #[inline(always)] + pub fn set_output_note_created(&mut self, index: usize, value: Felt) { + let base = self.output_flags_base(); + self.note_flags[base + index * OUTPUT_FLAGS_STRIDE + 1] = value; + } + + /// Returns the input-note list index linked to output-note entry `index`. Meaningful only + /// when the entry's `will_be_erased` flag is set (which implies the link was written). + #[inline(always)] + pub fn output_note_linked_input(&self, index: usize) -> usize { + self.note_flags[self.output_flags_base() + index * OUTPUT_FLAGS_STRIDE + 2] + .as_canonical_u64() as usize + } + + /// Links output-note entry `index` to input-note list entry `input_index`. + #[inline(always)] + pub fn set_output_note_linked_input(&mut self, index: usize, input_index: usize) { + let base = self.output_flags_base(); + self.note_flags[base + index * OUTPUT_FLAGS_STRIDE + 2] = Felt::from(input_index as u32); + } +} diff --git a/tests/fixtures/batch-kernel/src/note_tracker.rs b/tests/fixtures/batch-kernel/src/note_tracker.rs new file mode 100644 index 000000000..f71f6e900 --- /dev/null +++ b/tests/fixtures/batch-kernel/src/note_tracker.rs @@ -0,0 +1,281 @@ +//! Batch kernel note tracker: determines note erasure and binds the batch note lists to the +//! verified per-transaction notes. +//! +//! Mirrors `asm/kernels/batch/lib/note_tracker.masm` from the protocol batch kernel +//! (0xMiden/protocol#2905). + +extern crate alloc; +use alloc::vec::Vec; + +use miden_stdlib_sys::{ + Digest, Felt, Word, adv_load_preimage, assert_eq, felt, + intrinsics::{advice::adv_push_mapvaln, crypto::merge}, +}; + +use crate::memory::{ + self, BatchMemory, MAX_NOTES_PER_BATCH, NOTE_ENTRY_FELT_LEN, erasure_erased, erasure_expected, +}; + +// SORTED NOTE LIST LOOKUP +// ================================================================================================= + +/// Finds `key` in a flat sorted note list, returning the entry index if present. The Rust +/// counterpart of the MASM kernel's `sorted_array::find_key_value` lookups. +/// +/// Inlined into its callers: as an outlined procedure its search state spills into +/// memory-backed VM locals, which costs more than the lookup itself. +#[inline(always)] +fn find_key(list: &[Felt], key: &[Felt; 4]) -> Option { + let mut lo = 0; + let mut hi = list.len() / NOTE_ENTRY_FELT_LEN; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let mid_key = memory::note_key(list, mid); + if word_lt(mid_key, key) { + lo = mid + 1; + } else if word_lt(key, mid_key) { + hi = mid; + } else { + return Some(mid); + } + } + None +} + +/// Compares two words, most-significant felt first. The Rust counterpart of the MASM kernel's +/// `word::lt` (and the same order as `Word`'s host-side `Ord`). +/// +/// Written as straight-line code over 4-felt arrays: constant indices need no bounds checks, +/// while an index loop compiles to bounds-checked dynamic indexing and memory-backed loop state, +/// which costs an order of magnitude more VM cycles than these felt comparisons themselves. +#[inline(always)] +pub fn word_lt(a: &[Felt; 4], b: &[Felt; 4]) -> bool { + if a[3] != b[3] { + return a[3] < b[3]; + } + if a[2] != b[2] { + return a[2] < b[2]; + } + if a[1] != b[1] { + return a[1] < b[1]; + } + a[0] < b[0] +} + +/// Asserts two words are equal, felt by felt. The Rust counterpart of `assert_eqw`. +#[inline(always)] +fn assert_eq_word(a: &[Felt; 4], b: &[Felt; 4]) { + assert_eq(a[0], b[0]); + assert_eq(a[1], b[1]); + assert_eq(a[2], b[2]); + assert_eq(a[3], b[3]); +} + +/// Pipes a transaction's note tuples (8-felt entries) from the advice map into memory, asserting +/// their sequential hash equals `commitment`, and returns them as piped. The Rust counterpart of +/// the per-transaction scratch region loads in `process_tx_input_notes` / +/// `process_tx_output_notes`. +#[inline(always)] +fn load_tx_note_tuples(commitment: Word) -> Vec { + let len_felts = adv_push_mapvaln(commitment).as_canonical_u64() as usize; + assert!( + len_felts.is_multiple_of(NOTE_ENTRY_FELT_LEN), + "a transaction note tuple list length is not a multiple of the note entry length" + ); + assert!( + len_felts / NOTE_ENTRY_FELT_LEN <= MAX_NOTES_PER_BATCH, + "a transaction's note set contains more notes than the maximum allowed" + ); + + adv_load_preimage(Felt::from((len_felts / 4) as u32), commitment) +} + +// ERASURE CROSS-REFERENCE +// ================================================================================================= + +/// For input-note list entry `idx`: if it is unauthenticated (its note id is non-empty) and that +/// note id appears in the output-note list, marks the input entry erasure-expected and links the +/// matching output entry back to it. This is the static (order-independent) erasure +/// determination; the temporal ordering is enforced during per-transaction processing. +/// +/// If two input entries carry the same note id (impossible for real notes, whose nullifier is +/// derived from the note), the second link overwrites the first and the kernel later aborts. +#[inline(always)] +fn cross_reference_one_input(memory: &mut BatchMemory, idx: usize) { + let note_id = memory::note_value(&memory.input_notes, idx); + if memory::is_empty_word(note_id) { + return; + } + if let Some(j) = find_key(&memory.output_notes, note_id) { + memory.set_input_note_erasure(idx, erasure_expected()); + memory.set_output_note_will_be_erased(j, felt!(1)); + memory.set_output_note_linked_input(j, idx); + } +} + +/// Cross-references the input and output note lists to determine erasure (see +/// [`cross_reference_one_input`]). +#[inline(always)] +fn cross_reference_erasure(memory: &mut BatchMemory) { + for idx in 0..memory.num_input_notes() { + cross_reference_one_input(memory, idx); + } +} + +// PER-TRANSACTION OUTPUT NOTES +// ================================================================================================= + +/// Marks output-note list entry `j` as created, asserting it was not already created. +#[inline(always)] +fn mark_output_note_created(memory: &mut BatchMemory, j: usize) { + assert!( + memory.output_note_created(j) == felt!(0), + "a note-id-sorted output-note list entry was created by more than one transaction" + ); + memory.set_output_note_created(j, felt!(1)); +} + +/// Advances input-note list entry `idx`'s erasure flag from expected (1) to erased (2) — its +/// creator output note has been processed — asserting it was expected. +#[inline(always)] +fn flip_input_erasure_created(memory: &mut BatchMemory, idx: usize) { + assert!( + memory.input_note_erasure(idx) == erasure_expected(), + "an erased input note was consumed before the transaction that creates it" + ); + memory.set_input_note_erasure(idx, erasure_erased()); +} + +/// Binds one per-transaction output note (a `[DETAILS_COMMITMENT, METADATA_COMMITMENT]` tuple) to +/// the note-id-sorted output-note list: derives its note id, looks it up, marks it created, and — +/// if it erases an input note — advances that input note's erasure flag. +#[inline(always)] +fn bind_one_output_note(memory: &mut BatchMemory, note: &[Felt]) { + // Derive the output note's id as merge(details_commitment, metadata_commitment). + let details_commitment = Digest::from_word(memory::load_word(note, 0)); + let metadata_commitment = Digest::from_word(memory::load_word(note, 4)); + let note_id = Word::from(merge([details_commitment, metadata_commitment])); + let note_id = [note_id[0], note_id[1], note_id[2], note_id[3]]; + + // Look the note id up in the note-id-sorted output-note list and mark the entry created. + let j = find_key(&memory.output_notes, ¬e_id) + .expect("a transaction output note is missing from the note-id-sorted output-note list"); + mark_output_note_created(memory, j); + + // If this output note erases an input note (cross-referenced earlier), advance that input + // note's erasure flag from expected to erased (creator processed). + if memory.output_note_will_be_erased(j) != felt!(0) { + let linked_input_index = memory.output_note_linked_input(j); + flip_input_erasure_created(memory, linked_input_index); + } +} + +/// Verifies transaction `tx_index`'s `OUTPUT_NOTES_COMMITMENT_idx` against its piped +/// `(DETAILS_COMMITMENT, METADATA_COMMITMENT)` tuples, then binds each of those notes to the +/// batch output-note list (see [`bind_per_tx_notes`] for what binding proves). +#[inline(always)] +fn process_tx_output_notes(memory: &mut BatchMemory, tx_index: usize) { + if memory::is_empty_word(memory.tx_output_notes_commitment(tx_index)) { + // Empty commitment: the transaction has no output notes, so there is nothing to bind. + return; + } + let commitment = + memory::load_word(&memory.tx_headers, tx_index * crate::memory::TX_HEADER_FELT_LEN + 12); + let notes = load_tx_note_tuples(commitment); + for note in notes.chunks_exact(NOTE_ENTRY_FELT_LEN) { + bind_one_output_note(memory, note); + } +} + +// PER-TRANSACTION INPUT NOTES +// ================================================================================================= + +/// Marks input-note list entry `idx` as consumed, asserting it was not already consumed. +#[inline(always)] +fn mark_input_note_consumed(memory: &mut BatchMemory, idx: usize) { + assert!( + memory.input_note_consumed(idx) == felt!(0), + "a nullifier-sorted input-note list entry was consumed by more than one transaction" + ); + memory.set_input_note_consumed(idx, felt!(1)); +} + +/// Asserts input-note list entry `idx` is not expected-to-be-erased at the point it is consumed: +/// if it is created in this batch, its creator has already been processed. Rejects +/// consume-before-create / circular dependencies. +#[inline(always)] +fn assert_input_not_consumed_before_created(memory: &BatchMemory, idx: usize) { + assert!( + memory.input_note_erasure(idx) != erasure_expected(), + "an erased input note was consumed before the transaction that creates it" + ); +} + +/// Binds one per-transaction input note (a `[NULLIFIER, NOTE_ID_OR_EMPTY]` tuple) to the +/// nullifier-sorted input-note list: looks it up by nullifier, asserts it is present and that its +/// note id matches, enforces the erasure ordering gate, then marks it consumed. +#[inline(always)] +fn bind_one_input_note(memory: &mut BatchMemory, note: &[Felt]) { + // Look the per-transaction note up in the nullifier-sorted input-note list by its nullifier. + let idx = find_key(&memory.input_notes, memory::word_at(note, 0)) + .expect("a transaction input note is missing from the nullifier-sorted input-note list"); + + // Assert the list entry's note id equals the per-transaction note's, binding the id as well + // as the nullifier (so an unauthenticated note cannot be matched to the wrong list entry). + assert_eq_word(memory::note_value(&memory.input_notes, idx), memory::word_at(note, 4)); + + // Enforce the erasure ordering gate (reject consume-before-create), then mark the entry + // consumed. + assert_input_not_consumed_before_created(memory, idx); + mark_input_note_consumed(memory, idx); +} + +/// Verifies transaction `tx_index`'s `INPUT_NOTES_COMMITMENT_idx` against its piped +/// `(NULLIFIER, NOTE_ID_OR_EMPTY)` tuples, then binds each of those notes to the batch +/// input-note list (see [`bind_per_tx_notes`] for what binding proves). +#[inline(always)] +fn process_tx_input_notes(memory: &mut BatchMemory, tx_index: usize) { + if memory::is_empty_word(memory.tx_input_notes_commitment(tx_index)) { + // Empty commitment: the transaction has no input notes, so there is nothing to bind. + return; + } + let commitment = + memory::load_word(&memory.tx_headers, tx_index * crate::memory::TX_HEADER_FELT_LEN + 8); + let notes = load_tx_note_tuples(commitment); + for note in notes.chunks_exact(NOTE_ENTRY_FELT_LEN) { + bind_one_input_note(memory, note); + } +} + +/// Processes every transaction in batch order, binding its input notes then its output notes. +/// +/// Binding a per-transaction note means locating it in the batch's sorted note list — by +/// nullifier for inputs, by note id for outputs — and marking the matching entry (consumed for +/// inputs, created for outputs). Because every per-transaction note must be found and each entry +/// may be marked at most once, binding proves the host-provided list is exactly the multiset of +/// per-transaction notes: the host cannot inject, omit, or duplicate entries. +/// +/// Inputs are bound before outputs within a transaction so a note created and consumed by the +/// same transaction is rejected — at consume time its erasure flag is still `Expected` (its +/// creating output has not been processed), tripping the gate in +/// [`assert_input_not_consumed_before_created`]. +#[inline(always)] +fn bind_per_tx_notes(memory: &mut BatchMemory) { + for tx_index in 0..memory.num_transactions() { + process_tx_input_notes(memory, tx_index); + process_tx_output_notes(memory, tx_index); + } +} + +// NOTE TRACKING +// ================================================================================================= + +/// Tracks the batch's notes: determines erasure by cross-referencing the prepared batch input- +/// and output-note lists, then binds both lists to the verified per-transaction notes while +/// enforcing the creator-before-consumer ordering gate. Assumes the prologue has loaded and +/// strict-sorted the two sorted note lists into memory. +#[inline(always)] +pub fn track_notes(memory: &mut BatchMemory) { + cross_reference_erasure(memory); + bind_per_tx_notes(memory); +} diff --git a/tests/fixtures/batch-kernel/src/prologue.rs b/tests/fixtures/batch-kernel/src/prologue.rs new file mode 100644 index 000000000..4a4e84dec --- /dev/null +++ b/tests/fixtures/batch-kernel/src/prologue.rs @@ -0,0 +1,231 @@ +//! Batch kernel prologue: loads and verifies the batch's structural commitments from the advice +//! provider. +//! +//! Mirrors `asm/kernels/batch/lib/prologue.masm` from the protocol batch kernel +//! (0xMiden/protocol#2905), extended with the per-transaction expiration running-minimum of +//! 0xMiden/protocol#3019. + +extern crate alloc; +use alloc::vec::Vec; + +use miden_stdlib_sys::{ + Felt, Word, adv_load_preimage, felt, intrinsics::advice::adv_push_mapvaln, pipe_words_to_memory, +}; + +use crate::{ + memory::{ + self, BatchMemory, INPUT_FLAGS_STRIDE, MAX_NOTES_PER_BATCH, MAX_TRANSACTIONS_PER_BATCH, + NOTE_ENTRY_FELT_LEN, OUTPUT_FLAGS_STRIDE, TX_HEADER_FELT_LEN, TX_TUPLE_FELT_LEN, + }, + note_tracker, +}; + +// CONSTANTS +// ================================================================================================= + +/// Advice-map key under which the nullifier-sorted input-note list is provided. The key is the +/// word hash of the domain message `miden::batch_kernel::input_note_list` (the MASM kernel +/// evaluates `word("miden::batch_kernel::input_note_list")` at assembly time; the value below is +/// `miden_core::utils::hash_string_to_word` of the same message). +#[inline(always)] +fn input_note_list_key() -> Word { + Word::from([ + felt!(0x643bd4845322a3ce_u64), + felt!(0x59fe43373dd36f9c_u64), + felt!(0xb025720fddafbf03_u64), + felt!(0x5737d0d4e9e438c8_u64), + ]) +} + +/// Advice-map key under which the note-id-sorted output-note list is provided: the word hash of +/// the domain message `miden::batch_kernel::output_note_list`. +#[inline(always)] +fn output_note_list_key() -> Word { + Word::from([ + felt!(0x3526094d3f4b4d20_u64), + felt!(0x4156c0a181a827b7_u64), + felt!(0xeca6acdd31b62cbf_u64), + felt!(0xe0922efc58e94e7d_u64), + ]) +} + +// SORTED NOTE LIST LOADING +// ================================================================================================= + +/// Pipes a sorted note list (8-felt `[KEY, VALUE]` entries) from the advice map into memory and +/// returns it as piped. The list is not hashed against a commitment; its integrity is +/// established later by binding every entry to a verified per-transaction note. +#[inline(always)] +fn load_note_list(key: Word) -> Vec { + let len_felts = adv_push_mapvaln(key).as_canonical_u64() as usize; + // The MASM kernel derives `num_notes` with field divisions; a length that is not a multiple + // of 8 felts wraps it to a non-u32 felt, which the range check below rejects. + assert!( + len_felts.is_multiple_of(NOTE_ENTRY_FELT_LEN), + "a batch note list length is not a multiple of the note entry length" + ); + assert!( + len_felts / NOTE_ENTRY_FELT_LEN <= MAX_NOTES_PER_BATCH, + "a batch note list contains more entries than the maximum allowed" + ); + + let (_hash, data) = pipe_words_to_memory(Felt::from((len_felts / 4) as u32)); + data +} + +/// Asserts a flat note list is strictly increasing by its KEY word, which also proves there are +/// no duplicate keys. +#[inline(always)] +fn assert_list_strictly_sorted(list: &[Felt]) { + let mut entries = list.chunks_exact(NOTE_ENTRY_FELT_LEN); + let Some(mut previous) = entries.next() else { + return; + }; + for current in entries { + assert!( + note_tracker::word_lt(memory::word_at(previous, 0), memory::word_at(current, 0)), + "a batch note list is not strictly sorted by its key" + ); + previous = current; + } +} + +/// Loads and verifies the nullifier-sorted input-note list. +#[inline(always)] +fn prepare_input_note_list() -> Vec { + let entries = load_note_list(input_note_list_key()); + assert_list_strictly_sorted(&entries); + entries +} + +/// Loads and verifies the note-id-sorted output-note list. +#[inline(always)] +fn prepare_output_note_list() -> Vec { + let entries = load_note_list(output_note_list_key()); + assert_list_strictly_sorted(&entries); + entries +} + +// TRANSACTION EXPIRATIONS +// ================================================================================================= + +/// Reads each transaction's `expiration_block_num` from the advice stack and returns the running +/// minimum over all transactions. +/// +/// The MASM implementation of 0xMiden/protocol#3019 pops one felt per transaction; the compiler +/// SDK reads the advice stack with word granularity, so each transaction contributes one +/// `[expiration_block_num, 0, 0, 0]` word instead. +/// +/// 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. +#[inline(always)] +fn load_tx_expirations(num_transactions: usize) -> Felt { + let (_hash, data) = pipe_words_to_memory(Felt::from(num_transactions as u32)); + + let mut min = felt!(0xffffffff_u64); + for expiration in data.chunks_exact(4) { + let expiration_block_num = expiration[0]; + if expiration_block_num < min { + min = expiration_block_num; + } + } + min +} + +// PROLOGUE +// ================================================================================================= + +/// Loads to memory and verifies the batch's structural commitments from the advice provider. +/// +/// Performs the following steps: +/// - Layer 1: pipes the `(tx_id, account_id)` tuples from the advice map keyed by `BATCH_ID`, +/// asserting that the sequential hash of the piped data matches `BATCH_ID`. The number of +/// transactions is derived from the piped length. +/// - Layer 2: for each transaction, pipes its pre-image from the advice map keyed by the verified +/// `tx_id`, asserting that the sequential hash of the piped data matches the `tx_id`. +/// - Note lists: loads the input-note list (sorted by nullifier) and the output-note list (sorted +/// by note id) from the advice map and asserts each is strictly sorted by its key. Their +/// integrity is established later, when the note tracker binds every entry to a verified +/// per-transaction note. +/// - Expirations: reads each transaction's `expiration_block_num` from the advice stack and +/// accumulates the running minimum. +/// +/// 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)` 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. +#[inline(always)] +pub fn prepare_batch(batch_id: Word) -> BatchMemory { + // Layer 1: pipe BATCH_ID's mapped value + verify. + // --------------------------------------------------------------------------------------------- + + let len_felts = adv_push_mapvaln(batch_id).as_canonical_u64() as usize; + // Each tx contributes 2 words (8 felts): tx_id + account_id_pair. + assert!( + len_felts.is_multiple_of(TX_TUPLE_FELT_LEN), + "the batch transaction tuple list length is not a multiple of the tuple length" + ); + let num_transactions = len_felts / TX_TUPLE_FELT_LEN; + assert!( + num_transactions <= MAX_TRANSACTIONS_PER_BATCH, + "the batch contains more transactions than the maximum allowed" + ); + + // Pipe the tuples into memory, asserting their sequential hash equals BATCH_ID; the piped + // buffer is kept as-is as the tuple region. + let tx_tuples = adv_load_preimage(Felt::from((len_felts / 4) as u32), batch_id); + + // Layer 2: for each transaction, pipe + verify its header into the flat header buffer. + // --------------------------------------------------------------------------------------------- + + let mut tx_headers: Vec = Vec::with_capacity(num_transactions * TX_HEADER_FELT_LEN); + for tx_index in 0..num_transactions { + let tx_id = memory::load_word(&tx_tuples, tx_index * TX_TUPLE_FELT_LEN); + let len_felts = adv_push_mapvaln(tx_id).as_canonical_u64() as usize; + // The MASM kernel stores each header in a fixed 16-felt slot; the length is implicitly + // pinned by the hash check against the `TransactionId::new` pre-image. + assert!( + len_felts == TX_HEADER_FELT_LEN, + "a transaction header does not match the TransactionId pre-image layout" + ); + + // Pipe the header into memory, asserting its sequential hash equals TX_ID. + let header_data = adv_load_preimage(Felt::from((TX_HEADER_FELT_LEN / 4) as u32), tx_id); + tx_headers.extend_from_slice(&header_data); + } + + // Note lists: load the sorted note lists and assert each is strictly sorted by its key. + // --------------------------------------------------------------------------------------------- + + let input_notes = prepare_input_note_list(); + let output_notes = prepare_output_note_list(); + // One shared buffer for both parallel flag arrays: the input-note flags + // (`[erasure = 0, consumed = 0]` per entry) followed by the output-note flags + // (`[will_be_erased = 0, is_created = 0, linked_input_index = 0]` per entry). + let output_flags_base = (input_notes.len() / NOTE_ENTRY_FELT_LEN) * INPUT_FLAGS_STRIDE; + let note_flags = alloc::vec![ + felt!(0); + output_flags_base + (output_notes.len() / NOTE_ENTRY_FELT_LEN) * OUTPUT_FLAGS_STRIDE + ]; + + // Expirations: accumulate the running minimum of the transactions' expiration block numbers. + // --------------------------------------------------------------------------------------------- + + let batch_expiration_block_num = load_tx_expirations(num_transactions); + + BatchMemory { + tx_tuples, + tx_headers, + input_notes, + output_notes, + note_flags, + output_flags_base, + batch_expiration_block_num, + } +} diff --git a/tests/integration/src/end_to_end/batch_kernel.rs b/tests/integration/src/end_to_end/batch_kernel.rs new file mode 100644 index 000000000..2f46787d1 --- /dev/null +++ b/tests/integration/src/end_to_end/batch_kernel.rs @@ -0,0 +1,463 @@ +//! Smoke tests for the Rust implementation of the Miden protocol batch kernel +//! (`tests/fixtures/batch-kernel`). +//! +//! The fixture mirrors the MASM batch kernel of the protocol repository +//! (`asm/kernels/batch` of 0xMiden/protocol#2905, plus the expiration running-minimum of +//! 0xMiden/protocol#3019); this test plays the role of the protocol's `BatchKernel` input +//! builders: it derives batch data from mock transactions the same way +//! `BatchKernel::prepare_inputs` does, executes the compiled kernel on the VM, and checks the +//! outputs against commitments computed with the host hasher. + +use std::collections::BTreeSet; + +use miden_core::{EMPTY_WORD, Felt, Word, crypto::hash::Poseidon2, utils::hash_string_to_word}; +use miden_debug::{DebugQuery, Felt as TestFelt}; +use miden_processor::advice::AdviceInputs; +use midenc_expect_test::expect; +use midenc_frontend_wasm::WasmTranslationConfig; + +use crate::{ + CompilerTest, + testing::{executor_with_std, stripped_mast_size_str}, +}; + +/// Advice-map keys under which the sorted note lists are provided to the kernel. Must match the +/// key constants baked into the fixture's `prologue.rs`. +const INPUT_NOTE_LIST_KEY_MESSAGE: &str = "miden::batch_kernel::input_note_list"; +const OUTPUT_NOTE_LIST_KEY_MESSAGE: &str = "miden::batch_kernel::output_note_list"; + +/// Byte address (in Rust memory) at which the kernel writes its output words. +const OUT_ADDR: u32 = 20 * 65536; + +/// Shorthand for a word of the given canonical felt values. +fn word(a: u64, b: u64, c: u64, d: u64) -> Word { + Word::from([ + Felt::new_unchecked(a), + Felt::new_unchecked(b), + Felt::new_unchecked(c), + Felt::new_unchecked(d), + ]) +} + +// MOCK TRANSACTIONS +// ================================================================================================= + +/// The witness data of one proven transaction, as far as the batch kernel is concerned. +struct MockTransaction { + /// The account the transaction executes against, as `[prefix, suffix]`. + account_id: [Felt; 2], + /// The account's state commitment before the transaction. + init_account_commitment: Word, + /// The account's state commitment after the transaction. + final_account_commitment: Word, + /// The consumed notes' `(nullifier, note_id_or_empty)` tuples: the note id is set for notes + /// consumed unauthenticated and empty for authenticated ones. + input_notes: Vec<(Word, Word)>, + /// The produced notes' `(details_commitment, metadata_commitment)` tuples. + output_notes: Vec<(Word, Word)>, + /// The block number at which the transaction expires. + expiration_block_num: u32, +} + +impl MockTransaction { + /// Mirrors `build_input_note_commitment`: the sequential hash of the + /// `(nullifier, note_id_or_empty)` tuples, or the empty word if there are none. + fn input_notes_commitment(&self) -> Word { + if self.input_notes.is_empty() { + return EMPTY_WORD; + } + let elements: Vec = self + .input_notes + .iter() + .flat_map(|(nullifier, note_id)| { + nullifier.as_elements().iter().chain(note_id.as_elements()).copied() + }) + .collect(); + Poseidon2::hash_elements(&elements) + } + + /// Mirrors `OutputNotes::commitment`: the sequential hash of the + /// `(details_commitment, metadata_commitment)` tuples, or the empty word if there are none. + fn output_notes_commitment(&self) -> Word { + if self.output_notes.is_empty() { + return EMPTY_WORD; + } + let elements: Vec = self + .output_notes + .iter() + .flat_map(|(details, metadata)| { + details.as_elements().iter().chain(metadata.as_elements()).copied() + }) + .collect(); + Poseidon2::hash_elements(&elements) + } + + /// Mirrors `TransactionId::input_elements`: the felt sequence hashed into the transaction id. + fn header_elements(&self) -> Vec { + let mut elements = Vec::with_capacity(16); + elements.extend_from_slice(self.init_account_commitment.as_elements()); + elements.extend_from_slice(self.final_account_commitment.as_elements()); + elements.extend_from_slice(self.input_notes_commitment().as_elements()); + elements.extend_from_slice(self.output_notes_commitment().as_elements()); + elements + } + + /// Mirrors `TransactionId::new`. + fn id(&self) -> Word { + Poseidon2::hash_elements(&self.header_elements()) + } +} + +/// Mirrors `NoteId`: the merge of the note's details and metadata commitments. +fn note_id(details_commitment: Word, metadata_commitment: Word) -> Word { + Poseidon2::merge(&[details_commitment, metadata_commitment]) +} + +// KERNEL INPUT BUILDERS +// ================================================================================================= + +/// Mirrors `BatchId::from_ids`: the sequential hash of the `(tx_id, account_id)` tuples. +fn batch_id(transactions: &[MockTransaction]) -> Word { + Poseidon2::hash_elements(&layer1_elements(transactions)) +} + +/// Mirrors `BatchId::hash_input_elements`: for each transaction, +/// `[transaction_id[4], account_id_prefix, account_id_suffix, 0, 0]`. +fn layer1_elements(transactions: &[MockTransaction]) -> Vec { + let mut elements = Vec::with_capacity(transactions.len() * 8); + for tx in transactions { + elements.extend_from_slice(tx.id().as_elements()); + elements.extend_from_slice(&[ + tx.account_id[0], + tx.account_id[1], + Felt::new_unchecked(0), + Felt::new_unchecked(0), + ]); + } + elements +} + +/// Builds the advice inputs consumed by the batch kernel, mirroring +/// `BatchKernel::build_advice_inputs` (plus the expiration advice stack of +/// 0xMiden/protocol#3019, adapted to one `[expiration_block_num, 0, 0, 0]` word per transaction). +fn build_advice_inputs(transactions: &[MockTransaction]) -> AdviceInputs { + let mut map: Vec<(Word, Vec)> = Vec::new(); + + // Layer 1: BATCH_ID -> [(tx_id, account_id) tuples]. + map.push((batch_id(transactions), layer1_elements(transactions))); + + // Pre-erasure union of every transaction's notes, sorted below: input notes by nullifier, + // output notes by note id. + let mut input_list: Vec<(Word, Word)> = Vec::new(); + let mut output_list: Vec = Vec::new(); + + for tx in transactions { + // Layer 2: tx_id -> the felt sequence TransactionId::new hashes. + map.push((tx.id(), tx.header_elements())); + + // Layer 3a: per-tx INPUT_NOTES_COMMITMENT -> [NULLIFIER, NOTE_ID_OR_EMPTY] tuples. + if !tx.input_notes.is_empty() { + let mut preimage = Vec::with_capacity(tx.input_notes.len() * 8); + for (nullifier, note_id_or_empty) in &tx.input_notes { + preimage.extend_from_slice(nullifier.as_elements()); + preimage.extend_from_slice(note_id_or_empty.as_elements()); + input_list.push((*nullifier, *note_id_or_empty)); + } + map.push((tx.input_notes_commitment(), preimage)); + } + + // Layer 3b: per-tx OUTPUT_NOTES_COMMITMENT -> [DETAILS_COMMITMENT, METADATA_COMMITMENT] + // tuples. + if !tx.output_notes.is_empty() { + let mut preimage = Vec::with_capacity(tx.output_notes.len() * 8); + for (details, metadata) in &tx.output_notes { + preimage.extend_from_slice(details.as_elements()); + preimage.extend_from_slice(metadata.as_elements()); + output_list.push(note_id(*details, *metadata)); + } + map.push((tx.output_notes_commitment(), preimage)); + } + } + + // Sort the input-note list by nullifier and the output-note list by note id, ascending. + input_list.sort_by_key(|a| a.0); + output_list.sort_unstable(); + + // INPUT_NOTE_LIST_KEY -> [NULLIFIER, NOTE_ID_OR_EMPTY] (8 felts per note). + let mut input_blob = Vec::with_capacity(input_list.len() * 8); + for (nullifier, note_id_or_empty) in &input_list { + input_blob.extend_from_slice(nullifier.as_elements()); + input_blob.extend_from_slice(note_id_or_empty.as_elements()); + } + map.push((hash_string_to_word(INPUT_NOTE_LIST_KEY_MESSAGE), input_blob)); + + // OUTPUT_NOTE_LIST_KEY -> [NOTE_ID, 0, 0, 0, 0] (8 felts per note; the VALUE word is unused). + let mut output_blob = Vec::with_capacity(output_list.len() * 8); + for id in &output_list { + output_blob.extend_from_slice(id.as_elements()); + output_blob.extend_from_slice(EMPTY_WORD.as_elements()); + } + map.push((hash_string_to_word(OUTPUT_NOTE_LIST_KEY_MESSAGE), output_blob)); + + // Advice stack: each transaction's expiration_block_num, in transaction order, one word per + // transaction. + let mut stack = Vec::with_capacity(transactions.len() * 4); + for tx in transactions { + stack.push(Felt::new_unchecked(tx.expiration_block_num as u64)); + stack.extend_from_slice(&[Felt::new_unchecked(0); 3]); + } + + AdviceInputs::default().with_map(map).with_stack(stack) +} + +/// Returns the operand stack arguments for the kernel entrypoint: +/// `[out_ptr, BLOCK_COMMITMENT, BATCH_ID]`. +fn build_args(block_commitment: Word, batch_id: Word) -> Vec { + let mut args = vec![Felt::new_unchecked(OUT_ADDR as u64)]; + args.extend_from_slice(block_commitment.as_elements()); + args.extend_from_slice(batch_id.as_elements()); + args +} + +// EXPECTED OUTPUTS +// ================================================================================================= + +/// Computes the expected `INPUT_NOTES_COMMITMENT` the way `ProposedBatch` does: the sequential +/// hash over the nullifier-sorted, post-erasure `(nullifier, note_id_or_empty)` tuples, where a +/// note is erased when it is consumed unauthenticated and its note id is created by a +/// transaction of the same batch. +fn expected_input_notes_commitment(transactions: &[MockTransaction]) -> Word { + let created: BTreeSet = transactions + .iter() + .flat_map(|tx| tx.output_notes.iter()) + .map(|(details, metadata)| note_id(*details, *metadata)) + .collect(); + + let mut entries: Vec<(Word, Word)> = + transactions.iter().flat_map(|tx| tx.input_notes.iter().copied()).collect(); + entries.sort_by_key(|a| a.0); + entries.retain(|(_, note_id)| *note_id == EMPTY_WORD || !created.contains(note_id)); + + if entries.is_empty() { + return EMPTY_WORD; + } + let elements: Vec = entries + .iter() + .flat_map(|(nullifier, note_id)| { + nullifier.as_elements().iter().chain(note_id.as_elements()).copied() + }) + .collect(); + Poseidon2::hash_elements(&elements) +} + +// SMOKE TESTS +// ================================================================================================= + +/// Compiles the batch kernel fixture and executes it against mock batches: +/// +/// 1. a two-transaction batch without intra-batch notes, checking `INPUT_NOTES_COMMITMENT`, +/// `BATCH_NOTE_TREE_ROOT` and `batch_expiration_block_num`; +/// 2. a batch where one transaction creates a note a later transaction consumes, checking the +/// note is erased from `INPUT_NOTES_COMMITMENT`; +/// 3. a batch with a tampered `BATCH_ID` pre-image, checking the kernel rejects it; +/// 4. a batch where a note is consumed before the transaction that creates it, checking the +/// kernel rejects it. +#[test] +fn batch_kernel() { + let mut test = CompilerTest::rust_source_cargo_miden( + "../fixtures/batch-kernel", + WasmTranslationConfig::default(), + ["--entrypoint".to_string(), "batch_kernel::entrypoint".to_string()], + ); + let package = test.compile_package(); + + // The serialized size of the compiled kernel's MAST forest, with debug info stripped. + expect!["101603"].assert_eq(&stripped_mast_size_str(&package)); + + // The reference block commitment is dropped by the kernel (verification is still a TODO + // there), so any word will do. + let block_commitment = word(101, 102, 103, 104); + + // Executes the kernel and returns the resulting trace along with the consumed VM cycles, or + // the execution error along with the cycle at which the kernel rejected the batch; this is + // `Executor::execute` unrolled through the debug executor to observe the cycle counter. + type ExecutionOutcome = Result<(miden_debug::ExecutionTrace, usize), (String, usize)>; + let execute = |transactions: &[MockTransaction], advice: AdviceInputs| -> ExecutionOutcome { + let mut exec = executor_with_std(build_args(block_commitment, batch_id(transactions))); + exec.with_advice_inputs(advice); + let mut executor = exec.into_debug(package.clone(), test.session.source_manager.clone()); + while !executor.stopped { + if let Err(err) = executor.step() { + return Err((err.to_string(), executor.cycle)); + } + } + let cycles = executor.cycle; + Ok((executor.into_execution_trace(), cycles)) + }; + + // Scenario 1: two transactions, no intra-batch note relationships. + // - tx1 consumes one authenticated note (empty note id) and creates one note. + // - tx2 consumes one unauthenticated note whose note id is not created in this batch, and + // creates two notes. + { + let transactions = [ + MockTransaction { + account_id: [Felt::new_unchecked(10), Felt::new_unchecked(11)], + init_account_commitment: word(1, 1, 1, 1), + final_account_commitment: word(2, 2, 2, 2), + input_notes: vec![(word(1000, 0, 0, 1), EMPTY_WORD)], + output_notes: vec![(word(80, 0, 0, 80), word(81, 0, 0, 81))], + expiration_block_num: 1234, + }, + MockTransaction { + account_id: [Felt::new_unchecked(20), Felt::new_unchecked(21)], + init_account_commitment: word(3, 3, 3, 3), + final_account_commitment: word(4, 4, 4, 4), + input_notes: vec![(word(2000, 0, 0, 2), word(90, 0, 0, 90))], + output_notes: vec![ + (word(82, 0, 0, 82), word(83, 0, 0, 83)), + (word(84, 0, 0, 84), word(85, 0, 0, 85)), + ], + expiration_block_num: 800, + }, + ]; + + let (trace, cycles) = execute(&transactions, build_advice_inputs(&transactions)) + .expect("kernel should accept the batch"); + + // The VM cycles consumed by the kernel for this two-transaction batch. + expect!["31350"].assert_eq(&cycles.to_string()); + + let input_notes_commitment = read_word(&trace, OUT_ADDR); + assert_eq!( + input_notes_commitment, + expected_input_notes_commitment(&transactions), + "kernel INPUT_NOTES_COMMITMENT should match the commitment derived on the host" + ); + + let batch_note_tree_root = read_word(&trace, OUT_ADDR + 16); + assert_eq!( + batch_note_tree_root, EMPTY_WORD, + "BATCH_NOTE_TREE_ROOT is not wired up yet and should be the empty word" + ); + + let expiration: miden_core::Felt = + trace.parse_result().expect("kernel should return batch_expiration_block_num"); + assert_eq!( + expiration, + Felt::new_unchecked(800), + "batch_expiration_block_num should be the minimum over the transactions" + ); + } + + // Scenario 2: tx1 creates a note that tx2 consumes unauthenticated; the note is erased and + // only tx1's authenticated input note remains in the commitment. + { + let details = word(50, 0, 0, 50); + let metadata = word(51, 0, 0, 51); + let transactions = [ + MockTransaction { + account_id: [Felt::new_unchecked(10), Felt::new_unchecked(11)], + init_account_commitment: word(1, 1, 1, 1), + final_account_commitment: word(2, 2, 2, 2), + input_notes: vec![(word(1000, 0, 0, 1), EMPTY_WORD)], + output_notes: vec![(details, metadata)], + expiration_block_num: 900, + }, + MockTransaction { + account_id: [Felt::new_unchecked(20), Felt::new_unchecked(21)], + init_account_commitment: word(3, 3, 3, 3), + final_account_commitment: word(4, 4, 4, 4), + input_notes: vec![(word(2000, 0, 0, 2), note_id(details, metadata))], + output_notes: vec![], + expiration_block_num: 1000, + }, + ]; + + let (trace, cycles) = execute(&transactions, build_advice_inputs(&transactions)) + .expect("kernel should accept the batch"); + + // The VM cycles consumed for a batch that erases a note. + expect!["27536"].assert_eq(&cycles.to_string()); + + let expected = expected_input_notes_commitment(&transactions); + assert_ne!(expected, EMPTY_WORD, "the authenticated note should remain post-erasure"); + assert_eq!( + read_word(&trace, OUT_ADDR), + expected, + "the erased note should be excluded from INPUT_NOTES_COMMITMENT" + ); + + let expiration: miden_core::Felt = + trace.parse_result().expect("kernel should return batch_expiration_block_num"); + assert_eq!(expiration, Felt::new_unchecked(900)); + } + + // Scenario 3: a tampered BATCH_ID pre-image must be rejected by the Layer 1 hash check. + { + let transactions = [MockTransaction { + account_id: [Felt::new_unchecked(10), Felt::new_unchecked(11)], + init_account_commitment: word(1, 1, 1, 1), + final_account_commitment: word(2, 2, 2, 2), + input_notes: vec![(word(1000, 0, 0, 1), EMPTY_WORD)], + output_notes: vec![], + expiration_block_num: 900, + }]; + + let mut advice = build_advice_inputs(&transactions); + let key = batch_id(&transactions); + let mut tampered: Vec = advice.map.get(&key).expect("layer 1 advice entry").to_vec(); + tampered[0] += Felt::new_unchecked(1); + advice.map.insert(key, tampered); + + let cycles = match execute(&transactions, advice) { + Err((_, cycles)) => cycles, + Ok(_) => panic!("kernel should reject a tampered BATCH_ID pre-image"), + }; + + // The cycle at which the Layer 1 hash check rejects the tampered pre-image. + expect!["913"].assert_eq(&cycles.to_string()); + } + + // Scenario 4: tx1 consumes a note that only tx2 creates; the consume-before-create ordering + // gate must reject the batch. + { + let details = word(50, 0, 0, 50); + let metadata = word(51, 0, 0, 51); + let transactions = [ + MockTransaction { + account_id: [Felt::new_unchecked(10), Felt::new_unchecked(11)], + init_account_commitment: word(1, 1, 1, 1), + final_account_commitment: word(2, 2, 2, 2), + input_notes: vec![(word(2000, 0, 0, 2), note_id(details, metadata))], + output_notes: vec![], + expiration_block_num: 900, + }, + MockTransaction { + account_id: [Felt::new_unchecked(20), Felt::new_unchecked(21)], + init_account_commitment: word(3, 3, 3, 3), + final_account_commitment: word(4, 4, 4, 4), + input_notes: vec![], + output_notes: vec![(details, metadata)], + expiration_block_num: 1000, + }, + ]; + + let advice = build_advice_inputs(&transactions); + let cycles = match execute(&transactions, advice) { + Err((_, cycles)) => cycles, + Ok(_) => panic!("kernel should reject a note consumed before it is created"), + }; + + // The cycle at which the consume-before-create ordering gate rejects the batch. + expect!["15188"].assert_eq(&cycles.to_string()); + } +} + +/// Reads a word the kernel wrote to Rust memory at `byte_addr`. +fn read_word(trace: &miden_debug::ExecutionTrace, byte_addr: u32) -> Word { + let felts: [TestFelt; 4] = trace + .read_from_rust_memory(byte_addr) + .unwrap_or_else(|| panic!("failed to read output word at {byte_addr:#x}")); + Word::from([felts[0].0, felts[1].0, felts[2].0, felts[3].0]) +} diff --git a/tests/integration/src/end_to_end/mod.rs b/tests/integration/src/end_to_end/mod.rs index 2688adf91..ea82be8f2 100644 --- a/tests/integration/src/end_to_end/mod.rs +++ b/tests/integration/src/end_to_end/mod.rs @@ -1,5 +1,6 @@ mod abi; mod arithmetic; +mod batch_kernel; mod crypto; mod debuginfo; mod differential;