Zinder is a self-hosted Zcash chain indexer for wallets, explorers, and application backends. It runs on top of a Zebra full node and exposes a stable gRPC data plane that pins every read to a single chain epoch, so a sync batch never mixes data across competing tips.
It speaks two protocols on the same data: a native WalletQuery API for new clients, and a CompactTxStreamer compatibility surface for existing lightwalletd wallets like Zodl. Zallet and other Rust consumers integrate through a typed client crate.
Zinder reads chain data from a Zebra node managed by the Z3 platform stack. Bring up Z3 first, then attach zinder. Both can run on a laptop; there is no separate Z3 instance to provision.
Prerequisites. Docker (or Docker Desktop) with Docker Compose v2, plus a clone of this repo.
1. Bring up the Z3 stack (one-time, from your Z3 checkout):
# testnet (fast; ~10 GB chain)
git clone https://github.com/ZcashFoundation/z3.git && cd z3
Z3_NETWORK=testnet docker compose up -d
# or mainnet (~256 GB chain; expect a long initial sync)
Z3_NETWORK=mainnet docker compose up -dThis creates the z3-<network> Docker network and the z3-<network>-cookie volume that zinder attaches to.
2. Bring up zinder (from this repo):
# testnet
docker compose --env-file deploy/.env.testnet -f deploy/docker-compose.yml up -d
# or mainnet
docker compose --env-file deploy/.env.mainnet -f deploy/docker-compose.yml up -dThe first up -d does two things in sequence:
- Builds
zinder-ingest:latest,zinder-query:latest, andzinder-explorer:latestlocally from the shareddeploy/Dockerfilebuilder. The first target compiles every shipped runtime binary once; later targets reuse the same BuildKit Cargo cache. - Starts
zinder-ingest,zinder-query, andzinder-explorer. The writer's unified ingest loop probes Zebra's tip, classifies the gap, and dispatches the right phase:BulkCatchupat 32-way pipelined fetches and up to 1,000 blocks per epoch on a cold store (about 30 to 60 minutes on testnet, several hours on mainnet), then transitions toTipFollowautomatically once the gap closes through the reorg window. No bootstrap step, no separate one-shot service.
From another terminal:
docker logs -f zinder-ingest # full event log
docker logs zinder-ingest | grep ingest_phase_changed # phase transitionsSubsequent up -d calls reuse the existing store. The loop picks up from wherever the writer left off: seconds of work in TipFollow after a brief restart; a return to BulkCatchup after a long absence until the gap closes.
For the phase model, the upstream-sync diagnostic, alternative deployment shapes (bare-metal, Kubernetes), and forked-store recovery, see Initial sync. For the architectural relationship between Zinder and Z3 (or any future upstream platform), and how observability federation works across the two stacks, see Upstream platform binding.
Observability is on by default. Every up -d brings a zinder-prometheus container that scrapes the writer (zinder-ingest:9105/metrics) and the reader (zinder-query:9106/metrics) over a Zinder-owned Docker network. Metrics survive sync runs and restarts. Add --profile observability to also bring zinder-grafana (port 3002) with the bundled dashboards; skip the flag if you'd rather feed metrics into a sibling Grafana (Z3's, Grafana Cloud, etc.).
3. Verify both planes:
# Writer: "ready" once the loop's BulkCatchup phase has filled the store and
# the TipFollow phase has closed the last ~100 blocks of lag to Zebra's tip
curl -sS http://127.0.0.1:9105/readyz
# {"status":"ready","cause":"ready","current_height":4016431,"target_height":4016431}
# Reader: always "ready" as soon as the writer has committed anything
curl -sS http://127.0.0.1:9106/readyz
# {"status":"ready","cause":"ready","current_height":4016431,"target_height":4016431}
# Tail the writer as it follows the chain tip
docker logs -f zinder-ingestPoint a wallet at localhost:9101 (native WalletQuery gRPC) as soon as the reader is ready. The lightwalletd-compatible surface is a separate service shipped in the same workspace; see single-container deployment for the bundled topology that adds it.
Multiple networks side-by-side. Each --env-file produces an independently named stack (zinder-mainnet, zinder-testnet, zinder-regtest) with its own volumes and host ports. Mainnet uses the canonical ports above; testnet adds +10000 (19101, 19106, ...); regtest adds +20000. Bringing up another network is docker compose --env-file deploy/.env.<network> -f deploy/docker-compose.yml up -d — the existing stack keeps running.
Beyond Docker. See the VM runbook for systemd-managed deployments, the Railway runbook for hosted topologies, and the single-container image when you want one process tree instead of two containers.
- Wallet developers get a native
WalletQuerygRPC API alongside lightwalletdCompactTxStreamercompatibility. The wallet surface covers compact blocks, tree state, subtree roots, transaction lookup, typed broadcast, chain events, mempool views, and transparent-address artifacts. Capability strings advertise each feature so clients can negotiate explicitly rather than infer from version pins. - Application backends get epoch-consistent reads through a typed query API and a dedicated explorer plane (
zinder-explorer) serving block summaries, transaction details, mempool views, and typed search. Materialized views consume canonical artifacts and can be rebuilt without affecting wallet sync. - Rust consumers depend on
zinder-client, a typed crate exposingChainIndexwith two implementations selected by topology:LocalChainIndexfor colocated readers (RocksDB-secondary, no tonic round-trip) andRemoteChainIndexfor cross-host (gRPC). Zallet uses the same trait. - Operators get explicit
/healthz,/readyz, and/metricsendpoints with aphasefield (bulk_catchup,following_tip,awaiting_upstream) orthogonal to typed readiness causes (syncing,node_unavailable,upstream_not_ready,reorg_window_exceeded, others), so load balancers and incident response act on machine-readable state. Production configuration refuses to start with placeholder credentials, unsafe binds, or missing storage.
The deployment target is single-operator self-hosting backed by one Zebra node. Public endpoints sit behind operator-controlled TLS termination, authentication, rate limiting, and quota accounting.
- What Zinder is and is not: the first link new integrators should follow.
- Service boundaries: the boundary contract Zinder is built against.
- Integration surfaces: how wallet, application, explorer, and operator clients connect to Zinder.
- Architecture index: full documentation index.
Zinder is one product split into independently scalable planes. One process writes canonical chain state; every other plane reads from it through a typed contract. The boundary rule is enforced by review, not by code: ingest is the only writer, and reads pin to a single ChainEpoch so a sync batch never mixes data across competing tips.
flowchart LR
Zebra["Zebra node"]
subgraph zinder["Zinder"]
direction TB
Source["zinder-source<br/>(source adapters)"]
Ingest["zinder-ingest<br/>(sole writer)"]
Store[("zinder-store<br/>canonical RocksDB")]
Query["zinder-query<br/>WalletQuery API"]
Compat["zinder-compat-lightwalletd<br/>CompactTxStreamer"]
Explorer["zinder-explorer<br/>(optional)"]
Source --> Ingest
Ingest --> Store
Store --> Query
Compat --> Query
Ingest -. "ChainEvents subscription" .-> Query
Ingest -. "ChainEvents subscription" .-> Explorer
end
Zebra --> Source
Query --> NativeClients["Zallet, native wallets, Rust SDKs"]
Compat --> LightwalletdClients["Zodl, Android SDK, lightwalletd clients"]
Explorer --> Explorers["explorers, analytics"]
- Node source boundary (
zinder-source). All Zebra node coupling is isolated here. Adapters normalize upstream node observations intoNodeSourcevalues; no other crate imports Zebra or source-specific types, so a new source backend is a new module here rather than a workspace-wide refactor. See node source boundary. - Chain ingestion plane (
zinder-ingest). The only writer to canonical storage. Owns the unified ingest loop (bulk catch-up and tip-follow phases), reorg handling, artifact builders, and the atomic chain-epoch commit (commit_ingest_batch) that makes a new epoch visible. See chain ingestion, chain events, and ADR-0015. - Canonical storage (
zinder-store). RocksDB-backedPrimaryChainStoreandSecondaryChainStorerole handles exposed to services through the domain-shapedChainEpochReadApi. RocksDB types are private; the public read API is epoch-bound, so callers always resolve oneChainEpochbefore reading any artifact. See storage backend and ADR-0003. - Wallet data plane (
zinder-query). Read-only wallet and application API overWalletQueryApi, served as the nativeWalletQuerygRPC service. Owns compact block ranges, tree state, subtree roots, transaction lookup, transaction broadcast, the publicChainEventsproxy, mempool snapshots/events, and transparent-address reads. Never calls upstream nodes, never writes storage, never custodies keys. See wallet data plane. - Compatibility plane (
zinder-compat-lightwalletd). Translates the vendored lightwalletdCompactTxStreamercalls ontoWalletQueryApi. It opens canonical and derive storage only as read-only secondaries: no source client, no canonical writes, and no parallel artifact construction. New compatibility adapters must use the same shape. See protocol boundary. - Explorer plane (
zinder-explorer, optional). Serves block summaries, transaction details, typed search, mempool dashboards, and other explorer-shaped reads throughExplorerQuery. Owns materialized views (consuming canonical artifacts and theChainEventsstream) that cannot affect canonical state; any derived view can be discarded and rebuilt from canonical artifacts. See explorer plane and derive plane (the reusable SDK pattern this service exercises).
Two foundation crates are shared across every plane: zinder-core (chain vocabulary: ChainEpoch, BlockArtifact, Network) and zinder-proto (.proto files and generated wire modules, including the pinned vendored lightwalletd schemas). Every binary also exposes an operational HTTP surface (/healthz, /readyz, /metrics) with typed readiness causes; that contract is owned by zinder-runtime. See service operations.
For the full boundary contract, read service boundaries.
Domain crates under crates/ define stable contracts with no service runtime:
zinder-core: chain vocabulary (ChainEpoch,BlockArtifact,CompactBlockArtifact,Network).zinder-store: RocksDB-backed canonical storage. OwnsPrimaryChainStore,SecondaryChainStore,ChainEpochReader,ChainEpochReadApi,ChainEvent,StreamCursorTokenV1. RocksDB types are private; the public API is domain-shaped.zinder-source: upstream source adapters. OwnsNodeSource,NodeAuth,NodeCapabilities,ZebraJsonRpcSource,TransactionBroadcaster.zinder-proto: protocol ownership. Owns.protofiles and tonic-generated modules underv1::wallet, privatev1::ingest, and vendoredcompat::lightwalletd.
Deployable services under services/:
zinder-ingest: the only writer to canonical RocksDB. Owns the unified ingest loop (bulk-catchup + tip-follow phases, see ADR-0015), backup checkpoints, artifact builders, the private writer-status endpoint, and the upstream-source config CLI.zinder-query: read-only wallet and application API overChainEpochReadApi. ProvidesWalletQueryApi(Rust) andWalletQueryGrpcAdapter(tonic).zinder-compat-lightwalletd: translates the vendored lightwalletdCompactTxStreamergRPC service toWalletQueryApi.
Run before considering any change complete:
cargo fmt --all --check
cargo check --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo nextest run --profile=ci
cargo nextest run --profile=ci-perf
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps
cargo deny check
cargo machete
git diff --checkcargo nextest run is the canonical workspace runner. Tests are tiered by directory as documented in the Testing Runbook: T0 unit, T1 integration, T2 perf, T3 live, and consumer certification. The default/ci profile runs T0 and T1; ci-perf runs T2; ci-live runs upstream-node T3; ci-zallet-live runs the real Zallet binary gate when a Zinder-native Zallet build is available. cargo test continues to work as a libtest fallback (and is what cargo mutants shells), but is not the documented gate.
Heavier probes for trust-sensitive storage or parser changes:
cargo llvm-cov --workspace --all-features --no-report
cargo mutants --workspace --all-features \
--file crates/zinder-store/src/chain_store.rs \
--file crates/zinder-store/src/chain_store/validation.rs \
--file crates/zinder-source/src/source_block.rs \
--re 'chain_event_history|finalized_only_commit_without_artifacts|validate_reorg_window_change|from_raw_block_bytes'T3 live tests use the same env-var schema as production binaries and are double-gated by #[ignore = LIVE_TEST_IGNORE_REASON] plus a runtime require_live() check. Mainnet is rejected unless tests opt in by name. To run T3 against a local Zebra:
ZINDER_TEST_LIVE=1 \
ZINDER_NETWORK=zcash-regtest \
ZINDER_NODE__JSON_RPC_ADDR=http://127.0.0.1:39232 \
ZINDER_NODE__AUTH__METHOD=basic \
ZINDER_NODE__AUTH__USERNAME=zebra \
ZINDER_NODE__AUTH__PASSWORD=zebra \
cargo nextest run --profile=ci-live --run-ignored=allFor testnet, swap ZINDER_NETWORK=zcash-testnet and use Zebra cookie auth (the cookie file's user:pass split feeds ZINDER_NODE__AUTH__USERNAME and ZINDER_NODE__AUTH__PASSWORD). Mainnet live tests require an explicit mainnet opt-in as documented in the Testing Runbook.
Use the local observability smoke when a change needs visible runtime evidence rather than only test output:
scripts/observability-smoke.sh runIt starts Prometheus and Grafana, runs the Zinder host binaries against the
selected local node source, verifies checkpoint backup restore, generates native
and lightwalletd-compatible gRPC traffic, prints the scraped metric samples, and
writes readiness reports under .tmp/observability/reports. See
observability/README.md for public-network commands,
calibration runs, ports, tunables, and the stop command.