diff --git a/.containerignore b/.containerignore
new file mode 100644
index 0000000..3ea0852
--- /dev/null
+++ b/.containerignore
@@ -0,0 +1,2 @@
+target/
+.git/
diff --git a/Cargo.lock b/Cargo.lock
index 81a7b7b..1ed5e20 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2423,6 +2423,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+[[package]]
+name = "loadgen"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "ztest",
+]
+
[[package]]
name = "lock_api"
version = "0.4.14"
diff --git a/Cargo.toml b/Cargo.toml
index 79625f2..900180f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["macros"]
+members = ["macros", "crates/loadgen"]
resolver = "2"
# Post-ironwood dependency line. The whole zcash_* wallet stack now resolves
diff --git a/crates/loadgen/Cargo.toml b/crates/loadgen/Cargo.toml
new file mode 100644
index 0000000..9c2060c
--- /dev/null
+++ b/crates/loadgen/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "loadgen"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+homepage.workspace = true
+
+[[bin]]
+name = "loadgen"
+path = "src/main.rs"
+
+[dependencies]
+# The whole point: reuse ztest's load-test library (LwdClient / LoadDriver /
+# ChainLinkOracle / LoadReport). default-features off drops the wallet backends
+# (librustzcash/zingo) we don't need; kube/k8s-openapi remain non-optional but
+# we never touch them — we only speak gRPC to an already-running zainod.
+ztest = { path = "../..", default-features = false }
+
+tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
+clap = { version = "4", features = ["derive"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+anyhow = "1"
diff --git a/crates/loadgen/Containerfile b/crates/loadgen/Containerfile
new file mode 100644
index 0000000..f754846
--- /dev/null
+++ b/crates/loadgen/Containerfile
@@ -0,0 +1,25 @@
+# Multi-stage build for the `loadgen` zainod load generator.
+#
+# Build context MUST be the ztest repo root (loadgen depends on the ztest lib
+# via `path = "../.."` and inherits the workspace `[patch.crates-io]`):
+#
+# podman build -f crates/loadgen/Containerfile -t loadgen:dev .
+#
+# `ztest` is pulled with default-features off (see crates/loadgen/Cargo.toml),
+# so the optional zingolib/pepper-sync/test-vector git deps are NOT fetched —
+# only crates.io + the public lightwallet-protocol patch. No build creds needed.
+
+FROM docker.io/library/rust:1.95-slim-bookworm AS builder
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends protobuf-compiler pkg-config git \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /src
+COPY . .
+RUN cargo build --release -p loadgen
+
+FROM docker.io/library/debian:bookworm-slim AS runtime
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /src/target/release/loadgen /usr/local/bin/loadgen
+ENTRYPOINT ["/usr/local/bin/loadgen"]
diff --git a/crates/loadgen/README.md b/crates/loadgen/README.md
new file mode 100644
index 0000000..b2bce4e
--- /dev/null
+++ b/crates/loadgen/README.md
@@ -0,0 +1,103 @@
+# loadgen
+
+A concurrency/load generator for a **running** zainod. It attaches to a zainod's
+gRPC `CompactTxStreamer` endpoint, fans out N concurrent clients driving a chosen
+RPC, and reports real latency percentiles, throughput, error counts, and
+correctness violations.
+
+Unlike the rest of ztest it does **not** spawn a topology — it points at an
+endpoint you give it (`--target`). The same binary loads a regtest node on a
+laptop or a mainnet node in-cluster, and runs as a plain Kubernetes Job.
+
+## Architecture
+
+```mermaid
+flowchart LR
+ subgraph job["k8s Job (runs IN-cluster)"]
+ lg["loadgen
N per-task gRPC connections"]
+ end
+ lg -->|"CompactTxStreamer RPC
GetBlockRange · GetBlock · GetLatestBlock"| z["zainod service
zaino.<ns>.svc:8137"]
+ z -->|"fetch backend: JSON-RPC
state backend: direct DB read"| zeb["zebra"]
+ lg -.->|"hdrhistogram + chain-link oracle"| rep["LoadReport
human table (stderr) + JSON (stdout)"]
+```
+
+It reuses `ztest::loadtest` (the `LwdClient` / `LoadDriver` / `ChainLinkOracle` /
+`LoadReport` stack); this crate is the runnable front-end. Three seams are kept
+independent:
+
+- **driver** — the swarm + metrics (`ztest::loadtest`), speaks only the gRPC wire
+ protocol, so it is version- and target-agnostic.
+- **target** — supplied as a URI; how the zainod got there (regtest spawn,
+ ephemeral deploy, mainnet) is not loadgen's concern.
+- **reporting** — a human table on stderr and a machine-readable JSON summary on
+ stdout (`--json`).
+
+## Running
+
+### Locally (against any reachable endpoint)
+
+```
+cargo run -p loadgen -- \
+ --target http://127.0.0.1:8137 \
+ --rpc block-range --connections 64 --duration 30 --json
+```
+
+### In-cluster (the representative path)
+
+Latency is only meaningful measured **inside** the cluster — a port-forward from a
+remote node would dominate the numbers. Run it as a Job in the target namespace;
+the target is derived from the pod's own namespace:
+
+```
+kubectl -n apply -f crates/loadgen/k8s/job.yaml
+kubectl -n logs -f job/loadgen
+```
+
+### Connection sweep (find the knee)
+
+`scripts/sweep.sh` runs a sequential connection sweep (one Job per level — never
+parallel, which would confound the measurement) and collects one JSON line per
+level:
+
+```
+scripts/sweep.sh "1 4 8 16 32 64 128" 20
+```
+
+## Flags
+
+| flag | default | meaning |
+|---|---|---|
+| `--target` | (required) | zainod gRPC endpoint, e.g. `http://zaino.ns.svc:8137` |
+| `--rpc` | `block-range` | `block-range` \| `latest-block` \| `block` |
+| `--connections` | 64 | concurrent connections (each a spawned task) |
+| `--conn-mode` | `per-task` | `per-task` (socket per client) or `shared` (one multiplexed channel) |
+| `--range` | — | `START..END`; either side may be empty (`a..` = to tip, `..b` = from genesis) |
+| `--tip-window` | 50000 | when `--range` is absent, sweep the last N blocks below the discovered tip |
+| `--blocks` | 100 | blocks per `GetBlockRange` window |
+| `--dist` | `even` | how windows spread across the pool (`even` \| `scatter`) |
+| `--duration` / `--count` | 30s | run for D seconds, or N ops per connection |
+| `--no-oracle` | off | disable the chain-link correctness oracle |
+| `--json` | off | emit the JSON summary to stdout |
+
+## Output
+
+- **Human table → stderr** (via `ztest::loadtest::LoadReport::print`).
+- **JSON summary → stdout** with `--json`: per-op `p50/p90/p99/p99.9/max` (ms),
+ throughput, total ops, error count, and any correctness violations.
+- **Logs** — structured `tracing` (set `ZTEST_LOG=loadgen=info,ztest=info`).
+
+The **chain-link oracle** validates every response under load: blocks must link
+(`prev_hash == prior.hash`), heights strictly increase, genesis is well-formed.
+It adapts per RPC — it validates single blocks for `GetBlock` and is skipped for
+the block-less `GetLatestBlock`. `0 violations` means every block served under
+load was a correctly-linked chain segment, not just that the server was fast.
+
+## Reproducibility & caveats
+
+- The container image (`zingodevops/loadgen`) pins the exact binary; the committed
+ `k8s/job.yaml` pins the exact args. A run is `kubectl apply` + read the logs.
+- Results are a **measurement, not a calibrated SLO**: absolute latency is only
+ trustworthy on a CPU-pinned, I/O-calibrated node. Trust the *shapes* (saturation,
+ tail knee, collapse) and the differential (backend/version A/B), not raw absolutes.
+- Target zainod should run with a **real finalised state** (not
+ `ZAINO_EPHEMERAL_FINALISED_STATE`, which inflates read performance).
diff --git a/crates/loadgen/k8s/job.yaml b/crates/loadgen/k8s/job.yaml
new file mode 100644
index 0000000..673c5de
--- /dev/null
+++ b/crates/loadgen/k8s/job.yaml
@@ -0,0 +1,52 @@
+# In-cluster load run: a plain Job that hits the LOCAL zaino service directly.
+#
+# Nothing here is target-specific — apply it into whichever namespace's zaino
+# you want to test; the target is derived from the pod's own namespace, and the
+# height window is auto-discovered from the chain tip:
+#
+# kubectl --context zingo-infra -n preview-070-rc1b apply -f job.yaml
+# kubectl --context zingo-infra -n preview-070-rc1b logs -f job/loadgen
+#
+# The Job runs in the target namespace so in-cluster DNS resolves and no
+# cross-namespace NetworkPolicy is in the way.
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: loadgen
+ labels:
+ app: loadgen
+spec:
+ backoffLimit: 0
+ ttlSecondsAfterFinished: 3600
+ template:
+ metadata:
+ labels:
+ app: loadgen
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: loadgen
+ image: zingodevops/loadgen:dev
+ imagePullPolicy: Always
+ env:
+ - name: POD_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ - name: ZTEST_LOG
+ value: loadgen=info,ztest=info
+ args:
+ - --target=http://zaino.$(POD_NAMESPACE).svc.cluster.local:8137
+ - --connections=64
+ - --tip-window=50000
+ - --blocks=100
+ - --conn-mode=per-task
+ - --duration=30
+ - --json
+ resources:
+ requests:
+ cpu: "1"
+ memory: 512Mi
+ limits:
+ cpu: "2"
+ memory: 1Gi
diff --git a/crates/loadgen/scripts/sweep.sh b/crates/loadgen/scripts/sweep.sh
new file mode 100755
index 0000000..b0d2227
--- /dev/null
+++ b/crates/loadgen/scripts/sweep.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Connection-count sweep for loadgen: run one Job per concurrency level,
+# SEQUENTIALLY (parallel levels would load the server at once and confound the
+# measurement), and collect one JSON line per level.
+#
+# scripts/sweep.sh [connections] [duration_s] [rpc]
+# scripts/sweep.sh preview-070-rc1c "1 4 8 16 32 64 128" 20 block-range
+#
+# Requires: kubectl context already pointed at the target cluster, and a zaino
+# service reachable at zaino..svc.cluster.local:8137.
+set -euo pipefail
+
+NS="${1:?usage: sweep.sh [connections] [duration_s] [rpc]}"
+CONNS="${2:-1 4 8 16 32 64 128}"
+DUR="${3:-20}"
+RPC="${4:-block-range}"
+IMG="${LOADGEN_IMAGE:-docker.io/zingodevops/loadgen:dev}"
+OUT="${OUT:-sweep-${NS}-${RPC}.jsonl}"
+
+: > "$OUT"
+for c in $CONNS; do
+ name="loadgen-c${c}"
+ kubectl -n "$NS" delete job "$name" --ignore-not-found >/dev/null 2>&1 || true
+ cat </dev/null
+apiVersion: batch/v1
+kind: Job
+metadata: { name: $name, labels: { app: loadgen } }
+spec:
+ backoffLimit: 0
+ ttlSecondsAfterFinished: 1800
+ template:
+ metadata: { labels: { app: loadgen } }
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: loadgen
+ image: $IMG
+ imagePullPolicy: Always
+ env:
+ - { name: POD_NAMESPACE, valueFrom: { fieldRef: { fieldPath: metadata.namespace } } }
+ - { name: ZTEST_LOG, value: "loadgen=info,ztest=info" }
+ args:
+ - --target=http://zaino.$NS.svc.cluster.local:8137
+ - --rpc=$RPC
+ - --connections=$c
+ - --tip-window=50000
+ - --blocks=100
+ - --conn-mode=per-task
+ - --duration=$DUR
+ - --json
+EOF
+ kubectl -n "$NS" wait --for=condition=complete "job/$name" --timeout=300s >/dev/null 2>&1 \
+ || kubectl -n "$NS" wait --for=condition=failed "job/$name" --timeout=5s >/dev/null 2>&1 || true
+ json=$(kubectl -n "$NS" logs "job/$name" 2>/dev/null | grep '^{' | head -1 || true)
+ if [ -n "$json" ]; then
+ echo "$json" >> "$OUT"
+ echo "conns=$c OK"
+ else
+ echo "conns=$c FAILED"
+ kubectl -n "$NS" logs "job/$name" 2>/dev/null | tail -3 || true
+ fi
+ kubectl -n "$NS" delete job "$name" --ignore-not-found >/dev/null 2>&1 || true
+done
+
+echo "=== results in $OUT ==="
+cat "$OUT"
diff --git a/crates/loadgen/src/main.rs b/crates/loadgen/src/main.rs
new file mode 100644
index 0000000..2f3a60d
--- /dev/null
+++ b/crates/loadgen/src/main.rs
@@ -0,0 +1,337 @@
+//! Load generator for a *running* zainod: attaches to a gRPC endpoint rather
+//! than spawning a topology, so one binary loads a regtest node on a laptop or
+//! a mainnet node in-cluster and runs as a plain k8s Job.
+//!
+//! It emits a measurement, not a pass/fail SLO: absolute latency gating is only
+//! trustworthy on a CPU-pinned, I/O-calibrated cluster, so treat the numbers as
+//! observations and gate via the A/B differential path instead.
+
+use std::time::Duration;
+
+use anyhow::{Result, bail};
+use clap::{Parser, ValueEnum};
+use tracing::{info, warn};
+use ztest::loadtest::{
+ ChainLinkOracle, ConnMode, Distribution, LoadDriver, LoadReport, LwdClient, Scenario, Until,
+};
+use ztest::observ::{self, Sink};
+
+/// Concurrency/load generator for zainod (gRPC CompactTxStreamer).
+#[derive(Parser, Debug)]
+#[command(name = "loadgen", version, about)]
+struct Args {
+ /// Target zainod gRPC endpoint, e.g. `http://zaino.preview-070-rc1b.svc:8137`.
+ #[arg(long)]
+ target: String,
+
+ /// Number of concurrent connections (each a spawned task).
+ #[arg(long, default_value_t = 64)]
+ connections: usize,
+
+ /// Which RPC to load. `block-range` sweeps windows; `latest-block` polls the
+ /// tip (ignores --range/--blocks); `block` fetches single blocks (ignores --blocks).
+ #[arg(long, value_enum, default_value_t = RpcArg::BlockRange)]
+ rpc: RpcArg,
+
+ /// Height range `START..END`; either side may be empty (`a..` = to tip,
+ /// `..b` = from genesis). Overrides tip auto-discovery.
+ #[arg(long)]
+ range: Option,
+
+ /// Auto-discover the chain tip (GetLatestBlock) and sweep the last N blocks.
+ /// Used when --range is absent.
+ #[arg(long, default_value_t = 50_000)]
+ tip_window: u64,
+
+ /// Blocks per `GetBlockRange` window each connection fetches.
+ #[arg(long, default_value_t = 100)]
+ blocks: u64,
+
+ /// How windows are spread across the pool.
+ #[arg(long, value_enum, default_value_t = DistArg::Even)]
+ dist: DistArg,
+
+ /// Connection model: one shared multiplexed channel, or a real socket per task.
+ #[arg(long, value_enum, default_value_t = ConnModeArg::PerTask)]
+ conn_mode: ConnModeArg,
+
+ /// Run for this many seconds. Mutually exclusive with --count.
+ #[arg(long, group = "budget")]
+ duration: Option,
+
+ /// Each connection performs this many ops, then stops. Mutually exclusive with --duration.
+ #[arg(long, group = "budget")]
+ count: Option,
+
+ /// Stagger spawns by this many milliseconds to avoid a SYN burst.
+ #[arg(long, default_value_t = 1)]
+ spawn_stagger_ms: u64,
+
+ /// Disable the chain-link correctness oracle (enabled by default).
+ #[arg(long)]
+ no_oracle: bool,
+
+ /// Emit a machine-readable JSON summary to stdout (the human table always goes to stderr).
+ #[arg(long)]
+ json: bool,
+
+ /// Label for the run, shown in the report.
+ #[arg(long, default_value = "loadgen")]
+ label: String,
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+enum DistArg {
+ Even,
+ Scatter,
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+enum ConnModeArg {
+ Shared,
+ PerTask,
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+enum RpcArg {
+ BlockRange,
+ LatestBlock,
+ Block,
+}
+
+/// A `--range` spec with Rust-style open bounds: `a..b`, `a..` (end = tip),
+/// `..b` (start = genesis), `..` (whole chain). Open ends are filled by
+/// [`RangeSpec::resolve`] once the tip is known.
+#[derive(Clone, Copy, Debug)]
+struct RangeSpec {
+ start: Option,
+ end: Option,
+}
+
+impl RangeSpec {
+ fn needs_tip(&self) -> bool {
+ self.end.is_none()
+ }
+
+ fn resolve(&self, tip: Option) -> Result {
+ let end = match self.end {
+ Some(e) => e,
+ None => tip.ok_or(RangeError::TipUnknown)?,
+ };
+ HeightRange::new(self.start.unwrap_or(0), end)
+ }
+}
+
+impl std::str::FromStr for RangeSpec {
+ type Err = RangeError;
+
+ fn from_str(s: &str) -> Result {
+ let (start, end) = s.split_once("..").ok_or(RangeError::Format)?;
+ let bound = |x: &str| -> Result