feat(batch kernel): wire up INPUT_NOTES_COMMITMENT & note erasure#2905
feat(batch kernel): wire up INPUT_NOTES_COMMITMENT & note erasure#2905mmagician wants to merge 33 commits into
INPUT_NOTES_COMMITMENT & note erasure#2905Conversation
9b2a31a to
7e97e87
Compare
7dbd6b7 to
5bccee1
Compare
INPUT_NOTES_COMMITMENT
5bccee1 to
133f9bb
Compare
133f9bb to
59c2efb
Compare
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.
… 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.
Match the operand-stack (# =>) and advice-stack (# AS =>) comment style used throughout the rest of the protocol assembly.
Reconstruct the batch's input notes from a host-provided global list sorted by nullifier, bind every per-transaction input note to that list (lookup via sorted_array::find_key_value, with consumption + note-id checks so the host cannot omit, inject, duplicate, or alter notes), and hash the list to emit INPUT_NOTES_COMMITMENT == ProposedBatch::input_notes().commitment() for batches without intra-batch note erasure. BatchExecutor loads the CoreLibrary itself so the sorted_array event handlers are registered, and BatchVerifier now verifies against the real input-notes commitment. Intra-batch note erasure is a follow-up; the output-note advice (sorted list + per-tx output tuples) and the output memory regions are provided here as scaffolding for it.
093ac8e to
622bee3
Compare
…ut notes Add the output-note half of the note tracker: load the host-provided note-id-sorted output list, bind every per-transaction output note to it (deriving each NoteId via poseidon2::merge of its details/metadata commitments), and cross-reference the two lists to mark notes that are both created and consumed within the batch. Processing transactions in batch order (outputs before inputs) with a 1->2 erasure gate rejects consuming such a note before its creator runs (circular/incorrectly-ordered dependencies), and the epilogue hashes only the surviving input notes. INPUT_NOTES_COMMITMENT now matches ProposedBatch::input_notes().commitment() for batches with intra-batch erasure.
…gate Process each transaction's input notes before its output notes. With outputs-first, a note created and consumed by the same transaction had its erasure flag advanced to 2 before the consume check, so it was erased instead of rejected. Inputs-first means the consume sees the flag still at 1 (the creating output has not been processed yet), tripping the consume-before-create gate, matching the Rust note tracker's rejection. Cross-transaction erasure is unaffected (an earlier transaction's outputs are still processed before any later transaction's inputs). Adds a test that drives the gate by claiming a consumed note is created in-batch without any transaction creating it.
…tives Extract the batch kernel's MASM error constants (build.rs + the errors::batch_kernel module, mirroring tx_kernel/protocol) and assert the exact ERR_BATCH_* raised by each global-list binding and erasure negative test (via matches_execution_error), instead of just checking is_err. The three layer-tamper tests still assert only failure, since their hash check uses pipe_preimage_to_memory's bare assertion, which carries no named error code.
Rewrite comments that referenced the PR's development history ("currently",
"still all-zero placeholders", "not yet", "wired up in follow-up PRs") so they
describe what the code does. Tighten the CHANGELOG entry to match the
surrounding single-sentence style.
Address review feedback on PR #2905: - Add bounds asserts so the kernel rejects oversized inputs before they overflow the fixed-size memory regions: the per-batch transaction count, the two global note-list lengths, and each transaction's note-set length are checked against MAX_TRANSACTIONS_PER_BATCH / MAX_NOTES_PER_BATCH. The global-list check is the load-bearing one, since those lists are not bound by a commitment hash. - Make note_tracker.masm the single source of truth for the advice-map sentinel keys: build.rs generates the matching Rust constants, and both the kernel and the tests derive them, removing the duplicated literals. - Add epilogue negative tests (unconsumed input note, uncreated output note) and an oversized-list bound test.
Address pre-push review findings on the batch verifier and output stack: - BatchVerifier::verify now returns the verified proof's security level instead of discarding it, so the acceptance test pins the proof's actual level (via a zero-minimum verify) rather than assuming the hardcoded MIN_PROOF_SECURITY_LEVEL constant equals it. - Rename the verifier's stored minimum to min_proof_security_level to distinguish it from the actual verified level. - Add a BatchOutputs::into_stack_outputs round-trip unit test covering distinct, non-zero fields.
The BATCH_ID and reference block commitment come from the ProvenBatch's own fields and are not recomputed from its transactions, so a successful verification binds nothing the batch's constructor could not forge. State this in the warning and restate the trust-boundary caveat on verify().
- note_tracker.masm: lowercase pointer names in stack-state comments.
- kernel.rs: rename generated module to generated_constants; rename the
per-tx note Vec to preimage_data.
- test_batch_kernel.rs: introduce FELTS_PER_NOTE_ENTRY and use
MAX_INPUT_NOTES_PER_BATCH instead of magic 8/1024; parametrize the three
tampered-advice tests with rstest; drop the thin note-list-key wrappers in
favor of BatchKernel::{input,output}_note_list_key.
- build.rs / batch_executor.rs: trim verbose doc comments.
- memory.masm: drop redundant [4] suffixes on uppercase words; add the per-tx-header layout to the memory table; define what an erasure "creator" is; split complex word-loading getters onto commented lines; prefer idx over i in index doc comments. - prologue.masm: drop the redundant "is the batch's BatchId value" clause; replace "..." stack comments with nested-bracket notation; add an advice-stack comment after the Layer 2 mapvaln; explain the div.4.
Restructure the note tracker to mirror the tx kernel's stage layout: - prologue: prepare_batch now also loads and strict-sorts the global input- and output-note lists (the list-loading procs move here from note_tracker). - note_tracker: keeps the erasure cross-reference and per-tx binding, exposed via a new run entry point track_notes. - epilogue (new lib module): the consumed/created/erasure assertions and the commitment computation; hash_non_erased_input_notes becomes compute_input_notes_commitment, plus a placeholder compute_output_notes_commitment (empty word, wired into the BATCH_NOTE_TREE_ROOT output) and a finalize entry point returning both commitments. - main: prologue -> note_tracker::track_notes -> epilogue::finalize, then truncate to the 16-felt output region.
The consumed-exactly-once and no-pending-erasure checks iterate the same input entries as the commitment hash loop, so assert them inline per entry instead of in two separate sweeps. The output-note created check stays its own pass over the output list.
Replace the magic 0xBA7C000x sentinels with keys derived by hashing a domain message. build.rs hashes each message once and emits the value to both the kernel (a generated note_list_keys.masm module the prologue pushes) and the Rust advice builder, so the two sides agree by construction and no magic hex remains. The key word is still four equal felts of the value.
Resolve the test_batch_kernel.rs import conflict from the miden-tx-batch-prover -> miden-tx-batch crate rename on next; the BatchVerifier additions carried into the renamed crate via directory-rename detection. cargo check --all-targets, batch suite (51 tests), and make lint all pass.
- main.masm / prologue.masm: tighten the doc comments per review (reword the program/validation summary, the INPUT_NOTES_COMMITMENT description and the Layer 2 step; drop redundant lines) and replace the output truncation with swapdw dropw dropw. - note-list keys: make each key a full poseidon2-hash word instead of a felt repeated four times. build.rs now emits a generated MASM proc per key that pushes the word, so the prologue execs it (no dup dup dup), and the Rust advice builder uses the matching [u64; 4] word.
Replace the generate_batch_kernel_keys codegen (a generated MASM module +
Rust constants + reverse-push-order workaround) with the assembler's native
word(...) constant: the prologue defines
const INPUT_NOTE_LIST_KEY=word("miden::batch_kernel::input_note_list")
and pushes it directly, while the Rust advice builder derives the same word
from the same message via miden_core's hash_string_to_word. Both sides hash
the identical domain string with the identical function, so they agree by
construction (asserted implicitly by the kernel's advice lookups in tests).
Removes the build.rs key generation entirely.
Address review comments asking for more explanation: - Define what "binding" a per-transaction note means (look it up in the batch's sorted list and mark the entry; proves the list is exactly the per-tx note set) on bind_per_tx_notes, and trim the input-before-output rationale to one sentence. - Explain the scratch region and add per-block inline comments and stack comments to the per-tx input/output binding procs. - Comment the empty-commitment fast path. - Reword the bind_scratch_* summaries. - Rename "global ... note list" to "batch ... note list" within this file (matching the error messages).
Rename the 'global input/output-note list' to 'nullifier-sorted input-note list' / 'note-id-sorted output-note list' across the batch kernel (docs, error messages, section headers, the memory-layout table, and load_note_list), distinguishing the sorted batch lists from the per-transaction notes per review.
- absorb_input_entry: use poseidon2::absorb_double_words_from_memory instead of manually loading the rate words and permuting (the loaded rate words were immediately discarded), per review. - compute_input_notes_commitment: split into commented blocks; clarify that the empty-set commitment is the empty word (matching build_input_note_commitment's early return), not the hash of zero elements. - Trim the verbose advice-key comments in prologue.masm and kernel.rs and drop the word "sentinel".
- remove the unused SCRATCH_WORDS_COUNT/WORD_INDEX slots and accessors (note counts are derived from pipe_preimage_to_memory's end_ptr) - document the duplicate-note-id conservative abort in the erasure cross-reference and the non-multiple-of-8 advice-length failure mode in load_note_list - reword the main.masm output docs: the placeholder outputs are bound by the proof, just not yet derived from batch data
…sert Absorb contiguous non-erased entries in a single absorb_double_words_from_memory call, so the hasher state round-trips through memory once per run (once in total for a batch with no erasures) instead of once per entry. Also remove the epilogue's pending-erasure assert: an entry with erasure == 1 either was consumed - tripping the note tracker's ordering gate at consume time (erasure never transitions back to 1) - or was not, tripping the epilogue's consumption assert. This also removes the error constant duplicated with note_tracker.masm.
- tampered-advice case for Layer 3' (OUTPUT_NOTES_COMMITMENT preimage) - oversized output-note list, layer-1 tuple list, and per-transaction note set (each length assert fires before its hash check) - consumed-twice / created-twice checks, driven by executing the kernel directly with a forged BATCH_ID over a duplicated transaction tuple list, modeling a malicious prover bypassing ProposedBatch validation - gate BatchExecutor::extend_advice_inputs behind the testing feature since it exists only to drive these rejection paths
…hable assert" This reverts commit 9f4f2f8.
Replace the test-only execute_kernel_raw helper with a testing-gated BatchExecutor::with_batch_id override (mirroring extend_advice_inputs), so the consumed-twice / created-twice tests run through the real executor path instead of duplicating processor/host setup.
Make BatchExecutor a stateless unit struct again. The production execute path derives its inputs and delegates to a private execute_with_inputs; the testing-gated execute_with_advice_overrides entry point merges override advice and delegates to the same body, so execute contains no testing-only code. Also drop the forged-batch-id override and the consumed-twice / created-twice tests that needed it: those kernel checks are unreachable through a valid ProposedBatch and are not worth a test-only input path.
…executor Replace the testing-gated execute_with_advice_overrides with a plain advice_inputs parameter on BatchExecutor::execute, mirroring how TransactionArgs carry caller advice into the transaction executor (and execute_tx_view_script's advice_inputs parameter). Advice is untrusted by design - the kernel verifies everything it consumes - so extending it is a legitimate production capability, not a testing hook. No cfg-gated code remains in the executor.
Trim test doc comments to what each test exercises, drop comments that restate the code or justify choices that only made sense during review (cross-references to the tx-kernel test layout, duplicated section headers, redundant TODO), and tighten the remaining prose.
- strongly type the note lists in build_advice_inputs (Nullifier, NoteId) - rename Layer 3 / Layer 3' to Layer 3a / 3b across kernel docs; relabel the prologue's global-list step to avoid the collision - clarify everywhere that input notes sort by nullifier and output notes by note id - drop NULLIFIER[4]-style suffixes (uppercase already means word) and apply the suggested doc/comment trims - shorten the batch error messages per suggestions (the duplicated consumed-before-created constant is kept in sync in note_tracker)
INPUT_NOTES_COMMITMENTINPUT_NOTES_COMMITMENT & note erasure
| for note_commit in tx.input_notes().iter() { | ||
| let nullifier = note_commit.nullifier(); | ||
| let note_id_or_empty = | ||
| note_commit.header().map_or(Word::empty(), |header| header.id().as_word()); |
There was a problem hiding this comment.
NoteTracker turns an unauthenticated note with a valid inclusion proof into InputNoteCommitment::from(nullifier), so batch.input_notes().commitment() hashes (NULLIFIER, EMPTY). This advice path still puts (NULLIFIER, NOTE_ID) in the global list, so the epilogue emits a different commitment and that batch should fail verification. A regression test with unauthenticated_note_proofs would catch it.
Could this use the batch-level input note representation instead of the per-tx one?
| # => [num_notes, num_words, KEY, write_ptr] | ||
| # A length that is not a multiple of 8 felts wraps `num_notes` (field `div`) to a non-u32 | ||
| # felt, which the range check below rejects. | ||
| dup u32lte.MAX_NOTES_PER_BATCH assert.err=ERR_BATCH_NOTE_LIST_TOO_LONG |
There was a problem hiding this comment.
ProposedBatch checks MAX_INPUT_NOTES_PER_BATCH / MAX_OUTPUT_NOTES_PER_BATCH after erasure, but this list is pre-erasure.
A batch can have more than 1024 notes created and consumed inside the batch, end with at most 1024 final notes, pass ProposedBatch, and then fail here because the kernel stores the pre-erasure lists in fixed 1024-entry regions.
Builds on the batch kernel skeleton (#2904) to fill in the first part of the verification chain. The kernel recomputes the batch
INPUT_NOTES_COMMITMENTas the nullifier-sorted, post-erasure commitment matchingProposedBatch::input_notes().commitment(). Output notes are processed only to track erasure; computingBATCH_NOTE_TREE_ROOTand theexpiration_block_numrunning-minimum are deferred to follow-up PRs.What this does
Prologue
Verification of data layers
The kernel takes
[BLOCK_COMMITMENT, BATCH_ID, pad(8)]and walks a layered advice map, each layer keyed by a hash the previous layer verified. It then writes all this data to memory. The advice map is structured as:BATCH_ID-> the(tx_id, account_id)tuple listtx_id_i-> the transaction header preimage, i.e.[INIT_ACCOUNT_COMMITMENT_i, FINAL_ACCOUNT_COMMITMENT_i, INPUT_NOTES_COMMITMENT_i, OUTPUT_NOTES_COMMITMENT_i, FEE_ASSET_i]. For the scope of this PR, we only care about the input & output notes commitments.INPUT_NOTES_COMMITMENT_i->(NULLIFIER, EMPTY_OR_COMMITMENT)tuplesOUTPUT_NOTES_COMMITMENT_i->(NOTE_DETAILS_COMMITMENT, METADATA_COMMITMENT)tuplesThe prologue only verifies Layers 1 and 2: it writes the tuple list and the per-transaction headers (which include each
INPUT_NOTES_COMMITMENT_i/OUTPUT_NOTES_COMMITMENT_i) to memory, intx_idorder. The Layer 3 note pre-images are not loaded here — they are streamed in per transaction later (see Note matching).Loading "global" notes
So at this point we have, in
tx_idorder, each transaction's verified input/output notes commitments — but our goal is to computeINPUT_NOTES_COMMITMENTover the notes sorted by their nullifier, and post-erasure.To do this, we load two more lists into memory, supplied by the host and unverified (for now):
prepare_input_note_listprepare_output_note_listSo at the end of the prologue's
prepare_batch, for both input and output notes we have a verifiedtx_id-ordered view (the per-transaction commitments) and an untrustednullifier/note-id-sorted list.The next step is to match them.
Note tracker
Note erasure
A note is erased when it is created and consumed within the same batch. Only an unauthenticated input note (one carrying a note id) can be erased, so for each such entry in the input list we look up its note id in the output list. If it is found, the note is created somewhere in the batch and is a candidate for erasure: we flag the input entry expected-to-be-erased and link it to the matching output entry.
This is a static, order-independent pass over the (untrusted) sorted lists. Whether the erasure is actually valid — the creating transaction runs before the consuming one — is enforced during matching.
Note matching
So far we've determined erasure, but the
nullifier/note-id-sorted lists are still untrusted.We now process one transaction at a time. Its note tuples are piped from the advice provider into a small scratch buffer — re-used for each transaction, so only one transaction's notes are written to scratch memory at a time — and verified by asserting their hash equals that transaction's
INPUT_NOTES_COMMITMENT_i/OUTPUT_NOTES_COMMITMENT_i. Then, for each note, we look it up in the untrusted list — input notes bynullifier, output notes bynote id— assert the looked-up entry matches, and mark it {consumed for inputs, created for outputs}.Because every verified per-transaction note must be found, each entry may be marked at most once, and the lists are strictly sorted (no duplicates), this proves the untrusted lists are exactly the per-transaction note sets: the host cannot inject, omit, or duplicate notes.
Epilogue
Once every note is matched, we:
INPUT_NOTES_COMMITMENTas the sequential hash over the non-erased input entries innullifierorder, reproducingbuild_input_note_commitment