A Rust lightwalletd for Zcash: a caching proxy that serves compact blockchain data to shielded light wallets over gRPC.
Beta software. lightwalletd-rs is under active development and has not been security-audited. Expect breaking changes, and run it at your own risk: it is provided "as is", without warranty of any kind (see LICENSE).
lightwalletd-rs is neither a node nor a wallet. It is a caching proxy between a Zcash full node
(zebrad) and light wallets:
gRPC (CompactTxStreamer) JSON-RPC (HTTP)
wallet <───────────────────────> lightwalletd-rs <───────────────────────> zebrad (full node)
(Zcash - serves compact blocks - has the full chain
light - caches them on disk
wallets) - proxies the rest
It ingests blocks from the node and converts each into a CompactBlock, a pruned form with the zk proofs
stripped that shrinks a block from ~2 MB to a few KB. Compact blocks are cached on disk and streamed to
wallets over the standard Zcash light-client gRPC. The remaining calls (send transaction, tree state,
mempool, transparent-address balances) are proxied to the node.
For the full design see docs/ARCHITECTURE.md; for the specifications it
implements, docs/protocol-references.md.
- All 20
CompactTxStreamermethods: blocks, transactions, tree state, subtrees, nullifiers, transparent-address balances and txids, and mempool streaming. - An on-disk compact-block cache (
redb), filled by a background ingestor that rolls back reorgs and recovers from corruption or gaps on its own. - TLS by default. Plaintext requires an explicit opt-in flag.
- Prometheus metrics (per-method request counts, latency histograms, and a gauge of open client
connections) on
127.0.0.1:9068unless disabled, plus gRPC Server Reflection sogrpcurlworks without a local.protocheckout. - Input validation up front, per-connection stream and keepalive limits, and a graceful drain on
SIGINT/SIGTERM. - A darkside mode serving a controllable in-memory mock chain for deterministic wallet tests.
- Rust (stable, 2024 edition).
protoc, the Protocol Buffers compiler, onPATH; the.protocontract is compiled at build time. Install it withbrew install protobuf(macOS) orapt-get install -y protobuf-compiler(Debian/Ubuntu).- A reachable
zebradnode with JSON-RPC enabled (mainnet, testnet, or regtest), synced far enough to serve the range you need.
Build the binary:
cargo build --release # or: make buildRun it against a local zebrad, in plaintext (local development only):
./target/release/lightwalletd-rs \
--rpc-url http://127.0.0.1:8232 \
--rpc-user "$RPC_USER" --rpc-password "$RPC_PASSWORD" \
--grpc-bind 127.0.0.1:9067 \
--no-tls-very-insecureOn first start it ingests from Sapling activation (or --start-height) and fills the on-disk cache under
--data-dir; later starts resume from the cache. Once it is serving, point a wallet at
127.0.0.1:9067. The server speaks the standard Zcash light-client gRPC, so Zcash light wallets
connect unchanged.
For anything beyond local testing, serve over TLS instead of --no-tls-very-insecure.
The proxy needs two things: how to reach the node, and where to listen.
Backend node. Point it at zebrad's JSON-RPC with --rpc-url, or with --rpc-host / --rpc-port
(defaults 127.0.0.1:8232). Credentials come from --rpc-user / --rpc-password, or from a zcash.conf
via --zcash-conf (which reads rpcuser / rpcpassword / rpcbind / rpcport). Flags take precedence
over the file.
| Flag | Default | Purpose |
|---|---|---|
--grpc-bind |
127.0.0.1:9067 |
gRPC listen address |
--rpc-url |
— | full JSON-RPC URL of the node (overrides --rpc-host/--rpc-port) |
--rpc-user / --rpc-password |
— | node RPC credentials (or via --zcash-conf) |
--zcash-conf |
— | read credentials and host/port from a zcash.conf |
--data-dir |
./lightwalletd-rs-data |
directory for the on-disk block cache |
--start-height |
Sapling activation | height to ingest from when the cache is empty |
--tls-cert / --tls-key |
— | PEM certificate / key (required unless --no-tls-very-insecure or --gen-cert-very-insecure) |
--grpc-web |
off | also serve gRPC-web, so a browser wallet needs no proxy (see gRPC-web) |
--grpc-web-allow-origin |
any origin | browser origin allowed to call the gRPC-web transport (repeatable) |
--metrics-bind |
127.0.0.1:9068 |
address to serve Prometheus /metrics on (disable with --no-metrics) |
--log-level |
info |
tracing filter (an explicit RUST_LOG env var always wins) |
--log-file |
— | write JSON lines here instead of human-readable stderr output |
--backend |
rpc |
which backend serves chain data: rpc or readstate (see Backends) |
--zebra-state-dir |
zebra's default cache dir | zebrad cache directory to read from (readstate backend only) |
--zebra-indexer-url |
— | zebrad indexer gRPC host:port, required with --backend readstate |
Run lightwalletd-rs --help for the full list, including cache resync (--sync-from-height,
--redownload, --nocache) and per-connection resource limits (--max-concurrent-streams,
--keepalive-*). Two flags cap how much of the backend node the wallet-facing path may use at once:
--client-node-concurrency (default 8) and, for transparent-address queries,
--client-scan-concurrency (default 2), both described under
Resource limits. Those two, --ingest-window/--ingest-concurrency,
and --log-level/--log-file also read from LWD_CLIENT_NODE_CONCURRENCY/LWD_CLIENT_SCAN_CONCURRENCY/
LWD_INGEST_WINDOW/LWD_INGEST_CONCURRENCY/LWD_LOG_LEVEL/LWD_LOG_FILE when the flag is absent.
--backend selects how lightwalletd-rs reaches chain data:
rpc(default): every read and write goes overzebrad's JSON-RPC. Works with any reachable node, local or remote, and is the only supported choice for a remote node.readstate: reads (blocks, tree states, subtrees, the transparent-address index, mined transactions, tip/chain info) are served in-process from a co-locatedzebrad's state viazebra_state::ReadStateService, paired withzebra_rpc::sync::TrustedChainSyncover the node's indexer gRPC for true-tip fidelity. Writes and node-only surfaces (sendrawtransaction, the mempool,getinfo) still go over JSON-RPC; the split is deliberate. See ADR 0023 and the design doc for the full rationale.
Requirements for readstate:
- A same-host
zebrad 6.x(state format major v28) withindexer_listen_addrset inzebrad.toml(the indexer gRPC ships in default release binaries; only the listen address needs configuring). - Built with the non-default
readstatecargo feature:cargo build --release --features readstate(pulls in RocksDB and the zebra crate tree, so the default build stays lean; ADR 0012). --zebra-indexer-url <host:port>pointing at that indexer gRPC (required with--backend readstate);--zebra-state-dironly if the cache directory isn't zebra's own default.- A state-format mismatch against the running zebrad fails fast at startup with a message pointing
at
--backend rpc.
Measured envelope (2026-07 mainnet benchmarks,
contrib/bench/results/rss-bench-2026-07.md; wire
parity in rss-parity-2026-07.md): readstate wins
decisively on read surfaces (GetTreeState 4.1x faster, GetTaddressTxids up to 7.3x, time-to-tip
on light recent blocks ~25% faster) but ingest is parse-bound and loses on heavy historical blocks.
Sandblasting-era ingest is ~38% slower, and a full genesis→tip sync is ~19% slower overall (1h 38m vs
1h 22m), because the in-process path pays zebra's structured-Block deserialize plus a re-serialize
plus the compact-block parse on one process's cores, where the JSON-RPC path pipelines block
serialization into zebrad's own process instead. Wire output between the two backends is
byte-identical by construction: 5,997 blocks byte-compared, plus treestates, subtrees, addresses,
transactions and errors. See the parity report for the two wire differences found and fixed.
Recipe: rpc remains the default and the recommended choice for most deployments. For the
fastest possible cold sync followed by the fastest steady-state serving, sync once with
--backend rpc, then restart against the same --data-dir with --backend readstate. The on-disk
compact-block cache is byte-identical between backends, so switching does not require a re-sync.
The gRPC server runs over TLS by default: --tls-cert and --tls-key are required unless you pass
--no-tls-very-insecure (plaintext: development only, never in production) or --gen-cert-very-insecure
(an in-memory self-signed certificate generated at startup, also development only). For local testing you
can instead generate a self-signed pair yourself:
openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"See docs/ARCHITECTURE.md#tls for details.
Browsers cannot speak gRPC. --grpc-web serves the gRPC-web translation of the same API from the same
port, so a web wallet talks to this server directly instead of through Envoy or grpcwebproxy:
lightwalletd-rs ... --grpc-web --grpc-web-allow-origin https://wallet.exampleIt is off by default: enabling it also makes the listener accept HTTP/1.1, which a browser needs on a
plaintext port. --grpc-web-allow-origin is repeatable and restricts the transport to an allowlist;
with none given every origin is allowed, which is the usual shape for a public instance serving wallets
on many origins. Origins are validated at startup against the exact scheme://host[:port] form a
browser sends, so a trailing slash or a stray path fails loudly instead of denying the origin it was
meant to allow.
gRPC-web cannot carry a client-streamed request, so GetTaddressBalanceStream is unreachable
from a browser; use the unary GetTaddressBalance. All server-streaming methods work.
contrib/grpc-web-smoke.html is a dependency-free page that calls
GetLightdInfo over this transport, for checking a deployment from a real browser. See
docs/ARCHITECTURE.md#grpc-web and
ADR 0026.
Prometheus metrics are served on /metrics at 127.0.0.1:9068 by default; override the address with
--metrics-bind or turn it off entirely with --no-metrics:
lightwalletd-rs ... # metrics on 127.0.0.1:9068
lightwalletd-rs ... --metrics-bind 127.0.0.1:9100
lightwalletd-rs ... --no-metricsWhat it reports: request counts by method and gRPC status (grpc_server_handled_total), a latency
histogram (grpc_server_handling_seconds), and how many client connections are open right now
(grpc_server_connections_current). The per-method series need traffic before they appear.
Method labels come from what this build serves, not from the request. Anything else a client asks
for lands in a single unknown bucket, so the traffic still shows up and the registry does not grow
a series per request (ADR 0035). --no-metrics
stops the recording too, not just the endpoint.
Because metrics are on by default, two instances on the same host collide on :9068: the second
instance logs an error for the failed bind and keeps serving without metrics rather than
exiting. Give each instance its own --metrics-bind address (or --no-metrics).
See docs/ARCHITECTURE.md#metrics.
gRPC Server Reflection is always registered, so grpcurl -plaintext <addr> list (and describe) work
against a running server with no local .proto checkout needed.
Logging defaults to human-readable text on stderr, controlled by --log-level (an explicit RUST_LOG
always wins). --log-file <path> switches to JSON lines appended to that file instead. See
docs/ARCHITECTURE.md#logging.
docker build -t lightwalletd-rs .
docker compose up # a zebra node + lightwalletd-rsdocker-compose.yml brings up a zebra node and the proxy in front of it, serving over TLS from a
certificate mounted at ./certs (see the comments in that file). The node syncs the chain on first run,
which takes hours and tens to hundreds of GB.
make build # compile
make test # unit + end-to-end tests
make lint # clippy -D warnings
make fmt # check formatting
make verify # fmt + lint + build + test (the pre-commit check)make test runs the unit tests and a suite of deterministic end-to-end tests (tests/) that drive an
in-process darkside server over gRPC with vendored, network-free data. contrib/smoke-test.sh is an
optional manual check that drives a live darkside binary with grpcurl and jq; it downloads data from
the internet, so it is not run in CI.
A fresh instance normally ingests the whole range from its own node before it can serve, which takes hours on mainnet. It can instead download that range from a peer that already holds it, verifying every block against its own node on the way in.
Consuming. Point a new instance at a peer:
lightwalletd-rs --snapshot-url https://peer.example:9069 [...]The import runs before the server starts serving and resumes from wherever the cache already reaches, so an
interrupted run continues at the epoch boundary it got to. It also stops at your own node's tip: nothing
above that can be verified yet, so the ingestor picks the rest up instead. A peer that is unreachable, out
of date or dishonest is not fatal: the server starts and ingests from the node as usual. Combining it with
--redownload means "discard the local cache, then bootstrap from the peer".
Every block is checked four ways before it is stored: the transferred bytes against the digest the peer published, the chain linkage within and across epochs, the note-commitment tree sizes against the outputs and actions each block carries, and every block hash against your own node. That last one is what ties the snapshot to the real chain, since a compact block's hash is asserted by the publisher rather than derivable from its contents.
One epoch body is held in memory at a time, and the largest on mainnet is 1.21 GB. The buffer grows by doubling, so the transient peak while it resizes is what to plan for: budget around 3 GB of free memory. A peer cannot make the importer hold more than 2 GB whatever its manifest claims.
Serving. Publishing is off by default and needs a restart:
lightwalletd-rs --snapshot-serve [--snapshot-bind 127.0.0.1:9069] [...]The blocks already ingested are the snapshot; nothing is re-fetched from the node and nothing is rewritten
on disk. On the first start the server computes one digest per completed 10,000-block epoch, throttled
against the ingestor, and publishes each epoch as it becomes available, so consumers can start importing
before that finishes. GET /snapshot/manifest lists what is available and GET /snapshot/epoch/{index}
serves one epoch body, compressed if the client asks.
A server can only serve the range it holds: an instance started with --start-height above Sapling
activation publishes a snapshot starting there, which only helps a consumer that wants the same window.
The chain has to match too; a testnet snapshot is rejected by a mainnet consumer at the manifest check,
before any epoch is fetched. Finally, a full mainnet snapshot is on the order of 50 GB, most of it in
the heavily shielded 2022-2023 range, which does not compress. Serving it is a real bandwidth
commitment.
Security note: --snapshot-bind defaults to loopback. Binding it to a public address lets anyone who
can reach it download the entire cache at whatever bandwidth the host will give them; the server warns at
startup when the address is not loopback. --snapshot-max-concurrent-downloads (default 4) bounds how many
downloads are served at once, and further requests get 429 rather than queueing.
See ADR 0024 for the trust model and the measurements behind it.
--darkside-very-insecure serves a controllable, in-memory mock chain instead of proxying a real node, for
deterministic wallet tests (reorgs, confirmations, edge cases). It exposes a DarksideStreamer control
plane alongside the normal CompactTxStreamer. Testing only, never in production. It shuts itself
down after --darkside-timeout-minutes (default 30) so a forgotten or leaked mock server does not run
forever. See docs/ARCHITECTURE.md#darkside-mode.
--donation-address u1... advertises a Zcash unified address in GetLightdInfo. Wallets read it to offer
users the option of donating to whoever operates the server; it is advisory only and carries no payment
logic. The address is decoded at startup, so a malformed or truncated one fails fast rather than being
served.
--ping-very-insecure enables the Ping gRPC, a benchmark/testing call. It is off by default: a client
controls both the sleep duration and the concurrency it observes, so leaving it open is a needless
denial-of-service surface.
A reproducible harness in contrib/bench/ measures the hot read-path (serving
compact blocks from a warm cache with the node idle) for both this implementation and the reference Go
lightwalletd, under identical resource limits, plus a separate
ingest/full-sync comparison against a real mainnet node. The comparison here is a deliberate,
method-first exception to this project's usual no-comparison stance; the read-path methodology is
recorded in ADR 0017, and the windowed ingestor itself in
ADR 0020.
Environment disclaimer. All numbers below (read-path and ingest alike) were captured 2026-07-13/14
on a Debian 12 host (kernel 6.1.0-40-amd64), AMD Ryzen 7 7840HS, 16 CPUs, 28 GiB RAM, against a real,
fully synced zebrad v6.0.0 mainnet node (JSON-RPC, no auth). Pinned versions: this tree 3dfb1c9; the
pre-windowed-ingestor OLD Rust baseline 3885827 (used only for the ingest A/B/C comparison); Go
fdf1af5 for the read-path harness container and 61fee32 (current master at run time) for the ingest
comparison; ghz v0.121.0; Docker 29.0.0 / Compose v2.40.3. The read-path harness still caps both
proxies at 2 vCPU / 2 GiB inside Docker Compose, but this run used native Linux Docker rather than the
harness's original Docker-Desktop-on-macOS-arm64 target, so it carries no VM overhead. Two zebrad
nodes (mainnet + testnet) ran throughout every measurement: nontrivial background CPU/disk load
shared by both implementations under test. Read every number below as relative, not absolute.
Two mainnet profiles are measured: dense (post-NU5, blocks 3,350,000–3,361,999, with Sapling/Orchard
activity) and light (pre-Sapling, blocks 20,000–31,999, no shielded content). Both proxies serve
identical compact blocks (see the fairness note below), so this is a like-for-like comparison. Numbers
below are the median over 5 reps (warm-up discarded) at each concurrency, from a full sweep of the
documented default curve (concurrency 1–64, REPS=5, DURATION=8s; tables from scripts/aggregate.py,
charts from scripts/plot.py).
Once concurrency exceeds 2, Rust dominates GetBlockRange throughput on both profiles
(dense: ~330k blocks/s plateau vs Go's 60k–140k; light: ~105k–146k vs Go's 7k–80k) and holds flat
GetBlock p99s under load, where Go's p99 blows out to 33–55 ms at concurrency ≥ 16. Go wins
low-concurrency (c = 1–2) dense range streaming and GetBlock p50 at moderate concurrency. Rust's redb
cache is larger on disk (~2.3× on both profiles); Go's peak RSS runs ~3× Rust's on dense. The throughput
charts use a 1,000-block GetBlockRange window per request (W = 1000); the harness also records 100- and
10,000-block windows.
| impl | peak RSS (MiB) | cache on disk (MiB) | max CPU (cores) |
|---|---|---|---|
| rust | 79.2 | 45.8 | 2.00 |
| go | 250.5 | 19.5 | 2.03 |
Full numbers: GetBlock latency and GetBlockRange throughput (W = 1000)
GetBlock latency, median p50 / p99 (µs):
| concurrency | rust p50 | rust p99 | go p50 | go p99 |
|---|---|---|---|---|
| 1 | 242 | 377 | 247 | 454 |
| 2 | 152 | 354 | 115 | 280 |
| 4 | 115 | 236 | 125 | 346 |
| 8 | 141 | 306 | 174 | 778 |
| 16 | 307 | 664 | 234 | 5897 |
| 32 | 669 | 1284 | 392 | 55552 |
| 64 | 1496 | 2657 | 807 | 53214 |
GetBlockRange throughput, median blocks/s (W = 1000):
| concurrency | rust | go |
|---|---|---|
| 1 | 28,971 | 55,499 |
| 2 | 66,124 | 59,123 |
| 4 | 215,960 | 60,251 |
| 8 | 323,195 | 69,118 |
| 16 | 323,573 | 70,638 |
| 32 | 318,823 | 80,898 |
| 64 | 321,345 | 87,159 |
Because pre-Sapling blocks carry no Sapling/Orchard data, GetBlockRange (shielded-only by default)
serves near-empty blocks here; this profile measures the framing/overhead floor of the wallet-sync path.
The full blocks still carry heavy transparent transaction data (many vin/vout plus per-transaction
overhead) and this range averages 4.18 txids/block, so GetBlock (which returns the full compact
block) and the on-disk footprint are larger than on dense, not smaller.
| impl | peak RSS (MiB) | cache on disk (MiB) | max CPU (cores) |
|---|---|---|---|
| rust | 204.0 | 257.0 | 2.16 |
| go | 262.9 | 109.2 | 2.04 |
Full numbers: GetBlock latency and GetBlockRange throughput (W = 1000)
GetBlock latency, median p50 / p99 (µs):
| concurrency | rust p50 | rust p99 | go p50 | go p99 |
|---|---|---|---|---|
| 1 | 1125 | 1509 | 1211 | 1795 |
| 2 | 955 | 1937 | 458 | 1225 |
| 4 | 436 | 1082 | 494 | 988 |
| 8 | 596 | 1051 | 656 | 2999 |
| 16 | 865 | 2116 | 1100 | 34003 |
| 32 | 1555 | 5098 | 2076 | 36411 |
| 64 | 2870 | 11162 | 3953 | 33827 |
GetBlockRange throughput, median blocks/s (W = 1000):
| concurrency | rust | go |
|---|---|---|
| 1 | 41,847 | 7,375 |
| 2 | 61,617 | 7,875 |
| 4 | 110,120 | 13,373 |
| 8 | 111,988 | 13,498 |
| 16 | 111,090 | 15,877 |
| 32 | 113,337 | 19,875 |
| 64 | 118,177 | 24,760 |
- Fairness (identical blocks).
populate.shverifies this on every run and refuses to proceed on a mismatch: theGetBlockRangestream over each full range (12,000 blocks per profile, 24,000 total) hashes identically between Rust and Go (content identity: the responses decode to the same messages, not a wire-byte claim). The unaryGetBlockpath was additionally spot-checked at sampled heights. - Dual source of truth. Client-side (
ghz) and server-side (grpc_server_handling_seconds) both corroborate the same ordering; for streamingGetBlockRangethe two servers time the handler differently (tonic returns the stream lazily and records only stream setup;grpc_prometheustimes the full drain), so the client-side throughput above, whereghzdrains the whole stream identically for both, is the comparable measure. - Saturation. Both proxies approach their 2-vCPU cap at high concurrency (measured ~2.0–2.16 cores; cgroup CPU accounting reads a few percent over the cap from sampling jitter, e.g. light Rust's 2.16), so the upper curve is a saturation regime, not linear scaling.
- Cache on disk. Measured after population. The Rust
redbcache is larger than Go's flat append-only files on both profiles (~2.3×): B-tree overhead, most visible on the transparent-heavylightprofile. The dense Rust figure (45.8 MiB) carries some churn from interrupted first-populate attempts under this run; a pristine single-pass ingest may land somewhat smaller. - Fidelity. The smaller
GetBlockRangewindow (W = 100) is noisier than W = 1000 / 10000; see the ± spread in the rawaggregate.pyoutput. The harness records the full curve (W ∈ {100, 1000, 10000}); reproduce withrun-bench.sh, thenaggregate.py(tables) andplot.py(charts).
The windowed concurrent ingestor (ADR 0020)
fetches up to --ingest-window blocks (default 64) with --ingest-concurrency concurrent node requests
(default 8) and commits each window in one cache transaction, instead of one round-trip and one fsync
per block. It was benchmarked three ways against the same live mainnet zebrad: A/B/C fixed
480-second ingest windows at three start heights (this tree vs the pre-windowed OLD Rust baseline vs Go);
B4 a complete genesis-to-tip wall-clock sync; B1 a tuning sweep; B2 read-path latency while
the ingestor runs flat out.
A/B/C: blocks ingested in a fixed 480 s window, one implementation at a time.
| range | start height | NEW Rust blocks (b/s) | OLD Rust blocks (b/s) | Go blocks (b/s) |
|---|---|---|---|---|
| R1 modern pre-spam | 1,500,000 | 238,912 (497.7) | 26,401 (55.0) | 169,016 (352.1) |
| R2 sandblasting | 1,780,000 | 17,856 (37.2) | 2,701 (5.6) | 3,170 (6.6) |
| R3 recent | 3,300,000 | 111,353 (232.0)* | 26,701 (55.6) | 111,367 (232.0)* |
* R3 is tip-capped for NEW Rust and Go: both fully caught up to the live chain tip inside the 480 s window and then idled (NEW Rust's effective catch-up before tip was ≈297 blocks/s, Go's ≈281 blocks/s); the OLD baseline was nowhere near tip-capped. NEW vs OLD Rust: 9.0× (R1), 6.6× (R2), 4.2×+ (R3, capped). NEW Rust vs Go: 1.41× (R1), 5.6× (R2, the sandblasting era's heavy shielded blocks), parity on R3 only because both were tip-capped. Zero txid/hash-mismatch errors across all nine runs.
Full sync, genesis to tip, default settings: NEW Rust completed in 4,950 s (1 h 22 m 30 s), 689 blocks/s overall, 42.0 GiB final cache (13.2 KB/block). Go did not finish inside an 8-hour cap: stopped at 2,046,039 / 3,412,340 (59.9%), 71.0 blocks/s to that point, 25.6 GiB cache (13.4 KB/block); extrapolating its own measured post-spam rates over the remaining, entirely-post-spam chain gives ≈34,000–36,000 s (≈9.5–10 h) total. Both runs were error-free.
The windowed ingestor is worth ≈7× on a full sync, and nearly the entire gap sits in the sandblasting segment (1.5M→2.0M): Rust crossed it at 148 blocks/s (3,370 s) against Go's 22 blocks/s (23,181 s), a 6.9× gap. Rust's own non-spam segments ran 1,000–2,400 blocks/s, and Go's pre-spam segments 310–374 blocks/s; both are node-bound per request.
Tuning --ingest-window / --ingest-concurrency. A 12-cell sweep at the sandblasting start height
(node-bound, where client-side pipelining matters most) found concurrency is the dominant knob and keeps
paying past 8: at the default window 64, going 8→16 concurrency is +37% and 16→32 is another +6%; at
window 256 the same steps are +48% and +5%. Window size alone (at fixed concurrency) is nearly free,
moving throughput only ±10% from 16→256, because it mostly caps useful concurrency (at
window 16, the 16→32 concurrency step is exactly flat, since a 16-block window can never have more than
16 fetches in flight). The default 64/8 is conservative but sound (within 3% of the concurrency-8
ceiling); an operator catching up through a spam-era range on a well-provisioned node can get ~1.6× the
default throughput at 256/32 (59.7 vs 36.3 blocks/s), at the cost of higher peak RSS and 4× the
outstanding RPC load on the node.
Read-path latency under active sync (B2). GetBlock at concurrency 4 while the ingestor runs flat
out stays sub-millisecond for both implementations: p50 0.157 ms / p99 1.547 ms for Rust, p50 0.157 ms /
p99 1.391 ms for Go, both around 13k req/s. Idle (no ingest activity), Rust is fastest overall: p50
0.098 ms, 23,144 req/s, ~14% above idle Go (p50 0.111 ms, 20,335 req/s). Active sync costs both
implementations about the same (~1.6× on p50, ~2× on p99 vs idle), consistent with shared CPU/RPC-node
contention with a flat-out ingestor rather than a serving-path stall; Rust's spawn_blocking read path
holds up under load as designed.
Caveats.
- Shared-host load. Every ingest and read-path number above was captured with two
zebradnodes (mainnet + testnet) syncing/gossiping in the background, and Part B's node was simultaneously serving the ingestor under test. Comparisons are like-for-like under identical load, but absolute rates include this noise. - Go's full-sync total is extrapolated, not measured: it hit the 8-hour cutoff at 59.9% of the chain. The extrapolation uses Go's own measured post-spam rates (all directly observed elsewhere in this same benchmark run), and 95% of the remaining work sits in ranges where that rate was directly measured, but it is still a projection, not a completed run.
- R3 tip-capping means the R3 row above measures catch-up-to-tip-then-idle, not sustained ingest throughput at that height; the effective catch-up rates noted above are the more representative number for that range.
- Aggressive tuning has a resource cost the throughput numbers alone don't show: over a 300 s sandblasting sample, Rust's default 64/8 settings held ~2 cores and up to 801 MiB peak RSS against Go's ~0.4 cores and 37 MiB. Raising concurrency further trades more memory and RPC load for more throughput.
- Reproduce with
contrib/bench/scripts/plot.py contrib/bench/results contrib/bench/chartsfor the read-path charts (seecontrib/bench/README.md); the ingest/full-sync numbers are hand-transcribed fromcontrib/bench/results/mainnet-2026-07-summary.mdandcontrib/bench/results/mainnet-2026-07-phase2.mdinto a clearly marked data block at the top ofplot.py, since the raw multi-hour sync logs are not committed.
docs/ARCHITECTURE.md: what it is, how data flows, and the responsibility of each module.docs/decisions/: architecture decision records, the why behind the design.docs/protocol-references.md: the ZIPs, BIPs, and spec sections each module implements.CHANGELOG.md: release notes.SECURITY.md: how to report a vulnerability.
lightwalletd-rs is inspired by and indebted to the original Go
lightwalletd. Its protocol, behavior, and years of accumulated
design decisions were the reference this implementation followed; this project would not have been
possible without it. Thanks to the Zcash community that built and maintains it.
Licensed under the MIT License.