zingod indexes the Zcash chain from one or more trusted Zebra or Zakura validators and serves
it to light wallets over lightwalletd-compatible gRPC. With [p2p] on it also takes checked
headers, blocks and mempool listings from Zcash peers
(architecture §2–3).
The container image: docker.md. The methods it answers: rpc_api.md.
A validator with JSON-RPC enabled:
- Zebra (
zebrad):[rpc] listen_addrinzebrad.toml. The live suite tests against zebrad 6.2.3. - Zakura (
zakurad, a Zebra fork):[rpc] listen_addrinzakurad.toml. Its installer and guides are at https://zakura.com/, its image at https://hub.docker.com/r/zakuracore/zakura.
Zingod reads a validator through the Zcash JSON-RPC interface, which the Zcash RPC reference at https://zcash.github.io/rpc/ specifies. It calls ten methods of that interface:
| Method | Specification |
|---|---|
getblock |
https://zcash.github.io/rpc/getblock.html |
getblockchaininfo |
https://zcash.github.io/rpc/getblockchaininfo.html |
getblockhash |
https://zcash.github.io/rpc/getblockhash.html |
getblockheader |
https://zcash.github.io/rpc/getblockheader.html |
getdeprecationinfo |
https://zcash.github.io/rpc/getdeprecationinfo.html |
getinfo |
https://zcash.github.io/rpc/getinfo.html |
getpeerinfo |
https://zcash.github.io/rpc/getpeerinfo.html |
getrawmempool |
https://zcash.github.io/rpc/getrawmempool.html |
getrawtransaction |
https://zcash.github.io/rpc/getrawtransaction.html |
sendrawtransaction |
https://zcash.github.io/rpc/sendrawtransaction.html |
Zakura 1.8.4 lists all ten in the OpenRPC document of its authenticated listener and of its restricted, unauthenticated one: https://github.com/zakura-core/zakura/blob/v1.8.4/crates/zakura-rpc/src/methods/tests/openrpc.json, https://github.com/zakura-core/zakura/blob/v1.8.4/crates/zakura-rpc/src/methods/tests/openrpc-restricted.json.
An eleventh call, checkadmission, is Zingod's own and optional
(architecture §6).
cargo build --release -p zingod # produces target/release/zingod
zingod generate-config # writes every key at its default
zingod start # reads $XDG_CONFIG_HOME/zingod/zingod.toml
zingod start -c /path/to/zingod.toml
zingod verify # read-only page-checksum scrub
zingod hold # out of rotation until `zingod resume`
zingod overload off # override the overload detector-c, --config <FILE> works before or after any subcommand; zingod --help describes each.
Environment variables prefixed ZINGOD_CONFIG_ win, with __ separating nested keys
(ZINGOD_CONFIG_SYNC__CONCURRENCY=64 sets [sync] concurrency); then the TOML file; then the
built-in defaults. An unknown key, or any value zingod start refuses, fails the load for every
subcommand. Default paths sit under the user's XDG base
directories; with no home directory (e.g. HOME unset under a service manager) the load fails
naming each path to set: serve.state_dir, every enabled index.<name>.path, and
p2p.cache_dir when [p2p] is enabled (and --config for the file itself).
example_configs/zingod.toml documents every key and its
default. In outline:
| Key or table | For |
|---|---|
network |
mainnet (default), testnet or regtest, declared rather than read off the validator |
validator_client |
legacy_rpc (default) or grpc (below) |
[metrics] |
the admin listener's listen_address (unset = off; see Network exposure) |
[[trusted_validators]] |
one table per validator: address, credentials, timeouts, link budget, indexer_address, priority tier |
[submission] |
when SendTransaction relays again (architecture §6) |
[p2p] |
Zingod's own Zcash peers, off by default |
[serve] |
gRPC listen address, [serve.tls], state_dir, hold_goaway_delay_secs |
[grpc] |
connection, stream and read caps, connection lifetimes, trusted_proxies, [grpc.overload] |
[sync] |
the shared block-fetch pipeline and finality depth |
[index.<name>] |
per index: enabled, path (at least one on; utxo_set comes with compact_block) |
[snapshot] |
fill empty indexes from an index snapshot |
There is no vote among trusted validators: each one added adds failover, so two is the first set that survives one being down.
validator_client picks one client type for every [[trusted_validators]] entry:
legacy_rpc(default): JSON-RPC for everything, zebrad, zakurad or zcashd.grpc: blocks over zebrad's indexer gRPC at each entry'sindexer_address, the rest over JSON-RPC. It needs a zebrad whose indexer servesGetBlockRange(Zingod's zebra fork; stock zebrad lacks it). An entry withoutindexer_addressfails boot. Uselegacy_rpcwith zakurad: itsGetBlockRangestreamsBlockAndHeight, where Zingod readsBlockAndHash(https://github.com/zakura-core/zakura/blob/v1.8.4/crates/zakura-rpc/proto/indexer.proto).
The indexer gRPC is plaintext unless [trusted_validators.indexer_tls] sets up mutual TLS.
example_configs/ holds a zebrad and a zingod config for the Public
Testnet, set up against each other (zebrad's JSON-RPC on 127.0.0.1:18232).
- Install zebrad with
cargo install zebrad --locked. - Replace
<PATH_TO_ZEBRA>inzebrad.tomland<ZINGOD_DATA>inzingod.tomlwith writable directories. - Run
zebrad -c docs/example_configs/zebrad.toml start. - In another shell, run
cargo run --release -p zingod -- start -c docs/example_configs/zingod.toml.
zingod then serves gRPC on 127.0.0.1:8137.
Each index syncs from genesis (or from an index snapshot), so a first run on Mainnet or Testnet takes a long time; a restart resumes from each index's durable height. It serves while it syncs (what each method answers meanwhile).
Plan for the whole chain's worth of indexes on local storage, preferably NVMe: the indexes are
read through mmap. Every index file carries page checksums: zingod verify
finds a damaged index, then delete its directory and let it resync.
With aria2c on PATH (the Docker image and the Nix package ship it), zingod fills empty
indexes from a published index snapshot before it boots:
[snapshot]
manifest = "https://snapshots.zingolabs.dev/zingod-snapshot-1.0.0/manifest.json"
connections = 8 # total across every archive, at most 16The manifest, {"network", "height", "indexes": {<index>: {"archive", "sha256", "archive_bytes", "installed_bytes"}}}, lists one archive per index, all from one seed at one height. Each
archive is a zstd tar of that index directory's contents (no top-level directory); archive
resolves against the manifest URL, installed_bytes = its unpacked size.
- Only an enabled index whose directory is missing or empty is filled; one holding data is never touched. With none empty nothing is downloaded. An empty index the manifest has no archive for stops startup.
- Free space is checked per filesystem before any download; too little stops startup and names the directory and the bytes needed.
- Downloads retry transient errors without limit, and a restart resumes each archive at the step it stopped in (download, unpack, install). A manifest for another network, a hash mismatch or a permanent download error stops startup.
While it runs the state is provisioning, with its progress in detail.
zingod reports one state, the same on every surface: /readyz (admin and gRPC listeners), the
public /status, the zingod_state{state} gauge and the State changed log line. Each reads one
GrpcSnapshot (architecture §8). The state answers
three questions in order: can the node answer at all (availability); is what it serves validated
by a trusted validator (trust); how far is that from the network's best (currency). Trust is
judged on the served tip, what a wallet gets, never on the best block alone. The first rule
that matches wins:
state |
When |
|---|---|
held |
an operator ran zingod hold |
down |
the serving runtime stalled (no heartbeat for 30 s), boot waits on its first validator answer, overloaded, or the served tip is unvouched and stale |
provisioning |
not booted yet: an index-snapshot bootstrap, or plain startup |
degraded |
the served tip is unvouched but fresh (p2p peers carried it) |
syncing |
the served tip is not the best yet, or a validator's mempool is off and none is on (zebrad's, for minutes after a catch-up) |
serving |
otherwise |
- Unvouched = no trusted validator has had the served block on its best chain, allowing one block past the last vouch (the race a validator's next poll closes); or every trusted validator is down under a stale best. Fresh = the served block's time within 24 target spacings of the clock.
- A vouched served tip behind an unvouched best (a reorg's window, architecture §4)
is
syncing, with thetip unvouchedalarm raised: a currency gap, not a trust gap. /readyzanswers200only forservinganddegraded. A degraded node keeps serving so a load balancer keeps routing to it while every trusted validator is down; alert on the state, not on/readyz. Requests are never refused forsyncing.detail(null= nothing measured yet):provisioning:{phase, bytes, total_bytes, bytes_per_sec}, the snapshot's download, verify and unpack;nullon plain startup.syncing:{height, target, blocks_per_sec}: the served height, the verified best (or a catching-up validator's estimate when higher), and the rate over the last ~60 s.serving:{start, mbps}: gRPC egress over the last closed UTC 10-minute window (startin unix seconds, 3 significant figures, under 0.1 published as 0).degraded,down,held:{reason, message},reasonone ofno_validator_reachable,tip_unvouched(degradedonly),tip_stale(downonly),runtime_stalled,overloaded,goaway_pending,goaway_sent. No reason is shared between states.
| Listener | Path | Answers |
|---|---|---|
| gRPC | /readyz |
{"state", "detail"}; 200 or 503 |
| gRPC | /status |
the public projection below, always 200 |
| admin | /readyz |
as on the gRPC listener |
| admin | /livez |
503 once the serving runtime misses its heartbeat for 30 s |
| admin | /metrics |
Prometheus exposition |
The gRPC listener answers both paths over HTTPS/1.1 or h2 for load-balancer monitors, from
process start (before an index-snapshot bootstrap or boot), so they answer provisioning or
down from the first second; until boot finishes every gRPC method answers UNAVAILABLE.
/status is safe for the open internet: it carries no validator address, peer, disk, mempool or
tailnet name.
{"state": "serving", "detail": {"start": 1790000400, "mbps": 12.5},
"chain": {"network": "mainnet", "tip": {"height": 3515406, "hash": "0000…"}, "served": 3515400,
"validators": {"trusted": 2, "answering": 1}, "alarms": []},
"build": {"version": "1.0.0"},
"indexes": {"compact_block": ["GetBlock", "GetBlockRange", "GetBlockRangeNullifiers"], "utxo_set": []}}tip= the verified best block,served= the height RPCs answer at (nullbefore boot, as isvalidators).answeringcounts trusted validators live or catching up.alarms: one sentence per raised alarm (architecture §11).indexesmaps each configured index to the RPCs it gates. An RPC answers when the node is ready and it names no index or its index is listed.
Every metric carries its HELP text. Every histogram is a Prometheus native histogram, carried
only by the protobuf exposition: /metrics answers protobuf when the scrape's Accept offers
application/vnd.google.protobuf (Prometheus or Alloy with scrape_native_histograms), else the
text format, which has every counter and gauge but no histogram. zebra-network's own series are
cut to a summary with no peer identity. Filesystem free space and block-device I/O are per host:
read them from the node exporter or cAdvisor.
zingod hold takes a node out of rotation until zingod resume, across restarts, deploys and
reboots. It is the only drain: a shutdown signal closes the gRPC listener at once, so a planned
stop is zingod hold, then systemctl stop.
holdwrites the marker<[serve] state_dir>/held;resumeremoves it. There is no network endpoint: holding a node requires write access to its state directory.- The running zingod checks the marker every second, from process start. Held, it reports
heldabove every other state (stopping included),goaway_pendingand thengoaway_sentonce[serve] hold_goaway_delay_secshas passed. At that point every gRPC connection, and each one accepted while held, gets an HTTP/2 GOAWAY, its in-flight streamsmax_connection_age_grace. Set the delay to at least the time your load balancer takes to mark a server down, plus its DNS TTL. - A held process keeps running and answering its health paths and metrics. On resume, a node
that fell behind reports
syncingfirst. - A stopped or killed zingod is never reported
held, only absent, so a dashboard can tell a planned removal from a crash. holdblocks until the running zingod reportsgoaway_sent(with[metrics] listen_addressset) or for the longest it can take to send its GOAWAYs, then printssafe to stop. Both commands print the state on stdout, what changed on stderr, and are idempotent.
/readyz reports down with reason overloaded while a node is persistently short of capacity
(causes and [grpc.overload]), so a load balancer
moves traffic off it. zingod overload off | on | set <key> <value> | unset <key> overrides that
with markers in <[serve] state_dir>/overload/, which survive restarts like held; the running
zingod applies them within a second and logs a WARN naming them.
Run under Type=notify and zingod reports its own startup. Without NOTIFY_SOCKET (any other
service manager, a container, a shell) nothing is sent; it must be a filesystem path, not an
abstract @ socket.
[Service]
Type=notify
ExecStart=/usr/bin/zingod start --config /etc/zingod/zingod.toml
Restart=always
# Budget for a start that is not moving (opening indexes, reaching the validator)
TimeoutStartSec=15minREADY=1is sent the first time/readyzpasses, or once the node is held.systemctl startandrestartreturn only once zingod serves at the tip, so a deploy that cannot catch up shows as a failed unit.- Until then each 10 s check that saw progress (snapshot bytes, the fetch, served or an index
height) extends the start by 5 minutes, so
TimeoutStartSecbounds only a stalled start. Units ordered after zingod wait for it;systemctl start --no-blockreturns at once. TimeoutStopSecmust cover[grpc] shutdown_grace_secsplus each index's final commit.
Boot fails with exit code 1 if a [[trusted_validators]] entry cannot make a client, the gRPC
address cannot be bound (before any index-snapshot download) or an index cannot open. On mainnet
and testnet no validator needs to answer for boot to finish; regtest reads its upgrade schedule
from the first validator that answers.
SIGINT or SIGTERM closes the gRPC listener at once, sends every open connection a GOAWAY, waits
up to [grpc] shutdown_grace_secs for in-flight streams, lets each index commit what it holds,
and exits 0, during boot too. Behind a load balancer, run zingod hold first.
zingod never restarts in-process and stops on any fault it cannot vouch past
(failure policy), so run it under a service manager
that restarts it with backoff. Opening an index checks each file's length, tail page and .crc
file, never the data pages, so a restarted zingod serves again within seconds, once the NFS has
fetched every block above the durable tips again (logged as Fetching non-finalized blocks).
zingod verify -c /etc/zingod/zingod.toml reads every file each enabled index's MANIFEST
seals against its page checksums
(zingod-persistence: Verifying offline).
It is safe beside a live daemon: plain reads, no mmap, no lock, nothing written. Bytes past a
file's seal (a live writer's next batch) count as orphaned_bytes, never as corruption.
The report is JSON on stdout, a summary per index on stderr; exit codes are in --help. Per
index: heights = durable tip + 1, one entry per sealed file with lost, bad_sums (the .crc
file no longer matches its manifest digest) and bad_pages (4 KiB pages whose CRC disagrees);
null = disabled. A corrupt or lost file means delete that index's directory and resync it.
zingod listens on two interfaces:
- gRPC (
[serve] grpc_listen_address):[serve] tlspicks how it is served. Unset, a loopback address serves plaintext (a wallet on the same host) and any other address refuses to start untiltlsis set.tls = "off"serves HTTP/2 in the clear off loopback too, for a TLS-terminating proxy on a trusted network, with a warning at startup. Its/readyzand/statusare safe for the open internet. - Admin (
[metrics] listen_address, off by default): no authentication or encryption, and the metrics publish the chain tip, traffic and validator addresses. zingod warns at startup when it binds a non-private address. Keep it on loopback or a network only the scraper reaches.
Outbound, each validator link is plain HTTP JSON-RPC.
[serve.tls]
cert_path = "/etc/zingod/fullchain.pem" # chain, leaf first
key_path = "/etc/zingod/key.pem" # PKCS#8, PKCS#1 or SEC1A bad pair fails startup. A renewed pair is picked up within a minute with no restart, so an ACME client renewing in place needs no hook; one that fails to load keeps the previous pair serving.
With tls = "self_signed", zingod writes cert.pem and key.pem (mode
0600, ECDSA P-256, no expiry) under <state_dir>/tls and serves that pair from then on; each
startup logs its sha256: fingerprint for a client that pins it. No CA signs it, so a wallet
verifying against system roots (zingolib among them) refuses the connection: wallets at large
need a CA-issued pair. Its names are localhost, the loopbacks and grpc_listen_address's own address;
delete cert.pem to have one re-issued for the kept key, for instance after the address changes.
A cert.pem with no key beside it fails startup rather than being replaced.
The [grpc] caps refuse rather than queue, except the read lanes; each is described in the
example config and in zingod-grpc: Serve stack.
No stream has a cap on its total length, so a full sync streams for as long as it keeps
receiving. Two [grpc] bounds catch a stream that makes no progress: unread_timeout_secs (its
client stopped reading: the connection closes) and silent_timeout_secs (nothing to send: the
stream ends UNAVAILABLE; GetMempoolStream exempt).
Behind a proxy every connection comes from the proxy's address, so max_connections_per_ip
would cap the whole server rather than each client. List the proxy in trusted_proxies and have
it send a PROXY protocol header
(v1 or v2) naming the client:
[serve]
tls = "off" # the proxy terminates TLS
[grpc]
trusted_proxies = ["10.0.0.5/32"]A connection from a trusted address MUST open with the header, or it is closed; any other
address is served without one. With nginx, terminate TLS in the stream module and pass the h2c
through with proxy_protocol on; (the http module's grpc_pass cannot send the header):
stream {
server {
listen 443 ssl;
ssl_certificate /etc/ssl/zingod.pem;
ssl_certificate_key /etc/ssl/zingod.key;
ssl_alpn h2;
proxy_pass 127.0.0.1:8137;
proxy_protocol on;
}
}With HAProxy, use server zingod 127.0.0.1:8137 send-proxy-v2 in a mode tcp backend. A proxy
that cannot send the header needs trusted_proxies empty and max_connections_per_ip raised to
max_connections.
Every connection is a file descriptor. At boot zingod raises its soft RLIMIT_NOFILE to the
hard limit, then refuses to start if max_connections plus 1024 descriptors for index files and
validators does not fit. Raise the hard limit (systemd LimitNOFILE=, docker
--ulimit nofile=) or lower max_connections.
logfmt to stdout, filtered by RUST_LOG: what to watch.