Sfoglia il codice sorgente

script/research: add fee-model calibration tooling

Four research utilities used to derive the gas constants.
darkfi 4 settimane fa
parent
commit
ae57b9e8b4

+ 5 - 0
script/research/fee-model/.gitignore

@@ -0,0 +1,5 @@
+/target
+bench_results.json
+db_bench_results.json
+capacity.json
+Cargo.lock

+ 48 - 0
script/research/fee-model/Cargo.toml

@@ -0,0 +1,48 @@
+[package]
+name = "fee-model"
+version = "0.1.0"
+description = "Fee calibration utilities for DarkFi"
+authors = ["Dyne.org foundation <foundation@dyne.org>"]
+repository = "https://codeberg.org/darkrenaissance/darkfi"
+license = "AGPL-3.0-only"
+edition = "2021"
+
+[[bin]]
+name = "bench"
+path = "src/bench.rs"
+
+[[bin]]
+name = "capacity"
+path = "src/capacity.rs"
+
+[[bin]]
+name = "db_bench"
+path = "src/db_bench.rs"
+
+[workspace]
+
+[dependencies]
+darkfi-sdk = {path = "../../../src/sdk"}
+darkfi = {path = "../../../", features = ["zk"]}
+darkfi-serial = {path = "../../../src/serial"}
+rand = "0.8.5"
+serde = {version = "1.0", features = ["derive"]}
+serde_json = "1.0"
+sled-overlay = "0.1.20"
+pasta_curves = "0.5.1"
+bridgetree = "0.7.0"
+wasmer = {version = "6.1.0", features = ["singlepass"]}
+wasmer-compiler-singlepass = {version = "6.1.0"}
+smol = "2.0.2"
+
+# Fee modeling specific dependencies
+darkfi-contract-test-harness = {path = "../../../src/contract/test-harness"}
+darkfi_money_contract = {path = "../../../src/contract/money", features = ["client", "no-entrypoint"]}
+darkfi_dao_contract = {path = "../../../src/contract/dao", features = ["client", "no-entrypoint"]}
+darkfi_deployooor_contract = {path = "../../../src/contract/deployooor", features = ["client", "no-entrypoint"]}
+tracing = "0.1.44"
+tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] }
+
+[patch.crates-io]
+halo2_proofs = {git="https://github.com/parazyd/halo2", branch="v050"}
+halo2_gadgets = {git="https://github.com/parazyd/halo2", branch="v050"}

+ 83 - 0
script/research/fee-model/Containerfile.capacity

@@ -0,0 +1,83 @@
+# Two-stage Containerfile for the DarkFi capacity benchmark.
+#
+# Build context must be the repo root (not this directory), because
+# fee-model/Cargo.toml uses path = "../../../" for darkfi deps:
+#
+#   cd /path/to/darkfi
+#   podman build -t darkfi-capacity \
+#       -f script/research/fee-model/Containerfile.capacity .
+#
+# Run:
+#   podman run --rm --cpuset-cpus=0-3 --memory=8g \
+#       -v ./out:/out darkfi-capacity
+#
+# The image bundles the capacity binary and the money contract WASM.
+# The benchmark writes results to /out/capacity.json.
+
+# ===========================================================================
+# Stage 1: Build
+# ===========================================================================
+FROM docker.io/debian:bookworm-slim AS rust_builder
+
+ARG RUST_VER=stable
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    build-essential cmake pkg-config clang libclang-dev llvm-dev \
+    libssl-dev libsqlite3-dev curl ca-certificates git python3 \
+    && rm -rf /var/lib/apt/lists/*
+
+RUN curl https://sh.rustup.rs -sSf | bash -s -- -y --default-toolchain "${RUST_VER}"
+ENV PATH="/root/.cargo/bin:${PATH}"
+RUN rustup target add wasm32-unknown-unknown
+
+WORKDIR /opt/darkfi
+
+# Copy the full source tree (preserves the ../../../ path layout
+# that fee-model/Cargo.toml uses for darkfi path dependencies).
+COPY . /opt/darkfi
+
+# Build zkas first (native target, no special flags needed).
+RUN make zkas
+
+# Build contract WASMs with --import-undefined linker flag.
+# RUSTFLAGS is set directly so it reliably reaches cargo through the
+# contract Makefiles.  Only WASM targets are built here, so the
+# --import-undefined flag only reaches wasm-ld, never the native linker.
+# This fixes "undefined symbol: drk_log_" on Rust 1.82+ where extern "C"
+# without #[link(wasm_import_module = ...)] no longer auto-imports.
+RUN export RUSTFLAGS="-C link-arg=--import-undefined" && \
+    make -C src/contract/money && \
+    make -C src/contract/dao && \
+    make -C src/contract/deployooor
+
+# Build the capacity binary.
+WORKDIR /opt/darkfi/script/research/fee-model
+RUN cargo build --release --bin capacity
+
+# ===========================================================================
+# Stage 2: Runtime (slim)
+# ===========================================================================
+FROM docker.io/debian:bookworm-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libssl3 ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /opt/fee-model
+
+# Copy the capacity binary
+COPY --from=rust_builder /opt/darkfi/script/research/fee-model/target/release/capacity \
+    /opt/fee-model/capacity
+
+# Copy the money contract WASM (needed by deploy scenarios)
+COPY --from=rust_builder /opt/darkfi/src/contract/money/darkfi_money_contract.wasm \
+    /opt/darkfi/contracts/darkfi_money_contract.wasm
+
+# Point the benchmark at the bundled WASM
+ENV MONEY_WASM_PATH=/opt/darkfi/contracts/darkfi_money_contract.wasm
+
+# Output directory (mount a volume here to retrieve results)
+RUN mkdir -p /out
+VOLUME /out
+
+ENTRYPOINT ["/opt/fee-model/capacity"]

+ 26 - 0
script/research/fee-model/Makefile

@@ -0,0 +1,26 @@
+.PHONY: all clean bench db_bench capacity
+
+# CPU core to pin bench/db_bench to (single-threaded microbenchmark reproducibility)
+CPU_CORE ?= 2
+CPU_PIN := taskset -c $(CPU_CORE)
+
+all: bench
+
+# Low-level microbenchmark of WASM/hash/sig/ZK primitives -> bench_results.json
+bench:
+	$(CPU_PIN) cargo run --release --bin bench > bench_results.json
+
+# Database I/O microbenchmark (ns/byte across payload sizes) -> db_bench_results.json
+db_bench:
+	$(CPU_PIN) cargo run --release --bin db_bench > db_bench_results.json
+
+# Validator block capacity benchmark (per-tx gas + wall-clock time across
+# scenarios, tagged with machine_info) -> capacity.json
+#   make capacity
+#   MONEY_WASM_PATH=/path/to.wasm make capacity   # bundled/container WASM
+capacity:
+	cargo run --release --bin capacity > capacity.json
+
+clean:
+	cargo clean
+	rm -f bench_results.json db_bench_results.json capacity.json

+ 191 - 0
script/research/fee-model/README.md

@@ -0,0 +1,191 @@
+# Fee Calibration
+
+Utilities for calibrating fee constants from resource usage measurements.
+
+- **bench** / **db_bench** measure primitive timings (WASM, hashes, sigs,
+  ZK, sled I/O) in nanoseconds. These derive the measured gas constants in
+  `src/validator/fees.rs`.
+- **capacity** measures per-tx validator gas (verify_fees=false), i.e. the
+  base cost excluding the fee call. It adds `FEE_CALL_GAS` to report
+  `gas_per_tx_with_fee`.
+
+## bench
+
+Low-level microbenchmark of WASM opcodes, hashes, signatures, and ZK
+circuits.
+
+```
+make bench
+```
+
+Writes bench_results.json (JSON on stdout, progress on stderr).
+
+ZK proof files must be generated first:
+
+```
+cd ../zkvm-metering/generator
+make
+./generator
+```
+
+What it measures:
+
+| Operation                                            | Iterations |
+|------------------------------------------------------|------------|
+| WASM opcode (add)                                    | 1000000    |
+| Poseidon, Sinsemilla hashes                          | 1000000    |
+| Pallas Schnorr signature verify                      | 1000000    |
+| ZK circuit verify (k=11, k=14)                       | 1000       |
+| ZK circuit compile (verifying key build, k=11, k=14) | 1000       |
+
+Timed closures exclude RNG, allocation, and formatting so the numbers
+reflect the primitive. ZK stats are reported per-row in nanoseconds.
+
+In addition to the aggregate stats, `bench` emits a `circuits` map keyed
+by contract circuit name (money/dao `*.zk.bin`). Each entry records `k`,
+`compile_p50_ns_per_row`, `vk_size_bytes`, `opcodes_count`,
+`witnesses_count`, and `literals_count`.
+
+## db_bench
+
+Standalone database microbenchmark. Measures sled I/O across four
+scenarios (set new key, overwrite, get, contains_key) and eight payload
+sizes (32b to 8KiB), fits a linear regression of ns/byte, and expresses
+the slopes as ratios against the WASM-add baseline.
+
+```
+make db_bench
+```
+
+Writes db_bench_results.json.
+
+## capacity
+
+Measures per-tx gas and per-tx wall-clock time (verify + apply) across
+the scenarios below. Each scenario builds a batch of prebuilt
+transactions and runs it through the validator. Output is raw
+measurements (gas_per_tx, secs_per_tx).
+
+Scenarios:
+
+| Scenario            | Shape                                                     |
+|---------------------|-----------------------------------------------------------|
+| transfer_simple     | transfer 1-in/2-out                                       |
+| transfer_20in_2out  | transfer 20-in/2-out                                      |
+| transfer_1in_20out  | transfer 1-in/20-out                                      |
+| dao_propose_20recip | DAO propose, 20 recipients                                |
+| dao_exec_20recip    | DAO exec, 20 recipients                                   |
+| dao_vote            | DAO vote                                                  |
+| otc_swap            | OTC swap                                                  |
+| token_mint          | token mint                                                |
+| dao_mint            | DAO mint                                                  |
+| deploy_512kb        | deploy 512 KiB WASM                                       |
+| deploy_1024kb       | deploy 1024 KiB WASM                                      |
+| mixed               | ~78% transfers, 10% votes, 6% execs, 4% mints, 2% deploys |
+
+Run with:
+
+```
+make capacity
+```
+
+`make capacity` runs all scenarios. Set `MONEY_WASM_PATH` only when the
+money contract WASM is not at the default source-tree path:
+
+```
+MONEY_WASM_PATH=/path/to/darkfi_money_contract.wasm make capacity
+```
+
+Timing: verification_secs is the median of 3 verify-only (write=false)
+runs. apply_secs comes from one verify+apply (write=true) pass, as
+max(0, apply_total - verification_secs). Verify-only trials reuse the
+same base state because a write=true run spends the prebuilt coins and
+would break the next trial. No explicit warmup is needed: building the
+transactions already executes each one once.
+
+machine_info (CPU model, cores, RAM, disk type) is auto-detected and
+included in the output so runs from different machines can be grouped.
+
+Example output:
+
+    {
+      "machine_info": { "cpu_model": "...", "physical_cores": 12, ... },
+      "results": [
+        {
+          "scenario": "transfer_20in_2out",
+          "gas_per_tx": 96336180.0,
+          "secs_per_tx": 0.326,
+          "tps": 3.07
+        }
+      ]
+    }
+
+Each result object also includes `gas_per_tx_with_fee`, `fee_overhead_gas`,
+`verify_fees`, `block_gas_limit`, `op_shape`, `tx_count`, `gas_used`,
+`verification_secs`, `apply_secs`, and `total_secs` (omitted above for
+brevity).
+
+## Recalibration
+
+The gas-model constants live in `src/validator/fees.rs`, plus
+`FEE_CALL_GAS` in `src/contract/money/src/client/fee_v1.rs`. They are
+expressed relative to a single WASM opcode (the `wasm_add` baseline). The
+ratios are approximate.  A given constant set should be calibrated
+against the target validator hardware profile.
+
+### Where each constant comes from
+
+`wasm_add` below means the p50 of one WASM opcode (`wasm_add.p50_ns` from
+bench, or `wasm_add_p50_ns` from db_bench). db_bench pre-divides its slopes
+by wasm_add, so its `ratios.*` fields are already in gas units.
+
+| Constant                  | Benchmark | Read from                      | Convert                             |
+|---------------------------|-----------|--------------------------------|-------------------------------------|
+| POSEIDON_HASH_GAS         | bench     | poseidon_hash.p50_ns           | / wasm_add.p50_ns                   |
+| SINSEMILLA_HASH_GAS       | bench     | sinsemilla_hash.p50_ns         | / wasm_add.p50_ns                   |
+| PALLAS_SCHNORR_VERIFY_GAS | bench     | pallas_signature_verify.p50_ns | / wasm_add.p50_ns                   |
+| VERIFY_GAS_PER_ROW        | bench     | zk_verify.*.p50_ns             | / wasm_add.p50_ns (already per-row) |
+| COMPILE_GAS_PER_ROW       | bench     | zk_compile.*.p50_ns            | / wasm_add.p50_ns (already per-row) |
+| READ_GAS_PER_BYTE         | db_bench  | ratios.read_per_byte           | already in gas units                |
+| WRITE_GAS_PER_BYTE        | db_bench  | ratios.write_new_per_byte      | already in gas units                |
+| STATE_GROWTH_GAS          | db_bench  | db_set_new.intercept_ns        | / wasm_add_p50_ns                   |
+
+`FEE_CALL_GAS` is set conservatively (see `fee_v1.rs` and
+`doc/src/arch/fees.md`); it does not require recalibration when gas
+constants change because it carries deliberate headroom.
+
+### Re-deriving all constants from fresh measurements
+
+1. `make bench` and `make db_bench` (CPU-pinned): measure primitive
+   timings, then re-derive each constant from the ratios above.
+2. `make capacity`: record the new `gas_per_tx` per scenario.
+
+`secs_per_tx` (timing) only changes if the hardware or implementation
+changed, not from constant edits.
+
+### Changing one constant (e.g. STATE_GROWTH_GAS 20k -> 40k)
+
+A constant edit does not require re-running `bench` / `db_bench` — those
+measure time, not gas. Gas is computed from the constants at runtime, so
+only the gas-valued outputs drift. After editing the constant in
+`src/validator/fees.rs`:
+
+1. `make capacity`: every scenario that inserts keys gets a new
+   `gas_per_tx`; `secs_per_tx` is unchanged.
+2. Re-check the block gas limit L against the new `gas_per_tx` (below).
+
+Redo `capacity` after any `fees.rs` edit.
+
+## Calibration
+
+Pick the block gas limit L by combining `gas_per_tx` (hardware-independent)
+with `secs_per_tx` per hardware tier:
+
+    time_bound_tps(h, s) = 1 / secs_per_tx(h, s)
+    gas_bound_tps(L, s)  = L / gas_per_tx(s)
+    effective_tps(h, s, L) = min(time_bound_tps, gas_bound_tps)
+
+Run capacity on each target hardware tier, sweep candidate L values, and
+pick the largest L where the slowest tier's worst-case block still fits
+within the block time. Any larger and validators get blocks they cannot
+process in time.

+ 362 - 0
script/research/fee-model/src/bench.rs

@@ -0,0 +1,362 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::{fs, hint::black_box, io::Cursor, path::PathBuf};
+
+#[path = "bench_util.rs"]
+mod bench_util;
+
+use bridgetree::Hashable;
+use darkfi::{
+    zk::{empty_witnesses, Proof, VerifyingKey, ZkCircuit},
+    zkas::ZkBinary,
+};
+use darkfi_sdk::crypto::{
+    keypair::{Keypair, PublicKey},
+    merkle_node::MerkleNode,
+    schnorr::{SchnorrPublic, SchnorrSecret},
+    util::poseidon_hash,
+};
+use darkfi_serial as serial;
+use pasta_curves::{group::ff::Field, pallas};
+use serde::{Deserialize, Serialize};
+use wasmer::{Imports, Instance, Module, Store, Value};
+use wasmer_compiler_singlepass::Singlepass;
+
+/// Number of iterations for each benchmark measurement
+const ITERATIONS: usize = 1_000_000;
+
+/// Number of iterations for ZK (expensive operations)
+const ZK_ITERATIONS: usize = 1000;
+
+/// Statistics for benchmark measurements
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct BenchmarkStats {
+    mean_ns: u64,    // Mean (average)
+    p50_ns: u64,     // Median
+    p90_ns: u64,     // 90th percentile
+    p99_ns: u64,     // 99th percentile
+    max_ns: u64,     // Maximum observed
+    std_dev_ns: u64, // Standard deviation
+    iterations: usize,
+}
+
+/// Statistics for ZK circuit verification at different k values
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ZkVerifyStats {
+    k_11: BenchmarkStats, // 2^11 = 2,048 rows
+    k_14: BenchmarkStats, // 2^14 = 16,384 rows
+}
+
+/// Statistics for ZK circuit compilation (VerifyingKey building) at different k values
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ZkCompileStats {
+    k_11: BenchmarkStats, // 2^11 = 2,048 rows
+    k_14: BenchmarkStats, // 2^14 = 16,384 rows
+}
+
+/// Per-circuit benchmark results with metadata
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct CircuitBenchmark {
+    name: String,
+    k: u32,
+    /// Per-row compilation stats (median)
+    compile_p50_ns_per_row: u64,
+    /// VerifyingKey size in bytes
+    vk_size_bytes: usize,
+    /// Circuit metadata extracted from zk.bin
+    opcodes_count: usize,
+    witnesses_count: usize,
+    literals_count: usize,
+}
+
+/// Calculate percentiles from times
+fn percentiles(mut times: Vec<u64>, iters: usize) -> BenchmarkStats {
+    times.sort_unstable();
+    let p50 = times[iters / 2];
+    let p90 = times[iters * 90 / 100];
+    let p99 = times[iters * 99 / 100];
+    let max = times[iters - 1];
+
+    // Calculate mean and standard deviation
+    let mean = times.iter().map(|&x| x as f64).sum::<f64>() / iters as f64;
+    let variance = times
+        .iter()
+        .map(|&x| {
+            let diff = x as f64 - mean;
+            diff * diff
+        })
+        .sum::<f64>()
+        / iters as f64;
+    let std_dev = variance.sqrt() as u64;
+
+    BenchmarkStats {
+        mean_ns: mean as u64,
+        p50_ns: p50,
+        p90_ns: p90,
+        p99_ns: p99,
+        max_ns: max,
+        std_dev_ns: std_dev,
+        iterations: iters,
+    }
+}
+
+/// Measure execution time for an operation with statistics.
+fn measure<F: FnMut()>(op: F, iters: usize) -> BenchmarkStats {
+    percentiles(bench_util::collect_times(op, iters), iters)
+}
+
+/// Measure execution time for a ZK circuit operation, returning per-row statistics.
+/// Includes a minimal warmup phase to handle cold-start overhead without significant runtime cost.
+fn measure_zk<F: FnMut()>(mut op: F, iters: usize, k: u32) -> BenchmarkStats {
+    // Minimal warmup (5 iterations) to handle cold-start overhead for expensive ZK ops.
+    // This adds negligible overhead (~0.5% for 1000 iterations) while stabilizing measurements.
+    for _ in 0..5 {
+        let _ = op();
+    }
+
+    let rows = (1usize << k) as u64;
+    let times = bench_util::collect_times(op, iters);
+    let mut stats = percentiles(times, iters);
+    stats.mean_ns /= rows;
+    stats.p50_ns /= rows;
+    stats.p90_ns /= rows;
+    stats.p99_ns /= rows;
+    stats.max_ns /= rows;
+    stats.std_dev_ns /= rows;
+    stats
+}
+
+/// Collect all measurements for JSON output
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct Measurements {
+    wasm_add: BenchmarkStats,
+    poseidon_hash: BenchmarkStats,
+    sinsemilla_hash: BenchmarkStats,
+    pallas_signature_verify: BenchmarkStats,
+    zk_verify: ZkVerifyStats,
+    zk_compile: ZkCompileStats,
+    /// Per-circuit benchmarks with VK sizes
+    #[serde(flatten)]
+    circuits: std::collections::HashMap<String, CircuitBenchmark>,
+}
+
+/// Load a ZK circuit from a .zk.bin file
+fn load_zk_circuit(name: &str) -> (ZkCircuit, u32) {
+    let zk_bin =
+        std::fs::read(&format!("../zkvm-metering/generator/src/opcodes/proof/{}.zk.bin", name))
+            .unwrap();
+    let zkbin = ZkBinary::decode(&zk_bin, false).unwrap();
+    let verifier_witnesses = empty_witnesses(&zkbin).unwrap();
+    let circuit = ZkCircuit::new(verifier_witnesses, &zkbin);
+    (circuit, zkbin.k)
+}
+
+/// Benchmark ZK circuit verification for a given circuit file.
+fn measure_zk_verify(name: &str) -> BenchmarkStats {
+    let proof_bin =
+        std::fs::read(&format!("../zkvm-metering/generator/src/opcodes/proof/{}.proof.bin", name))
+            .unwrap();
+    let vk_bin =
+        std::fs::read(&format!("../zkvm-metering/generator/src/opcodes/proof/{}.vks.bin", name))
+            .unwrap();
+    let pi_bin =
+        std::fs::read(&format!("../zkvm-metering/generator/src/opcodes/proof/{}.pi.bin", name))
+            .unwrap();
+
+    let (circuit, k) = load_zk_circuit(name);
+
+    let proof: Proof = serial::deserialize(&proof_bin).unwrap();
+    let mut vk_buf = Cursor::new(vk_bin);
+    let vk = VerifyingKey::read::<Cursor<Vec<u8>>, ZkCircuit>(&mut vk_buf, circuit).unwrap();
+    let public_inputs: Vec<pallas::Base> = serial::deserialize(&pi_bin).unwrap();
+
+    measure_zk(
+        || {
+            black_box(proof.verify(&vk, &public_inputs).unwrap());
+        },
+        ZK_ITERATIONS,
+        k,
+    )
+}
+
+/// Benchmark ZK circuit compilation (VerifyingKey building) for a given circuit file.
+fn measure_zk_compile(name: &str) -> BenchmarkStats {
+    let (circuit, k) = load_zk_circuit(name);
+
+    measure_zk(
+        || {
+            black_box(VerifyingKey::build(k, &circuit));
+        },
+        ZK_ITERATIONS,
+        k,
+    )
+}
+
+/// Find all circuit .zk.bin files in the contract directories
+fn find_contract_circuits() -> Vec<(String, PathBuf)> {
+    let base_path = PathBuf::from("../../../src/contract");
+    let mut circuits = Vec::new();
+
+    for contract in &["money", "dao"] {
+        let contract_path = base_path.join(format!("{}/proof", contract));
+        if let Ok(entries) = fs::read_dir(&contract_path) {
+            for entry in entries.flatten() {
+                let path = entry.path();
+                if path.extension().and_then(|s| s.to_str()) == Some("zk") {
+                    let name = path.file_stem().unwrap().to_string_lossy().to_string();
+                    circuits.push((name, path.with_extension("zk.bin")));
+                }
+            }
+        }
+    }
+
+    circuits.sort();
+    circuits
+}
+
+/// Benchmark a single contract circuit and return results
+fn benchmark_contract_circuit(name: &str, zk_bin_path: &PathBuf) -> CircuitBenchmark {
+    // Read the zk.bin file
+    let zk_bin = fs::read(zk_bin_path).unwrap();
+
+    // Decode the circuit
+    let zkbin = ZkBinary::decode(&zk_bin, false).unwrap();
+
+    let k = zkbin.k;
+    let opcodes_count = zkbin.opcodes.len();
+    let witnesses_count = zkbin.witnesses.len();
+    let literals_count = zkbin.literals.len();
+
+    // Create empty witnesses and circuit
+    let verifier_witnesses = empty_witnesses(&zkbin).unwrap();
+    let circuit = ZkCircuit::new(verifier_witnesses, &zkbin);
+
+    // Benchmark compilation with fewer iterations for speed
+    let circuit_iters = 100;
+    let compile_times = bench_util::collect_times(
+        || {
+            black_box(VerifyingKey::build(zkbin.k, &circuit));
+        },
+        circuit_iters,
+    );
+    let mut compile_stats = percentiles(compile_times, circuit_iters);
+    let rows = (1usize << k) as u64;
+    compile_stats.p50_ns /= rows;
+
+    // Build the VK to measure its size
+    let vk = VerifyingKey::build(zkbin.k, &circuit);
+    let mut vk_buf = Vec::new();
+    vk.write(&mut vk_buf).unwrap();
+    let vk_size_bytes = vk_buf.len();
+
+    CircuitBenchmark {
+        name: name.to_string(),
+        k,
+        compile_p50_ns_per_row: compile_stats.p50_ns,
+        vk_size_bytes,
+        opcodes_count,
+        witnesses_count,
+        literals_count,
+    }
+}
+
+fn main() {
+    // WASM opcode benchmarks
+    let wasm_add = {
+        let mut store = Store::new(Singlepass::new());
+        let module = Module::new(&store, bench_util::WASM_ADD).unwrap();
+        let instance = Instance::new(&mut store, &module, &Imports::new()).unwrap();
+        let func = instance.exports.get_function("add").unwrap();
+        measure(
+            || {
+                black_box(func.call(&mut store, &[Value::I32(10), Value::I32(20)]).unwrap());
+            },
+            ITERATIONS,
+        )
+    };
+
+    // Generate inputs outside the timed closures; these benchmarks calibrate
+    // the primitive operations, not RNG or allocation overhead.
+    let mut rng = rand::thread_rng();
+    let poseidon_input = (pallas::Base::random(&mut rng), pallas::Base::random(&mut rng));
+    let sinsemilla_input = (
+        MerkleNode::from(pallas::Base::random(&mut rng)),
+        MerkleNode::from(pallas::Base::random(&mut rng)),
+    );
+
+    // Hash operations
+    let poseidon_time = measure(
+        || {
+            let (a, b) = black_box(poseidon_input);
+            black_box(poseidon_hash::<2>([a, b]));
+        },
+        ITERATIONS,
+    );
+
+    let sinsemilla_time = measure(
+        || {
+            let (left, right) = black_box(sinsemilla_input);
+            black_box(Hashable::combine(0.into(), &left, &right));
+        },
+        ITERATIONS,
+    );
+
+    // Pallas Schnorr signature verification
+    let keypair = Keypair::random(&mut rng);
+    let public_key = PublicKey::from_secret(keypair.secret);
+    let message = b"DarkFi fee calibration benchmark message";
+    let signature = keypair.secret.sign(message);
+
+    let signature_verify_time = measure(
+        || {
+            black_box(public_key.verify(message, &signature));
+        },
+        ITERATIONS,
+    );
+
+    let measurements = Measurements {
+        wasm_add,
+        poseidon_hash: poseidon_time,
+        sinsemilla_hash: sinsemilla_time,
+        pallas_signature_verify: signature_verify_time,
+        zk_verify: ZkVerifyStats {
+            k_11: measure_zk_verify("poseidon_hash"),
+            k_14: measure_zk_verify("sparse_merkle_root"),
+        },
+        zk_compile: ZkCompileStats {
+            k_11: measure_zk_compile("poseidon_hash"),
+            k_14: measure_zk_compile("sparse_merkle_root"),
+        },
+        circuits: {
+            let contract_circuits = find_contract_circuits();
+            if contract_circuits.is_empty() {
+                std::collections::HashMap::new()
+            } else {
+                let mut results = std::collections::HashMap::new();
+                for (name, zk_bin_path) in contract_circuits {
+                    let result = benchmark_contract_circuit(&name, &zk_bin_path);
+                    results.insert(name, result);
+                }
+                results
+            }
+        },
+    };
+
+    println!("{}", serde_json::to_string_pretty(&measurements).unwrap());
+}

+ 44 - 0
script/research/fee-model/src/bench_util.rs

@@ -0,0 +1,44 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! Helpers for `bench` and `db_bench` microbenchmarks.
+
+use std::time::Instant;
+
+/// Collect `iters` nanosecond timing samples for `op`.
+pub fn collect_times<F: FnMut()>(mut op: F, iters: usize) -> Vec<u64> {
+    let mut times = Vec::with_capacity(iters);
+    for _ in 0..iters {
+        let start = Instant::now();
+        op();
+        times.push(start.elapsed().as_nanos() as u64);
+    }
+    times
+}
+
+/// Minimal WASM module exporting `add: (i32, i32) -> i32`. Used as the
+/// single-opcode baseline in both microbenchmarks.
+pub const WASM_ADD: &[u8] = &[
+    0x00, 0x61, 0x73, 0x6d, // Magic
+    0x01, 0x00, 0x00, 0x00, // Version
+    0x01, 0x07, 0x01, // Type section
+    0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // (i32, i32) -> i32
+    0x03, 0x02, 0x01, 0x00, // Function section
+    0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // Export "add"
+    0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b,
+];

+ 1588 - 0
script/research/fee-model/src/capacity.rs

@@ -0,0 +1,1588 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! Validator block capacity benchmark
+//!
+//! Measures per-tx gas and per-tx wall-clock time (verify + apply) for a
+//! set of prebuilt-transaction scenarios. Each scenario builds a batch of
+//! transactions and runs it through the validator. Output is raw
+//! measurements (gas_per_tx, secs_per_tx) tagged with machine info.
+//!
+//! Scenarios:
+//!
+//! | scenario                | shape                                                     |
+//! |-------------------------|-----------------------------------------------------------|
+//! | transfer_simple         | transfer 1-in/2-out                                       |
+//! | transfer_20in_2out      | transfer 20-in/2-out                                      |
+//! | transfer_1in_20out      | transfer 1-in/20-out                                      |
+//! | dao_propose_20recip     | DAO propose, 20 recipients                                |
+//! | dao_exec_20recip        | DAO exec, 20 recipients                                   |
+//! | dao_vote                | DAO vote                                                  |
+//! | otc_swap                | OTC swap                                                  |
+//! | token_mint              | token mint                                                |
+//! | dao_mint                | DAO mint                                                  |
+//! | deploy_512kb            | deploy 512 KiB WASM                                       |
+//! | deploy_1024kb           | deploy 1024 KiB WASM                                      |
+//! | mixed                   | ~78% transfers, 10% votes, 6% execs, 4% mints, 2% deploys |
+//!
+//! verification_secs is the median of 3 verify-only (write=false) runs;
+//! apply_secs is derived from one write=true pass as
+//! max(0, apply_total - verification_secs). Verify-only trials reuse the
+//! same base state because write=true spends the prebuilt coins.
+//!
+//! Usage:
+//!   make capacity
+//!   MONEY_WASM_PATH=/path/to.wasm make capacity   # bundled/container WASM
+
+use darkfi::{
+    tx::{ContractCallLeaf, Transaction, TransactionBuilder},
+    validator::consensus::BLOCK_GAS_LIMIT,
+    Result,
+};
+use darkfi_contract_test_harness::{Holder, TestHarness};
+use darkfi_dao_contract::model::Dao as DaoModel;
+use darkfi_money_contract::{
+    client::{
+        fee_v1::FEE_CALL_GAS,
+        transfer_v1::{TransferCallBuilder, TransferCallInput},
+        OwnCoin,
+    },
+    model::{CoinAttributes, TokenId, DARK_TOKEN_ID},
+    MoneyFunction, MONEY_CONTRACT_ZKAS_BURN_NS_V1, MONEY_CONTRACT_ZKAS_MINT_NS_V1,
+};
+use darkfi_sdk::{
+    crypto::{
+        contract_id::MONEY_CONTRACT_ID,
+        pasta_prelude::*,
+        util::fp_mod_fv,
+        BaseBlind, Blind, FuncId, FuncRef, Keypair, ScalarBlind, DAO_CONTRACT_ID,
+    },
+    pasta::pallas,
+    ContractCall,
+};
+use darkfi_serial::Encodable;
+use rand::rngs::OsRng;
+use serde::Serialize;
+use std::time::Instant;
+use tracing::{debug, error, info, warn};
+
+// ---------------------------------------------------------------------------
+// Helpers (capacity-only: WASM padding, machine-info detection, merkle filler)
+// ---------------------------------------------------------------------------
+
+/// Convert a `pallas::Base` field element to a `u64`, erroring if the value
+/// does not fit in the low 8 bytes.
+fn fp_to_u64(f: pallas::Base) -> Result<u64> {
+    let repr = f.to_repr();
+    let mut bytes = [0u8; 8];
+    bytes.copy_from_slice(&repr.as_ref()[..8]);
+    for b in &repr.as_ref()[8..] {
+        if *b != 0 {
+            return Err(darkfi::Error::Custom("fp_to_u64 overflow".into()));
+        }
+    }
+    Ok(u64::from_le_bytes(bytes))
+}
+
+/// DAO keys bundle for convenience.
+struct DaoKeys {
+    notes: Keypair,
+    proposer: Keypair,
+    proposals: Keypair,
+    votes: Keypair,
+    exec: Keypair,
+    early_exec: Keypair,
+}
+
+/// Encode a `u64` as unsigned LEB128 (used by the WASM binary format).
+fn encode_uleb128(mut value: u64) -> Vec<u8> {
+    let mut out = Vec::new();
+    loop {
+        let mut byte = (value & 0x7F) as u8;
+        value >>= 7;
+        if value != 0 {
+            byte |= 0x80;
+        }
+        out.push(byte);
+        if value == 0 {
+            break;
+        }
+    }
+    out
+}
+
+/// Build a WASM custom section (section id 0x00) with the given name and
+/// payload.  Custom sections are ignored by the WASM runtime during
+/// execution but are still parsed by `wasmparser::validate`, so the
+/// deployooor contract accepts them.
+fn build_custom_section(name: &str, data: &[u8]) -> Vec<u8> {
+    let name_bytes = name.as_bytes();
+    let name_len_bytes = encode_uleb128(name_bytes.len() as u64);
+    let payload_len = name_len_bytes.len() + name_bytes.len() + data.len();
+    let payload_len_bytes = encode_uleb128(payload_len as u64);
+
+    let mut section = Vec::with_capacity(1 + payload_len_bytes.len() + payload_len);
+    section.push(0x00); // custom section id
+    section.extend_from_slice(&payload_len_bytes);
+    section.extend_from_slice(&name_len_bytes);
+    section.extend_from_slice(name_bytes);
+    section.extend_from_slice(data);
+    section
+}
+
+/// Pad `wasm` to approximately `target_bytes` total by appending a single
+/// custom data section named ``padding``.  If `target_bytes` is not larger
+/// than the input, the original bytes are returned unchanged.
+fn pad_wasm_to_size(wasm: &[u8], target_bytes: usize) -> Vec<u8> {
+    if target_bytes <= wasm.len() {
+        return wasm.to_vec();
+    }
+    let name = "padding";
+    let overhead = 1 // section id
+        + encode_uleb128(0).len()
+        + encode_uleb128(name.len() as u64).len()
+        + name.len();
+    let fill = target_bytes - wasm.len() - overhead;
+    let mut result = wasm.to_vec();
+    result.extend_from_slice(&build_custom_section(name, &vec![0u8; fill]));
+    result
+}
+
+/// Hardware information embedded in the capacity benchmark output so that
+/// runs from different machines can be grouped during analysis.
+#[derive(Clone, Debug, Serialize)]
+struct MachineInfo {
+    cpu_model: String,
+    physical_cores: usize,
+    logical_cores: usize,
+    ram_gb: u64,
+    disk_type: String,
+}
+
+/// Detect machine hardware info from `/proc` and `/sys`.  Falls back to
+/// `"unknown"` for any field that cannot be read.
+fn detect_machine_info() -> MachineInfo {
+    MachineInfo {
+        cpu_model: detect_cpu_model().unwrap_or_else(|| "unknown".into()),
+        physical_cores: detect_physical_cores().unwrap_or(0),
+        logical_cores: detect_logical_cores().unwrap_or(0),
+        ram_gb: detect_ram_gb().unwrap_or(0),
+        disk_type: detect_disk_type().unwrap_or_else(|| "unknown".into()),
+    }
+}
+
+fn detect_cpu_model() -> Option<String> {
+    let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok()?;
+    for line in cpuinfo.lines() {
+        if let Some(rest) = line.strip_prefix("model name") {
+            if let Some(val) = rest.split(':').nth(1) {
+                return Some(val.trim().to_string());
+            }
+        }
+    }
+    None
+}
+
+/// Count physical cores as the number of distinct `(physical id, core id)`
+/// pairs in `/proc/cpuinfo`. This is correct on multi-socket and multi-core
+/// systems without assuming a single socket. Returns `None` when the kernel
+/// does not expose topology (some VMs/containers).
+fn detect_physical_cores() -> Option<usize> {
+    let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok()?;
+    let mut cores: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
+    let mut cur_phys: Option<String> = None;
+    for line in cpuinfo.lines() {
+        if let Some(rest) = line.strip_prefix("physical id") {
+            cur_phys = rest.split(':').nth(1).map(|s| s.trim().to_string());
+        } else if let Some(rest) = line.strip_prefix("core id") {
+            let cur_core = rest.split(':').nth(1).map(|s| s.trim().to_string());
+            if let (Some(p), Some(c)) = (&cur_phys, &cur_core) {
+                cores.insert((p.clone(), c.clone()));
+            }
+        }
+    }
+    if cores.is_empty() { None } else { Some(cores.len()) }
+}
+
+fn detect_logical_cores() -> Option<usize> {
+    let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").ok()?;
+    Some(cpuinfo.lines().filter(|l| l.starts_with("processor")).count())
+}
+
+fn detect_ram_gb() -> Option<u64> {
+    let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
+    for line in meminfo.lines() {
+        if let Some(rest) = line.strip_prefix("MemTotal:") {
+            let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
+            return Some(kb / 1_024 / 1024);
+        }
+    }
+    None
+}
+
+/// Best-effort disk type detection.  Checks the rotational flag of the
+/// block device backing the working directory.  Returns `"ssd"` (rota=0),
+/// `"hdd"` (rota=1), or `"unknown"`.
+fn detect_disk_type() -> Option<String> {
+    // Walk /sys/block and find the device whose name is a prefix of the
+    // root filesystem's source.  This is rough but good enough for tagging.
+    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
+    let root_source = mounts.lines().find(|l| l.split_whitespace().nth(1) == Some("/"))?;
+    let dev_path = root_source.split_whitespace().next()?;
+    let dev_name = dev_path.rsplit('/').next()?;
+
+    // Strip the trailing partition index to recover the base block device.
+    // NVMe and MMC use a 'p' separator before the partition number
+    // (nvme0n1p3 -> nvme0n1, mmcblk0p1 -> mmcblk0); SATA/virtio use bare
+    // digits (sda3 -> sda, vda1 -> vda).
+    let base_name = if dev_name.starts_with("nvme") || dev_name.starts_with("mmcblk") {
+        if let Some(p_idx) = dev_name.rfind('p') {
+            let after = &dev_name[p_idx + 1..];
+            if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) {
+                &dev_name[..p_idx]
+            } else {
+                dev_name
+            }
+        } else {
+            dev_name
+        }
+    } else {
+        dev_name.trim_end_matches(|c: char| c.is_ascii_digit())
+    };
+
+    let rota_path = format!("/sys/block/{}/queue/rotational", base_name);
+    let rota = std::fs::read_to_string(&rota_path).ok()?;
+    let rota = rota.trim();
+    match rota {
+        "0" => Some("ssd".into()),
+        "1" => Some("hdd".into()),
+        _ => None,
+    }
+}
+
+/// Pre-populate every holder's Merkle tree with `count` filler coins via
+/// batched genesis mints. Coins are minted to `holder`, but every holder's
+/// wallet verifies the tx and appends to its own tree, so all trees grow
+/// uniformly. Genesis mint always runs at block 0 (enforced by the
+/// contract).
+async fn pre_populate_merkle_tree(
+    th: &mut TestHarness,
+    holder: &Holder,
+    count: usize,
+) -> Result<()> {
+    // Each genesis mint output adds ~3M gas (31M base + 3M/output).
+    // With 128 outputs per batch we stay well under CONTRACT_GAS_LIMIT (800M).
+    const BATCH_SIZE: usize = 128;
+    let batches = count / BATCH_SIZE;
+    let remainder = count % BATCH_SIZE;
+
+    for i in 0..batches {
+        let amounts: Vec<u64> = vec![1_000_000_000u64; BATCH_SIZE];
+        th.genesis_mint_to_all(holder, &amounts, 0).await?;
+        if (i + 1) % 4 == 0 {
+            debug!("  filler batch {}/{} ({} coins total)", i + 1, batches, (i + 1) * BATCH_SIZE);
+        }
+    }
+
+    if remainder > 0 {
+        let amounts: Vec<u64> = vec![1_000_000_000u64; remainder];
+        th.genesis_mint_to_all(holder, &amounts, 0).await?;
+    }
+
+    Ok(())
+}
+
+const TRANSFER_AMOUNT: u64 = 1_000;
+const TRANSFER_COIN_VALUE: u64 = 10_000;
+
+// Default WASM path (overridable via MONEY_WASM_PATH env var). The
+// containerized run sets this to the bundled WASM location.
+const DEFAULT_MONEY_WASM_PATH: &str = "../../../src/contract/money/darkfi_money_contract.wasm";
+
+fn money_wasm_path() -> String {
+    std::env::var("MONEY_WASM_PATH").unwrap_or_else(|_| DEFAULT_MONEY_WASM_PATH.into())
+}
+
+/// Number of verify-only trials run per scenario; the median `total_secs`
+/// is reported as `verification_secs`, reducing single-shot scheduling
+/// noise. `write=false` is required so each trial verifies against the
+/// same base state (a `write=true` trial would spend the prebuilt txs'
+/// coins and break the next trial).
+const TRIALS: usize = 3;
+
+// ---------------------------------------------------------------------------
+// Scenario definitions
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum OpKind {
+    Transfer,       // 1 input, 2 outputs (baseline)
+    TransferNIn,    // N inputs, 2 outputs
+    TransferNOut,   // 1 input, N outputs
+    DaoPropose,     // N recipients
+    DaoExec,        // N recipients (pre-voted)
+    DaoVote,        // single-shape
+    OtcSwap,        // single-shape
+    TokenMint,      // single-shape
+    DaoMint,        // single-shape (deploys N distinct DAOs)
+    Deploy,         // N KB WASM
+    Mixed,          // mixed workload
+}
+
+#[derive(Clone, Debug)]
+struct Scenario {
+    name: String,
+    op_kind: OpKind,
+    batch_size: usize,
+    /// Complexity dimensions (interpreted per op_kind)
+    transfer_inputs: usize,
+    transfer_outputs: usize,
+    dao_recipients: usize,
+    deploy_kb: usize,
+    prepopulate_coins: usize,
+}
+
+fn all_scenarios() -> Vec<Scenario> {
+    vec![
+        Scenario {
+            name: "transfer_simple".into(),
+            op_kind: OpKind::Transfer,
+            batch_size: 50,
+            transfer_inputs: 1,
+            transfer_outputs: 2,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "transfer_20in_2out".into(),
+            op_kind: OpKind::TransferNIn,
+            batch_size: 10,
+            transfer_inputs: 20,
+            transfer_outputs: 2,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "transfer_1in_20out".into(),
+            op_kind: OpKind::TransferNOut,
+            batch_size: 10,
+            transfer_inputs: 1,
+            transfer_outputs: 20,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "dao_propose_20recip".into(),
+            op_kind: OpKind::DaoPropose,
+            batch_size: 10,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 20,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "dao_exec_20recip".into(),
+            op_kind: OpKind::DaoExec,
+            batch_size: 10,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 20,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "dao_vote".into(),
+            op_kind: OpKind::DaoVote,
+            batch_size: 10,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 1,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "otc_swap".into(),
+            op_kind: OpKind::OtcSwap,
+            batch_size: 20,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "token_mint".into(),
+            op_kind: OpKind::TokenMint,
+            batch_size: 50,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 256,
+        },
+        Scenario {
+            name: "dao_mint".into(),
+            op_kind: OpKind::DaoMint,
+            batch_size: 20,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 0,
+            deploy_kb: 0,
+            prepopulate_coins: 0,
+        },
+        Scenario {
+            name: "deploy_512kb".into(),
+            op_kind: OpKind::Deploy,
+            batch_size: 5,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 0,
+            deploy_kb: 512,
+            prepopulate_coins: 0,
+        },
+        Scenario {
+            name: "deploy_1024kb".into(),
+            op_kind: OpKind::Deploy,
+            batch_size: 3,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 0,
+            deploy_kb: 1024,
+            prepopulate_coins: 0,
+        },
+        Scenario {
+            name: "mixed".into(),
+            op_kind: OpKind::Mixed,
+            batch_size: 51,
+            transfer_inputs: 0,
+            transfer_outputs: 0,
+            dao_recipients: 1,
+            deploy_kb: 512,
+            prepopulate_coins: 256,
+        },
+    ]
+}
+
+// ---------------------------------------------------------------------------
+// Mixed workload definition (for mixed scenario)
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, Debug)]
+struct WorkloadMix {
+    transfers: usize,
+    dao_votes: usize,
+    dao_execs: usize,
+    token_mints: usize,
+    deployments: usize,
+}
+
+impl WorkloadMix {
+    fn total(&self) -> usize {
+        self.transfers + self.dao_votes + self.dao_execs + self.token_mints + self.deployments
+    }
+}
+
+impl WorkloadMix {
+    fn mixed() -> Self {
+        // 51 txs: ~78% transfers, ~10% DAO votes, ~6% DAO execs,
+        // ~4% token mints, ~2% deployments.
+        WorkloadMix { transfers: 40, dao_votes: 5, dao_execs: 3, token_mints: 2, deployments: 1 }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// DAO context setup
+// ---------------------------------------------------------------------------
+
+struct DaoContext {
+    dao_obj: DaoModel,
+    dao_keys: DaoKeys,
+    gov_token_id: TokenId,
+    proposals: Vec<(darkfi_dao_contract::model::DaoProposal, Vec<CoinAttributes>)>,
+    vote_data: Vec<(u64, u64, ScalarBlind, ScalarBlind)>,
+}
+
+/// DAO setup parameters.  Different scenarios need different combinations:
+///
+/// - `dao_propose`: `num_gov_coins = batch_size`, no proposals, no pre-votes
+/// - `dao_vote`: `num_gov_coins = 2*batch_size` (stakes + vote coins),
+///    `num_proposals = batch_size`, no pre-votes
+/// - `dao_exec`: `num_gov_coins = 2*batch_size` (stakes + setup votes),
+///    `num_proposals = batch_size`, `num_pre_votes = batch_size`
+struct DaoSetup {
+    num_gov_coins: usize,
+    num_proposals: usize,
+    num_pre_votes: usize,
+    recipients_per_proposal: usize,
+}
+
+/// Set up a DAO with gov tokens, proposals, and optional pre-votes.
+///
+/// Each `token_mint_with_blind_to_all` call executes and gives Alice one gov
+/// token coin.  Each `dao_propose_transfer_to_all` executes and consumes one
+/// gov coin (stake).  Each `dao_vote_to_all` executes and consumes one gov
+/// coin.  After setup, Alice retains `num_gov_coins - num_proposals -
+/// num_pre_votes` gov coins for building vote/propose txs.
+async fn setup_dao_context(
+    th: &mut TestHarness,
+    setup: &DaoSetup,
+    block_height: u32,
+) -> Result<(DaoContext, u32)> {
+    let gov_token_blind = BaseBlind::random(&mut OsRng);
+    let gov_token_id = th.derive_token_id(&Holder::Alice, gov_token_blind);
+
+    let dao_keys = DaoKeys {
+        notes: th.wallet(&Holder::Dao).keypair.clone(),
+        proposer: Keypair::random(&mut OsRng),
+        proposals: Keypair::random(&mut OsRng),
+        votes: Keypair::random(&mut OsRng),
+        exec: Keypair::random(&mut OsRng),
+        early_exec: Keypair::random(&mut OsRng),
+    };
+
+    let dao_obj = DaoModel {
+        proposer_limit: 20_000_000_000,
+        quorum: 10_000_000_000,
+        early_exec_quorum: 10_000_000_000,
+        approval_ratio_quot: 67,
+        approval_ratio_base: 100,
+        gov_token_id,
+        notes_public_key: dao_keys.notes.public,
+        proposer_public_key: dao_keys.proposer.public,
+        proposals_public_key: dao_keys.proposals.public,
+        votes_public_key: dao_keys.votes.public,
+        exec_public_key: dao_keys.exec.public,
+        early_exec_public_key: dao_keys.early_exec.public,
+        bulla_blind: Blind::random(&mut OsRng),
+    };
+
+    // Fund DAO treasury at genesis (needed for exec transfers).
+    // Create one coin per exec tx so each can spend a distinct treasury
+    // coin (otherwise batch verification fails with DuplicateNullifier).
+    let dao_spend_hook = FuncRef {
+        contract_id: *DAO_CONTRACT_ID,
+        func_code: darkfi_dao_contract::DaoFunction::Exec as u8,
+    }
+    .to_func_id();
+    let dao_bulla = dao_obj.to_bulla();
+    let num_treasury_coins = setup.num_pre_votes.max(1);
+    let treasury_per_coin = 500_000_000_000u64;
+    let treasury_amounts = vec![treasury_per_coin; num_treasury_coins];
+    let (genesis_tx, genesis_params) = th
+        .genesis_mint(
+            &Holder::Dao,
+            &treasury_amounts,
+            Some(dao_spend_hook),
+            Some(dao_bulla.inner()),
+        )
+        .await?;
+    th.genesis_mint_to_all_with(genesis_tx, &genesis_params, 0).await?;
+
+    let mut bh = block_height;
+
+    // DAO mint
+    th.dao_mint_to_all(
+        &Holder::Alice,
+        &dao_obj,
+        &dao_keys.notes.secret,
+        &dao_keys.proposer.secret,
+        &dao_keys.proposals.secret,
+        &dao_keys.votes.secret,
+        &dao_keys.exec.secret,
+        &dao_keys.early_exec.secret,
+        bh,
+    )
+    .await?;
+    bh += 1;
+
+    // Mint gov token coins (each call executes and gives Alice one coin).
+    // The value must exceed the DAO's proposer_limit (20B) to be valid as
+    // a propose stake.
+    for _ in 0..setup.num_gov_coins {
+        th.token_mint_with_blind_to_all(
+            100_000_000_000u64,
+            &Holder::Alice,
+            &Holder::Alice,
+            gov_token_blind,
+            bh,
+        )
+        .await?;
+        bh += 1;
+    }
+
+    // Create proposals
+    let recipient_holders: Vec<Holder> =
+        th.holder_keys.iter().filter(|h| **h != Holder::Dao).cloned().collect();
+
+    let mut proposals = Vec::with_capacity(setup.num_proposals);
+    let mut vote_data = Vec::with_capacity(setup.num_pre_votes);
+
+    for i in 0..setup.num_proposals {
+        let coin_attrs: Vec<CoinAttributes> = (0..setup.recipients_per_proposal)
+            .map(|j| {
+                let holder = recipient_holders[(i * setup.recipients_per_proposal + j) % recipient_holders.len()];
+                CoinAttributes {
+                    public_key: th.wallet(&holder).keypair.public,
+                    value: 1_000_000_000u64,
+                    token_id: *DARK_TOKEN_ID,
+                    spend_hook: FuncId::none(),
+                    user_data: pallas::Base::ZERO,
+                    blind: Blind::random(&mut OsRng),
+                }
+            })
+            .collect();
+
+        let proposal = th
+            .dao_propose_transfer_to_all(
+                &Holder::Alice,
+                &coin_attrs,
+                pallas::Base::ZERO,
+                &dao_obj,
+                &dao_keys.proposer.secret,
+                bh,
+                100,
+            )
+            .await?;
+        bh += 1;
+
+        // Pre-vote if needed
+        if i < setup.num_pre_votes {
+            let vote = th.dao_vote_to_all(&Holder::Alice, true, &dao_obj, &proposal, bh).await?;
+            bh += 1;
+
+            let note = vote.note.decrypt_unsafe(&dao_keys.votes.secret).unwrap();
+            let vote_option = note[0];
+            let yes_blind = Blind(fp_mod_fv(note[1]));
+            let all_vote_value_raw = note[2];
+            let all_blind = Blind(fp_mod_fv(note[3]));
+
+            let all_vote_value = fp_to_u64(all_vote_value_raw).unwrap();
+            let vote_option_val = fp_to_u64(vote_option).unwrap();
+            let yes_vote_value = if vote_option_val == 1 { all_vote_value } else { 0 };
+
+            vote_data.push((yes_vote_value, all_vote_value, yes_blind, all_blind));
+        }
+
+        proposals.push((proposal, coin_attrs));
+    }
+
+    Ok((DaoContext { dao_obj, dao_keys, gov_token_id, proposals, vote_data }, bh))
+}
+
+// ---------------------------------------------------------------------------
+// Transaction builders (prebuild txs, not timed)
+// ---------------------------------------------------------------------------
+
+/// Build `n` transfer transactions (1 input, 2 outputs).  Each spends a
+/// distinct genesis coin.
+async fn build_transfer_txs(
+    th: &mut TestHarness,
+    n: usize,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    let amounts = vec![TRANSFER_COIN_VALUE; n];
+    th.genesis_mint_to_all(&Holder::Alice, &amounts, 0).await?;
+
+    let mut coins = th.coins_by_token(&Holder::Alice, *DARK_TOKEN_ID);
+    coins.retain(|c| c.note.value == TRANSFER_COIN_VALUE);
+    coins.truncate(n);
+
+    let mut txs = Vec::with_capacity(n);
+    for coin in coins.into_iter().take(n) {
+        let (tx, _params, _spent) = th
+            .transfer(
+                TRANSFER_AMOUNT,
+                &Holder::Alice,
+                &Holder::Bob,
+                &[coin],
+                *DARK_TOKEN_ID,
+                block_height,
+                false,
+            )
+            .await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` transfer transactions with `num_inputs` inputs each and 2
+/// outputs.  Pre-mints `n * num_inputs` distinct genesis coins so each tx
+/// gets a fresh set of inputs.
+async fn build_transfer_n_input_txs(
+    th: &mut TestHarness,
+    n: usize,
+    num_inputs: usize,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    const COIN_VALUE: u64 = 10_000_000_000u64;
+    let total_coins = n * num_inputs;
+    let amounts = vec![COIN_VALUE; total_coins];
+    th.genesis_mint_to_all(&Holder::Alice, &amounts, 0).await?;
+
+    let mut coins = th.coins_by_token(&Holder::Alice, *DARK_TOKEN_ID);
+    coins.retain(|c| c.note.value == COIN_VALUE);
+    coins.truncate(total_coins);
+
+    let amount = num_inputs as u64 * COIN_VALUE - 1;
+
+    let mut txs = Vec::with_capacity(n);
+    for i in 0..n {
+        let input_slice = &coins[i * num_inputs..(i + 1) * num_inputs];
+        let (tx, _params, _spent) = th
+            .transfer(
+                amount,
+                &Holder::Alice,
+                &Holder::Bob,
+                input_slice,
+                *DARK_TOKEN_ID,
+                block_height,
+                false,
+            )
+            .await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build a single transfer tx with 1 input and `num_outputs` outputs using
+/// `TransferCallBuilder` directly (the wallet `transfer()` helper only does
+/// 2-3 outputs). Returns the tx without executing.
+fn build_transfer_n_output_tx(
+    th: &TestHarness,
+    sender: &Holder,
+    recipients: &[Holder],
+    input_coin: &OwnCoin,
+    token_id: TokenId,
+    output_value: u64,
+    num_outputs: usize,
+) -> Result<Transaction> {
+    let wallet = th.wallet(sender);
+    let (mint_pk, mint_zkbin) = th.proving_keys.get(MONEY_CONTRACT_ZKAS_MINT_NS_V1).unwrap();
+    let (burn_pk, burn_zkbin) = th.proving_keys.get(MONEY_CONTRACT_ZKAS_BURN_NS_V1).unwrap();
+
+    let inputs = vec![TransferCallInput {
+        coin: input_coin.clone(),
+        merkle_path: wallet.money_merkle_tree.witness(input_coin.leaf_position, 0).unwrap(),
+        user_data_blind: Blind::random(&mut OsRng),
+    }];
+
+    let outputs: Vec<CoinAttributes> = (0..num_outputs)
+        .map(|i| {
+            let recipient = recipients[i % recipients.len()];
+            CoinAttributes {
+                public_key: th.wallet(&recipient).keypair.public,
+                value: output_value,
+                token_id,
+                spend_hook: FuncId::none(),
+                user_data: pallas::Base::ZERO,
+                blind: Blind::random(&mut OsRng),
+            }
+        })
+        .collect();
+
+    let builder = TransferCallBuilder {
+        clear_inputs: vec![],
+        inputs,
+        outputs,
+        mint_zkbin: mint_zkbin.clone(),
+        mint_pk: mint_pk.clone(),
+        burn_zkbin: burn_zkbin.clone(),
+        burn_pk: burn_pk.clone(),
+    };
+
+    let (params, secrets) = builder.build()?;
+    let mut data = vec![MoneyFunction::TransferV1 as u8];
+    params.encode(&mut data)?;
+    let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data };
+    let mut tx_builder =
+        TransactionBuilder::new(ContractCallLeaf { call, proofs: secrets.proofs }, vec![])?;
+    let mut tx = tx_builder.build()?;
+    let sigs = tx.create_sigs(&secrets.signature_secrets)?;
+    tx.signatures = vec![sigs];
+    Ok(tx)
+}
+
+/// Build `n` transfer transactions with 1 input and `num_outputs` outputs
+/// each.  Pre-mints `n` distinct genesis coins so each tx gets a fresh input.
+async fn build_transfer_n_output_txs(
+    th: &mut TestHarness,
+    n: usize,
+    num_outputs: usize,
+    _block_height: u32,
+) -> Result<Vec<Transaction>> {
+    const OUTPUT_VALUE: u64 = 1_000_000_000u64;
+    let total_value = num_outputs as u64 * OUTPUT_VALUE;
+    let amounts = vec![total_value; n];
+    th.genesis_mint_to_all(&Holder::Alice, &amounts, 0).await?;
+
+    let mut coins = th.coins_by_token(&Holder::Alice, *DARK_TOKEN_ID);
+    coins.retain(|c| c.note.value == total_value);
+    coins.truncate(n);
+
+    let recipients = [Holder::Alice, Holder::Bob];
+    let mut txs = Vec::with_capacity(n);
+    for coin in coins.into_iter().take(n) {
+        let tx = build_transfer_n_output_tx(
+            th,
+            &Holder::Alice,
+            &recipients,
+            &coin,
+            *DARK_TOKEN_ID,
+            OUTPUT_VALUE,
+            num_outputs,
+        )?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` token mint transactions.  Each mints a fresh token.
+async fn build_token_mint_txs(
+    th: &mut TestHarness,
+    n: usize,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    th.genesis_mint_to_all(&Holder::Alice, &[100_000_000_000u64 * n as u64], 0).await?;
+
+    let mut txs = Vec::with_capacity(n);
+    for _ in 0..n {
+        let token_blind = BaseBlind::random(&mut OsRng);
+        let (tx, _mint_params, _auth_params, _fee_params) = th
+            .token_mint(
+                1_000_000_000u64,
+                &Holder::Alice,
+                &Holder::Alice,
+                token_blind,
+                None,
+                None,
+                block_height,
+            )
+            .await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` DAO vote transactions.  Each votes on a distinct proposal with
+/// a distinct gov token coin.  Uses `mark_spent_nullifier` between builds
+/// because `dao_vote()` finds the first gov coin internally and doesn't
+/// remove it from the wallet's unspent list.
+async fn build_dao_vote_txs(
+    th: &mut TestHarness,
+    n: usize,
+    dao_ctx: &DaoContext,
+    block_height: u32,
+    proposal_offset: usize,
+) -> Result<Vec<Transaction>> {
+    let gov_token_id = dao_ctx.gov_token_id;
+    let mut txs = Vec::with_capacity(n);
+
+    for i in 0..n {
+        let gov_coins = th.coins_by_token(&Holder::Alice, gov_token_id);
+        if gov_coins.is_empty() {
+            warn!("Not enough gov token coins for {} vote txs (built {})", n, txs.len());
+            break;
+        }
+        let used_nullifier = gov_coins[0].nullifier();
+
+        let proposal = &dao_ctx.proposals[(i + proposal_offset).min(dao_ctx.proposals.len() - 1)].0;
+        let (tx, _params, _fee_params) = th
+            .dao_vote(&Holder::Alice, true, &dao_ctx.dao_obj, proposal, block_height)
+            .await?;
+
+        // Remove the used gov coin from the wallet's unspent list so the
+        // next dao_vote() call finds a different coin.  We use retain()
+        // instead of mark_spent_nullifier() to avoid inserting the nullifier
+        // into the SMT (which would cause the validator's batch verification
+        // to reject the tx as a double-spend).
+        th.wallet_mut(&Holder::Alice).unspent_money_coins.retain(|c| c.nullifier() != used_nullifier);
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` DAO exec transactions.  Each executes a pre-voted proposal.
+async fn build_dao_exec_txs(
+    th: &mut TestHarness,
+    n: usize,
+    dao_ctx: &DaoContext,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    let mut txs = Vec::with_capacity(n);
+    for i in 0..n {
+        let (proposal, coin_attrs) = &dao_ctx.proposals[i.min(dao_ctx.proposals.len() - 1)];
+        let (yes_vote_value, all_vote_value, yes_blind, all_blind) =
+            dao_ctx.vote_data[i.min(dao_ctx.vote_data.len() - 1)];
+
+        // Find the first DAO treasury coin's nullifier before building,
+        // so we can remove it after (dao_exec_transfer reads the Dao
+        // wallet immutably and doesn't update it).
+        let dao_coin_nullifier = {
+            let dao_wallet = th.wallet(&Holder::Dao);
+            dao_wallet
+                .unspent_money_coins
+                .iter()
+                .find(|c| c.note.spend_hook != FuncId::none())
+                .map(|c| c.nullifier())
+        };
+
+        let (tx, _params, _fee_params) = th
+            .dao_exec_transfer(
+                &Holder::Alice,
+                &dao_ctx.dao_obj,
+                &dao_ctx.dao_keys.exec.secret,
+                &Some(dao_ctx.dao_keys.early_exec.secret),
+                proposal,
+                coin_attrs.clone(),
+                yes_vote_value,
+                all_vote_value,
+                yes_blind,
+                all_blind,
+                block_height,
+            )
+            .await?;
+
+        // Remove the spent treasury coin so the next exec tx selects a
+        // different one (avoids DuplicateNullifier in batch verification).
+        if let Some(nf) = dao_coin_nullifier {
+            th.wallet_mut(&Holder::Dao).unspent_money_coins.retain(|c| c.nullifier() != nf);
+        }
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` DAO propose transactions with `recipients_per_proposal`
+/// recipients each.  Uses `mark_spent_nullifier` between builds because
+/// `dao_propose_transfer()` finds the first gov coin internally.
+async fn build_dao_propose_txs(
+    th: &mut TestHarness,
+    n: usize,
+    recipients_per_proposal: usize,
+    dao_ctx: &DaoContext,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    let gov_token_id = dao_ctx.gov_token_id;
+    let recipient_holders: Vec<Holder> =
+        th.holder_keys.iter().filter(|h| **h != Holder::Dao).cloned().collect();
+
+    let mut txs = Vec::with_capacity(n);
+    for i in 0..n {
+        let gov_coins = th.coins_by_token(&Holder::Alice, gov_token_id);
+        if gov_coins.is_empty() {
+            warn!("Not enough gov token coins for {} propose txs (built {})", n, txs.len());
+            break;
+        }
+        let used_nullifier = gov_coins[0].nullifier();
+
+        let coin_attrs: Vec<CoinAttributes> = (0..recipients_per_proposal)
+            .map(|j| {
+                let holder = recipient_holders[(i * recipients_per_proposal + j) % recipient_holders.len()];
+                CoinAttributes {
+                    public_key: th.wallet(&holder).keypair.public,
+                    value: 1_000_000_000u64,
+                    token_id: *DARK_TOKEN_ID,
+                    spend_hook: FuncId::none(),
+                    user_data: pallas::Base::ZERO,
+                    blind: Blind::random(&mut OsRng),
+                }
+            })
+            .collect();
+
+        let (tx, _params, _fee_params, _proposal) = th
+            .dao_propose_transfer(
+                &Holder::Alice,
+                &coin_attrs,
+                pallas::Base::ZERO,
+                &dao_ctx.dao_obj,
+                &dao_ctx.dao_keys.proposer.secret,
+                block_height,
+                100,
+            )
+            .await?;
+
+        // Remove the used gov coin from the wallet's unspent list so the
+        // next dao_propose_transfer() call finds a different coin.
+        th.wallet_mut(&Holder::Alice).unspent_money_coins.retain(|c| c.nullifier() != used_nullifier);
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` OTC swap transactions.  Each swaps a DARK coin (Alice) for a
+/// DAWN coin (Bob).  Pre-mints `n` DARK coins and `n` DAWN coins so each
+/// tx gets distinct inputs.
+async fn build_otc_swap_txs(
+    th: &mut TestHarness,
+    n: usize,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    // Mint N DARK coins to Alice
+    th.genesis_mint_to_all(&Holder::Alice, &vec![10_000_000_000u64; n], 0).await?;
+
+    // Mint N DAWN coins to Bob (same token, N separate mints)
+    let dawn_blind = BaseBlind::random(&mut OsRng);
+    let dawn_token_id = th.derive_token_id(&Holder::Bob, dawn_blind);
+    for _ in 0..n {
+        th.token_mint_with_blind_to_all(
+            10_000_000_000u64,
+            &Holder::Bob,
+            &Holder::Bob,
+            dawn_blind,
+            block_height,
+        )
+        .await?;
+    }
+
+    let alice_coins = th.coins_by_token(&Holder::Alice, *DARK_TOKEN_ID);
+    let bob_coins = th.coins_by_token(&Holder::Bob, dawn_token_id);
+
+    let alice_swap_coins: Vec<_> = alice_coins.iter().filter(|c| c.note.value == 10_000_000_000).take(n).cloned().collect();
+    let bob_swap_coins: Vec<_> = bob_coins.iter().take(n).cloned().collect();
+
+    let mut txs = Vec::with_capacity(n);
+    for i in 0..n.min(alice_swap_coins.len()).min(bob_swap_coins.len()) {
+        let (tx, _params, _fee_params) = th
+            .otc_swap(
+                &Holder::Alice,
+                &alice_swap_coins[i],
+                &Holder::Bob,
+                &bob_swap_coins[i],
+                block_height,
+            )
+            .await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` DAO mint transactions.  Each deploys a distinct DAO (different
+/// `bulla_blind`).  No setup needed beyond `TestHarness::new()` (native
+/// contracts are already deployed).
+async fn build_dao_mint_txs(
+    th: &mut TestHarness,
+    n: usize,
+    block_height: u32,
+) -> Result<Vec<Transaction>> {
+    let dao_keys = DaoKeys {
+        notes: th.wallet(&Holder::Dao).keypair.clone(),
+        proposer: Keypair::random(&mut OsRng),
+        proposals: Keypair::random(&mut OsRng),
+        votes: Keypair::random(&mut OsRng),
+        exec: Keypair::random(&mut OsRng),
+        early_exec: Keypair::random(&mut OsRng),
+    };
+
+    let mut txs = Vec::with_capacity(n);
+    for _ in 0..n {
+        let gov_token_blind = BaseBlind::random(&mut OsRng);
+        let gov_token_id = th.derive_token_id(&Holder::Alice, gov_token_blind);
+
+        let dao_obj = DaoModel {
+            proposer_limit: 20_000_000_000,
+            quorum: 10_000_000_000,
+            early_exec_quorum: 10_000_000_000,
+            approval_ratio_quot: 67,
+            approval_ratio_base: 100,
+            gov_token_id,
+            notes_public_key: dao_keys.notes.public,
+            proposer_public_key: dao_keys.proposer.public,
+            proposals_public_key: dao_keys.proposals.public,
+            votes_public_key: dao_keys.votes.public,
+            exec_public_key: dao_keys.exec.public,
+            early_exec_public_key: dao_keys.early_exec.public,
+            bulla_blind: Blind::random(&mut OsRng),
+        };
+
+        let (tx, _params, _fee_params) = th
+            .dao_mint(
+                &Holder::Alice,
+                &dao_obj,
+                &dao_keys.notes.secret,
+                &dao_keys.proposer.secret,
+                &dao_keys.proposals.secret,
+                &dao_keys.votes.secret,
+                &dao_keys.exec.secret,
+                &dao_keys.early_exec.secret,
+                block_height,
+            )
+            .await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+/// Build `n` deployment transactions using padded money contract WASM.
+/// `target_kb` controls the padded WASM size.
+async fn build_deploy_txs(
+    th: &mut TestHarness,
+    n: usize,
+    block_height: u32,
+    target_kb: usize,
+) -> Result<Vec<Transaction>> {
+    let base_wasm = std::fs::read(money_wasm_path())?;
+    let padded_wasm = pad_wasm_to_size(&base_wasm, target_kb * 1024);
+
+    th.genesis_mint_to_all(&Holder::Alice, &[100_000_000_000u64 * n as u64], 0).await?;
+
+    let mut txs = Vec::with_capacity(n);
+    for _ in 0..n {
+        let (tx, _deploy_params, _fee_params) =
+            th.deploy_contract(&Holder::Alice, padded_wasm.clone(), block_height).await?;
+        txs.push(tx);
+    }
+    Ok(txs)
+}
+
+// ---------------------------------------------------------------------------
+// Benchmark execution
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, Copy, Debug, Default)]
+struct TrialResult {
+    tx_count: usize,
+    verification_secs: f64,
+    apply_secs: f64,
+    total_secs: f64,
+    gas_used: u64,
+}
+
+/// Verify (and optionally apply) a prebuilt tx batch with external
+/// wall-clock timing.
+///
+/// `write=false` runs the verify phase only (the overlay is discarded, so
+/// the prebuilt txs' coins are not spent and the batch can be re-run).
+/// `write=true` additionally applies the state transitions.
+///
+/// `verify_fees` is always `false` because the txs are prebuilt without
+/// fee calls (fee overhead is added separately via the `FEE_CALL_GAS`
+/// constant).
+///
+/// On failure, logs each tx's function codes to aid debugging.
+async fn verify_batch(
+    th: &TestHarness,
+    txs: &[Transaction],
+    block_height: u32,
+    write: bool,
+    tx_labels: &[&str],
+) -> Result<TrialResult> {
+    let validator = th.wallet(&Holder::Alice).validator.read().await;
+    let block_target = validator.consensus.module.target;
+
+    debug!(
+        "Verifying batch of {} txs at block_height={} (write={})",
+        txs.len(),
+        block_height,
+        write
+    );
+    for (i, tx) in txs.iter().enumerate() {
+        let label = tx_labels.get(i).copied().unwrap_or("?");
+        let codes: Vec<u8> = tx.calls.iter().map(|c| c.data.data.first().copied().unwrap_or(0)).collect();
+        debug!("  tx[{}] {}: {} calls, function_codes={:?}", i, label, tx.calls.len(), codes);
+    }
+
+    let start = Instant::now();
+    let result = validator
+        .add_test_transactions(txs, block_height, block_target, write, false)
+        .await;
+    let total_secs = start.elapsed().as_secs_f64();
+
+    match result {
+        Ok((gas_used, _paid)) => {
+            Ok(TrialResult {
+                tx_count: txs.len(),
+                verification_secs: total_secs,
+                apply_secs: 0.0,
+                total_secs,
+                gas_used,
+            })
+        }
+        Err(e) => {
+            // Log the full batch composition for debugging
+            error!("Batch verification failed ({} txs): {}", txs.len(), e);
+            for (i, tx) in txs.iter().enumerate() {
+                let label = tx_labels.get(i).copied().unwrap_or("?");
+                let codes: Vec<u8> =
+                    tx.calls.iter().map(|c| c.data.data.first().copied().unwrap_or(0)).collect();
+                error!("  tx[{}] {}: {} calls, function_codes={:?}", i, label, tx.calls.len(), codes);
+            }
+            Err(e)
+        }
+    }
+}
+
+/// Measure a scenario's capacity with repeated verify-only trials and a
+/// single verify+apply pass.
+///
+/// Methodology:
+///   1. 3 `write=false` runs, timed. The median `total_secs` is reported
+///      as `verification_secs`; `gas_used` is taken from the median trial
+///      (identical across trials since gas is computed during verify,
+///      independent of `write`).
+///   2. One `write=true` run (verify + apply). `apply_secs` is derived as
+///      `max(0, apply_total − verification_secs)`, giving an honest
+///      verify/apply split without an instrumented validator API.
+///   3. `total_secs = verification_secs + apply_secs`.
+///
+/// No explicit warmup is performed: the tx-build phase (`build_*_txs` calls
+/// the harness helpers that build and execute each tx) already primes the
+/// ZK verifying-key cache and sled page cache before this function runs.
+///
+/// `write=false` for the repeated trials is essential: a `write=true` run
+/// spends the prebuilt txs' coins (nullifiers recorded), so the next trial
+/// would be rejected as a double-spend.
+async fn measure_capacity(
+    th: &TestHarness,
+    all_txs: &[Transaction],
+    block_height: u32,
+    tx_labels: &[&str],
+) -> Result<TrialResult> {
+    if all_txs.is_empty() {
+        return Ok(TrialResult::default());
+    }
+
+    let trials = TRIALS;
+
+    // Measured verify-only trials.
+    let mut trial_totals: Vec<f64> = Vec::with_capacity(trials);
+    let mut last_gas: u64 = 0;
+    for i in 0..trials.max(1) {
+        let r = verify_batch(th, all_txs, block_height, false, tx_labels).await?;
+        debug!("Trial {}/{}: total_secs={:.4}, gas={}", i + 1, trials.max(1), r.total_secs, r.gas_used);
+        trial_totals.push(r.total_secs);
+        last_gas = r.gas_used;
+    }
+    trial_totals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+    let verification_secs = trial_totals[trial_totals.len() / 2];
+
+    // Verify + apply pass (single). Apply overhead is the difference between
+    // this run's total and the median verify-only time.
+    let apply_run = verify_batch(th, all_txs, block_height, true, tx_labels).await?;
+    let apply_secs = (apply_run.total_secs - verification_secs).max(0.0);
+    let total_secs = verification_secs + apply_secs;
+
+    Ok(TrialResult {
+        tx_count: all_txs.len(),
+        verification_secs,
+        apply_secs,
+        total_secs,
+        gas_used: last_gas,
+    })
+}
+
+// ---------------------------------------------------------------------------
+// Output
+// ---------------------------------------------------------------------------
+
+#[derive(Serialize)]
+struct CapacityOutput {
+    machine_info: MachineInfo,
+    fee_overhead_gas: u64,
+    results: Vec<CapacityResults>,
+}
+
+#[derive(Serialize)]
+struct CapacityResults {
+    scenario: String,
+    op_shape: OpShapeSer,
+    tx_count: usize,
+    gas_used: u64,
+    gas_per_tx: f64,
+    gas_per_tx_with_fee: f64,
+    verification_secs: f64,
+    apply_secs: f64,
+    total_secs: f64,
+    secs_per_tx: f64,
+    tps: f64,
+    fee_overhead_gas: u64,
+    verify_fees: bool,
+    block_gas_limit: u64,
+}
+
+#[derive(Serialize)]
+struct OpShapeSer {
+    op_kind: String,
+    inputs: usize,
+    outputs: usize,
+    recipients: usize,
+    deploy_kb: usize,
+}
+
+// ---------------------------------------------------------------------------
+// Scenario runner
+// ---------------------------------------------------------------------------
+
+async fn run_scenario(
+    scenario: &Scenario,
+) -> Result<CapacityResults> {
+    use Holder::{Alice, Bob, Dao};
+
+    let block_height = 1u32;
+    let mut th = TestHarness::new(&[Alice, Bob, Dao], false).await?;
+
+    // Pre-populate state
+    if scenario.prepopulate_coins > 0 {
+        debug!("Pre-populating with {} filler coins", scenario.prepopulate_coins);
+        pre_populate_merkle_tree(&mut th, &Alice, scenario.prepopulate_coins).await?;
+    }
+
+    // Setup DAO if needed.  setup_dao_context returns the final block
+    // height after all setup txs have been executed; this must be used for
+    // building and verifying the measured txs, otherwise DAO txs fail with
+    // SnapshotTooOld (the merkle snapshot in the ZK proof is stale relative
+    // to the verifying block height).
+    let mut current_bh = block_height;
+    let dao_ctx = match scenario.op_kind {
+        OpKind::DaoPropose => {
+            let setup = DaoSetup {
+                num_gov_coins: scenario.batch_size,
+                num_proposals: 0,
+                num_pre_votes: 0,
+                recipients_per_proposal: scenario.dao_recipients,
+            };
+            let (ctx, bh) = setup_dao_context(&mut th, &setup, current_bh).await?;
+            current_bh = bh;
+            Some(ctx)
+        }
+        OpKind::DaoVote => {
+            let setup = DaoSetup {
+                num_gov_coins: scenario.batch_size * 2,
+                num_proposals: scenario.batch_size,
+                num_pre_votes: 0,
+                recipients_per_proposal: scenario.dao_recipients,
+            };
+            let (ctx, bh) = setup_dao_context(&mut th, &setup, current_bh).await?;
+            current_bh = bh;
+            Some(ctx)
+        }
+        OpKind::DaoExec => {
+            let setup = DaoSetup {
+                num_gov_coins: scenario.batch_size * 2,
+                num_proposals: scenario.batch_size,
+                num_pre_votes: scenario.batch_size,
+                recipients_per_proposal: scenario.dao_recipients,
+            };
+            let (ctx, bh) = setup_dao_context(&mut th, &setup, current_bh).await?;
+            current_bh = bh;
+            Some(ctx)
+        }
+        OpKind::Mixed => {
+            let mix = WorkloadMix::mixed();
+            let num_proposals = mix.dao_execs + mix.dao_votes;
+            let setup = DaoSetup {
+                num_gov_coins: num_proposals * 2 + mix.dao_votes,
+                num_proposals,
+                num_pre_votes: mix.dao_execs,
+                recipients_per_proposal: scenario.dao_recipients,
+            };
+            let (ctx, bh) = setup_dao_context(&mut th, &setup, current_bh).await?;
+            current_bh = bh;
+            Some(ctx)
+        }
+        _ => None,
+    };
+
+    // Build transactions (not timed).  Use current_bh so txs are built
+    // against the state that setup left behind.
+    debug!("Building transactions for {} at block_height={}", scenario.name, current_bh);
+    let (all_txs, tx_labels): (Vec<Transaction>, Vec<&'static str>) = match scenario.op_kind {
+        OpKind::Transfer => {
+            let txs = build_transfer_txs(&mut th, scenario.batch_size, current_bh).await?;
+            (txs, vec!["transfer"; scenario.batch_size])
+        }
+        OpKind::TransferNIn => {
+            let txs = build_transfer_n_input_txs(&mut th, scenario.batch_size, scenario.transfer_inputs, current_bh).await?;
+            (txs, vec!["transfer_n_in"; scenario.batch_size])
+        }
+        OpKind::TransferNOut => {
+            let txs = build_transfer_n_output_txs(&mut th, scenario.batch_size, scenario.transfer_outputs, current_bh).await?;
+            (txs, vec!["transfer_n_out"; scenario.batch_size])
+        }
+        OpKind::DaoPropose => {
+            let ctx = dao_ctx.as_ref().unwrap();
+            let txs = build_dao_propose_txs(&mut th, scenario.batch_size, scenario.dao_recipients, ctx, current_bh).await?;
+            (txs, vec!["dao_propose"; scenario.batch_size])
+        }
+        OpKind::DaoExec => {
+            let ctx = dao_ctx.as_ref().unwrap();
+            let txs = build_dao_exec_txs(&mut th, scenario.batch_size, ctx, current_bh).await?;
+            (txs, vec!["dao_exec"; scenario.batch_size])
+        }
+        OpKind::DaoVote => {
+            let ctx = dao_ctx.as_ref().unwrap();
+            let txs = build_dao_vote_txs(&mut th, scenario.batch_size, ctx, current_bh, 0).await?;
+            (txs, vec!["dao_vote"; scenario.batch_size])
+        }
+        OpKind::OtcSwap => {
+            let txs = build_otc_swap_txs(&mut th, scenario.batch_size, current_bh).await?;
+            (txs, vec!["otc_swap"; scenario.batch_size])
+        }
+        OpKind::TokenMint => {
+            let txs = build_token_mint_txs(&mut th, scenario.batch_size, current_bh).await?;
+            (txs, vec!["token_mint"; scenario.batch_size])
+        }
+        OpKind::DaoMint => {
+            let txs = build_dao_mint_txs(&mut th, scenario.batch_size, current_bh).await?;
+            (txs, vec!["dao_mint"; scenario.batch_size])
+        }
+        OpKind::Deploy => {
+            let txs = build_deploy_txs(&mut th, scenario.batch_size, current_bh, scenario.deploy_kb).await?;
+            (txs, vec!["deploy"; scenario.batch_size])
+        }
+        OpKind::Mixed => {
+            let mix = WorkloadMix::mixed();
+            let ctx = dao_ctx.as_ref().unwrap();
+            let txs = build_mixed_txs(&mut th, &mix, ctx, scenario.deploy_kb, current_bh, mix.dao_execs).await?;
+            let labels = build_mixed_labels(&mix);
+            (txs, labels)
+        }
+    };
+
+    debug!("Total prebuilt txs: {}", all_txs.len());
+
+    // Measure capacity
+    let result = measure_capacity(&th, &all_txs, current_bh, &tx_labels).await?;
+
+    let tps = if result.total_secs > 0.0 {
+        result.tx_count as f64 / result.total_secs
+    } else {
+        0.0
+    };
+    let gas_per_tx =
+        if result.tx_count > 0 { result.gas_used as f64 / result.tx_count as f64 } else { 0.0 };
+    let gas_per_tx_with_fee = gas_per_tx + FEE_CALL_GAS as f64;
+    let secs_per_tx = if result.tx_count > 0 {
+        result.total_secs / result.tx_count as f64
+    } else {
+        0.0
+    };
+
+    Ok(CapacityResults {
+        scenario: scenario.name.clone(),
+        op_shape: OpShapeSer {
+            op_kind: format!("{:?}", scenario.op_kind),
+            inputs: scenario.transfer_inputs,
+            outputs: scenario.transfer_outputs,
+            recipients: scenario.dao_recipients,
+            deploy_kb: scenario.deploy_kb,
+        },
+        tx_count: result.tx_count,
+        gas_used: result.gas_used,
+        gas_per_tx,
+        gas_per_tx_with_fee,
+        verification_secs: result.verification_secs,
+        apply_secs: result.apply_secs,
+        total_secs: result.total_secs,
+        secs_per_tx,
+        tps,
+        fee_overhead_gas: FEE_CALL_GAS,
+        verify_fees: false,
+        block_gas_limit: BLOCK_GAS_LIMIT,
+    })
+}
+
+/// Build a mixed workload (used by `mixed` scenario).
+async fn build_mixed_txs(
+    th: &mut TestHarness,
+    mix: &WorkloadMix,
+    dao_ctx: &DaoContext,
+    deploy_kb: usize,
+    block_height: u32,
+    proposal_offset: usize,
+) -> Result<Vec<Transaction>> {
+    let mut all_txs = Vec::new();
+
+    if mix.transfers > 0 {
+        let txs = build_transfer_txs(th, mix.transfers, block_height).await?;
+        debug!("  Built {} transfer txs", txs.len());
+        all_txs.extend(txs);
+    }
+    if mix.token_mints > 0 {
+        let txs = build_token_mint_txs(th, mix.token_mints, block_height).await?;
+        debug!("  Built {} token_mint txs", txs.len());
+        all_txs.extend(txs);
+    }
+    if mix.dao_votes > 0 {
+        let txs = build_dao_vote_txs(th, mix.dao_votes, dao_ctx, block_height, proposal_offset).await?;
+        debug!("  Built {} dao_vote txs", txs.len());
+        all_txs.extend(txs);
+    }
+    if mix.dao_execs > 0 {
+        let txs = build_dao_exec_txs(th, mix.dao_execs, dao_ctx, block_height).await?;
+        debug!("  Built {} dao_exec txs", txs.len());
+        all_txs.extend(txs);
+    }
+    if mix.deployments > 0 {
+        let txs = build_deploy_txs(th, mix.deployments, block_height, deploy_kb).await?;
+        debug!("  Built {} deploy txs ({}KB)", txs.len(), deploy_kb);
+        all_txs.extend(txs);
+    }
+
+    Ok(all_txs)
+}
+
+/// Generate human-readable labels for each tx in a mixed workload batch,
+/// matching the build order in `build_mixed_txs`.
+fn build_mixed_labels(mix: &WorkloadMix) -> Vec<&'static str> {
+    let mut labels = Vec::with_capacity(mix.total());
+    labels.extend(std::iter::repeat("transfer").take(mix.transfers));
+    labels.extend(std::iter::repeat("token_mint").take(mix.token_mints));
+    labels.extend(std::iter::repeat("dao_vote").take(mix.dao_votes));
+    labels.extend(std::iter::repeat("dao_exec").take(mix.dao_execs));
+    labels.extend(std::iter::repeat("deploy").take(mix.deployments));
+    labels
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+fn main() -> Result<()> {
+    smol::block_on(async {
+        // Use stderr for tracing so JSON output on stdout stays clean.
+        tracing_subscriber::fmt()
+            .with_env_filter(tracing_subscriber::EnvFilter::new("warn"))
+            .with_writer(std::io::stderr)
+            .init();
+
+        let machine_info = detect_machine_info();
+
+        let scenarios = all_scenarios();
+
+        let mut all_results = Vec::new();
+
+        for scenario in &scenarios {
+            info!("=== Scenario: {} ===", scenario.name);
+            let result = run_scenario(scenario).await?;
+            all_results.push(result);
+        }
+
+        let output = CapacityOutput {
+            machine_info,
+            fee_overhead_gas: FEE_CALL_GAS,
+            results: all_results,
+        };
+        println!("{}", serde_json::to_string_pretty(&output).unwrap());
+
+        Ok(())
+    })
+}

+ 379 - 0
script/research/fee-model/src/db_bench.rs

@@ -0,0 +1,379 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! Standalone database microbenchmark for fee constant calibration.
+//!
+//! Measures sled I/O costs across four scenarios and eight payload sizes
+//! to characterize the per-byte cost model. 
+//!
+//! Usage:
+//!     make db_bench
+//!     cargo run --release --bin db_bench > db_bench_results.json
+
+use std::{
+    hint::black_box,
+    sync::atomic::{AtomicUsize, Ordering},
+};
+
+#[path = "bench_util.rs"]
+mod bench_util;
+
+use serde::{Deserialize, Serialize};
+use sled_overlay::sled;
+use wasmer::{Imports, Instance, Module, Store, Value};
+use wasmer_compiler_singlepass::Singlepass;
+
+const ITERATIONS: usize = 1_000_000;
+
+const PAYLOAD_SIZES: [(&str, usize); 8] = [
+    ("32b", 32),
+    ("128b", 128),
+    ("256b", 256),
+    ("512b", 512),
+    ("1kib", 1024),
+    ("2kib", 2048),
+    ("4kib", 4096),
+    ("8kib", 8192),
+];
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct SizeStats {
+    p50_ns: u64,
+    mean_ns: u64,
+    iterations: usize,
+    payload_bytes: usize,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ScenarioStats {
+    scenario: String,
+    sizes: Vec<(String, SizeStats)>,
+    /// Linear regression slope: ns per byte (derived from p50 across all sizes).
+    slope_ns_per_byte: f64,
+    /// Regression intercept: fixed overhead in ns.
+    intercept_ns: f64,
+    /// R-squared goodness of fit.
+    r_squared: f64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct DbBenchResults {
+    wasm_add_p50_ns: u64,
+    db_set_new: ScenarioStats,
+    db_set_overwrite: ScenarioStats,
+    db_get: ScenarioStats,
+    db_contains_key: ScenarioStats,
+    ratios: Ratios,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct Ratios {
+    /// db_set_new slope / wasm_add p50
+    write_new_per_byte: f64,
+    /// db_set_overwrite slope / wasm_add p50
+    write_overwrite_per_byte: f64,
+    /// db_get slope / wasm_add p50
+    read_per_byte: f64,
+    /// db_contains_key slope / wasm_add p50
+    contains_key_per_byte: f64,
+    /// write_new / read
+    write_read_ratio: f64,
+}
+
+fn bench_key(i: usize) -> [u8; 32] {
+    let mut key = [0u8; 32];
+    key[..8].copy_from_slice(&(i as u64).to_le_bytes());
+    key
+}
+
+fn p50(times: &mut Vec<u64>, iters: usize) -> u64 {
+    times.sort_unstable();
+    times[iters / 2]
+}
+
+fn mean(times: &[u64], iters: usize) -> u64 {
+    times.iter().map(|&x| x as f64).sum::<f64>() as u64 / iters as u64
+}
+
+/// Linear regression of p50 times against payload sizes.
+/// Returns (slope_ns_per_byte, intercept_ns, r_squared).
+fn linear_regression(stats: &[(usize, u64)]) -> (f64, f64, f64) {
+    let n = stats.len() as f64;
+    let sum_x: f64 = stats.iter().map(|(bytes, _)| *bytes as f64).sum();
+    let sum_y: f64 = stats.iter().map(|(_, p50)| *p50 as f64).sum();
+    let sum_xy: f64 = stats.iter().map(|(b, p)| (*b as f64) * (*p as f64)).sum();
+    let sum_x2: f64 = stats.iter().map(|(b, _)| (*b as f64).powi(2)).sum();
+
+    let denom = n * sum_x2 - sum_x * sum_x;
+    let slope = if denom == 0.0 { 0.0 } else { (n * sum_xy - sum_x * sum_y) / denom };
+    let intercept = (sum_y - slope * sum_x) / n;
+
+    let mean_y = sum_y / n;
+    let ss_tot: f64 = stats.iter().map(|(_, p)| (*p as f64 - mean_y).powi(2)).sum();
+    let ss_res: f64 =
+        stats.iter().map(|(b, p)| (*p as f64 - (intercept + slope * *b as f64)).powi(2)).sum();
+    let r_squared = if ss_tot == 0.0 { 1.0 } else { 1.0 - ss_res / ss_tot };
+
+    (slope, intercept, r_squared)
+}
+
+fn measure_wasm_add() -> u64 {
+    let mut store = Store::new(Singlepass::new());
+    let module = Module::new(&store, bench_util::WASM_ADD).unwrap();
+    let instance = Instance::new(&mut store, &module, &Imports::new()).unwrap();
+    let func = instance.exports.get_function("add").unwrap();
+    let times = bench_util::collect_times(
+        || {
+            black_box(func.call(&mut store, &[Value::I32(10), Value::I32(20)]).unwrap());
+        },
+        ITERATIONS,
+    );
+    let mut t = times;
+    p50(&mut t, ITERATIONS)
+}
+
+/// Benchmark inserting unique keys into a growing tree (new-key writes).
+fn measure_db_set_new() -> ScenarioStats {
+    let config = sled::Config::new().temporary(true).path("fee_db_bench_set_new");
+    let db = config.open().unwrap();
+
+    let mut size_stats = vec![];
+
+    for (name, size) in &PAYLOAD_SIZES {
+        let tree = db.open_tree(format!("set_new_{name}")).unwrap();
+        let value = vec![42u8; *size];
+        let mut idx = 0usize;
+
+        let times = bench_util::collect_times(
+            || {
+                let key = bench_key(idx);
+                idx += 1;
+                tree.insert(key.as_slice(), value.as_slice()).unwrap();
+            },
+            ITERATIONS,
+        );
+        let mut t = times;
+        let p = p50(&mut t, ITERATIONS);
+        let m = mean(&t, ITERATIONS);
+        size_stats.push((
+            name.to_string(),
+            SizeStats { p50_ns: p, mean_ns: m, iterations: ITERATIONS, payload_bytes: *size },
+        ));
+        db.drop_tree(format!("set_new_{name}")).unwrap();
+    }
+
+    let regression_data: Vec<(usize, u64)> =
+        size_stats.iter().map(|(_, s)| (s.payload_bytes, s.p50_ns)).collect();
+    let (slope, intercept, r2) = linear_regression(&regression_data);
+
+    ScenarioStats {
+        scenario: "db_set_new".to_string(),
+        sizes: size_stats,
+        slope_ns_per_byte: slope,
+        intercept_ns: intercept,
+        r_squared: r2,
+    }
+}
+
+/// Benchmark overwriting existing keys (no tree growth).
+fn measure_db_set_overwrite() -> ScenarioStats {
+    let config = sled::Config::new().temporary(true).path("fee_db_bench_set_overwrite");
+    let db = config.open().unwrap();
+
+    let mut size_stats = vec![];
+
+    for (name, size) in &PAYLOAD_SIZES {
+        let tree = db.open_tree(format!("set_ow_{name}")).unwrap();
+        let value = vec![42u8; *size];
+
+        // Pre-populate all keys
+        for i in 0..ITERATIONS {
+            let key = bench_key(i);
+            tree.insert(key.as_slice(), value.as_slice()).unwrap();
+        }
+        tree.flush().unwrap();
+
+        // Timed loop: overwrite existing keys in round-robin
+        let idx = AtomicUsize::new(0);
+        let times = bench_util::collect_times(
+            || {
+                let current = idx.fetch_add(1, Ordering::Relaxed) % ITERATIONS;
+                let key = bench_key(current);
+                tree.insert(key.as_slice(), value.as_slice()).unwrap();
+            },
+            ITERATIONS,
+        );
+        let mut t = times;
+        let p = p50(&mut t, ITERATIONS);
+        let m = mean(&t, ITERATIONS);
+        size_stats.push((
+            name.to_string(),
+            SizeStats { p50_ns: p, mean_ns: m, iterations: ITERATIONS, payload_bytes: *size },
+        ));
+        db.drop_tree(format!("set_ow_{name}")).unwrap();
+    }
+
+    let regression_data: Vec<(usize, u64)> =
+        size_stats.iter().map(|(_, s)| (s.payload_bytes, s.p50_ns)).collect();
+    let (slope, intercept, r2) = linear_regression(&regression_data);
+
+    ScenarioStats {
+        scenario: "db_set_overwrite".to_string(),
+        sizes: size_stats,
+        slope_ns_per_byte: slope,
+        intercept_ns: intercept,
+        r_squared: r2,
+    }
+}
+
+/// Benchmark reading existing keys by payload size.
+fn measure_db_get() -> ScenarioStats {
+    let config = sled::Config::new().temporary(true).path("fee_db_bench_get");
+    let db = config.open().unwrap();
+
+    let mut size_stats = vec![];
+
+    for (name, size) in &PAYLOAD_SIZES {
+        let tree = db.open_tree(format!("get_{name}")).unwrap();
+        let value = vec![42u8; *size];
+
+        // Pre-populate
+        for i in 0..ITERATIONS {
+            let key = bench_key(i);
+            tree.insert(key.as_slice(), value.as_slice()).unwrap();
+        }
+        tree.flush().unwrap();
+
+        // Timed loop: read in round-robin
+        let idx = AtomicUsize::new(0);
+        let times = bench_util::collect_times(
+            || {
+                let current = idx.fetch_add(1, Ordering::Relaxed) % ITERATIONS;
+                let key = bench_key(current);
+                black_box(tree.get(key.as_slice()).unwrap());
+            },
+            ITERATIONS,
+        );
+        let mut t = times;
+        let p = p50(&mut t, ITERATIONS);
+        let m = mean(&t, ITERATIONS);
+        size_stats.push((
+            name.to_string(),
+            SizeStats { p50_ns: p, mean_ns: m, iterations: ITERATIONS, payload_bytes: *size },
+        ));
+        db.drop_tree(format!("get_{name}")).unwrap();
+    }
+
+    let regression_data: Vec<(usize, u64)> =
+        size_stats.iter().map(|(_, s)| (s.payload_bytes, s.p50_ns)).collect();
+    let (slope, intercept, r2) = linear_regression(&regression_data);
+
+    ScenarioStats {
+        scenario: "db_get".to_string(),
+        sizes: size_stats,
+        slope_ns_per_byte: slope,
+        intercept_ns: intercept,
+        r_squared: r2,
+    }
+}
+
+/// Benchmark contains_key on existing keys by payload size.
+/// Note: contains_key cost should not depend on value size, but we measure
+/// across payload sizes anyway to confirm this and detect any tree-depth effects.
+fn measure_db_contains_key() -> ScenarioStats {
+    let config = sled::Config::new().temporary(true).path("fee_db_bench_contains");
+    let db = config.open().unwrap();
+
+    let mut size_stats = vec![];
+
+    for (name, size) in &PAYLOAD_SIZES {
+        let tree = db.open_tree(format!("contains_{name}")).unwrap();
+        let value = vec![42u8; *size];
+
+        // Pre-populate
+        for i in 0..ITERATIONS {
+            let key = bench_key(i);
+            tree.insert(key.as_slice(), value.as_slice()).unwrap();
+        }
+        tree.flush().unwrap();
+
+        // Timed loop: contains_key in round-robin (mix of hits and a few misses)
+        let idx = AtomicUsize::new(0);
+        let times = bench_util::collect_times(
+            || {
+                let current = idx.fetch_add(1, Ordering::Relaxed) % (ITERATIONS + 100);
+                let key = bench_key(current);
+                black_box(tree.contains_key(key.as_slice()).unwrap());
+            },
+            ITERATIONS,
+        );
+        let mut t = times;
+        let p = p50(&mut t, ITERATIONS);
+        let m = mean(&t, ITERATIONS);
+        size_stats.push((
+            name.to_string(),
+            SizeStats { p50_ns: p, mean_ns: m, iterations: ITERATIONS, payload_bytes: *size },
+        ));
+        db.drop_tree(format!("contains_{name}")).unwrap();
+    }
+
+    let regression_data: Vec<(usize, u64)> =
+        size_stats.iter().map(|(_, s)| (s.payload_bytes, s.p50_ns)).collect();
+    let (slope, intercept, r2) = linear_regression(&regression_data);
+
+    ScenarioStats {
+        scenario: "db_contains_key".to_string(),
+        sizes: size_stats,
+        slope_ns_per_byte: slope,
+        intercept_ns: intercept,
+        r_squared: r2,
+    }
+}
+
+fn main() {
+    let wasm_add_p50 = measure_wasm_add();
+
+    let set_new = measure_db_set_new();
+    let set_overwrite = measure_db_set_overwrite();
+    let get = measure_db_get();
+    let contains = measure_db_contains_key();
+
+    let baseline = wasm_add_p50 as f64;
+    let ratios = Ratios {
+        write_new_per_byte: set_new.slope_ns_per_byte / baseline,
+        write_overwrite_per_byte: set_overwrite.slope_ns_per_byte / baseline,
+        read_per_byte: get.slope_ns_per_byte / baseline,
+        contains_key_per_byte: contains.slope_ns_per_byte / baseline,
+        write_read_ratio: if get.slope_ns_per_byte > 0.0 {
+            set_new.slope_ns_per_byte / get.slope_ns_per_byte
+        } else {
+            0.0
+        },
+    };
+
+    let results = DbBenchResults {
+        wasm_add_p50_ns: wasm_add_p50,
+        db_set_new: set_new,
+        db_set_overwrite: set_overwrite,
+        db_get: get,
+        db_contains_key: contains,
+        ratios,
+    };
+
+    println!("{}", serde_json::to_string_pretty(&results).unwrap());
+}