Chain backends
zecd talks to exactly one upstream at a time. By default that is a self-hosted Zebra full node over its stock JSON-RPC, with no lightwalletd and no zaino in the stack - and that remains the recommendation. Since 0.6.0 a lightwalletd gRPC endpoint is also accepted, for deployments where running a full node is not practical.
This page explains why the full node is the default, what zecd derives from it, the connection and security model of that hop, and what changes when you point it at a light server instead.
Version note. The light-mode option is 0.6.x and later. The 0.5.x line is zebra-only, and everything on this page except Light mode applies to it unchanged.
Why one full node and nothing else
zecd holds spend authority. Its entire view of the chain (balances, confirmations, incoming
payments) is whatever its upstream serves it, so the upstream is the trust root, and the design
goal is to make that trust root exactly one thing you run yourself: zebra -> zecd, two
processes, one compose file (see deployment).
Light-client infrastructure exists to serve many remote wallets from someone else's node: lightwalletd and zaino sit in front of a full node and re-serve compact blocks over gRPC to phones. zecd is the opposite shape: a single wallet server co-located with its own node. Putting lightwalletd or zaino between them would add a second daemon to deploy, monitor, and upgrade, a second failure domain, and a second codebase inside the trust boundary, in exchange for a data transformation zecd can do itself. So it does: everything a light-client server would provide (compact blocks, tree state, mempool visibility) is derived in-process from Zebra's existing RPCs.
The abstraction that keeps this a choice rather than a hard wire is the ChainSource trait
(src/chain/mod.rs): the sync engine, reorg recovery, rebroadcast loop, and 0-conf mempool flow
are all generic over it. That design paid off in 0.6.0, when the lightwalletd backend below
arrived as one more variant of AnySource and one more impl of the trait, with no changes above
it - the wallet, the RPC surface and the recovery model are identical on either backend.
What zecd derives from Zebra's RPC
Each ChainSource operation maps onto the same node RPCs lightwalletd itself uses
(src/chain/zebra.rs):
| Operation | Zebra JSON-RPC |
|---|---|
latest_block, server_info | getblockchaininfo (height, best hash, chain) |
compact_block_range | getblock verbosity=0 + getblock verbosity=1 (see below) |
tree_state | z_gettreestate (finalState hex, repackaged as the protobuf TreeState) |
subtree_roots | z_getsubtreesbyindex (per pool, from index 0) |
broadcast_tx | sendrawtransaction |
fetch_tx | getrawtransaction verbose=1 |
transparent_txids | getaddresstxids (batched addresses, height range) |
get_address_utxos | getaddressutxos (batched addresses) |
subscribe_mempool | getrawmempool + getrawtransaction, polled |
Compact blocks from getblock
Two RPCs per block. getblock verbosity=0 fetches the raw block by height; zecd parses it with
zcash_primitives and extracts the trial-decryption fields per transaction (Sapling
nullifier/cmu/epk, Orchard nullifier/cmx/epk, each with the 52-byte ciphertext prefix), the same
conversion lightwalletd performs. The parsed block's coinbase-claimed height is checked against
the requested height; a mismatch fails the stream. Then getblock verbosity=1 supplies the
note-commitment-tree sizes from its trees field, fetched by the parsed block's hash, not by
height, so a reorg between the two calls cannot pair one chain's raw bytes with another chain's
tree sizes.
The two calls for one block depend on each other, but the pairs for different blocks do not, so since 0.8.0 the stream keeps 32 blocks in flight and still delivers them to the scanner in height order; an error ends the range at the block that hit it. Every call to the node, from any path (block stream, memo drain, tip and mempool pollers), shares one budget of 64 in-flight calls: each is its own HTTP/1.1 request, and a node's JSON-RPC server refuses a burst past its connection limit rather than queueing it.
Genesis is never requested: zcash_primitives cannot parse the genesis block (no coinbase
height), so scan ranges never include height 0 and tree-state requests clamp to height 1 or
above.
When a wallet has transparent support enabled, the block stream also harvests every transparent output from the full block it already fetched, at no extra request; the wallet matches those against its own addresses to discover transparent receives. Compact blocks omit transparent inputs and outputs entirely, which is why this rides on the raw block.
Tree state and subtree roots
z_gettreestate provides the commitment-tree frontier at a height (used for wallet birthdays and
ChainState); z_getsubtreesbyindex provides all completed note-commitment-subtree roots per
shielded pool. Both are repackaged into the same protobuf shapes lightwalletd serves, so
librustzcash's TreeState::to_chain_state and AccountBirthday::from_treestate work unchanged.
Mempool
Zebra has no push stream, so ZebraSource synthesizes lightwalletd's GetMempoolStream
semantics with a poller, one per upstream since 0.8.0 (see connection model): every 2 seconds it re-reads getrawmempool, fetches each unseen txid
via getrawtransaction (deduplicating across polls), and yields the raw bytes. The stream
records the best block hash at subscription time and closes itself when getbestblockhash
changes. That close is load-bearing: it is the wallet actor's "sync now" signal, so a new block
triggers an immediate scan and a fresh subscription once caught up. Polling trades roughly the
poll interval of latency for the missing push stream; the 0-conf visibility it feeds
(getunconfirmedbalance, listunspent minconf=0) is described in
architecture.
Transparent address queries
For transparent-enabled wallets, librustzcash emits TransactionsInvolvingAddress requests to
find spends of UTXOs the wallet already holds (and to check ZIP-320 ephemeral addresses).
zecd services them with Zebra's always-on transparent address index: getaddresstxids over the
requested height range, one batched call for many addresses. Receive discovery is separate (the
block-scan matcher above); see transparent support.
Connection model
[backend] server resolves to a single endpoint (src/backend.rs). The token zebra (the
default) means zebra://127.0.0.1:8234 on mainnet and :18234 on testnet/regtest; point
zebrad's rpc.listen_addr there (Zebra ships with RPC disabled, and 8232/18232 are zecd's own
RPC ports). Any explicit zebra://host:port or bare host:port works. [zebra] holds the
node's RPC credentials: a cookie file (re-read on every connect, since zebrad regenerates it at
startup) wins over rpc_user/rpc_password; nothing set means no auth.
Since 0.8.0 one connection serves every wallet that resolves to the same endpoint
(src/chain/hub.rs). The hub owns the dial, the mempool subscription, the subtree roots, a
short-lived cache of the chain tip, and an LRU of fetched transactions, and hands each wallet
actor a handle onto them; before 0.8.0 each actor dialled the endpoint itself, so N wallets
meant N connections and N mempool pollers against one node. Compact block ranges pass through
uncached, because a cache keyed by height is not safe across a reorg. The dial (client construction plus one
getblockchaininfo round trip) is bounded by connect_timeout_secs (default 10). A dead
upstream is retried with exponential backoff and full jitter (src/backoff.rs): the wait is
uniform in [0, min(base * 2^attempt, max)], with reconnect_base_secs (default 1) and
reconnect_max_secs (default 60), resetting after a successful connection. Every unary request
carries a hard 30-second deadline and a 64 MiB response-size cap, so a node that accepts and then
hangs (or floods) cannot stall the sync engine.
The error contract separates transport from application outcomes. An Err from any
ChainSource method is transport-class: it invalidates that connection generation and the
next operation reconnects. Several actors reacting to one outage produce one re-dial, not one
each. Outcomes the node itself decided ride in Ok: a rejected broadcast comes back as a
non-zero BroadcastOutcome (surfaced to RPC callers as -26), and an unknown txid on
fetch_tx is Ok(None) (Zebra's -5 reply), neither of which kills the connection.
Connection state is observable. The resolved endpoint and a conn_state of down, syncing,
or ready ride on the wallet's SyncStatus and surface in three places: getpeerinfo (the
upstream appears as the single "peer", with conn_state as an extension field), the health
server's /status, and the /readyz failure reason. See
operations.
Through a SOCKS5 proxy (Tor)
New in 0.8.0. [backend] proxy = "socks5://host:port" (or --proxy) routes every
connection zecd makes through one SOCKS5 proxy, most usefully a local Tor daemon. It covers
both backends, and it is daemon-wide: wallets cannot override it.
- The proxy resolves the destination. zecd hands it a
host:portstring, never a resolved address, so no DNS query leaves the machine and a.onionupstream works. - TLS is layered over the proxied stream, with certificate verification still pinned to the
destination hostname, so the proxy carries lightwalletd traffic it cannot read. A
zebra://connection is plaintext HTTP, and stays plaintext on the proxy's hop to the node; zecd logs a warning for any non-loopback zebra endpoint either way. - No proxy authentication. A user or password in the URL is refused rather than ignored. Restrict the proxy by source address instead; a loopback listener is the usual arrangement.
- A loopback upstream names the proxy's loopback, not this machine's.
config checkwarns about that combination. - The cleartext-credential gate below still applies to the destination:
[zebra]credentials toward a.onionor other non-local host needallow_remote_cleartext.
[backend]
server = "https://lwd.example.onion:443" # or zebra://<host>.onion:8234
proxy = "socks5://127.0.0.1:9050" # Tor's default SOCKS port
Local-only by design: the cleartext-credential gate
The hop to Zebra is plaintext HTTP. That is fine for the intended topology (same host, same
container network) and removes an entire TLS/CA surface from the reproducible build, but it means
the [zebra] Basic-auth header would cross the network in the clear. So ZebraClient::new
refuses to send credentials to a host that is not local (host_is_local in
src/chain/zebra.rs), before any network I/O:
- Loopback (
127.0.0.1,::1,localhost) is always local. - Private, non-globally-routable ranges (RFC1918, link-local, CGNAT, IPv6 unique-local and
link-local, including their IPv4-mapped forms) count as local by default: the self-hosted
Docker and LAN norm. Set
[backend] rfc1918_is_local = falsefor a strict loopback-only posture. - Any other hostname fails closed. The gate does no DNS lookup, so a name like
zebra.example.comis treated as non-local even if it would resolve to a private address. - A credentialed connect to anything non-local fails at startup with an error naming the
override:
[backend] allow_remote_cleartext = true(defaultfalse). Set it only when the hop is secured out of band (an SSH or WireGuard tunnel, a private overlay network).
Connections without credentials are always allowed, to any host: chain data is public, and
there is nothing to disclose. This is why the documented Docker stack (hostname zebra, no
[zebra] auth configured) works unchanged despite the fail-closed hostname rule.
The gate protects the credentials, not the chain data. Trusting a remote node with your wallet's chain view is a separate decision the threat model argues against regardless of transport.
[backend]
server = "zebra" # zebra://127.0.0.1:8234 (mainnet) / :18234 (test/regtest)
connect_timeout_secs = 10
reconnect_base_secs = 1
reconnect_max_secs = 60
rfc1918_is_local = true # false = loopback-only gate
allow_remote_cleartext = false
[zebra]
# rpc_cookie = "/var/lib/zebra/.cookie" # wins over user/password
# rpc_user = "..."
# rpc_password = "..."
The full key reference is in configuration.
Light mode
New in 0.6.0. Pointing [backend] server at a lightwalletd gRPC endpoint runs zecd as a
light client: no fully synced local node required. Every feature works, including transparent
addresses. The wallet, the RPC surface, the privacy policy and the from-seed recovery model are
identical - only where the compact blocks come from changes.
Full mode is still the recommendation. A light server is a third party inside your trust boundary: it sees which addresses and transactions you ask about, and it is what tells you your balance. Choose it when running a node genuinely is not an option, not by default.
Selecting a backend
The token decides the mode:
[backend] server | Mode | Notes |
|---|---|---|
zebra | full | The default: a local zebrad at 127.0.0.1:8234 (:18234 on test/regtest). |
zebra://host:port | full | A zebrad elsewhere. |
zecrocks | light | The zec.rocks public fleet (zec.rocks:443 mainnet, testnet.zec.rocks:443 testnet), TLS. |
https://host[:port] | light | Any lightwalletd, TLS. Port defaults to 443. |
http://host:port | light | Any lightwalletd, no TLS. Refused toward a public host unless allow_remote_cleartext. |
host:port | light | TLS decided by locality: plaintext toward loopback/private, TLS toward public. |
Per-wallet upstream overrides
New in 0.7.0. [backend] used to be daemon-global, so every wallet in a process dialled the
same upstream. A wallet's own [wallets.<name>] section can now override the keys that describe
which upstream it dials and the TLS trust that authenticates it: server, tls,
tls_roots, tls_ca_file, tls_pinned_sha256, tls_insecure_skip_verify, and
assume_transparent_in_compact_blocks.
Fallback is field by field, so a wallet overriding only server keeps every global TLS setting.
Wallets that resolve to the same endpoint share one connection.
[backend]
server = "zebra" # the deployment default
[wallets.hot] # spending wallet: local node
[wallets.replica] # watch-only replica of the same seed, light upstream
server = "zecrocks"
Deployment policy stays global on purpose: timeouts, reconnect backoff, the cleartext-locality
rules, and the [zebra] credentials are properties of the deployment rather than of one
endpoint. Existing configurations resolve exactly as they did before.
Note what this does not dilute. A light upstream is still a third party inside your trust boundary, and pointing one wallet at it puts that wallet's queries in front of it. The recommendation above is unchanged; this only lets one daemon hold wallets that made different calls on it.
TLS
tls forces ("yes") or disables ("no") it; the default "auto" uses the locality heuristic
above. tls_roots selects the OS trust store or the embedded bundle, tls_ca_file adds a
private CA, and tls_pinned_sha256 pins the leaf certificate's fingerprint - the right answer
for a self-signed server, since it authenticates rather than giving up on authenticating.
tls_insecure_skip_verify exists and is off: it leaves the connection encrypted but
unauthenticated, so an on-path attacker can impersonate the server.
Plaintext to a globally routable host is refused, not warned about. What it leaks is not
credentials but query privacy: which addresses and txids this wallet cares about, to everyone on
the path. Override with allow_remote_cleartext only when the hop is secured out of band.
Transparent addresses need a capable server
Transparent receives ride the ordinary block scan, which means the server has to put transparent data inside compact blocks. That arrived with the versioned lightwallet protocol in lightwalletd 0.5.0. zecd probes for it at connect and refuses to run a transparent-enabled wallet against a server that does not advertise it, rather than silently never discovering those receives.
No released lightwalletd populates that advertisement yet, so in practice an operator who knows
their server serves the data asserts it with assume_transparent_in_compact_blocks = true.
Asserting it wrongly reintroduces exactly the silent-loss failure the probe prevents.
Shielded-only wallets - the default - are unaffected.
Cost: transparent spend detection
Spend detection queries each funded transparent address separately. On a local node that is an
index lookup; on a light server it is a remote round trip apiece. A wallet tracking many funded
transparent addresses is therefore materially better served by its own zebra, and both the
daemon and zecd config check say so once when the configuration looks like that.