Skip to content

experiment: batch verification#1872

Draft
ozgb wants to merge 12 commits into
mainfrom
ozgb-batch-proof-verification
Draft

experiment: batch verification#1872
ozgb wants to merge 12 commits into
mainfrom
ozgb-batch-proof-verification

Conversation

@ozgb

@ozgb ozgb commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Overview

Experimental / draft. Batch ZK-proof verification for Midnight transactions, so the expensive aggregate crypto runs once per block/batch instead of once per transaction. Everything is gated behind node config and defaults OFF, so with the flags unset the node behaves as today.

Two ingress points feed a process-global proof cache that downstream get_verified_transaction reads to skip (defer) the ZK crypto:

  • Block import (batch_verify_block_import): received blocks are batch-verified up front (fail-fast; a bad proof rejects the block). (landed earlier on this branch)
  • Mempool (batch_verify_mempool): external submissions are batched natively via a custom MidnightChainApi + bounded queue + blocking worker pool.

This PR adds the two pieces that make the mempool path real and safe to enable:

  • Isolation fallback (ledger): when an aggregate batch fails, each transaction is re-verified individually to isolate the offender(s) — caching false for bad proofs and warming caches for good ones — instead of panicking. Extracts a shared warm_verified_tx helper.
  • Mempool ingress (node): MidnightChainApi wraps the stock FullChainApi, delegating everything except validate_transaction; external Midnight send_mn_transaction extrinsics are queued and drained in batches by N blocking workers, which warm the caches and build the same validity tag the runtime would (with_tag_prefix("Midnight").longevity(600).and_provides(tx_hash)). Invalid/unavailable cases delegate to the runtime for the authoritative result.

The transaction pool is now always the SingleState BasicPool<MidnightChainApi> (batch verification requires the single-state ChainApi seam). Pool limits are reconstructed from the CLI (--pool-limit/--pool-kbytes/--tx-ban-seconds); --pool-type is intentionally not applied.

⚙️ Configuration (new MidnightCfg fields, for perf testing)

All new fields live in node/src/cfg/midnight_cfg/mod.rs, default OFF/unset, and are optional in res/cfg presets — existing presets need no changes to keep today's behavior.

Field Type Default Meaning
batch_verify_block_import bool false Enable batch ZK-proof verification for received blocks at block import. Fail-fast: a bad proof anywhere in the batch rejects the block (no fallback).
batch_verify_mempool bool false Enable batch verification on the mempool ingress path (external send_mn_transaction submissions). A bad proof is isolated per-tx, not panicked.
batch_verify_max_batch_size usize 64 M — max transactions verified together in one aggregate crypto call. Upper bound on amortization; also an upper bound on per-batch latency, since every tx in a batch waits for the whole aggregate call to finish.
batch_verify_target_batch_size usize 16 k_target — queue depth (mempool path) that triggers an immediate dispatch, without waiting for the age timeout. Lower = lower latency but smaller/less-amortized batches; higher = better amortization but more latency under light load.
batch_verify_max_age_ms u64 50 tau — max time (ms) the oldest queued tx waits before a partial batch dispatches anyway (mempool path). Caps worst-case added latency when traffic is too light to hit k_target.
batch_verify_workers usize 4 N — number of blocking worker tasks draining the mempool batch queue concurrently. Each runs one aggregate crypto call at a time, so more workers only help up to available CPU cores. Mempool-only; block import is single-threaded per block.
batch_verify_queue_capacity usize 4096 Bounded mempool queue capacity. Submissions beyond this are shed immediately with a pool ImmediatelyDropped error instead of being queued.

Tuning notes (see scripts/tests/batch-verify-perf/FINDINGS.md for the block-import harness, its methodology, and open caveats):

  • Batching only pays off once a batch carries many proof-txs — at batch sizes ≤5 the aggregate call's fixed cost dominates and there's ~nothing to amortize (measured on the block-import path). batch_verify_max_batch_size / batch_verify_target_batch_size are the levers to explore the favourable regime; workload needs enough proof-txs per block/window to actually fill a batch (see the FINDINGS.md note on the DUST-output ceiling that limited the current harness).
  • batch_verify_target_batch_size and batch_verify_max_age_ms jointly set the mempool latency/throughput trade-off: raising either trades added tail latency for larger, more efficient batches.
  • batch_verify_workers is CPU-bound — sizing it above available cores just adds contention, not throughput.
  • batch_verify_queue_capacity is a burst-absorption/shedding valve, not a throughput knob: too small drops legitimate traffic under burst (watch queue_rejected_total), too large just grows tail latency for queued txs.
  • Useful metrics while tuning: midnight_batch_verify_batch_size (histogram), midnight_batch_verify_duration_seconds (per-batch aggregate crypto time, both ingress paths), midnight_batch_verify_batches_total{outcome=...}, midnight_batch_verify_txs_total, midnight_batch_verify_queue_depth, midnight_batch_verify_queue_rejected_total, midnight_batch_verify_dispatch_reason_total{trigger=...} (k_target vs tau), midnight_batch_verify_fallback_total (mempool-only).
  • New just targets for the block-import A/B harness: just batch-verify-perf-prime <NODE_IMAGE> <TOOLKIT_IMAGE> (prime + archive a proof-heavy chain, once) and just batch-verify-perf-bench <NODE_IMAGE> (A/B benchmark off vs on against the primed archive) — see scripts/tests/batch-verify-perf/README.md.

🗹 TODO before merging

  • Ready
  • Integration validation: with both flags on in a dev preset, sync a chain and author blocks; confirm received blocks import via the wrapper, mempool/authored txs warm the proof cache (no downstream inline-verify error logs), and a bad-proof submission is isolated (not a panic)
  • Decide whether to honor/warn on --pool-type fork-aware (currently ignored; SingleState is forced)
    • Fine as-is - this is just an experiment, not to be merged
  • Bump node minor version if this graduates from experiment to a shipped feature
    • It will not be shipped by this PR

📌 Submission Checklist

  • All commits are signed off (git commit -s) for the DCO
  • Changes are backward-compatible (both flags default off; note: the pool is now always SingleState, so a --pool-type fork-aware run now builds SingleState)
  • Pull request description explains why the change is needed
  • Self-reviewed the diff
  • I have included a change file, or skipped for this reason: experimental/draft branch
  • If the changes introduce a new feature, I have bumped the node minor version
  • Update documentation (if relevant)
  • Updated AGENTS.md if build commands, architecture, or workflows changed
  • No new todos introduced (the previous todo!() fallback is now implemented)

🧪 Testing Evidence

  • cargo check -p midnight-node-ledger -p midnight-node — clean

  • cargo fmt --check — clean

  • cargo clippy — no new findings (only pre-existing, workspace-allowed lints on touched files)

  • Unit tests:

    • ledger: fallback_isolates_bad_proof_and_caches_outcomes (mixed good/bad batch isolates the offender, caches true/false correctly, correct per-tx results) — passes on all 3 ledger versions
    • node: 7 tests for the worker pool — dispatch at k_target and tau, bounded-queue shedding (ImmediatelyDropped), oneshot resolution, invalid/unavailable → delegate, and native validity-tag shape
  • Additional tests are provided (if possible)

🔱 Fork Strategy

  • Node Client Update

Runtime/pallets are unchanged; the ledger crate is invoked natively by the node. No metadata or genesis rebuild required.

Links

N/A (experimental)

🤖 Generated with Claude Code

ozgb added 4 commits July 8, 2026 11:31
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
…gress

Verify transaction ZK proofs once, in an aggregate batch, instead of one
tx at a time. This lands the ledger-crate foundation, the native-direct
invocation machinery, and the block-import ingress point; all gated by
node config (MidnightCfg) and defaulting OFF.

Ledger crate:
- New process-global proof-verification cache (tx_hash -> bool), keyed by
  the state-independent tx_validation_cache_key, with insert/get accessors.
- get_verified_transaction consults the cache: defer crypto on a hit,
  reject on a cached-false, error-log + full inline verify on a miss.
- Bridge::batch_verify_transactions: real aggregate verification using the
  ledger-9 collect_proof_evidence/batch_proof_verify API. The v9-only
  crypto is isolated in a new per-version batch_verify module so the shared
  code still compiles against ledger 7/8. Only the mempool isolation
  fallback remains a todo!().
- Native (non-WASM) entry point host_api::ledger_9::batch_verify_transactions
  (no new runtime API / host function).

Node crate:
- MidnightCfg: batch_verify_block_import / batch_verify_mempool (default
  false) plus queue-tuning params, via serde defaults.
- batch_verify.rs: BatchVerifier builds the native inputs (state_key,
  BlockContext, runtime_version) from the client backend and calls the
  ledger natively; BatchVerifyMetrics. No backend.state_at trie view is
  needed - the ledger arena is process-global, so a BasicExternalities +
  LedgerStorageExt suffices.
- batch_block_import.rs: BatchVerifyBlockImport wraps only the import-queue
  block import (received blocks). Rejects a block only on a genuine invalid
  proof; on any setup/availability failure it delegates so downstream inline
  verification still runs (never wrongly halts sync).

Remaining (follow-up): mempool custom ChainApi + worker pool, and the
per-tx isolation fallback.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Follow-up to the batch ZK-proof verification core (b731346), delivering the two deferred pieces so the mempool ingress path is real and safe to enable.

Component A (ledger): implement the isolation fallback in Bridge::batch_verify_transactions (was todo!()). On aggregate-batch failure it verifies each ready transaction individually to isolate the offender(s), caching false for bad proofs and warming caches for good ones. Extracts a shared warm_verified_tx helper used by both the success loop and the fallback.

Component B (node): add MidnightChainApi plus a bounded queue and blocking worker pool that batch-verify external Midnight submissions natively, warming the proof/soft/strict caches and building the same validity tags the runtime would (delegating invalid/unavailable cases to the runtime). Reworks the transaction pool to BasicPool<MidnightChainApi> (SingleState), reconstructs pool limits from the CLI (--pool-limit/--pool-kbytes/--tx-ban-seconds), and reuses one BatchVerifier for the mempool and block-import paths.

Both flags (batch_verify_block_import, batch_verify_mempool) default off.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@datadog-official

datadog-official Bot commented Jul 14, 2026

Copy link
Copy Markdown

Pipelines

⚠️ Warnings

🚦 2 Pipeline jobs failed

+check (format + lint) | Fomatting and Linting   View in Datadog   GitHub Actions

+test | Run tests   View in Datadog   GitHub Actions

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 12fc88c | Docs | Give us feedback!

ozgb added 4 commits July 14, 2026 11:42
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Move the aggregate-verification duration timer inside BatchVerifier::batch_verify (around the batch_verify_transactions crypto call) so midnight_batch_verify_duration_seconds is recorded for the block-import path as well as the mempool path, which previously recorded nothing. Remove the mempool's now-redundant external timer to avoid double-counting; both paths funnel through this method.

Assisted-by: Claude:claude-4.8-opus
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Two-phase Docker A/B benchmark for block-import batch proof verification. prime.sh builds a proof-heavy dev chain (fan-out of shielded coins, then chunked batch-single-tx working around the single funder's DUST-output limit) and archives the node's base_path; benchmark.sh restores it into a non-authoring producer and full-syncs a fresh node with BATCH_VERIFY_BLOCK_IMPORT off vs on, comparing sync time (min of N repeats) and scraping the syncer's batch-verify metrics with a coverage/engagement verdict. Adds 'just batch-verify-perf-{prime,bench}'. See scripts/tests/batch-verify-perf/{README,FINDINGS}.md.

Assisted-by: Claude:claude-4.8-opus
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
@ozgb ozgb added bot:ai-assisted Authored or substantially edited by an AI agent and removed ai-assisted AI (LLM) assisted contribution labels Jul 14, 2026
ozgb added 3 commits July 14, 2026 16:57
Add ledger_proof_verify_duration_seconds / _txs_total (labelled by mode) to
LedgerMetrics so ZK proof-verification crypto is recorded at transaction
granularity on BOTH the inline (OFF) and batch (ON) block-import paths, which
already share one LedgerMetricsExt registry:

- mode="inline": per-tx well_formed WITH proofs in get_verified_transaction
  (the OFF / cold-proof-cache path).
- mode="batch": the aggregate batch_verify_proofs call (the ON crypto).
- mode="batch_prep": per-tx well_formed WITHOUT proofs on the batch path (the
  non-crypto work both paths pay).

get_verified_transaction now returns the inline crypto duration (Some only when
it actually verified proofs inline); apply_transaction records it. The OFF
verification path is otherwise unchanged -- well_formed is only wrapped in a
timer.

The existing midnight_batch_verify_duration_seconds records only on the ON path
(per-batch) and had nothing to diff against; these per-tx metrics give a clean
OFF-vs-ON crypto speedup that sidesteps the block/DB/sync wall-clock noise.
benchmark.sh scrapes them from both runs and reports full-verify and crypto-only
per-tx speedups (per-tx = _sum / _txs_total); README/FINDINGS updated.

Assisted-by: Claude:claude-4.8-opus
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
The OFF (inline) block-import path never emitted the ledger_proof_verify_*
mode="inline" metric, so the batch-verify perf harness reported "insufficient
samples" and could not compute the OFF-vs-ON per-tx crypto speedup.

Root cause: send_mn_transaction is unsigned, so during execute_block FRAME runs
ValidateUnsigned::pre_dispatch (validate_guaranteed_execution) BEFORE dispatching
the call. That pre_dispatch runs the inline ZK crypto and warms the STRICT cache
but discarded its timing; by the time apply_transaction (the only place recording
mode="inline") called get_verified_transaction, it hit the warm cache and got
None, so nothing was recorded.

Record the inline duration where the crypto actually runs on the OFF block-import
path: do_validate_guaranteed_execution now returns the inline duration and
validate_guaranteed_execution observes it. apply_transaction's recording stays as
a guarded fallback for paths with no preceding pre_dispatch (e.g. tests). On the
ON path the batch verifier pre-warms the STRICT/proof caches, so this records no
false inline samples.

Assisted-by: Claude:claude-4.8-opus
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
benchmark.sh now picks its run mode like toolkit-tokens-minter-e2e.sh: pass a
node image and it runs containers (unchanged); pass nothing and it runs a
locally-built binary (NODE_BIN, default target/release/midnight-node) as host
processes against the existing archive. This is the fast inner loop for node-side
changes -- build once and benchmark without waiting on a CI image.

Local mode uses host processes rather than a layered image because the image base
(amazonlinux 2023, glibc 2.34) is older than a typical dev host, so a freshly
built host binary can't run inside it. The local producer re-supplies the dev
preset's authoring args (the CLI replaces the preset's args array) and runs with
the repo root as CWD so the preset's relative res/ paths resolve; BASE_PATH points
it at the restored archive dir.

Docker and local modes share the polling, metrics-scrape and reporting logic via
mode-aware node-runner helpers (start_producer/start_syncer/syncer_alive/...);
README documents the new mode and its genesis-match caveat.

Assisted-by: Claude:claude-4.8-opus
Signed-off-by: Oscar Bailey <79094698+ozgb@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:ai-assisted Authored or substantially edited by an AI agent skip-changes-check-all skip-changes-check-issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant