Introduction
zecd is a shielded-first Zcash wallet server that speaks Bitcoin Core's JSON-RPC dialect.
What zecd is
zecd is a wallet daemon for Zcash built on librustzcash
(since 0.8.0, the Zakura Common forks of it, published on crates.io as zakura-*):
shielded-first (Ironwood by default, at the wallet's Orchard receiver, with opt-in Sapling
receivers and opt-in transparent t-address support), exposed through bitcoind's RPC dialect:
the same method names, response shapes, JSON-RPC 1.0 envelope, HTTP Basic/cookie auth, and error
codes as Bitcoin Core. An
integration that drives a coin purely through Bitcoin RPC (getnewaddress, poll
listtransactions/gettransaction/getbalance, sendtoaddress) works against zecd with
little or no change, and existing Bitcoin RPC client libraries (e.g. python-bitcoinrpc)
connect as-is.
It is written for integrators and operators: engineers wiring a payment system, exchange, or service to Zcash, and the SREs who run it. It is a light client: it syncs compact blocks in the background, never speaks P2P, and never indexes the chain itself.
Since 0.7.0 it is also a Rust library: a host process can bring up the wallet and dispatch any RPC in-process, with no socket in between. See Using zecd as a library.
Deployment model
zecd sits between your application and a self-hosted Zebra full node, talking to zebrad's JSON-RPC directly:
+----------------------+ +----------------+ +------------------+
| your app / | JSON-RPC | zecd | JSON-RPC | Zebra |
| Bitcoin RPC client | ---------> | wallet server | ---------> | (self-hosted |
| (python-bitcoinrpc, | Bitcoin | keys, scanning,| zebra:// | full node) |
| curl, existing | Core | proving, RPC | host:port | consensus, P2P, |
| bitcoind tooling) | dialect | surface | (local) | blocks, mempool |
+----------------------+ +----------------+ +------------------+
port 8232 mainnet / derives compact blocks, rpc.listen_addr
18232 testnet tree state, and mempool 8234 mainnet /
from the node RPCs itself 18234 testnet
The default [backend] server = "zebra" is shorthand for zebra://127.0.0.1:8234 on mainnet
(:18234 on test/regtest). Point zebrad's rpc.listen_addr there; Zebra ships with RPC
disabled. zecd derives compact blocks, tree state, and mempool visibility from the node's
RPCs itself, so in this mode there is no lightwalletd and no zaino to operate.
Since 0.6.0 that is the default, not the only option: [backend] server also accepts a
lightwalletd gRPC endpoint, for deployments where running a full node is not practical. See
Chain backends for both, and the trade-off between them.
Run the node yourself. zecd holds spend authority over real funds, and its entire view of
the chain (balances, confirmations, incoming payments) is whatever Zebra serves it. The
Zebra connection is plaintext HTTP and deliberately local-only: a cleartext-credential gate
refuses to send [zebra] RPC credentials to a globally-routable host (loopback and, by
default, private/LAN ranges are allowed).
Defining properties
- Bitcoin Core RPC conformance. Method names, field names/types, the JSON-RPC 1.0
envelope, Basic/cookie auth, error codes, and HTTP status mapping match Bitcoin Core, and a
conformance suite drives a live daemon with the same client logic
python-bitcoinrpcuses (see Testing & conformance). Intentional divergences are enumerated in the compatibility boundary; the wire format is specified in RPC conventions. - Shielded-first, transparent opt-in. A default wallet holds Ironwood notes, received
at its Orchard receiver: NU6.3 changed the value pool, not the address, which is why
[pools]still namesorchard. Sapling receivers and transparent (t-address) receiving/spending are enabled per wallet via[pools]config. Legacy Orchard V2 notes remain spendable, and moving them into Ironwood is a real pool crossing. See Addresses & shielded pools and Transparent support. - Stateless and seed-recoverable. zecd persists no off-chain state that a from-seed
restore couldn't rebuild: there are no address labels, and
zecd init --restorerecovers all funds and history from the chain. Shielded funds are recoverable unconditionally; transparent funds within the configured gap limit / initial-scan window. See Stateless & recoverable. - A self-hosted Zebra upstream by default. One local zebrad over JSON-RPC, with no zaino and no trusted third-party server in the path. Since 0.6.0 a lightwalletd endpoint is available as an explicit opt-in for deployments that cannot run a node; running your own remains the recommendation. See Chain backends.
- One spending wallet, any number of watch-only wallets. At most one loaded wallet holds
spending keys; watch-only replicas are built from an exported Unified Full Viewing Key and
addressed bitcoind-style at
/wallet/<name>. Since 0.8.0 an experimental fleet monitors large numbers of watch-only wallets in shared databases. See Watch-only wallets. - Reproducible builds. The release pipeline produces bit-for-bit reproducible static
binaries (a full-source-bootstrapped StageX image on amd64, a fully pinned Alpine build on
arm64) and deterministic
.tar.gz/.debpackages. See Reproducible builds. - ZIP-317 fees, ZIP-315 confirmations. Fees follow the deterministic
ZIP 317 formula and are never client-settable (explicit fee
parameters are rejected with
-8). Spendability follows ZIP 315's defaults (3 confirmations for the wallet's own change and transactions, 10 for third-party payments), configurable via[spend]. See Sending.
What zecd is not
- Not zcashd-RPC-compatible. zecd is intentionally not a zcashd clone: it does not
implement zcashd's
z_*surface except a small chosen subset (z_sendmanyplus the operation-tracking trio,z_shieldcoinbase,z_mergetoaddress,z_listtransactions,z_getaddressforaccount,z_validateaddress,z_listunifiedreceivers). Migrating an integration is a concept mapping, not a drop-in; see Migrating from zcashd. - No P2P. zecd never speaks the Zcash peer-to-peer protocol; Zebra is its only upstream,
and
getpeerinforeports at most that one connection. - Not a chain indexer. It tracks a single account per wallet, not arbitrary addresses or xpub derivation schemes, and holds no full-block or address index of its own.
- No per-address key import. Every address derives from the wallet seed (diversified
addresses of one ZIP-32 account); there is no
importprivkey/importaddress, and the only import path is a whole account via seed restore or UFVK.
Where to go next
- First run: the Quickstart takes you from a Zebra node to a funded wallet answering RPC; the Configuration reference covers every TOML key and CLI flag.
- Coming from zcashd: Migrating from zcashd.
- Building an integration: RPC conventions & wire format, then the method index for the full method-by-method comparison with bitcoind and zcashd.
- Driving it from Rust: Using zecd as a library, for callers who would rather not put an HTTP socket between their process and the wallet.
- Running it in production: Deployment (Docker,
.deb/systemd, release binaries) and the Operations runbook (backup/restore, monitoring, health endpoints). - Understanding the design: Architecture, Stateless & recoverable, the privacy policy ladder, and the threat model.
- Edges and gaps: the compatibility boundary and known limitations.
Quickstart
From zero to a first RPC call: point a local Zebra node's JSON-RPC at zecd, build the binary, initialize a wallet, run the daemon, and talk to it with any Bitcoin RPC client. The Docker compose route at the end does the same with one stack file.
Prerequisites: a local zebrad
zecd is a wallet server: it holds keys and scans compact blocks, but its entire view of the chain comes from a self-hosted Zebra full node's JSON-RPC. Run one on the same host (or private network) and let it sync before starting zecd.
Zebra ships with its RPC endpoint disabled. Enable it in zebrad.toml on the port zecd's
default backend expects:
[rpc]
# The port zecd's default `server = "zebra"` dials:
# mainnet -> zebra://127.0.0.1:8234
# testnet -> zebra://127.0.0.1:18234
listen_addr = "127.0.0.1:8234" # testnet: "127.0.0.1:18234"
Any explicit [backend] server = "zebra://host:port" works too if you prefer a different port
or a co-located container host; see Configuration. If zebrad's cookie
authentication is enabled, point zecd at the cookie (or set user/password) in the [zebra]
config section; with enable_cookie_auth = false on a loopback-only listener, no credentials
are needed. The connection is plaintext HTTP and deliberately local-only: never expose a
Zebra RPC port publicly (see the Zebra backend for the
cleartext-credential gate).
Two port families are in play: 8234/18234 are Zebra's RPC (what zecd dials), while 8232/18232 are zecd's own RPC defaults (what your clients dial), mirroring bitcoind's 8332/18332 convention.
Install
Four ways to get a binary, in rough order of convenience:
cargo install zecd # from crates.io
The release tarballs and .deb packages are prebuilt, reproducible
static binaries for amd64 and arm64, and the Docker stack below
brings up zecd and Zebra together. Building from source (including cargo install) needs
Rust 1.91 or later since 0.8.0:
git clone https://github.com/zecrocks/zecd && cd zecd
cargo build --release --bin zecd
Always use --release: a debug build takes more than 20 seconds to prove a single shielded
send. The same applies to cargo install, which builds in release mode by default.
Release candidates are opt-in. cargo install zecd resolves to the newest stable release,
so a pre-release has to be asked for by name (cargo install zecd --version 0.9.0-rc1). The
same holds for a dependency: a zecd = "0.8" requirement will not pick up a -rc.
Check the node is reachable
Before creating anything, confirm zecd can reach the node and agrees with it about which chain this is:
./target/release/zecd --testnet chain-info
It prints the upstream's tip, the chain name, and the birthday a wallet created now would get, and exits non-zero exactly when a wallet created here would not sync: wrong chain, or a consensus upgrade already active that this build does not know. It opens no wallet and writes nothing, so it is also the right probe against a live deployment later. See Probing the chain without a wallet.
Initialize a wallet
zecd init creates the wallet and exits. It needs the zebrad from the previous step reachable
(a new wallet's birthday defaults to just below the current chain tip, which init fetches from
the node).
# Testnet (drop --testnet for mainnet):
./target/release/zecd --datadir ./data --testnet init --wallet default
This generates three things:
- An age identity at
<datadir>/identity.txt(mode 0600), the key that encrypts the wallet seed at rest so the daemon can send unattended. - A 24-word mnemonic seed phrase, printed to stdout exactly once. Back it up now. It is the only way to recover the wallet: shielded funds are unconditionally recoverable from it on any librustzcash wallet; the on-disk data directory is a rebuildable cache (see Stateless & recoverable).
- The wallet account in
<datadir>/default/zec/lrz/data.sqlite, pluskeys.tomlat<datadir>/default/holding the age-encrypted mnemonic. (Wallets created before 0.7.0 kept the database flat besidekeys.tomland are moved into place automatically on first start; see wallet data layout.)
Variants (see Key custody for the trade-offs):
zecd --datadir ./data --testnet init --restore # restore from an existing mnemonic
zecd --datadir ./data --testnet init --restore --birthday 2500000 # much faster: scan from a known height
zecd --datadir ./data --testnet init --encrypt # passphrase-encrypted (Bitcoin Core style,
# starts locked; unlock via walletpassphrase)
zecd --datadir ./data --testnet init --ufvk "uview1..." # watch-only wallet from a viewing key
A restore without --birthday scans from the earliest enabled pool's activation height,
which is safe but slow; pass any height at or before the wallet's first transaction. For
watch-only setups see Watch-only wallets.
Run the daemon
./target/release/zecd --datadir ./data --testnet \
--rpcuser zec --rpcpassword secret --rpcbind 127.0.0.1 --rpcport 18232
The daemon syncs compact blocks in the background and serves JSON-RPC immediately; balances
and history fill in as the scan catches up. Default RPC ports are 8232 (mainnet) and
18232 (testnet). CLI flags override the TOML config (default <datadir>/zecd.toml);
--rpcpassword can also come from the ZECD_RPC_PASSWORD environment variable, and
bitcoind-style salted credentials from --rpcauth (generate one with zecd rpcauth <user>).
The full flag and config reference is in Configuration.
The steps above pass everything on the command line, so no config file is needed to get started. For a persistent setup, have zecd write you a commented one to edit:
zecd example-config -o ./data/zecd.toml
It refuses to overwrite an existing file unless you pass --force, so it is safe to run
against a live deployment's datadir.
On mainnet, zecd refuses to start while [rpc] password is still the example placeholder
CHANGE-ME.
Talk to it
Exactly like bitcoind (HTTP Basic auth, JSON-RPC 1.0 envelope):
curl -s --user zec:secret --data-binary \
'{"jsonrpc":"1.0","id":"1","method":"getblockchaininfo","params":[]}' \
-H 'content-type: text/plain;' http://127.0.0.1:18232/
Or with python-bitcoinrpc, unchanged:
from bitcoinrpc.authproxy import AuthServiceProxy
rpc = AuthServiceProxy("http://zec:secret@127.0.0.1:18232")
print(rpc.getblockchaininfo())
addr = rpc.getnewaddress() # a u1.../utest1... Orchard Unified Address
print(rpc.getbalance())
print(rpc.listtransactions("*", 20))
Two things to know: getnewaddress returns a shielded Unified Address, and a
label argument is rejected with -8 because zecd keeps no labels
(see Addresses & shielded pools and
Stateless & recoverable). Wire-format details (envelope, batching,
error codes, multiwallet routing) are in the RPC conventions.
Cookie auth
If you set neither --rpcuser/--rpcpassword nor [rpc] user/password, zecd writes a
bitcoind-style cookie file to <datadir>/.cookie (mode 0600, regenerated with a fresh random
password on each start) and authenticates against it:
curl -s --user "$(cat ./data/.cookie)" --data-binary \
'{"jsonrpc":"1.0","id":"1","method":"getblockcount","params":[]}' \
-H 'content-type: text/plain;' http://127.0.0.1:18232/
The cookie's user is the fixed __cookie__, as in bitcoind.
Docker compose quickstart
deploy/docker-compose.yml runs the full self-hosted stack (Zebra and zecd on one private
compose network), on testnet by default:
cd deploy
docker compose up -d zebra # start the node; let it sync first
docker compose run --rm zecd init --wallet default # back up the printed mnemonic!
docker compose up -d # start zecd
curl localhost:9233/readyz # health probe (see guide/operations.md)
curl --user zec:CHANGE-ME --data-binary \
'{"method":"getblockchaininfo","id":1}' localhost:18232/
For mainnet, add -f docker-compose.mainnet.yml to every command; the overlay swaps each
service onto its mainnet config file (zebrad.mainnet.toml, zecd.mainnet.toml) while keeping
the ports and volumes identical:
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml up -d zebra
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml run --rm zecd init --wallet default
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml up -d
Before a mainnet deployment, set a real [rpc] password in deploy/zecd.mainnet.toml (zecd
refuses to start on mainnet with the shipped CHANGE-ME), and pin the Zebra image tag to a
release you have verified. The compose file publishes zecd's RPC (18232 on both networks, to
keep the mapping identical) and health (9233) ports on loopback only: RPC credentials are
spend authority over plaintext HTTP, so front them with TLS or a network policy before serving
other hosts. The image build, ARM variant, and .deb/systemd routes are covered in
Deployment.
Ironwood (NU6.3)
Ironwood is compiled in unconditionally: there is no build flag and no [pools] entry. zecd
activates it from consensus height alone, and NU6.3 is now active on both networks, at
mainnet height 3428143 and testnet height 4134000. A wallet whose shielded funds were received
after that point holds ironwood notes. Regtest opts in via the ZECD_REGTEST_NU63_HEIGHT
environment variable.
Running it needs an ironwood-capable node; Zebra activates Ironwood at the network's activation
height, and the compose stack pins zfnd/zebra:6.3.0.
Ironwood notes are received at ordinary Orchard addresses. Upstream models them as Orchard
"V3" notes that reuse Orchard's keys, addresses, and note cryptography, so there is no ironwood
receiver to request and nothing to enable in [pools]. What differs is the note's transaction
bundle. Received notes report pool == "ironwood" in getbalance, listtransactions, and
gettransaction. Sends ride the cached proving key like any other shielded send, so there is no
per-send proving penalty.
Because ironwood is a distinct value pool rather than a flavour of Orchard, an ironwood-to-Sapling
or ironwood-to-Orchard send is a turnstile crossing that reveals its amount on chain, and
FullPrivacy rejects it. See the privacy policy ladder.
NU7
NU7 activates on testnet at block 4,465,026. Mainnet has no NU7 height yet.
Support for it is in 0.9.0-rc1, a release candidate. From that block, transactions commit to the NU7 consensus branch id and keep the V6 format NU6.3 introduced. 0.8.1 and older build transactions that upgraded testnet nodes reject, so a testnet deployment needs 0.9.0-rc1 (or later) before that height, and a node that activates NU7 there. Like NU6.3, it is compiled in and activated by height; there is no flag. Mainnet deployments can stay on 0.8.1.
The 0.9.0-rc1 wallet database cannot be reopened by 0.8.1, so copy the data directory before upgrading if you might go back. See Upgrades.
Regtest opts in with ZECD_REGTEST_NU7_HEIGHT, which must be above
ZECD_REGTEST_NU63_HEIGHT (zecd refuses to start otherwise: network upgrades activate in
order).
Where to go next
- Configuration: every TOML section and key, CLI flags, environment variables (pools, privacy policy, confirmations, health probes).
- Deployment: reproducible images, release binaries, systemd, Kubernetes probes.
- Operations runbook: backup/restore, monitoring,
/healthz/readyz/status, upgrades, failure modes. - RPC reference: the wire format and the full method reference.
Configuration
zecd is configured by a TOML file plus Bitcoin-Core-style CLI flags and a handful of environment variables. This page is the complete reference: every TOML section and key with its type, default, and semantics, plus the CLI flags and environment variables.
For a fully commented starting point, ask the binary for one:
zecd example-config > ./data/zecd.toml
zecd example-config prints the annotated zecd.example.toml that ships with zecd, so you
do not need a source checkout to get it: it works the same from a release tarball, a .deb,
or a container. See Subcommands for --output-file and --force.
File location and precedence
The config file is <datadir>/zecd.toml, overridable with --conf <FILE>. Like bitcoind,
the file is located before its own datadir key can apply: the lookup uses only the
--datadir flag and the ZECD_DATADIR environment variable, never a datadir set inside
the file. If the file does not exist, built-in defaults apply.
Unknown keys anywhere in the file are a startup error (fail-fast), not a silent ignore: a typo cannot quietly disable a setting.
General precedence, highest first:
- CLI flag (some flags read an environment variable as a fallback; see Environment variables)
- TOML key
- Built-in default
Per-key exceptions are noted inline below (the RPC password has a three-way precedence;
rpcauth entries accumulate rather than override; per-wallet keys override global [pools]
keys).
Top-level keys
| Key | Type | Default | Description |
|---|---|---|---|
network | string | "test" | Chain to run on: "main"/"mainnet", "test"/"testnet", or "regtest". Overridden by --network, --testnet, --regtest. |
datadir | path | "./zecd-data" | Parent directory for per-wallet subdirectories, the RPC cookie file, the datadir lock, and (by default) the age identity. Overridden by --datadir / ZECD_DATADIR. |
default_wallet | string | "default" | Wallet served when a request hits / rather than /wallet/<name> (see multiwallet routing). |
The default network is testnet; mainnet must be selected explicitly. On mainnet,
zecd additionally refuses to start while [rpc] password is still the example placeholder
change-me (case-insensitive), since the RPC password is spend authority.
[wallets.<name>]
One section per wallet; each wallet is an independent seed, SQLite database, and directory,
served at /wallet/<name>. If no wallet section is declared, an implicit entry for
default_wallet is created at <datadir>/<name>. Every [pools] key can be overridden
per wallet here.
| Key | Type | Default | Description |
|---|---|---|---|
dir | path | <datadir>/<name> | This wallet's directory. keys.toml sits at its root; since 0.7.0 the wallet database (data.sqlite) lives in a per-coin, per-engine subdirectory below it, <dir>/zec/lrz/. Since 0.8.0 the block cache is held in memory, so the blockmeta.sqlite and blocks/ that older releases kept beside it are no longer written; zecd rescan removes them. Existing wallets migrate themselves on first start with no configuration change. See wallet data layout. |
keys_file | path | <dir>/keys.toml | Location of this wallet's keys.toml (the encrypted seed), independent of dir (for example a read-only mounted Kubernetes Secret while dir stays a disposable cache). For the default wallet, [keys] keys_file / ZECD_KEYS_FILE / --keys-file set this too, but an explicit per-wallet keys_file wins over all of them. |
pools | array of string | global [pools] enabled | Override of the enabled shielded pools for this wallet. |
default_receivers | array of string | see below | Override of the default UA receivers. A wallet that overrides pools but not default_receivers receives into everything it enabled; a wallet that overrides neither inherits the global default. Must be a subset of the wallet's enabled pools. |
transparent | bool | global value | Override of [pools] transparent. |
transparent_default | bool | global value | Override of [pools] transparent_default. |
transparent_gap_limit | integer | global value | Override of [pools] transparent_gap_limit. |
transparent_initial_scan | integer | global value | Override of [pools] transparent_initial_scan. |
transparent_allow_beyond_recovery_window | bool | global value | Override of [pools] transparent_allow_beyond_recovery_window. |
transparent_gap_warn_threshold | integer | global value | Override of [pools] transparent_gap_warn_threshold. |
Per-wallet backend overrides (0.7.0). [backend] used to be daemon-global, so every wallet
in a process dialled the same upstream. These keys, written directly in the wallet's own
section, override it:
| Key | Overrides |
|---|---|
server | [backend] server |
tls | [backend] tls |
tls_roots | [backend] tls_roots |
tls_ca_file | [backend] tls_ca_file |
tls_pinned_sha256 | [backend] tls_pinned_sha256 |
tls_insecure_skip_verify | [backend] tls_insecure_skip_verify |
assume_transparent_in_compact_blocks | [backend] assume_transparent_in_compact_blocks |
Only the settings that describe which upstream this wallet dials, and the TLS trust that
authenticates it, are overridable. Fallback is field by field, so a wallet overriding only
server keeps every global TLS setting. Deployment policy stays global and is deliberately not
listed above: timeouts, reconnect backoff, the cleartext-locality rules, and the [zebra]
credentials are properties of the deployment rather than of one endpoint.
One daemon can therefore serve a zebra-backed spending wallet beside a lightwalletd-backed
watch-only replica of the same seed. Existing configurations resolve exactly as before, and a
wallet with no overrides emits no backend keys from config show.
At most one loaded wallet may hold spending keys; any number of watch-only (UFVK) wallets may run alongside it; see Watch-only wallets. For thousands of watch-only wallets, see Fleet.
[fleet]
New in 0.8.0, experimental, off by default. Monitoring many watch-only wallets in one daemon,
sharing shard databases. The keys are described with the feature in
Fleet: enabled (false), manifest_dir
(<datadir>/fleet/zec/wallets.d), dir (<datadir>/fleet/zec/shards), shard_size (128)
and cohort_depth (10000). They may change in a patch release while the fleet is
experimental. fleet is a reserved wallet name.
[backend]
The chain upstream: a single node, either a self-hosted Zebra's JSON-RPC (the default and the
recommendation) or, since 0.6.0, a lightwalletd gRPC server. The server token picks which,
so read its row below before changing it. See Chain backends for the
deployment model, the trade-off between the two, and the cleartext-credential gate.
| Key | Type | Default | Description |
|---|---|---|---|
server | string | "zebra" | Upstream endpoint; the token also selects the mode. Full node: "zebra" (a local zebrad at 127.0.0.1:8234 mainnet, 127.0.0.1:18234 testnet/regtest; set zebrad's rpc.listen_addr accordingly) or an explicit zebra://host:port. Light mode: https://host[:port], http://host:port, the "zecrocks" preset, and a bare host:port - note that last one is lightwalletd, not zebrad. Overridden by --server. |
connect_timeout_secs | integer | 10 | Per-attempt dial timeout (seconds); clamped to at least 1. |
reconnect_base_secs | integer | 1 | Reconnect backoff base delay (seconds); clamped to at least 1. Backoff is exponential with full jitter. |
reconnect_max_secs | integer | 60 | Reconnect backoff cap (seconds); clamped to at least reconnect_base_secs. |
proxy | string | unset | New in 0.8.0. Route every outbound connection through a SOCKS5 proxy, most usefully Tor: "socks5://host:port" (socks5h:// is accepted as the same thing). It covers both upstream kinds, so nothing zecd dials bypasses it. The proxy resolves the destination, so no DNS leaves this host and a .onion upstream works; lightwalletd TLS runs over the proxied connection unchanged, still verified against the destination hostname. Proxy authentication is not supported (a user or password in the URL is refused), so restrict the proxy by source address. The port is required. A malformed value fails startup. Daemon-wide: wallets cannot override it. Overridden by --proxy. See Chain backends. |
rfc1918_is_local | bool | true | Treat private / non-globally-routable addresses (RFC1918, link-local, CGNAT, IPv6 ULA/link-local) as "local" for the cleartext-credential gate (the Docker/LAN norm). Set false for a strict loopback-only posture. Only IP literals and localhost are classified (zecd does no DNS lookup here), so name a LAN upstream by IP address. |
allow_remote_cleartext | bool | false | Escape hatch: allow [zebra] credentials to travel in plaintext to a globally-routable host, and a plaintext light-mode connection to one. Only set this when the hop is secured out-of-band (SSH/WireGuard tunnel, private overlay). |
The remaining keys apply only when server names a lightwalletd endpoint (0.6.x and later);
a zebra:// upstream ignores them. See Chain backends.
| Key | Type | Default | Description |
|---|---|---|---|
tls | string | "auto" | "yes" forces TLS, "no" forbids it, "auto" decides by locality (plaintext toward loopback/private, TLS toward public). An explicit https:///http:// scheme in server overrides it. |
tls_roots | string | "native" | Which root certificates to trust: "native" (the OS store, and SSL_CERT_FILE) or "webpki" (the embedded Mozilla bundle). |
tls_ca_file | path | unset | PEM of a private CA to trust in addition to the roots, so a privately-issued certificate validates normally, hostname and expiry included. |
tls_pinned_sha256 | array of string | [] | Acceptable leaf-certificate SHA-256 fingerprints. Non-empty pins the connection to those certificates. The right answer for a self-signed server: it authenticates the peer rather than giving up on authenticating it. Combined with tls_ca_file, the chain is validated against that CA as well. |
tls_insecure_skip_verify | bool | false | Accept any certificate: no chain, hostname, or expiry check. The connection stays encrypted but is no longer authenticated, so an on-path attacker can impersonate the server and observe every address and txid this wallet asks about. Prefer tls_pinned_sha256. Refused in combination with tls_ca_file/tls_pinned_sha256. |
assume_transparent_in_compact_blocks | bool | false | Assert that the upstream serves transparent (and Ironwood) data inside compact blocks. zecd normally reads this from the server's advertised protocol version and refuses to run a transparent-enabled wallet against a server that does not advertise it, since those receives would otherwise silently never appear. No released lightwalletd populates that advertisement yet, so asserting it is currently the practical path for a transparent wallet on a light upstream. Asserting it wrongly reintroduces exactly the silent-loss failure the check prevents. Shielded-only wallets never need it. |
[zebra]
Credentials for the zebrad endpoint. Omit the whole section when zebrad runs with
enable_cookie_auth = false. A cookie file wins over user/password; nothing set means no
authentication.
| Key | Type | Default | Description |
|---|---|---|---|
rpc_user | string | unset | RPC username for zebrad. |
rpc_password | string | unset | RPC password for zebrad. |
rpc_cookie | path | unset | Path to zebrad's cookie file; re-read on every reconnect (zebrad regenerates it at startup). Wins over rpc_user/rpc_password. |
[rpc]
zecd's own JSON-RPC server (the Bitcoin-Core-dialect surface; see Conventions & wire format).
| Key | Type | Default | Description |
|---|---|---|---|
bind | string (IP) | "127.0.0.1" | Listen address. Overridden by --rpcbind. |
port | integer | 8232 main / 18232 test+regtest | Listen port. Overridden by --rpcport. |
user | string | unset | HTTP Basic auth username. Overridden by --rpcuser. |
password | string | unset | HTTP Basic auth password. Precedence: --rpcpassword / ZECD_RPC_PASSWORD > password_file > this key. If no user/password pair is configured, cookie auth is used instead. |
password_file | path | unset | Read the RPC password from this file (trailing newline/CR trimmed), keeping the spend-equivalent secret out of a ConfigMap-bound TOML. A configured file that cannot be read is a fatal startup error. |
auth | array of string | [] | Bitcoin-Core-style rpcauth entries (<user>:<salt>$<hmac-sha256 hex>), each an additional accepted credential. Generate with zecd rpcauth <user> [password]. Entries from --rpcauth flags and this key accumulate (all are accepted), matching bitcoind. |
cookiefile | path | <datadir>/.cookie | Where the bitcoind-style cookie is written when no user/password is set: zecd mints a random secret at startup and writes __cookie__:<random> (mode 0600). |
work_queue | integer | 100 | Max concurrent in-flight requests before returning HTTP 503 (Bitcoin Core's -rpcworkqueue); clamped to at least 1. |
allow_duplicate_shielded_recipients | bool | false | Permit a repeated shielded address across the recipients of one z_sendmany, for callers deliberately paying one address from several memo-carrying outputs in a single transaction. zcashd refuses any repeated recipient, which is the default here too. Repeated transparent recipients stay refused either way. In-process callers get the same thing unconditionally through Node::send (see embedding). |
allowed_methods | array of string | [] | RPC method safelist. Empty means every method is served; non-empty serves only the listed methods, anything else returning -32601 ("Method not found") exactly as if it did not exist. Names are validated against the implemented method set at startup, so a typo fails fast. A coarse server-wide gate, not per-user. |
[keys]
Seed custody and unlock behavior. See Key custody for the two at-rest custody models (age identity vs. passphrase).
| Key | Type | Default | Description |
|---|---|---|---|
age_identity | path | <datadir>/identity.txt | age identity file used to decrypt the wallet seed for unattended sending (the identity-file custody model). Overridden by --age-identity / ZECD_AGE_IDENTITY. |
auto_unlock | bool | true | Decrypt the seed at startup so sends need no walletpassphrase (identity-file wallets only; passphrase-encrypted wallets always start locked). |
keys_file | path | unset | Location of the default wallet's keys.toml, independent of the datadir (mount it as a Secret). Equivalent to [wallets.<default>] keys_file; overridden by --keys-file / ZECD_KEYS_FILE, and by an explicit per-wallet keys_file. |
allow_multiple_spending_wallets | bool | false | Load more than one wallet holding spending keys. Refused by the daemon, which reports it as a config check error: an RPC credential is spend authority for whichever wallet a request routes to, so two loaded spenders leave no single answer to which keys a credential can spend. It exists for embedded hosts, which have no RPC credentials and name the wallet on every call. When on, the loaded spenders are logged to the zecd::audit target. |
bootstrap_from_keys | bool | true | When a wallet's keys.toml exists but its data.sqlite has no account, recreate the account from the seed on boot and rescan from the wallet's birthday: the setting that lets the data directory be a disposable cache. Set false to fail fast on an empty datadir instead. Watch-only wallets have no seed and are not covered. |
[pools]
Global defaults for which value pools each wallet uses; every key here can be overridden
per wallet in [wallets.<name>]. See Addresses & shielded pools and
Transparent support.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | array of string | ["orchard"] | Shielded pools the wallet receives into and spends from; supported values are "sapling" and "orchard". Change goes to the strongest enabled pool (Orchard if enabled). Must be non-empty. "ironwood" is not a value here and is rejected at startup - not because it is not a pool, but because this key selects receivers. Ironwood is very much a value pool: its own bundle in V6 transactions, its own valueBalance, and pool == "ironwood" in balances and history. What it lacks is a receiver: ironwood notes are Orchard V3 notes reusing Orchard's keys, addresses and receiver, so there is nothing to request or enable. It is compiled in unconditionally and switches on by consensus height (already active on mainnet and testnet), so an "orchard" wallet is receiving Ironwood notes today. See Addresses & shielded pools. |
default_receivers | array of string | = enabled | Receivers included in the Unified Addresses getnewaddress hands out when no per-call override is given. Must be a subset of enabled (a violation is a startup error). |
transparent | bool | false | Allow bare transparent (t1.../tm...) receiving addresses via getnewaddress "" "transparent". Off keeps zecd shielded-only (address_type = "transparent" is rejected with -8). |
transparent_default | bool | false | Make a bare transparent address the no-argument getnewaddress default. Requires transparent = true (validated at startup). |
transparent_gap_limit | integer | 20 | External transparent gap limit: how far past the wallet's issuance frontier (the highest of the last funded index, the highest index getnewaddress handed out, and the transparent_initial_scan floor) addresses stay exposed. Unlike shielded funds (always recoverable by trial decryption), transparent funds are only rediscovered within this window; a stateless restore recovers up to transparent_initial_scan + transparent_gap_limit. Size it to your outstanding unfunded handed-out addresses only: every recorded receive re-derives the whole window, so zecd warns above 1000 and logs an error above 10000 (neither blocks startup). Must be at least 1. |
transparent_initial_scan | integer | 0 | Initial scan depth: pre-expose external transparent indices 0..N once at startup/restore so the receive scan covers all of them, independent of the (small) steady-state gap limit. This is the knob for deep coverage; it raises the gap window's floor, so issuance continues past N and stays recoverable to N + transparent_gap_limit. Set to your issuance high-water mark; 0 disables pre-exposure. |
transparent_allow_beyond_recovery_window | bool | true | What getnewaddress "" "transparent" does once the recovery window is exhausted: true issues the address anyway with a loud warning that funds sent there may be unrecoverable from seed; false fails the call with an actionable -4 error (fail-closed). |
transparent_gap_warn_threshold | integer | 5 | Warn when fewer than this many in-window transparent address slots remain, giving lead time to widen the limits. 0 warns only on actual exhaustion. |
[sync]
| Key | Type | Default | Description |
|---|---|---|---|
interval_secs | integer | 20 | How often to poll Zebra for new blocks (seconds); clamped to at least 1. |
rebroadcast_secs | integer | 60 | How often (at most) to re-broadcast the wallet's own transactions that are unmined and unexpired (seconds); clamped to at least 1. |
fetch_memos | bool | true | New in 0.8.0. Whether to recover memos. Compact blocks carry no memos, so recovering them costs one upstream transaction fetch and trial decrypt per received transaction: on a from-seed restore, the drain that holds /readyz at 503 after the block scan reaches the tip. false skips those fetches for transactions the wallet did not spend in; its own sends are still fetched, so their recipients, amounts and fees stay complete. Balances, receives, status tracking, transparent spend checks and 0-conf visibility are unaffected. With it off, memo/memoStr are omitted from history, enhanced_through is null, and getwalletinfo reports fetch_memos: false. Reversible without a rescan: the skipped requests stay queued, so turning it back on backfills the memos. Keep it on if depositors are identified by memo. |
batch_size | integer | 10000 | New in 0.8.0. Blocks per download-and-scan batch; clamped to at least 1. A batch is held in memory, and the next one downloads while it scans, so two are resident at once: hundreds of megabytes at 25,000 on mainnet's densest ranges. 25,000 scanned about 12% faster than 10,000 on a 238k-block testnet restore, and larger gained nothing. config check warns above 50,000. |
writer_cache_mib | integer | 256 | New in 0.8.0. SQLite page-cache ceiling (MiB) of each wallet's writer connection, the one the sync loop scans through; clamped to at least 1. A ceiling, not an allocation, but a cache that has grown is kept for the life of the connection, and every configured wallet and every fleet shard has its own writer. config check warns when the value times the writer count passes 2 GiB. Durability does not depend on it. |
enhance_concurrency | integer | 16 | New in 0.8.0. How many transaction fetches the memo drain keeps in flight, which is also how many requests one drain pass services and how many stores run before queued commands are serviced again. Clamped to at least 1. On a 7,546-request drain against a nearby lightwalletd, 1 in flight took 1303 s and 16 took 194 s; the gain flattens past 64, and config check warns above it. Calls to a zebra upstream are capped at 64 in flight whatever this is set to. |
[spend]
Send policy: confirmations, privacy, and the proving pipeline. See Privacy policy for the five-rung ladder and its enforcement points.
| Key | Type | Default | Description |
|---|---|---|---|
trust_own_transactions | bool | true | New in 0.8.0 (and 0.7.1). Mark each transaction this wallet authors as trusted when it is stored, so a self-send's payment output waits trusted_confirmations rather than untrusted_confirmations. false keeps zecd from persisting the marker at all, so classification derives only from data a from-seed restore re-derives and an authoring instance and a restore of the same seed report identical balances at every depth, at the cost of self-sends waiting the untrusted depth. Applies to new sends; existing markers stay until a rescan. |
trusted_confirmations | integer | 3 | Confirmations before the wallet's own outputs are spendable (ZIP 315 default): change, and since 0.8.0 every output of a transaction this wallet authored, so a payment to its own address waits this depth too (see trust_own_transactions). Clamped to at least 1. |
untrusted_confirmations | integer | 10 | Confirmations before third-party outputs are spendable (ZIP 315 default). Must be at least trusted_confirmations (validated at startup). Anchors balances and spend proposals; getbalance's explicit minconf overrides per call. |
privacy_policy | string | "AllowRevealedRecipients" | What sends may reveal on-chain: "FullPrivacy", "AllowRevealedAmounts", "AllowRevealedRecipients", "AllowRevealedSenders" (permits funding a send from transparent UTXOs, with shielded change), or "AllowFullyTransparent". z_sendmany's per-call privacyPolicy overrides it. Note "AllowRevealedSenders" was a synonym for "AllowRevealedRecipients" before 0.6.1 and is now a rung of its own. |
orchard_action_limit | integer | 50 | Cap on the Orchard-family actions a single send may build; bounds memory/proving cost and yields a clean -8 for oversized sends. 0 disables the cap. Counted per bundle and summed, as the builder proves them: a post-NU6.3 send that spends legacy Orchard notes into Ironwood outputs builds two bundles, so its count is the Orchard bundle's actions plus the Ironwood bundle's (before 0.8.0 one max(inputs, outputs) was taken across both, which let such a send prove up to twice the cap). Reloadable on SIGHUP since 0.8.0. |
max_tx_bytes | integer | 250000 | New in 0.8.0. Largest transaction, in bytes, a send may build; 0 disables it. Refused with -8 when the transaction is planned, before proving. This is a relay bound, not a consensus one: consensus allows a transaction up to a 2 MB block, but nodes do not forward one past their mempool policy (zakura's default is exactly 250000), and such a transaction is not rejected so much as never mined. It bounds bytes where orchard_action_limit bounds actions; a post-NU6.3 send carrying both an Orchard and an Ironwood bundle reaches it sooner than its action count suggests. z_mergetoaddress selects under it rather than refusing. Raise it only if every node between the wallet and a miner raises its policy to match. Reloadable on SIGHUP. |
shutdown_drain_secs | integer | 60 | New in 0.8.0. How long the wallet actor spends finishing sends it has already accepted (a z_sendmany returns its opid before proving) when it is asked to stop; 0 drops them, as before. Set it below your supervisor's stop timeout, which is what actually bounds it (docker stop 10 s, Kubernetes 30 s, systemd 90 s); config check warns above 90. See shutdown. |
target_note_count | integer | 4 | How many change notes a send tries to leave behind, so the next send has several notes to spend in turn rather than serializing on one note's confirmation depth. Must be at least 1; 0 was previously a panic waiting for the first send. |
min_split_output_value | integer (zatoshis) | 10000000 (0.1 ZEC) | Floor below which change is not split into target_note_count notes. The floor applies to the wallet's balance rather than to a network, so a deployment built on small balances was receiving one change note where it wanted several. Both keys default to what was hard-coded before 0.7.0, and both are validated when the configuration loads. |
cache_proving_key | bool | true | Warm the Orchard proving keys on a background task at startup (so it does not delay the listeners) and prove sends through the PCZT path. Since 0.8.0 the keys are cached process-wide either way, so false gives up only the warm-up (the first send builds the key inline), the cached verifying key at the store step, and pipeline_proving. The warm-up is skipped when no loaded wallet can spend. Both paths produce identical transactions. |
pipeline_proving | bool | false | Run a send's proving step off the single-writer actor so a long proof no longer freezes background sync and status. Sends still serialize. Only engages on the cached-Orchard PCZT path (cache_proving_key = true, Orchard-only spends). |
[health]
Unauthenticated liveness/readiness probes on a separate port; see the operations runbook.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Serve /healthz, /readyz, /status. |
bind | string (IP) | "127.0.0.1" | Probe listen address (0.0.0.0 to expose off-host). |
port | integer | 9233 | Probe listen port (all networks). |
readiness | string | "synced" | What /readyz gates on. "synced" (the default): connected, scanned to within max_scan_lag blocks of the tip, and the transaction-enhancement backlog drained. "scanned" (new in 0.6.4): the same without the backlog term. "connected": backend connected and its tip past the wallet's birthday, without waiting for the scan at all. |
max_scan_lag | integer | 4 | Maximum chain_tip - fully_scanned gap at which /readyz reports ready. Consulted in "synced" and "scanned" modes; ignored in "connected". |
[log]
| Key | Type | Default | Description |
|---|---|---|---|
level | string | "info" | Default tracing filter: a level ("error" through "trace") or a comma-separated directive list such as "info,zecd::sync=debug". Overridden entirely by RUST_LOG when set. |
format | string | "text" | "text" (human-readable) or "json" (structured, for log aggregation). Logs go to stderr. Validated since 0.7.0: anything else used to be silently treated as text, so a typo like jsonl produced text logs with no complaint. It is now refused at startup, and zecd config check reports the same refusal. |
CLI flags
Flags use Bitcoin-Core-style names and always win over the corresponding TOML key.
| Flag | Overrides | Description |
|---|---|---|
--conf <FILE> | file location | Path to the TOML config (default <datadir>/zecd.toml). |
--datadir <DIR> | datadir | Data directory. Falls back to ZECD_DATADIR, then the file, then ./zecd-data. |
--testnet | network | Use testnet. |
--regtest | network | Use regtest (a local Zebra regtest chain). Wins over --testnet and --network. |
--network <NET> | network | "main", "test", or "regtest". |
--rpcbind <ADDR> | [rpc] bind | RPC bind address. |
--rpcport <PORT> | [rpc] port | RPC port. |
--rpcuser <USER> | [rpc] user | RPC username. |
--rpcpassword <PASS> | [rpc] password / password_file | RPC password; also readable from ZECD_RPC_PASSWORD. Passing it on the command line triggers a startup warning: argv is world-readable via ps / /proc/<pid>/cmdline. Prefer the environment variable or password_file. |
--rpcauth <USER:SALT$HASH> | accumulates with [rpc] auth | Additional rpcauth credential; may be repeated. |
--proxy <SOCKS5_URL> | [backend] proxy | Route every outbound connection through a SOCKS5 proxy. |
--server <SERVER> | [backend] server | Chain upstream, in the same token grammar as [backend] server: zebra, zebra://host:port, or a lightwalletd endpoint. |
--age-identity <FILE> | [keys] age_identity | age identity file; also readable from ZECD_AGE_IDENTITY. |
--keys-file <FILE> | [keys] keys_file | Default wallet's keys.toml path; also readable from ZECD_KEYS_FILE. An explicit [wallets.<name>] keys_file still wins. |
--version | Print the version and exit. |
Subcommands
Running zecd with no subcommand (or zecd run) starts the daemon.
Every flag in the table above is global: it is accepted on either side of the subcommand,
so zecd --conf /etc/zecd.toml config check and zecd config check --conf /etc/zecd.toml
are the same command. (Before 0.6.0 the flags had to precede the subcommand, which made
zecd config check --conf FILE - the way anyone would naturally write it - a usage error.)
init, export-ufvk, rescan, derive-address, chain-info and the config group honor
the datadir/network/keys flags; the RPC flags are inert for them. rpcauth, example-config
and licenses run before config resolution and ignore all of them, so they work when there is
no config file yet.
| Subcommand | Flags | Description |
|---|---|---|
init | --wallet <NAME> (default default), --restore, --mnemonic-file <FILE>, --encrypt, --ufvk <UFVK>, --birthday <HEIGHT> | Create and initialize a wallet, then exit. --restore reads the mnemonic from ZECD_MNEMONIC, else --mnemonic-file, else stdin. --encrypt reads the passphrase from ZECD_WALLET_PASSPHRASE, else prompts. --ufvk creates a watch-only wallet and conflicts with --restore/--encrypt. --birthday defaults to the current chain tip for new wallets; a restore without it scans from Sapling activation. |
export-ufvk | --wallet <NAME> (default default) | Print a wallet's Unified Full Viewing Key (reads the wallet DB; no identity/passphrase needed, and not blocked by a running daemon's datadir lock). |
rescan | --wallet <NAME> (default default), --yes | Destructive. Delete the wallet database so the next daemon start rebuilds the account from the seed and rescans from the wallet birthday. keys.toml and the seed are kept, and all funds and history are re-derived from the chain, so nothing is lost that a restore could not rebuild. Prompts for confirmation unless --yes. Takes the datadir lock like init, so it refuses to run while a daemon holds it: stop the daemon first. See Recovering a stuck sync. |
derive-address | --wallet <NAME>, --mnemonic, --mnemonic-file <FILE>, --ufvk <UFVK>, --address-type <TYPE>, --index <N>, --count <N>, --json | Derive addresses offline. Touches no network, no wallet database and no daemon, and takes no datadir lock, so it runs beside a live daemon. See Offline address derivation below. |
config check | --strict, -q, --quiet | Validate a config against this build without starting the daemon; exits non-zero if the daemon would refuse it. Prints the effective settings on stdout and the verdict on stderr. --strict also fails on warnings. See Validating a config. |
config show | Print the effective configuration as round-trippable TOML, then exit. Secrets are emitted as commented-out key names, never values. | |
chain-info | --server <TOKEN>, --json | New in 0.7.0. Dial the configured upstream and report its tip, then exit. See Probing the chain without a wallet. |
licenses | New in 0.7.0. Print the license texts of the third-party crates compiled into this binary, then exit. See Third-party licenses. | |
rpcauth <username> [password] | Generate a salted [rpc] auth credential line. Omitting the password generates a strong random one, printed once. Needs no datadir or config. | |
example-config | -o, --output-file <FILE>, --force | Print the annotated example config, then exit. Goes to stdout by default (-o - is the same), so it can be redirected or piped. With -o <FILE> it writes there instead and refuses to overwrite an existing file unless --force; the "wrote example config to ..." confirmation goes to stderr, so stdout carries config text and nothing else in every mode. The output is the shipped zecd.example.toml, byte for byte. Needs no datadir or config. |
run | Run the JSON-RPC daemon (the default when no subcommand is given). |
Reloading on SIGHUP
Since 0.8.0 a running daemon re-reads its configuration on SIGHUP and applies two keys:
[spend] orchard_action_limit and [spend] max_tx_bytes. Both bound what one send may build,
and a wallet whose notes have fragmented can fail every payout until the cap moves, so
restarting a live payment wallet to change a number would be the wrong cost. Every other
changed key is logged as needing a restart rather than silently ignored or half-applied.
There is deliberately no RPC for this: the caps bound what a single call can make the daemon
prove, so raising them takes access to the process.
systemctl kill -s HUP zecd # or: kill -HUP <pid>, docker kill -s HUP <container>
Validating a config
zecd config check --conf FILE answers "would this build accept this config?" without
starting the daemon.
The question is not hypothetical: zecd rejects unknown config keys, which is what keeps a typo'd knob from being silently ignored, but it also means a config valid for one build can be refused by another in either direction - an upgrade may not know a key yet, a rollback may have dropped one. Before 0.6.0 the only way to find out was to start the daemon on the target host.
Two properties are structural rather than promised:
- It reaches the daemon's verdict, not a second opinion. Every check is either the config resolver itself or a helper the daemon calls at startup, so the two cannot drift.
- It changes nothing. No datadir lock (so it runs against a live deployment), no wallet database, and no cookie file - minting one would invalidate the credential a running daemon already handed out.
Errors mean "the daemon would refuse, or would start and never sync". Warnings cover the
legal-but-risky shapes: an uninitialized wallet, a transparent_gap_limit wide enough to
stall restores, a bare RPC password on a non-loopback bind. Since 0.8.0 they also cover:
[sync] batch_sizeabove 50,000,enhance_concurrencyabove 64, and awriter_cache_mibwhose total across writer connections passes 2 GiB;- an
orchard_action_limitthatmax_tx_byteswill always bind first, and ashutdown_drain_secslonger than common supervisors' stop timeouts; - a
proxycombined with a loopback upstream, which the proxy would read as its own loopback; default_receiverswith several receivers, whose addresses history will not report verbatim;- fleet manifests present while
[fleet] enabledis false, a wallet directory overlapping the fleet's, and the fleet's experimental status when it is on (see Fleet).
--strict fails on warnings too; -q reports through the exit code alone.
A missing config file is an error here, unlike at startup where a missing file falls back to defaults: checking a file that is not there is a typo, and silently validating the defaults would confirm nothing.
zecd config check --conf /etc/zecd/zecd.toml || exit 1 # CI gate
zecd config check --conf /etc/zecd/zecd.toml --strict # also fail on warnings
stdout carries settings, stderr carries the verdict - the nginx -t / nginx -T split.
That is what makes the diff below a diff of configuration and nothing else:
diff <(zecd-old config show --conf zecd.toml 2>/dev/null) \
<(zecd-new config show --conf zecd.toml 2>/dev/null)
config show is the sshd -T to config check's sshd -t: it prints the effective
configuration - the file, CLI flags and environment resolved together, with every unset key
filled in by this build's default - as TOML. That is what an operator actually needs before an
upgrade, because a config file is only half the configuration and defaults move between
versions. Capturing it pins today's behaviour as an explicit file before taking the upgrade:
zecd-old config show --conf zecd.toml > effective.toml
The output re-parses: a round-trip test feeds it back through the resolver and requires an
identical second render, so a renderer that drifts from the schema fails a test rather than
emitting a config zecd would itself reject. Secrets - the RPC password, rpcauth hashes,
[zebra] credentials - are emitted as commented-out key names, never values, since this
output is the kind of thing that gets pasted into a bug report. They are commented rather than
redacted-in-place because a placeholder that parses would silently become a real, wrong
credential if the file were deployed, where an absent password falls back to cookie auth and
fails loudly. The cost is that a secret-bearing config does not round-trip byte for byte.
Unlike check, a missing config file is fine for show: "what would this binary do with no
config" is well defined, and is the quickest way to see the built-in defaults.
Probing the chain without a wallet
New in 0.7.0. zecd chain-info dials the configured upstream and reports its tip.
Before it, the chain tip was unreachable without a wallet: config check is deliberately
offline, and the daemon needs a wallet to start, so both "what height is the chain at?" and
"can this deployment reach its backend?" meant creating a wallet first.
$ zecd chain-info --conf /etc/zecd/zecd.toml
server zebra://127.0.0.1:8234
network main
chain main
tip height 3512847
tip hash 0000000000d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f80912
birthday for new 3512747
branch id c8e71055
round trip 14 ms
OK: reachable, chain matches, consensus rules understood
The summary goes to stdout and the verdict to stderr, so the two can be separated in a script.
--json emits an object instead, with network_matches, suggested_birthday, branch_id and
an unsupported_upgrades array.
It exits non-zero exactly when a wallet created there would not sync, which is what makes it usable as a deployment gate:
| Outcome | Exit | Meaning |
|---|---|---|
| Reachable, chain matches, upgrades understood | 0 | Good to init. |
Upstream serves a different chain than network | non-zero | Pointing at the wrong node. |
| Upstream reports an active network upgrade this build does not know | non-zero | The build is too old to follow current consensus. Update zecd before syncing a wallet against it. |
| Upstream reports a future upgrade this build does not know | 0, with a warning | Update before it activates. |
| Chain name unrecognized | 0, with a warning | Cannot confirm the match either way, which is not a pass. |
birthday for new is the birthday zecd init would record for a wallet created right now, and
it comes from the same function init itself calls, so the two cannot drift apart. Record it
alongside a mnemonic you generated yourself.
--server <TOKEN> probes a candidate endpoint instead of [backend] server, using the same
token grammar, so a new upstream can be tested before it is committed to a config file. The
override is applied by re-resolving the configuration with the token swapped, which means the
candidate carries the same [zebra] credentials, TLS settings and cleartext policy the daemon
would give it. What is probed is what a daemon on that token would dial, including the
no-network refusals: an endpoint this build would never dial fails here for that reason rather
than as a connection timeout.
Read-only, like config check: no datadir lock, no wallet database, no cookie file. It is safe
to run against a live deployment.
Third-party licenses
New in 0.7.0. zecd licenses prints the license texts of every third-party crate compiled into
the binary.
zecd links its dependencies statically, so those crates travel inside the shipped binary, and
most of the licenses in the tree require the text and copyright notice to be reproduced when
they do. The same text ships as THIRD-PARTY-LICENSES.txt in the release tarball, in the
Debian package, and in both container images under /usr/share/doc/zecd/.
zecd licenses | less
zecd licenses > THIRD-PARTY-LICENSES.txt
The container images are built FROM scratch and have no shell, so this subcommand is the one
place the notices are readable wherever zecd runs. It needs no datadir and no config file.
The bundle is generated from the lockfile at release time and covers a few hundred crates, so expect a few hundred kilobytes of output. Piping it into a pager that exits early is fine.
Offline address derivation
zecd derive-address answers "what address will this wallet hand out?" before the wallet has
a chain.
zecd init needs a live upstream (it anchors the account on the tree state at birthday - 1)
and getnewaddress needs a running daemon, so until 0.6.0 there was no way to learn an address
first. That is a chicken-and-egg for pre-provisioning deposit addresses, air-gapped and cold
setup, pointing a miner at a wallet that does not exist yet, and checking that a keys.toml
derives the addresses you expect before trusting it.
It touches no network, no wallet database and no daemon, and takes no datadir lock (like
export-ufvk), so it runs safely beside a live daemon.
Key sources, in the order it tries them:
| Source | Flag | Notes |
|---|---|---|
An initialized wallet's keys.toml | (default) | Uses the account UFVK pinned there, so no seed is decrypted and a locked, passphrase-encrypted, or watch-only wallet works. |
| A BIP-39 mnemonic | --mnemonic | Read from ZECD_MNEMONIC, else --mnemonic-file, else stdin. |
| A Unified Full Viewing Key | --ufvk <UFVK> | As printed by export-ufvk. Addresses are the same either way; only spending needs a seed. |
--index and --count derive a batch at consecutive indices (default: one address at index
0). --address-type reuses the same syntax and the same parsing code as
getnewaddress's address_type, so the CLI and the
RPC cannot drift, and it defaults to what that wallet's getnewaddress would hand out. For a
bare t-address the index is the BIP 44 external child index - the same index the daemon
exposes and the same one
z_getaddressforaccount
takes.
stdout is exactly the addresses, one per line, so it pipes; --json reports the derivation
key and indices alongside them.
# Ten deposit addresses, before the daemon exists.
zecd derive-address --address-type transparent --index 0 --count 10
# Check a keys.toml derives what you expect, from the mnemonic in your safe.
ZECD_MNEMONIC="$(cat /mnt/secure/phrase)" zecd derive-address --mnemonic --json
What it deliberately cannot reproduce is getnewaddress's next shielded address: those
diversifier indices are clock-derived, so only an explicit index is deterministic - which is
what an offline caller wants anyway.
Environment variables
| Variable | Used by | Description |
|---|---|---|
ZECD_DATADIR | daemon + subcommands | Data directory. Precedence: --datadir > ZECD_DATADIR > file datadir > ./zecd-data. |
ZECD_RPC_PASSWORD | daemon | RPC password; equivalent to --rpcpassword and wins over [rpc] password_file and inline password. Preferred over the flag (not visible in ps). |
ZECD_KEYS_FILE | daemon + init | Default wallet's keys.toml path; equivalent to --keys-file. |
ZECD_AGE_IDENTITY | daemon + init | age identity file path; equivalent to --age-identity. |
ZECD_MNEMONIC | init --restore | The seed phrase for a non-interactive restore. Takes precedence over --mnemonic-file and stdin. |
ZECD_WALLET_PASSPHRASE | init --encrypt | The at-rest passphrase for a non-interactive encrypted init; otherwise prompted twice on stdin. |
ZECD_REGTEST_NU63_HEIGHT | daemon (regtest only) | Activate NU6.3 (Ironwood) at this regtest height. Unset, the regtest chain never activates it. |
ZECD_REGTEST_NU7_HEIGHT | daemon (regtest only) | Activate NU7 at this regtest height (0.9.0-rc1 and later). Must be above ZECD_REGTEST_NU63_HEIGHT, or zecd refuses to start. |
ZECD_ALLOW_CORE_DUMPS | daemon + subcommands | Set to exactly 1 to opt out of the core-dump/ptrace hardening (RLIMIT_CORE=0 + PR_SET_DUMPABLE=0) for crash debugging. Any other value, including 0 or empty, keeps hardening on. The seed mlock is unaffected. |
RUST_LOG | daemon + subcommands | Standard tracing filter; overrides [log] level when set. |
Minimal example
A testnet daemon against a local zebrad with cookie auth on both hops:
network = "test"
datadir = "./data"
[backend]
server = "zebra" # zebra://127.0.0.1:18234 on testnet
[zebra]
rpc_cookie = "/var/lib/zebrad/.cookie"
# No [rpc] user/password: zecd writes its own cookie to ./data/.cookie,
# and local clients authenticate with it like bitcoin-cli does.
Migrating from zcashd
This page maps zcashd concepts and RPC methods onto zecd, and walks through the one supported
way to move funds. It is written for teams whose integration code speaks zcashd's RPC today
and who are replacing it with a zebra → zecd stack.
Philosophy: a Bitcoin Core dialect, not a zcashd clone
zecd is deliberately not zcashd-RPC-compatible. Instead of re-implementing zcashd's z_*
surface, it speaks Bitcoin Core's JSON-RPC dialect (the same method names, response shapes,
JSON-RPC 1.0 envelope, HTTP Basic/cookie auth, and error codes as bitcoind) and maps those
onto shielded (Orchard-first) operations. The bet is that far more tooling, client libraries,
and operational muscle memory exist for Bitcoin Core RPC than for zcashd's wallet API, and
that zcashd's own trajectory pointed the same way: current zcashd already deprecates
getnewaddress, z_getnewaddress, z_getbalance, and z_listaddresses (denied by default
under -allowdeprecated), so "keep calling zcashd methods forever" was never on offer.
zecd keeps a small, deliberately chosen z_* subset where Bitcoin Core has no counterpart
for a shielded concept:
z_sendmanyplus the operation-tracking trioz_getoperationstatus/z_getoperationresult/z_listoperationids: zcashd's asynchronous send pattern, kept so opid-based client code keeps working.z_listtransactions: per-output history withpool,memo/memoStr, and zatoshi amounts.z_getaddressforaccount: deterministic diversified-address derivation at a chosen diversifier index.
Everything else is the bitcoind method under the bitcoind name. The full per-method matrix is in the method index; the boundary itself (what zecd promises to match and where it intentionally diverges) is in Compatibility boundary.
Concept mapping
| zcashd | zecd |
|---|---|
| Validator + wallet in one process: zcashd validates the chain, indexes it, speaks P2P, and serves the wallet | Wallet server over a separate node: zecd is wallet-only and talks JSON-RPC to a self-hosted Zebra node (zebra://host:port, local-only plaintext), or since 0.6.0 to a lightwalletd server. No P2P, no mining or chain-index RPC |
Many address kinds: transparent t1..., Sprout, Sapling zs..., plus ZIP-316 unified accounts (z_getnewaccount + z_getaddressforaccount) | One account per wallet, diversified Unified Addresses: every getnewaddress returns a fresh diversified UA of the wallet's single account (Orchard receiver by default). All addresses derive from the seed; see Addresses & shielded pools |
| Sprout + Sapling + transparent pools | Ironwood by default (received at the Orchard receiver, so [pools] still names orchard); Sapling is opt-in via [pools], transparent receive/spend is opt-in via [pools] transparent (Transparent support). No Sprout support at all: move any Sprout funds with zcashd itself before decommissioning it |
Fee arguments: z_sendmany/z_mergetoaddress/z_shieldcoinbase accept an explicit fee (default null = ZIP-317); settxfee works | ZIP-317 only, never client-settable: the wallet computes the fee at build time. An explicit numeric fee on z_sendmany/z_shieldcoinbase/z_mergetoaddress is rejected -8 (null is fine); settxfee always returns -8; subtractfeefromamount/fee_rate on sends are -8 |
Zcash's error numbering (Zcash rpc/protocol.h), e.g. -18 = RPC_WALLET_BACKUP_REQUIRED | Bitcoin Core's numbering (Core rpc/protocol.h), e.g. -18 = RPC_WALLET_NOT_FOUND (unknown /wallet/<name>). This is the one numeric collision zecd actually emits, and only from multiwallet routing, which zcashd lacks; tooling that hard-codes Zcash's numbering should know. The money-path codes (-4/-5/-6/-8/-13 through -17/-20/-26) are identical across zcashd, Core, and zecd. See Conventions & wire format |
Per-key import/export: z_exportkey, z_importkey, dumpprivkey, importprivkey, z_exportwallet, backupwallet | Seed-only, no key import by design: every address derives from the wallet mnemonic; the backup is the mnemonic (plus config). See Stateless & recoverable |
Stateful bookkeeping: labels/"accounts", sent_notes UA echo | Stateless: no label store (label methods are -32601), outgoing history shows the single receiver actually paid, identically before and after a from-seed restore |
Default spend confirmations 10 (z_sendmany minconf) | ZIP-315 policy: 3 trusted / 10 untrusted, configurable in [spend]; minconf still overrides per call |
RPC mapping
For each commonly used zcashd wallet RPC, the zecd equivalent, or the supported alternative
where there is none. Methods not listed here (and not in the method index)
return method-not-found (-32601, HTTP 404).
Addresses and accounts
| zcashd | zecd | Notes |
|---|---|---|
z_getnewaccount | not supported | zecd is one account per wallet, created at zecd init. Need more accounts → more wallets ([wallets.<name>], one spending wallet max) |
z_getaddressforaccount | z_getaddressforaccount | Same shape; account must be 0. Receiver types are shielded-only (orchard/sapling); p2pkh is -8. Optional diversifier_index re-derives idempotently |
z_getnewaddress (deprecated in zcashd) | getnewaddress | Returns a fresh diversified UA (Orchard receiver by default; funds arriving there are Ironwood notes post-NU6.3). A label argument is rejected -8; the second arg is an address_type receiver override |
getnewaddress (deprecated in zcashd; returns a t-addr) | getnewaddress "" "transparent" | Only with [pools] transparent = true; returns a bare t1... address. See Transparent support |
z_listaddresses (deprecated), listaddresses | listreceivedbyaddress 0 true | include_empty=true enumerates every address the wallet has generated, with received totals |
z_validateaddress | z_validateaddress | Supported since 0.8.0 with ismine. Accepts every address kind and names it in address_type; returns no key material |
z_listunifiedreceivers | z_listunifiedreceivers | Supported since 0.8.0, in zcashd's shape. To match receipts to an issued address, diversifier_index on history entries is usually simpler |
Balances
| zcashd | zecd | Notes |
|---|---|---|
z_gettotalbalance (deprecated) | getbalance / getbalances | Wallet-level totals; getbalances splits trusted / untrusted_pending / immature |
z_getbalanceforaccount | getbalances | One account per wallet, so the wallet totals are the account totals |
z_getbalance (deprecated; per-address) | getreceivedbyaddress | zecd has no per-address balance (all diversified addresses fund one account); per-address received totals exist |
z_getbalanceforviewingkey | watch-only wallet | zecd export-ufvk on the spender, zecd init --ufvk elsewhere, then getbalance there. See Watch-only wallets |
getbalance | getbalance | Spendable under the ZIP-315 policy; explicit minconf overrides per call |
getunconfirmedbalance | getunconfirmedbalance | Includes 0-conf mempool receives |
History and unspent
| zcashd | zecd | Notes |
|---|---|---|
listtransactions | listtransactions | Core shape plus memo/memoStr; label fields always "" |
z_viewtransaction | gettransaction / z_listtransactions | gettransaction is the Core shape extended with memo fields; z_listtransactions carries zcashd's per-output vocabulary (pool, amountZat, outindex, ...) |
z_listreceivedbyaddress | listreceivedbyaddress / z_listtransactions | Core totals per address, or per-output entries with memos |
z_listunspent | listunspent | One entry per unspent note with synthesized (txid, vout); address empty for change |
listsinceblock | listsinceblock | Cursor semantics; removed always [] |
z_getnotescount | not supported |
Sending
| zcashd | zecd | Notes |
|---|---|---|
z_sendmany | z_sendmany | Same syntax, async opid flow, and fromaddress coin control (ANY_TADDR included). Differences: fromaddress must be one of this wallet's own addresses (a foreign one is -5), where zcashd will also spend from another of its accounts; explicit numeric fee → -8 (pass null or omit); privacyPolicy maps onto zecd's linear five-rung ladder, and LegacyCompat (or omitted) uses the configured [spend] privacy_policy default rather than zcashd's UA-dependent rule; at most 16 unfinished operations per wallet, beyond which new calls are -4 |
sendtoaddress, sendmany | sendtoaddress, sendmany | Synchronous bitcoind-style sends: build, prove, broadcast, return the txid. Extra trailing hex memo parameter on sendtoaddress. See Sending |
z_getoperationstatus / z_getoperationresult / z_listoperationids | same | Same semantics, including destructive one-shot z_getoperationresult. Wallet-scoped and in-memory (lost on restart, as in zcashd) |
z_shieldcoinbase | z_shieldcoinbase | Same signature, same response shape (remainingUTXOs/remainingValue/shieldingUTXOs/shieldingValue/opid), same opid flow. Difference: an explicit numeric fee is rejected -8 (pass null or omit). Sweeps mature transparent coinbase into one shielded output, no change in any pool; toaddress must have a shielded receiver. See Async operations |
z_mergetoaddress | z_mergetoaddress | Supported since 0.7.0, with zcashd's signature and response shape. Three differences: sources are one class per call, so a merge mixing transparent and shielded sources is -8 (call it twice); an explicit numeric fee is -8; and ANY_ORCHARD is added, covering the Orchard and Ironwood notes zcashd's wildcard set has no name for. See Async operations |
z_setmigration / z_getmigrationstatus | not supported | The Sapling-migration machinery has no zecd counterpart |
z_converttex | not supported |
Keys, backup, and wallet management
| zcashd | zecd | Notes |
|---|---|---|
backupwallet, z_exportwallet, z_importwallet | not supported | The backup is the mnemonic shown once at zecd init (plus your config). Restore with zecd init --restore --birthday <height>; the wallet DB rebuilds from seed + chain |
z_exportkey, z_importkey, dumpprivkey, importprivkey, importaddress, importpubkey | not supported | No per-address key import/export by design; all addresses derive from the seed |
z_exportviewingkey | zecd export-ufvk (CLI) | Prints the wallet's Unified Full Viewing Key; not an RPC |
z_importviewingkey | zecd init --ufvk <key> (CLI) | Creates a watch-only wallet |
encryptwallet, walletpassphrasechange | not supported | Encryption is set once at zecd init --encrypt; the passphrase never crosses the network |
walletpassphrase, walletlock | same | Bitcoin Core semantics (-13 locked send, -14 wrong passphrase, -15 unencrypted) |
walletconfirmbackup | not supported | zcashd's -18 "backup required" flow does not exist |
getrawchangeaddress, addmultisigaddress, keypoolrefill, lockunspent, listlockunspent | not supported | -32601 |
signmessage, verifymessage | same | Transparent addresses only, using zcashd's digest and encoding, so signatures are interoperable. See Utility & control |
settxfee | dispatched, always -8 | Fees are ZIP-317, computed by the wallet |
Migrating funds
The only supported migration path is an on-chain send from zcashd to an address generated by zecd. There is no key or wallet import, by design: zecd's statelessness and restore guarantees hold only for addresses derived from its own seed.
-
Set up the target: a synced Zebra node, then
zecd init(record the mnemonic offline) and start the daemon; see the Quickstart. -
On zecd, generate a receiving address:
curl -u user:pass --data-binary \ '{"jsonrpc":"1.0","id":"m","method":"getnewaddress","params":[]}' \ http://127.0.0.1:8232/The result is a Unified Address (
u1...). -
On zcashd, send everything to that UA with
z_sendmany. Note zcashd's defaultprivacyPolicyisLegacyCompat, which treats any transaction involving a UA asFullPrivacy, so spending zcashd's transparent funds to zecd's UA fails under the default; pass"AllowRevealedSenders"(this reveals the sending transparent addresses and amounts on-chain). Shielded Sapling funds crossing into Orchard need"AllowRevealedAmounts"(reveals only the amount crossing the pool turnstile). -
Wait for confirmations, then verify on zecd with
getbalance/listtransactions. Remember zecd's spendability policy defaults to 3 confirmations for your own transactions and 10 for third-party ones.
Two seed-related cautions:
- Do not share a seed phrase between apps: do not restore zcashd's mnemonic into zecd or
vice versa. zecd's restore guarantees hold only for wallets its own
initcreated. - As a deliberate escape hatch, a zecd seed phrase works in any other librustzcash-based wallet (for example Zodl): if something goes badly wrong with zecd, funds remain accessible elsewhere. Shielded funds are unconditionally recoverable from seed; transparent funds only within the configured gap-limit / initial-scan window; see Stateless & recoverable.
Operational differences
- You run two processes, not one. zecd needs a self-hosted Zebra node reachable over
local/private JSON-RPC (
zebra://...; plaintext HTTP guarded by a cleartext-credential gate). Everything zecd believes about the chain comes from that node. See Chain backends and Deployment. - Light-client sync. zecd derives compact blocks from the node and trial-decrypts them; it
keeps no chain index. After a restore, an enhancement backlog (re-fetching full
transactions to backfill memos and outgoing details) can keep the wallet in
initialblockdownload/scanningstate after the block scan reaches the tip. Watchgetwalletinfo.scanningand the/readyzhealth endpoint (Operations runbook). - Sends take a few seconds. Every shielded send builds a zero-knowledge proof, so
sendtoaddress/sendmanyhold the HTTP connection for a few seconds; raise client timeouts accordingly.z_sendmanykeeps zcashd's asynchronous pattern (returns an opid immediately) if you prefer not to block. - Sends serialize per wallet. A single-writer actor owns each wallet, so concurrent sends to one wallet queue rather than double-spend (Architecture).
- Multiwallet is bitcoind-style (
/wallet/<name>routing), with at most one spending wallet per daemon plus any number of watch-only wallets. - No P2P, mining, or chain-index RPC: those live on the Zebra node.
Client code changes checklist
For code that drives zcashd today:
- Endpoint: point the client at zecd (default port 8232 mainnet / 18232 testnet), with
/wallet/<name>paths if you configure multiple wallets. Auth is HTTP Basic or cookie, bitcoind-style. - Addresses: replace
z_getnewaccount+z_getaddressforaccount(orz_getnewaddress) withgetnewaddress; expect a UA. Drop anylabelarguments:getnewaddressrejects them with-8, and the label methods are gone (-32601). - Balances: replace
z_gettotalbalance/z_getbalanceforaccountwithgetbalance/getbalances. Keep parsing amounts as exact decimals (e.g. PythonDecimal): they are bare JSON numbers with 8 decimal places, never floats. - Fees: delete every fee argument. Pass
null(or omit) forz_sendmany'sfee; an explicit number is-8. Removesettxfee,subtractfeefromamount, andfee_rateusage. - Error handling: re-check any hard-coded error numbers against Bitcoin Core's
rpc/protocol.h. The money-path codes are unchanged; the notable collision is-18(zcashd "backup required" vs zecd/Core "wallet not found"). - Async sends: opid flows keep working, but budget for the per-wallet cap of 16
unfinished operations (
-4beyond it) and rememberz_getoperationresultconsumes each result exactly once. - Timeouts: raise HTTP client timeouts for
sendtoaddress/sendmany(proving takes seconds). - Confirmation assumptions: zecd's default spend policy is ZIP-315 (3 trusted / 10
untrusted) rather than a flat
minconf=10; passminconfexplicitly where your logic depends on it. - Removed surface: audit for calls to key import/export, wallet export, migration, and
label RPCs; replace with the alternatives in the mapping table or remove.
z_shieldcoinbasecarries over unchanged apart from the fee argument, and so doesz_mergetoaddressapart from the fee argument and the one-source-class-per-call rule.
Addresses & shielded pools
How zecd generates and interprets addresses: one ZIP-32 account per wallet, fresh diversified
Unified Addresses from getnewaddress, the [pools] configuration that controls which receivers
those addresses carry, and the address behaviors that follow from zecd's
stateless design.
One account, many diversified addresses
Each zecd wallet holds a single ZIP-32 account (m/32'/coin_type'/account'). getnewaddress
returns a fresh Unified Address (u1... on mainnet, utest1... on testnet) on every call, but
these are diversified addresses of the same account, not new derivation paths: each is a
different diversifier index of the account's keys. librustzcash advances to the next unused
diversifier and persists the cursor, so every call yields a new, unused address, and all of them
receive into the same account and are spendable by the same key (ZIP-316 + ZIP-32 diversification).
Practical consequences:
- Handing out a distinct address per counterparty costs nothing and needs no key management: there is no keypool to top up.
- Every address a wallet ever issued is owned by its one account.
getaddressinfo.isminerecognizes even an issued-but-never-recorded address cryptographically, by attributing it to the account's incoming viewing key (see getaddressinfo). - "Multiple accounts" in zecd means multiple wallets; see the multiwallet routing in the RPC overview.
Configuring pools: [pools]
zecd is shielded-first. Each wallet declares which shielded pools it uses and which receivers its
Unified Addresses include, via the global [pools] section and/or a per-wallet
[wallets.<name>] override:
[pools]
enabled = ["sapling", "orchard"] # pools the wallet receives into and spends from
default_receivers = ["sapling", "orchard"] # receivers in the UAs getnewaddress hands out
- Supported shielded pools are
saplingandorchard. Ironwood (NU6.3) is not a third name here, now or later: upstream models ironwood notes as Orchard "V3" notes that reuse Orchard's keys, addresses, and note cryptography, so there is no ironwood receiver typecode to request or enable. Ironwood notes are received at ordinary Orchard addresses; the distinction lives at the transaction-bundle level, and surfaces aspool == "ironwood"in balances and history once NU6.3 activates. See Ironwood (NU6.3). - The default (
[pools]omitted entirely) is Orchard-only. default_receiversmust be a subset ofenabled; naming a disabled pool is a startup error.default_receiversomitted defaults toenabled.- Transparent receiving is not a pool in this list. It is a separate opt-in capability flag
(
transparent = true) layered on top. See Transparent support.
Balances, listunspent, and the history RPCs always report across every supported pool, not
just the enabled ones (the scan trial-decrypts all pools, so funds in a since-disabled pool
still show).
address_type: the per-call receiver override
getnewaddress's second argument (Bitcoin Core's address_type position) selects which
receivers the returned address carries, constrained to the wallet's enabled pools:
| Call | Returns |
|---|---|
getnewaddress "" | UA with the wallet's configured default_receivers (or a bare t-address if transparent_default = true) |
getnewaddress "" "unified" | same as above (alias: "default") |
getnewaddress "" "orchard" | UA with an Orchard receiver only |
getnewaddress "" "sapling" | UA with a Sapling receiver only |
getnewaddress "" "sapling,orchard" | UA with both shielded receivers |
getnewaddress "" "transparent" | bare t1.../tm... address (requires [pools] transparent = true) |
Rejections:
| Code | When |
|---|---|
-5 | Unknown address_type token (e.g. "bech32"), or "transparent" combined with a shielded pool in a comma list (zecd hands out one receiver kind at a time) |
-8 | Requested shielded receiver set is not a subset of the wallet's enabled pools |
-8 | "transparent" requested on a wallet without [pools] transparent = true |
-8 | Non-empty label argument (zecd is stateless and stores no labels) |
The token syntax (-5 cases) is validated before wallet resolution; enablement (-8 cases) is
per-wallet. Full parameter/response reference:
getnewaddress and, for zcashd-style fixed-index derivation,
z_getaddressforaccount (shielded receivers only; p2pkh is
rejected -8 there).
Change and spending
- Change from a shielded send goes to the strongest enabled pool: Orchard if enabled, otherwise the first enabled pool (i.e. Sapling for a Sapling-only wallet).
- Inputs are spent from any enabled pool.
- Recipients can be any address type: a transparent or Sapling recipient is payable from
Orchard funds under the default privacy policy. What a send is allowed to reveal is governed by
the privacy policy ladder, not by
[pools].
Keys always derive all pools
The [pools] config is address-generation and spend policy only. The wallet's spending key
(USK) and viewing key (UFVK) always derive key material for all pools regardless of
configuration. Two consequences:
- Enabling a pool later (e.g. adding
saplingto an Orchard-only wallet) requires no key migration; the wallet starts issuing addresses with the new receiver. - A watch-only wallet imported from an exported UFVK can derive addresses for any pool the spending wallet could.
Same-seed instances do not hand out identical address sequences
Shielded diversifier indexes are clock-derived: for shielded address requests,
zcash_client_sqlite starts the index at the current Unix time (plus a fixed offset) and
increments past collisions. So two instances of the same key material (a restored wallet, a
watch-only UFVK pair, two same-seed daemons) hand out the same address only if they happen to
call getnewaddress within the same second.
This is harmless (every address either instance issues belongs to the same account, and funds
sent to any of them are found by both), but do not build anything that assumes cross-instance
getnewaddress equality. If you need a deterministic address, derive it at a fixed diversifier
index with z_getaddressforaccount, which re-derives the exact same address for the same index
and receiver set on any instance.
(Transparent addresses are the exception: the transparent chain is sequential, not clock-derived; see Transparent support.)
Outgoing history shows the single receiver actually paid
When you pay a multi-receiver UA, exactly one receiver is paid on-chain (the pool the transaction
selected). The full UA you typed is sender-side metadata that never reaches the chain: the
authoring instance could cache it, but a restore-from-seed recovers only the single receiver
actually paid. To keep history deterministic across a restore, zecd's history RPCs
(listtransactions, gettransaction.details, listsinceblock, z_listtransactions) always
report an outgoing recipient as that single paid receiver:
- a bare
t...orzs...address for a transparent or Sapling payment, or - a single-receiver UA for an Orchard payment (Orchard has no standalone encoding).
The reduction is idempotent (a bare or single-receiver address reports as itself). Since 0.8.0
it applies to incoming outputs too, so a payer and a payee print the same string for one
output. Under the default Orchard-only default_receivers that is the same string
getnewaddress handed out; a wallet configured for several receivers sees the single receiver
paid instead, and zecd warns about that configuration at startup and in config check.
This is the stateless counterpart of zcashd's persisted recipient mapping, which echoes
the typed UA on the authoring instance but degrades to the single receiver after a restore
anyway. To match a receipt back to an address you issued, use the integer rather than the
string: received entries carry diversifier_index, which every encoding of that address
shares, and z_listunifiedreceivers splits a UA into the receiver strings history reports. See also the history RPCs.
Transparent support
Transparent (t-address) receiving and spending is off by default: a zecd wallet is shielded-only until you opt in.
An additive capability, not a mode
Transparent support is a separate per-wallet flag, not a member of the [pools]
enabled/default_receivers lists (those stay shielded-only; see
Addresses & shielded pools). Setting transparent = true adds the ability to
hand out (and, with a further opt-in, spend from) bare transparent addresses alongside whatever
shielded pools the wallet uses. A wallet can be Orchard-only plus transparent, Sapling+Orchard
plus transparent, and so on.
[pools]
enabled = ["orchard"] # shielded pools; transparent is NOT listed here
default_receivers = ["orchard"]
transparent = true # allow bare t-addresses (receive; opt-in spend)
transparent_default = false # true: no-arg getnewaddress returns a t-address instead of a UA
# transparent_gap_limit = 20 # restore-recovery window (see below)
# transparent_initial_scan = 0 # pre-expose external indices 0..N (see below)
# transparent_allow_beyond_recovery_window = true # issue past the window (warn) vs fail closed
# transparent_gap_warn_threshold = 5 # warn when this few in-window slots remain
All of these can also be set per wallet in [wallets.<name>]; see the
configuration reference. transparent_default = true requires
transparent = true (a startup error otherwise).
Getting a transparent address
With transparent = true:
curl -s --user "$RPCUSER:$RPCPASS" --data-binary \
'{"jsonrpc":"1.0","id":"doc","method":"getnewaddress","params":["","transparent"]}' \
http://127.0.0.1:8232/
# {"result":"t1...","error":null,"id":"doc"} (tm... on testnet/regtest)
The result is a bare transparent address. Each getnewaddress call yields exactly one
address kind, a bare t-address or a shielded UA; a transparent receiver is never mixed into a
Unified Address zecd hands out. (ZIP-316 forbids a transparent-only UA, so internally zecd
derives a compliant UA carrying a p2pkh receiver and bare-encodes just the transparent
receiver.) The shielded address_type forms keep working unchanged, and
transparent_default = true merely flips the no-argument default. Requesting "transparent" on
a wallet without the flag is rejected -8.
Transparent addresses come from the account's sequential BIP-44 external chain, unlike shielded addresses, whose diversifier indexes are clock-derived. That sequentiality is exactly what makes the gap limit below meaningful.
Asking for a specific index, and asking which index you got
New in 0.6.0. getnewaddress hands out the next address and returns a bare string, which
leaves an operator reconciling an issued range against the chain without either half of the
loop. Both halves now exist, and neither adds persistent state:
| Question | Call |
|---|---|
| "Give me the address at index 7." | z_getaddressforaccount 0 ["p2pkh"] 7 |
| "Which index is this address?" | getaddressinfo - address_index, plus Core's hdkeypath and ischange |
| "What will index 7 be, before the wallet exists?" | zecd derive-address - offline, no daemon |
Deriving at an explicit index runs the same exposure path as sequential issuance, so the
two agree by construction: the same recovery-horizon classification, the same warnings, the
same refusal when transparent_allow_beyond_recovery_window = false would put the index out
of restore range, and the same refresh of the address matcher. Directly addressing an index
therefore moves the issuance frontier exactly as getnewaddress would, which is what keeps
the two windows below coherent - and without the matcher refresh a payment to a directly
addressed index would simply be missed.
Receive discovery: block scan + mempool matching
Compact blocks omit transparent inputs/outputs, and librustzcash's shielded scan never records transparent receives, so zecd owns transparent receive discovery and does it the way zcashd does: by scanning blocks, not by per-address node queries. zecd already fetches and parses every full block to derive compact blocks for the shielded scan (see the Zebra backend), so it matches each block's transparent outputs against an in-memory set of the wallet's exposed addresses at no extra request. The cost is O(outputs-per-block) with a constant-time set lookup, independent of how many addresses the wallet holds, so an operator tracking ~100k addresses pays no per-address cost per block.
Incoming transparent payments also show at 0-conf: the mempool poller matches each mempool
transaction's transparent outputs against the same address set and records matches unmined, so a
payment appears in getunconfirmedbalance / listtransactions / listunspent with minconf=0
before its first confirmation, the same as a shielded receive. Once mined it is confirmed by the
block scan. Received transparent funds are reported by getbalance, listunspent,
getreceivedbyaddress, and the history RPCs, and getaddressinfo reports the address as
ismine.
One caveat: the block scan is forward-only and only matches outputs paying exposed addresses.
A payment to an address that becomes exposed only after its funding block was scanned
(out-of-order funding deep into the gap, with a small transparent_gap_limit) is missed until a
from-seed rescan. transparent_initial_scan (below) is the mitigation; automatic reconciliation
against the node's address index is not yet implemented.
The same pass finds spends, including ones this wallet did not author: each block's transparent inputs are tested against the outputs the wallet still holds unspent, so a spend made by another wallet on the same seed, or while this one was down, marks the output spent and records the outgoing entry. That test is bounded by the outputs held rather than the addresses issued, and costs no request of its own, since the block is already parsed for the shielded scan. Shielded spends need none of this: they are found through note nullifiers.
Spending: two ways out, both opt-in
Transparent funds leave the wallet by naming a transparent source on
z_sendmany: one of the wallet's own t-addresses to
spend that address's coins, or ANY_TADDR for any of them. Which rung of the
privacy policy ladder you need depends on where the value lands.
Shielding (t→z), AllowRevealedSenders. With a shielded recipient, the payment and the
change both land in the shielded pool. This is the way received transparent funds get shielded,
and it is the direction most integrations want: the coins stop being transparent. What it
reveals is the sender side, the addresses being spent and the amounts held at them, which is why
it is a rung above the default rather than free.
z_sendmany "ANY_TADDR" '[{"address":"<own UA>","amount":1.0}]' null null "AllowRevealedSenders"
Fully transparent (t→t), AllowFullyTransparent. With every recipient a bare transparent
address, the change is kept transparent and the transaction never touches a shielded pool. This
is the most revealing kind of send (recipient, amount, funding inputs and change all public),
which is why it sits on the top rung. It is also the only route for sendtoaddress/sendmany,
which take no fromaddress and no per-call policy argument, so for them the config value
[spend] privacy_policy = "AllowFullyTransparent" is the whole switch. zcashd's NoPrivacy
maps onto the same rung.
Under the default policy (AllowRevealedRecipients) a transparent source is refused with
-4, naming the rung that would allow it, and a transparent-only wallet's
sendtoaddress/sendmany fails -6: those methods only ever select shielded notes. Paying
to a transparent recipient from shielded funds works under the default policy, with
shielded change; FullPrivacy and AllowRevealedAmounts reject transparent recipients with
-8.
Transparent coinbase is never selected by either route.
z_shieldcoinbase is its only spend path,
because consensus forbids a transparent output in a transaction spending it.
Because librustzcash's high-level transfer API funds payments from shielded notes only and has no persistent transparent-change form, zecd builds the fully-transparent transaction itself: greedy ZIP-317-aware coin selection over the wallet's spendable transparent UTXOs, recipient plus change outputs, signed with the account's derived transparent keys, then recorded through the normal sent-transaction path (spent UTXOs are locked against double-spend and the transaction rides the rebroadcast loop).
Transparent coinbase is not selectable. Consensus requires a transaction that spends
transparent coinbase to have no transparent output at all, so coin selection skips those UTXOs
even once they mature: a t-to-t send cannot pay a recipient and return change from them. Mature
coinbase still counts as spendable value, so it is reported on its own as
getbalances.mine.coinbase and
getwalletinfo.transparent.coinbase_balance, and an insufficient-funds -6 names the amount
rather than leaving the gap between balance and send unexplained.
Change is routed to the wallet's internal (change) transparent chain, which matters twice: it is recovered on a from-seed restore via the internal gap chain, and the history RPCs recognize the internal key scope as change and hide it, while a deliberate payment to one of your own external t-addresses stays visible as a send+receive pair, matching Bitcoin Core.
Sweeping many transparent UTXOs at once
A wallet paid repeatedly at t-addresses accumulates a long tail of small UTXOs.
z_mergetoaddress, new in 0.7.0, consolidates
them into one output:
curl -s --user "$RPCUSER:$RPCPASS" --data-binary \
'{"jsonrpc":"1.0","id":"doc","method":"z_mergetoaddress",
"params":[["ANY_TADDR"],"u1...",null,0,null,"","AllowRevealedSenders"]}' \
http://127.0.0.1:8232/
With a shielded destination this is a bulk shield: it needs AllowRevealedSenders, because
spending transparent UTXOs reveals the sender either way. With a transparent destination it is a
fully transparent transaction and needs AllowFullyTransparent. The default per-call limit is
50 UTXOs and the response's remainingUTXOs says whether to call again.
Coinbase is not included, and cannot be: see the next section.
Coinbase: shielding is the only way to spend it
Transparent coinbase (a block reward or fee paid to one of the wallet's t-addresses) is a special case, and the rule comes from consensus, not from zecd's policy: a transaction that spends a transparent coinbase output may not have any transparent output, change included. There is therefore no valid t-to-t coinbase spend to build, and the whole selected value must move into a single shielded output.
z_shieldcoinbase is the method that does it:
it sweeps mature transparent coinbase UTXOs into one shielded output at toaddress, which
must have a shielded receiver. It is asynchronous in zcashd's style, returning an opid that
z_getoperationstatus / z_getoperationresult resolve. The shielded payment is exactly
input_total - fee, with no change in any pool: emitting shielded change would leak how much
coinbase the wallet chose to sweep. Use the limit argument (default 50) to sweep in stages.
curl -s --user "$RPCUSER:$RPCPASS" --data-binary \
'{"jsonrpc":"1.0","id":"doc","method":"z_shieldcoinbase","params":["*","u1..."]}' \
http://127.0.0.1:8232/
# {"result":{"remainingUTXOs":0,"remainingValue":0.00000000,"shieldingUTXOs":3,
# "shieldingValue":9.37500000,"opid":"opid-..."},"error":null,"id":"doc"}
The surrounding behavior follows from the same rule:
- Maturity is the standard 100 blocks, enforced during input selection. Immature coinbase
is excluded from
listunspententirely (Bitcoin Core'sAvailableCoinsbehavior), and its value is reported ingetwalletinfo.immature_balancerather than counted as spendable. listunspentmarks it. Transparent entries carry zcashd'sgeneratedboolean,truewhen the output came from a coinbase transaction.- The regular send paths skip it. The transparent-to-transparent spend above always
produces transparent outputs, so it never selects a coinbase input; nothing you do with
sendtoaddress/sendmany/z_sendmanycan build a consensus-invalid coinbase spend by accident. - Shielded coinbase (ZIP-213) needs none of this. A block reward mined directly to a shielded address has no maturity rule and no spend restriction, so those notes are ordinary Orchard notes and spend through the normal send methods.
The gap limit: transparent recovery is bounded
zecd is stateless: everything on disk must be rebuildable from the seed plus a chain scan. For shielded funds that recovery is unconditional (note trial-decryption needs no address list). Transparent funds are different: a from-seed restore rediscovers them only within the external transparent gap limit: the standard HD-wallet gap mechanism, made sharper by statelessness (there is no persisted keypool to fall back on).
Mechanically, recovery is bounded by which addresses are exposed (present in the matcher's address set). The window is anchored at the wallet's issuance frontier, the highest of:
- the last funded external index,
- the highest index
getnewaddresshas handed out, and - the
transparent_initial_scanfloor.
Indices from the frontier up to frontier + transparent_gap_limit are exposed, and a payment to
index N is discovered iff N is exposed. A funded index (or a fresh issuance) advances the
frontier and drags the window up with it, as in any HD wallet. The block-scan and mempool matcher
carries that window as an in-memory gap lookahead: transparent_gap_limit addresses derived
past the frontier, written to the wallet database only when a payment to one actually arrives. A
wide gap therefore costs derivation, not stored rows.
[pools] transparent_gap_limit (default 20, applied only to transparent-enabled wallets;
librustzcash's own default is 10) sets the external window. Transparent change consumes the
internal chain and is recovered via the internal gap (librustzcash's default internal window;
zecd only varies the external limit).
The gap limit composes with transparent_initial_scan
Because the transparent_initial_scan floor is one of the frontier's three inputs, the two knobs
add rather than compete. A from-seed restore has forgotten which addresses were handed out (that
is what statelessness means), and before the scan finds a funded index it has no funded index
either, so its frontier starts at the floor. The recovery horizon of a stateless restore is
therefore:
transparent_initial_scan + transparent_gap_limit
The window used to be measured from the last funded index alone, and the floor did not count.
An operator who pre-exposed, say, 70 000 addresses with transparent_initial_scan had to inflate
transparent_gap_limit to ~71 000 before getnewaddress would keep issuing recoverable
addresses: every issuance past the floor otherwise tripped the gap limit, was warned about as
potentially unrecoverable, and genuinely was unrecoverable from seed. That workaround is no
longer needed, and for the reason in the next section it is now actively discouraged.
Two windows: live lookahead vs restore recovery
The running wallet and a from-seed restore do not cover the same range, because the two windows are anchored on different events. Both behaviours are correct, and the difference is what decides whether funds survive a restore.
| Anchored on | Moves when | |
|---|---|---|
| Live lookahead (what the running matcher reaches) | address exposure | you hand an address out, or an index is funded |
| Recovery horizon (what a from-seed restore rediscovers within) | funding | an index is funded |
Issuance leaves no trace on chain, so a restore cannot know which addresses you handed out; it starts its frontier at the floor and works forward from funded indices. A running wallet, by contrast, must credit a receive on any address it handed out, so its lookahead follows issuance.
The consequence is a band that is matched live but not recovered from seed. It opens only
when an address is issued at or past the recovery horizon, which is exactly the act
transparent_allow_beyond_recovery_window governs and
already warns about, so it is an accepted operator choice rather than a surprise. Funding-driven
movement never opens the band: funding extends the restore's own window too, so a restore chains
forward to it.
getwalletinfo.transparent reports both windows, so this is observable rather than inferred:
"transparent": {
"gap_limit": 20,
"lookahead_from": 1,
"lookahead_through": 20,
"recovery_horizon": 20,
"restorable": true
}
lookahead_from/lookahead_throughare the live window, both inclusive. They describe the forward reach only: every address with a database row is matched too, including indices belowlookahead_from.recovery_horizonistransparent_initial_scan + transparent_gap_limit.restorableis the one to watch. It islookahead_from <= recovery_horizon, andfalsemeans the wallet is currently crediting addresses that a restore of the same seed would not rediscover. Alert on it rather than comparing the integers yourself.
Do not read a false as data loss: those funds are held and spendable, and are only at risk if
the wallet is later rebuilt from seed alone. Raising transparent_initial_scan (not
transparent_gap_limit, for the reason below) is the fix.
Sizing transparent_gap_limit: keep it small
Size the gap limit to the addresses you have handed out that are still unfunded, and no
further. Deep restore coverage is what transparent_initial_scan is for: a one-time
pre-exposure, not a per-receive cost.
The reason is that the window is re-derived on the receive path. Recording a transparent receive regenerates the entire gap window (a full unified-address derivation per index), and repeats that regeneration once per already-recorded output of the same transaction. At roughly 1200 derivations per second, a 71 000-wide window costs about a minute per received UTXO, and quadratically more for a multi-output transaction, all of it on the wallet's single-writer actor inside the sync batch. In the field (a zecd 0.5.1-rc2 report) this presented as a restore that appeared to stall: one core pegged, block scan frozen for hours.
zecd audits the configured value at startup: above 1000 (a gap limit already costing ~1s of derivation per recorded receive) it logs a warning, and above 10000 (worst case past ~10s per receive) it logs an error. Neither is a hard failure, and the daemon starts either way: the value stays the operator's choice, and what it costs is performance, not correctness.
Large pre-generated runs: transparent_initial_scan
A big gap limit is the wrong tool when you pre-generate many addresses: the gap is a sliding
window kept gap_limit past the frontier forever, so an exchange that assigns 10 000 addresses
and sizes the gap to match re-derives 10 000 addresses on every recorded receive, indefinitely.
Instead set [pools] transparent_initial_scan = N to pre-expose external indices 0..N once
at startup/restore, so the block-scan matcher covers the whole issued range regardless of the
(small) steady-state gap_limit. Set N to your issuance high-water mark and keep
transparent_gap_limit small: the floor raises the frontier, so issuance continues past N with
a normal-sized gap and stays recoverable to N + transparent_gap_limit.
Pre-exposure is incremental and non-blocking: it must complete before the block scan (a restore only finds a high funded index if that index was exposed first), but per-index derivation is slow at depth (~1180 addresses/s, so a 100k run takes minutes), so zecd exposes it in chunks of 1000 indices, servicing queued RPC commands between chunks; reads, sends, and the health endpoints stay live throughout. Progress is observable two ways:
- a throttled heartbeat log (done/total, %, rolling addr/s, ETA), and
getwalletinfo'stransparent.initial_syncobject,{"exposed": n, "total": N, "complete": bool}, present whenever an initial-scan depth is configured (absent when the depth is 0).
When transparent receiving is enabled, getwalletinfo also reports the effective
transparent block (enabled, default, gap_limit) and the daemon logs the gap limit and
initial-scan depth at startup, so coverage can be audited against your issuance records.
At the edge of the recovery window
librustzcash itself fails closed at the gap: once gap_limit consecutive unfunded external
addresses (above the initial_scan floor) have been handed out, it refuses to allocate another,
precisely because a from-seed restore could not rediscover funds sent there. zecd turns that edge
into an operator choice:
transparent_allow_beyond_recovery_window = true(default):getnewaddressissues the address anyway and logs a loud warning that funds received there may be unrecoverable from seed (downgraded to info when the index is still belowtransparent_initial_scan, hence recoverable). A payment to such an address is still detected live (issuing it refreshes the matcher's address set); the risk is confined to a later from-seed restore.transparent_allow_beyond_recovery_window = false: the call fails-4with an actionable message naming the knobs (fail-closed; funds can never land on an unrecoverable address).
Independently, transparent_gap_warn_threshold (default 5) makes getnewaddress warn as the
last few in-window slots are consumed, and a one-time startup audit re-warns if a wallet is
already near or over the window, giving lead time before addresses land outside it. The lead time
is best spent raising transparent_initial_scan (or getting a lower index funded), not inflating
transparent_gap_limit, for the per-receive derivation reason above.
Not implemented
- Auto-shielding. Ordinary (non-coinbase) transparent UTXOs are not automatically shielded
into Orchard, and such a receive cannot fund a shielded send. Those funds can be spent
transparently (under
AllowFullyTransparent) or left in place. Coinbase is the exception, and it is explicit rather than automatic:z_shieldcoinbase(above). - Mixed inputs. Transparent UTXOs and shielded notes cannot fund a single send together.
- Automatic address-index reconciliation. No periodic cross-check of exposed addresses against Zebra's transparent address index to backfill receives the forward-only scan missed. Since 0.6.0 the primitives to do it yourself exist - derive or look up an address by index, and ask which index an address is (above) - but nothing runs that loop for you.
See Known limitations for the details and planned direction of each.
Watch-only wallets
A zecd wallet can run watch-only: initialized from a ZIP-316 Unified Full Viewing Key (UFVK) instead of a mnemonic, it sees everything the paired spending wallet sees (balances, incoming payments including 0-conf via the mempool stream, full history) and issues receive addresses of the same account, while holding no spending material on disk or in memory.
Why: split the invoicer from the spender
The typical deployment puts the internet-facing half of a payment system on a machine that cannot lose funds even if fully compromised:
internet-facing host hardened / offline-ish host
┌───────────────────────────┐ ┌───────────────────────────┐
│ payment server / invoicer │ │ payout service │
│ getnewaddress │ │ sendtoaddress, sendmany │
│ listtransactions │ │ │
│ gettransaction │ │ │
│ │ │ │ │ │
│ zecd (watch-only, UFVK) │ │ zecd (spending wallet) │
└────────┼──────────────────┘ └────────┼──────────────────┘
└──────────────► Zebra node(s) ◄──────────┘
The watch-only instance issues invoice addresses and detects payments; the spending wallet, the only holder of key material, lives elsewhere and signs payouts. Because both wallets carry the same account's viewing key, every invoice the watch-only instance hands out is detected and spendable by the spending wallet (see the pairing guarantee below). A compromise of the invoicer host leaks your transaction graph (see the privacy warning) but never funds.
Watch-only wallets can also be loaded in the same daemon alongside the spending wallet as
additional [wallets.<name>] entries, addressed at /wallet/<name>; see
multiwallet routing.
Exporting the key: zecd export-ufvk
zecd --datadir ./data export-ufvk --wallet default
Prints the wallet's UFVK (uview1... on mainnet, uviewtest1... on testnet) to stdout, with
an explanatory warning on stderr. --wallet defaults to default. Properties, all deliberate:
- Offline. It reads the UFVK from the wallet DB (where it is stored for scanning anyway) over a read-only connection. No upstream connection is made and no identity file or passphrase is needed. It works for locked and passphrase-encrypted wallets alike, and never touches spending material.
- Works while the daemon runs.
export-ufvkis deliberately exempt from the exclusive datadir lock thatzecd initand the daemon take, so you can export from a live wallet. - Network-checked. It refuses to run if the configured network contradicts the wallet on disk (the UFVK encoding is network-scoped, so a mismatched key would be rejected by the watch-only side anyway).
Creating the watch-only wallet: zecd init --ufvk
On the watch-only host, initialize a fresh datadir from the exported key:
zecd --datadir ./watch init --ufvk "uview1..." --birthday 2500000
--ufvkconflicts with--restoreand--encrypt(there is no mnemonic and nothing to encrypt). The malformed-key check runs before any directory or network I/O.- Unlike
export-ufvk,init --ufvkneeds the chain backend reachable: it fetches the chain tip and the tree state at the birthday to anchor the wallet. - An imported key may have history, so it is treated like a restore: pass
--birthday(a height at or before the account's first transaction) to avoid the safe-but-slow default, which scans from the earliest enabled pool's activation (Orchard/NU5 for the default Orchard-only configuration; Sapling activation if Sapling is enabled) and logs a warning. - The result is a seedless
keys.tomlwith the UFVK pinned into it (the same account-to-keys binding check spending wallets get: every startup verifies the DB account against the pin, so a swapped database fails closed).
No mnemonic is printed; there is none. Init confirms (one line, on stderr):
Watch-only wallet (imported UFVK): balances, history, and addresses are available; spending and wallet-encryption RPCs are disabled.
RPC semantics
zecd follows Bitcoin Core's modern model: a wallet without private keys
(createwallet ... disable_private_keys=true in Core). Watch-only is a property of the whole
wallet, never of individual addresses.
| Surface | Behavior on a watch-only wallet |
|---|---|
getwalletinfo.private_keys_enabled | false. This is the watch-only signal, as in Core. (unlocked_until is absent: the wallet is not encrypted, there is nothing to lock.) |
getnewaddress | Works: diversified addresses derive from the viewing key. See the pairing guarantee below. |
Reads (getbalance, listtransactions, listunspent, gettransaction, ...) | Fully available, including 0-conf mempool visibility. |
sendtoaddress, sendmany, z_sendmany | -4 Error: Private keys are disabled for this wallet, byte-identical to Core's refusal, returned before any balance check. (For z_sendmany the error surfaces through the operation result.) |
walletpassphrase, walletlock | -15 Error: running with an unencrypted wallet, but walletpassphrase was called. (resp. walletlock), the same as any unencrypted wallet, byte-identical to Core. |
getaddressinfo | Unchanged: iswatchonly stays false and own addresses stay ismine: true, solvable: true. This matches Core master, where iswatchonly is documented "(DEPRECATED) Always false" (per-address watch-only died with legacy wallets) and solvable is defined "ignoring the possible lack of private keys". Do not probe getaddressinfo for watch-only status; use getwalletinfo.private_keys_enabled. |
The pairing guarantee
Every address the watch-only instance issues is a diversified address of the same account as the spending wallet (the UFVK can only derive its own account's addresses), so an invoice issued by the watch-only instance is always detected and spendable by the paired spending wallet, whose note detection is viewing-key-based and does not depend on which instance issued the address.
What is not guaranteed is that the two instances hand out the same address sequence:
librustzcash picks shielded diversifier indexes from the clock, so two same-key wallets return
identical getnewaddress results only when called within the same second. To verify a pairing,
compare key material (export-ufvk on both sides returns the identical string), not
getnewaddress output. See Addresses & shielded pools for the diversifier
mechanics.
One spender, many watchers
A single daemon may load at most one wallet with spending keys, plus any number of watch-only wallets alongside it. This keeps spend authority unambiguous: there is never a question of which key signs. Two enforcement points:
- At
zecd init: creating a spending wallet is refused up front (before any directory or network I/O) when another configured wallet already holds spending keys. The error suggests--ufvkinstead. Watch-only inits are exempt: any number are allowed. - At daemon startup, as a backstop for wallets created out-of-band (independent inits later merged into one config, restores, external DB edits): after every wallet reports its watch-only flag, a second spender is fatal for the whole daemon. zecd will not silently pick which one is "the" spender; the error names both offending wallets.
Each watch-only wallet configured this way is a full stack of its own: a database, an actor
and a scan. For a large number of them, an experimental fleet (since 0.8.0) shares
one database and one scan per shard of wallets, and adds them at runtime with createwallet
instead of a config edit and a restart.
To resolve a violation, convert one spending wallet to watch-only (zecd export-ufvk +
zecd init --ufvk into a fresh datadir, then delete the spending datadir) or remove it from
the configuration.
A UFVK grants full view access
A Unified Full Viewing Key reveals everything: all balances, all addresses (incoming and
outgoing sides), and the full transaction history of the account, forever. export-ufvk emits
the account's full viewing key; there is no reduced-visibility export. It cannot spend, but
treat it as a privacy secret:
- Share it only with hosts that may see your entire transaction graph.
- A watch-only datadir still deserves protection (filesystem permissions, encryption at rest): it contains the decrypted history, even though it holds no spending material.
- There is no way to revoke a leaked UFVK short of moving all funds to a new seed.
For the custody models and what a spending wallet protects beyond this, see Key custody.
Fleet: many watch-only wallets
Experimental. New in 0.8.0, off unless
[fleet] enabled = true. While it carries this label, the manifest format, the[fleet]keys and thecreatewallet,loadwallet,unloadwalletandlistwalletdirRPCs may change in a patch release, with no migration. A deployment with one wallet, or a handful of[wallets.<name>]entries, does not need it and is unaffected by it.
A fleet lets one daemon monitor a large number of watch-only wallets: a service that watches viewing keys on other people's behalf, for example. Spending is untouched. The one spending wallet stays a conventional wallet with its own database and actor.
Why it exists
A conventional view wallet costs a full stack of its own: a database, an actor, a scan pass, note-commitment trees and a memo backlog. At thousands of wallets against one upstream, that duplication is what stops zecd scaling.
A fleet puts many viewing keys into one shard: one database holding one account per wallet, behind one actor, scanned in one pass. librustzcash's scanner already trial-decrypts each block once against every account in a database, so a block is fetched once and decrypted once against every key in the shard. Trial decryption itself is per key and cannot be shared; everything around it is.
Shards stay bounded because adding an account rewinds its database to that account's birthday. In one database for everything, onboarding a wallet with an old birthday would make every other wallet re-scan with it.
Enabling it
[fleet]
enabled = true
# manifest_dir = "fleet/zec/wallets.d" # relative to the datadir
# dir = "fleet/zec/shards" # relative to the datadir
# shard_size = 128
# cohort_depth = 10000
| Key | Default | Description |
|---|---|---|
enabled | false | Run the fleet. Off, the manifest directory is never read, no shard is opened, and createwallet/loadwallet refuse with -4. |
manifest_dir | <datadir>/fleet/zec/wallets.d | One manifest per wallet. Key material, not a cache: see backup. A value is taken relative to the datadir and replaces the default outright, coin directory included. |
dir | <datadir>/fleet/zec/shards | The shard databases, one shard-NNNN/lrz/ each. A cache, rebuildable from the manifests and the chain. Relative to the datadir, as above. |
shard_size | 128 | Accounts per shard. A bigger shard shares more scanning but is rewound by more arrivals. 0 is a startup error. |
cohort_depth | 10000 | How far below a shard's oldest birthday an arriving wallet may be and still join it, in blocks; deeper arrivals start a new shard. |
Both directories sit under <datadir>/fleet/<coin>/ because a viewing key serves one currency
and placement is decided by a birthday, which is a height on one chain.
fleet is a reserved wallet name. A [wallets.fleet] entry would resolve to
<datadir>/fleet. With the fleet enabled, zecd refuses to start on that overlap; with it
disabled, zecd config check warns, since enabling it later would make the daemon refuse to
start. config check also warns when manifests are present while the fleet is disabled, and
restates the experimental status when it is enabled.
Manifests
Each fleet wallet is one file, <manifest_dir>/<name>.toml:
# <datadir>/fleet/zec/wallets.d/acct-00417.toml
ufvk = "uview1..."
birthday = 2837400
- The file stem is the wallet name, served at
/wallet/<name>. Names are ASCII letters, digits,-,_and.. ufvkandbirthdayare both required and nothing else is accepted. The birthday is required because defaulting it would choose between a full-chain rescan and a scan that misses the wallet's funds.- Files not ending in
.tomlare ignored. - An unreadable or malformed manifest is skipped with a warning and reported by
listwalletdir, so one bad file does not hide every other wallet. An unreadable directory, or two manifests for one name, is fatal at startup. - Manifests are written atomically (temporary file, fsync, rename) and never overwritten, so an
interrupted
createwalletcannot leave a torn viewing key.
Add a wallet by writing a manifest and restarting, or without a restart with
createwallet.
Placement
Which shard holds a wallet is read back from the shard databases, not recorded in a side file,
so there is nothing to disagree with them. A new wallet joins an open shard unless the shard is
full (shard_size) or the wallet's birthday is more than cohort_depth blocks below the
shard's oldest birthday, in which case it starts a new shard and rescans only against its own
key.
Two limitations are why the fleet is experimental:
- Placement groups wallets by arrival, not by birthday. A shard's scan floor is its oldest member's birthday, so a wallet born at today's tip that lands beside a 2018 wallet waits for that shard to scan from 2018. A wide spread of birthdays places poorly.
- A wallet cannot be removed once imported.
unloadwalletstops serving it but deletes nothing: the manifest and the account stay, and the shard keeps scanning its key. Removing it means deleting its manifest and rebuilding the shard.
What a fleet wallet can do
Fleet wallets are watch-only and shielded-only. They answer the read RPCs (balances,
history, listunspent, getaddressinfo) for their own account and nothing else, and they
cannot sign. Every read that reports a wallet's own money, history or addresses is scoped to
that wallet's account, so a wallet never sees its shard-mates' funds.
A watch-only daemon builds no Sapling prover and, when no loaded wallet can spend, does not warm the Orchard proving keys either.
Readiness after onboarding
A new wallet is served the moment it is placed, before its account exists: importing needs tree state below its birthday and waits for the shard's next connected pass. In that window its balances and history are empty. Two fields say which state it is in:
importedonwaitforsync:falseuntil the wallet's account exists.syncedis never true before it.import_erroronwaitforsyncandgetwalletinfo: present only when the import failed. It is terminal, sowaitforsyncreturns immediately rather than waiting out its timeout.
A restart adopts every account already in its shard database rather than importing it again.
Backup
manifest_dir holds viewing keys that exist nowhere else in zecd, and createwallet writes to
it at runtime. Back it up continuously, like keys.toml. It is the exception to the rule that
the data directory is a disposable cache (see what to back up).
dir is a cache: delete it and the shards are rebuilt from the manifests plus a rescan. Put it
on scratch storage if that helps, and keep the manifests somewhere backed up.
Embedding
An embedder reaches a fleet wallet's database through Node::wallet_location, the only
supported route to a shard directory. See embedding.
Deployment
How to run zecd in production: the Docker Compose stack, the container images and how to
extract bare binaries from them, the prebuilt .tar.gz/.deb release artifacts, and the
health-probe wiring for Kubernetes and load balancers. Day-2 concerns (backups, monitoring,
upgrades, failure modes) are in the operations runbook.
The Docker Compose stack
deploy/docker-compose.yml runs the two-service stack: a Zebra full node and zecd talking
straight to Zebra's JSON-RPC over the private compose network. Testnet by default. The
config files it mounts (deploy/zebrad.toml, deploy/zecd.toml) are part of the stack;
the mainnet variants (*.mainnet.toml) are swapped in by the
docker-compose.mainnet.yml overlay.
First run is init-then-up: Zebra must be synced far enough before zecd can create a wallet (a new wallet's birthday defaults to just below the current chain tip, 100 blocks back).
cd deploy
docker compose up -d zebra # let it sync
docker compose run --rm zecd init --wallet default
docker compose up -d # start zecd
curl localhost:9233/healthz
curl localhost:9233/readyz
curl --user zec:CHANGE-ME --data-binary \
'{"method":"getblockchaininfo","id":1}' localhost:18232/
zecd init prints the wallet mnemonic to stdout once. Record it offline; it is the only
way to restore the wallet. See operations for what else to back up.
For mainnet, add -f docker-compose.mainnet.yml to every command. The overlay only swaps
each service's mounted config file; ports and wiring are unchanged (the mainnet configs
deliberately keep zecd on 18232 and Zebra on 18234 so the compose port mapping is identical
across networks):
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml up -d zebra
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml run --rm zecd init --wallet default
docker compose -f docker-compose.yml -f docker-compose.mainnet.yml up -d
Three things to change before trusting the stack with real funds:
- Pin Zebra. The compose file pins
zfnd/zebra:6.3.0for both networks, which activates Ironwood (NU6.3) at the network's activation height (see Zebra's source and release notes). The tag is an example. Pin to a release you have verified; Zebra's flags can vary between versions. On testnet, past NU7's activation (block 4,465,026) both the node and zecd must support NU7; see NU7. (Zebra tags have novprefix.) - Set a real RPC password. The shipped configs use
password = "CHANGE-ME". On mainnet zecd refuses to start while the[rpc]password is still that placeholder (case-insensitive): the RPC credential is spend authority. On testnet it starts, but change it anyway before exposing the port. - Keep the RPC port private. The compose file publishes 18232 and 9233 on loopback only. RPC credentials travel as plaintext HTTP Basic auth; to serve other hosts, front zecd with TLS or a reverse proxy, or accept the exposure knowingly.
The compose configs bind [rpc] and [health] to 0.0.0.0 inside the container (so the
published ports are reachable) and point [backend] server at zebra://zebra:18234. That
connection carries no credentials (enable_cookie_auth = false on the Zebra side), which
is what the cleartext-credential gate expects for a non-local
hostname.
zecd takes an exclusive lock on its data directory: never run two zecd instances (or replicas) against the same volume. The second one refuses to start rather than corrupt the wallet DB.
Container images
Two Dockerfiles produce interchangeable images:
Dockerfile(amd64): a reproducible StageX build. Full-source-bootstrapped base images pinned by digest, a statically linked muslzecd, deterministic flags (SOURCE_DATE_EPOCH=1,codegen-units=1,--build-id=none), and a barescratchruntime. Independent builders can reproduce the binary bit-for-bit.Dockerfile.arm64(arm64): StageX publishes amd64 images only, so ARM uses a static-musl Alpine build (rust:alpine, base image pinned by digest, toolchain pinned to exact apk versions, Rust pinned viaRUSTUP_TOOLCHAIN). Same output shape and the same runtime contract, and still bit-for-bit reproducible, but the toolchain is upstream binaries rather than StageX's full-source bootstrap. Both are published as one multi-arch image, so the same tag pulls the right architecture.
How the reproducibility works (and what to verify) is covered in reproducible builds.
Runtime contract
Both images honor the same contract, so they are drop-in interchangeable:
| Property | Value |
|---|---|
| Binary | /usr/local/bin/zecd (static musl, no shell or libc in the image) |
| Entrypoint | zecd, default args --datadir /var/lib/zecd |
| User | 10001:10001 (unprivileged, non-root) |
| Workdir / datadir | /var/lib/zecd (writable by the runtime user) |
| Exposed ports | 8232, 18232 (JSON-RPC mainnet/testnet), 9233 (health) |
| Base | scratch, plus a CA bundle at /etc/ssl/certs/ca-certificates.crt (SSL_CERT_FILE) for a TLS lightwalletd upstream, and the license texts under /usr/share/doc/zecd/ |
The image also ships a world-writable /tmp for SQLite's temporary files. Because the
runtime is scratch, there is no shell: debugging happens through the RPC and health
endpoints, or by mounting the datadir elsewhere.
Extracting bare binaries
Each Dockerfile has an export stage that copies the static binary to the image root, so
you can build and extract without running a container:
docker build --target export -o ./out . # amd64: ./out/zecd
docker build -f Dockerfile.arm64 --target export -o ./out . # arm64
This is exactly how the release workflow produces the published binaries, so a local
export should reproduce the binary inside the released .tar.gz/.deb bit-for-bit for
the same source.
Prebuilt release artifacts
Pushing a v* tag runs the Release workflow. It extracts the binary from each
Dockerfile's export stage (so published binaries inherit the reproducible image
pipeline) and attaches, per architecture (amd64 and arm64, both static musl builds):
zecd-<version>-linux-<amd64|arm64>.tar.gz: the binary plusREADME.md,CHANGELOG.md, both license files,THIRD-PARTY-LICENSES.txt, andzecd.example.toml. The tar is reproducible (sorted entries, fixed mtime, root-owned,gzip -n).zecd_<version>_<amd64|arm64>.deb: a reproducible Debian package (scripts/build-deb.sh: fixed mtimes,--root-owner-group,SOURCE_DATE_EPOCHanchored; verified bit-for-bit).SHA256SUMS: one file covering every artifact in the release, in the standardsha256sum -cformat.
Changed in 0.6.0. Artifacts now use the Debian/Go architecture names (
amd64/arm64) rather than Rust target triples, so the tarball and the.debagree on how they spell an architecture and the redundantunknown-linux-muslsegment is gone. The per-file.sha256sidecars were replaced by the singleSHA256SUMS. Through 0.5.2 the tarball was namedzecd-<version>-x86_64-unknown-linux-musl.tar.gzwith a.sha256beside it; a script that fetches release assets by filename needs updating.
Verify the checksums, then install:
sha256sum -c SHA256SUMS --ignore-missing # `shasum -a 256 -c` on macOS/BSD
sudo apt install ./zecd_<version>_amd64.deb # or _arm64.deb on ARM
--ignore-missing checks only the files you actually downloaded; drop it to require
every artifact in the release to be present.
The .deb installs:
/usr/bin/zecd/lib/systemd/system/zecd.service: installed but not enabled; it runszecd --datadir /var/lib/zecdas thezecduser with systemd hardening (NoNewPrivileges,ProtectSystem=strict,PrivateTmp, writable only in/var/lib/zecd)/usr/share/doc/zecd/:zecd.example.toml,README.md,THIRD-PARTY-LICENSES.txt, copyright, changelog
The postinst script creates the zecd system user/group and /var/lib/zecd (mode 0750).
No config file is installed under /etc; put your config at /var/lib/zecd/zecd.toml (the
datadir default) or point the unit at one with --conf. Then:
sudo -u zecd zecd init --datadir /var/lib/zecd # one-time wallet creation
sudo systemctl enable --now zecd
The same workflow pushes the GHCR image as one multi-arch manifest (amd64 and arm64) under
bare semver tags (<major>.<minor>.<patch> and <major>.<minor>), with no
per-architecture suffix. A manual workflow_dispatch run can dry-run the packaging without a tag;
image pushes are opt-in for those runs.
Ports
| Port | Service | Protocol | Notes |
|---|---|---|---|
| 8232 | zecd JSON-RPC (mainnet) | HTTP, Basic/cookie auth | Bitcoin-convention port; spend authority, keep private |
| 18232 | zecd JSON-RPC (testnet/regtest) | HTTP, Basic/cookie auth | Also used for mainnet in the compose stack (config choice) |
| 9233 | zecd health | HTTP, unauthenticated | /healthz, /readyz, /status |
| 8234 | Zebra JSON-RPC (mainnet) | HTTP | What server = "zebra" expects; set rpc.listen_addr here |
| 18234 | Zebra JSON-RPC (testnet/regtest) | HTTP | Testnet counterpart; keep off public interfaces |
Zebra ships with RPC disabled and has no default RPC port; 8234/18234 are the ports zecd's
default server = "zebra" preset dials, chosen next to Zebra's P2P ports (8233/18233).
Any explicit zebra://host:port works.
Health and readiness probes
zecd serves unauthenticated probes on a separate port (default 9233), designed for Kubernetes probes and load-balancer health checks:
GET /healthz: liveness. Always 200 while the process runs.GET /readyz: readiness. 200/503 plus a JSON body withready,locked, a per-wallet map, and (when not ready) areasonofactor_down,upstream_down,enhancing, orsyncing.GET /status: a JSON snapshot of per-wallet sync state, for humans and dashboards (see operations).
Defaults are [health] enabled = true, bind = "127.0.0.1", port = 9233. In a
container or behind a probe, set bind = "0.0.0.0" (the deploy configs do).
What /readyz means is a deployment choice, [health] readiness:
"synced"(default): ready only once every wallet is connected, withinmax_scan_lagblocks of the tip (default 4), and its transaction-enhancement backlog has drained. Strict: a from-birthday restore stays not-ready until the memo backfill finishes, which on a large wallet takes a long time (reasondistinguishessyncingfromenhancing). Use it when clients must not see stale balances or incomplete history."scanned"(new in 0.6.4): connected and withinmax_scan_lagof the tip, without the drained-backlog term. Balances and note spendability are current in this state, since both come from the block scan; only history completeness lags. Use it when a wallet holds many transparent UTXOs and sends regularly, where the backlog term makes/readyzflap after routine sends and takes a working node out of rotation. See the operations runbook."connected": ready as soon as the backend is connected and its chain tip is past the wallet's birthday (a sanity check that zecd is talking to the right, live network). Does not wait for the wallet scan at all, so RPC clients can reach zecd while it catches up. Reads may lag the tip arbitrarily.
A locked encrypted wallet is still ready (reads work); /readyz reports it via the
locked flag so a controller can drive a walletpassphrase without misreading it as a
sync stall. A dead wallet writer actor fails readiness (reason: "actor_down") even
though reads still answer; that needs a process restart.
[health]
bind = "0.0.0.0"
port = 9233
readiness = "synced" # the default; or "scanned", or "connected"
max_scan_lag = 4 # applies in "synced" and "scanned" modes
Kubernetes example:
startupProbe:
httpGet: { path: /healthz, port: 9233 }
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: { path: /healthz, port: 9233 }
readinessProbe:
httpGet: { path: /readyz, port: 9233 }
periodSeconds: 10
Changed in 0.6.0: the proving key no longer delays startup. It is built on a background task, so the daemon spawns its wallet actors and binds the health and RPC listeners immediately;
/healthzanswers within the first moments of process life. Through 0.5.2 the keygen ran before the listeners bound - seconds of CPU on a fast machine and considerably more on a small VPS at the time - during which the process was unreachable and not syncing, and startup probes had to be sized around it. If you widenedfailureThresholdorinitialDelaySecondsfor that reason, you can tighten it back.Only the first send can now observe the build, and only if it arrives before the build finishes, which on any real deployment it does not.
Startup-probe headroom is still worth keeping for the scan, which is unrelated to keygen:
after a restore or an upgrade with a long offline gap, prefer readiness = "connected" or a
generous readiness budget, since in the default "synced" mode a catching-up wallet answers
503 until it reaches the tip and finishes backfilling memos.
Allocator: why the images use mimalloc-secure
Both images build with --features mimalloc-secure. The static-musl binaries would
otherwise use musl's default allocator, which serializes on a lock under Orchard proving's
multi-threaded allocation churn: roughly 80x more futex syscalls per proof than mimalloc,
measured as about a 10% cost per shielded send on bare metal and several times worse in
syscall-expensive sandboxes (gVisor, nested virtualization, some CI). The -secure
variant adds heap hardening (guard pages, canary free-lists) for under 4% on the proving
path, recovering mitigations that replacing musl's hardened allocator would otherwise
drop. Native
glibc builds (from source, outside the images) do not need the feature; glibc's allocator
already scales.
Operations runbook
Running zecd on mainnet: what to back up, how to restore, what to monitor, how sends behave under failure, and how to upgrade. For getting the stack up in the first place, see Deployment; for config keys, see the configuration reference.
What to back up
Funds are recoverable from the mnemonic alone. Everything else is convenience.
| Artifact | Where | What it protects |
|---|---|---|
| 24-word mnemonic | shown once by zecd init | The funds. Record offline (paper/HSM). Loss of the server without it is loss of funds. |
| Birthday height | inside keys.toml; also record it with the mnemonic | Makes a from-seed restore fast. Any height at or before the wallet's first transaction works. |
keys.toml | <wallet dir>/keys.toml, or wherever keys_file points | The age-encrypted mnemonic plus network and birthday. Useless without the identity; pair the two for a full server restore. This is the file you ship as a Secret. |
identity.txt (age identity) | [keys] age_identity, default <datadir>/identity.txt | Decrypts keys.toml. This is spend authority. Store its backup separately from keys.toml backups. |
| Fleet manifests (only with a fleet) | [fleet] manifest_dir, default <datadir>/fleet/zec/wallets.d/ | The viewing keys and birthdays of every fleet wallet. They exist nowhere else, and createwallet writes them at runtime, so back the directory up continuously. |
Do not back up data.sqlite (since 0.7.0 it lives under <wallet dir>/zec/lrz/; see
wallet data layout). It is a cache derived from the chain: zecd is
stateless, so with the mnemonic (and birthday) the whole data
directory can be recreated. Shielded funds are unconditionally recoverable from seed;
transparent funds only within the gap-limit / initial-scan window (see
Transparent support).
Minimal runtime file set
Per wallet directory <dir>:
| Path | Role | Ship it? |
|---|---|---|
<dir>/keys.toml | Secret: encrypted seed + birthday/network | Yes. Mount as a Secret; relocate with keys_file / ZECD_KEYS_FILE. |
identity.txt | Secret: decrypts the seed (spend authority) | Yes, if auto-unlocking. Mount as a Secret (ZECD_AGE_IDENTITY). |
<dir>/zec/lrz/data.sqlite (+ -wal/-shm) | Cache: account, scan progress, balances, history. Rebuilt from keys.toml plus a rescan. | No. |
<dir>/zec/lrz/blocks/, blockmeta.sqlite | Legacy cache: the on-disk compact-block cache releases before 0.8.0 kept. Since 0.8.0 the batch being scanned is held in memory. Dead weight if present; zecd rescan removes it. | No. |
<datadir>/fleet/zec/wallets.d/ | Fleet manifests (viewing keys), written at runtime by createwallet | Yes, if running a fleet. Not a cache. |
<datadir>/fleet/zec/shards/ | Fleet shard databases. Rebuilt from the manifests plus a rescan. | No. |
<datadir>/.cookie | Ephemeral RPC cookie, minted at startup, removed on clean shutdown | No. |
Keep secrets out of the TOML (which typically lives in a ConfigMap):
- RPC password:
ZECD_RPC_PASSWORD,--rpcpassword, or[rpc] password_file(flag/env >password_file> inlinepassword). Prefer the env var orpassword_file: a password on the command line is visible to any local user viaps, and zecd warns at startup when it is passed that way. keys.tomllocation:ZECD_KEYS_FILE/--keys-file/[keys] keys_file(per-wallet[wallets.<name>] keys_file).- age identity:
ZECD_AGE_IDENTITY/--age-identity/[keys] age_identity.
Wallet data layout
Since 0.7.0 a wallet directory nests its derived state one coin and one engine deep:
<datadir>/<wallet>/keys.toml <- the seed. Stays at the root.
<datadir>/<wallet>/zec/lrz/data.sqlite
Before 0.7.0 these sat flat in the wallet directory, together with an on-disk block cache
(blockmeta.sqlite, blocks/). Since 0.8.0 the block cache is in memory, so 0.7.x leaves
those two in zec/lrz/ and nothing reads them; zecd rescan deletes them.
The split follows what can be rebuilt from what. keys.toml wraps a BIP-39 seed that serves
every coin and that nothing on any chain can reconstruct, so it stays at the top. Everything
below it is derived state, namespaced first by the coin that owns it and then by the library
that wrote it, so a second coin gets a sibling directory rather than a share of one flat
namespace, and replacing the storage library becomes a sibling directory plus a rescan.
The migration
Existing wallets migrate themselves on first start. No configuration changes, including for
a wallet with an explicit dir. What it guarantees:
- It runs under the datadir lock, before anything opens a wallet, and also at
zecd initandzecd rescan. - Artifacts are renamed within the wallet directory, never copied, so no free disk space is needed and nothing is deleted.
- The SQLite
-waland-shmsidecars travel with their databases. A-walholds committed transactions not yet checkpointed back, so moving a database without it would silently discard the most recent writes. - An interrupted run resumes: the next start moves whatever is left.
- A failure is fatal by design. It is not a silent fallback to rebuilding an empty database, because that would look like a working daemon with a wallet that has lost its history. The one case it cannot resolve on its own is an error leaving copies in both places, which it reports for an operator to settle.
Take the usual datadir backup before the upgrade anyway. The worst case is a from-seed restore, not lost funds, but a restore costs a scan.
zecd config check reports a pending move as a warning and the both-places state as an error,
so an upgrade can be dry-run against a live deployment before anything is stopped.
Read-only commands take no datadir lock and therefore cannot migrate. zecd export-ufvk reads
an un-migrated wallet database where it lies; zecd derive-address needs no fallback at all,
since it reads only keys.toml, which the migration never moves.
If you compute these paths yourself
Backup scripts, log shippers, and volume mounts that hard-code <wallet>/data.sqlite need
updating to <wallet>/zec/lrz/data.sqlite. The exclusion advice above is unchanged in
substance: exclude the engine directory, keep keys.toml.
Embedders must use config::engine_dir or config::WalletEntry::engine_dir rather than joining
the components by hand, since the layout is versioned by coin and engine. See
embedding.
Restore procedures
Server restore (you have keys.toml + identity.txt)
Put both files back at their configured paths and start the daemon. With
[keys] bootstrap_from_keys (default true), an empty data directory next to a present
keys.toml is rebuilt automatically on boot: zecd recreates the account from the seed and
rescans from the stored birthday. No init needed. This is the disposable-datadir pattern:
mount one Secret, start with an empty volume.
When the rebuild runs depends on the custody model:
- Identity /
auto_unlock: the seed decrypts at startup, so the rebuild runs as soon as Zebra is reachable. No human action. - Encrypted (
init --encrypt): the wallet starts locked with no account yet; address and spend RPCs return "account is not ready", and/statusreportslocked: true. The rebuild runs at the firstwalletpassphrase, after which the wallet syncs (and stays synced while locked). zecd probes datadir writability when it loads the wallet, so a read-only datadir fails at startup rather than at unlock time. - Watch-only (
--ufvk): no seed, not covered by bootstrap. Recreate withzecd init --ufvkagainst an empty datadir (see Watch-only wallets).
Set bootstrap_from_keys = false to fail fast on an empty datadir instead.
From-seed restore (you have only the mnemonic)
zecd init --datadir /var/lib/zecd --restore --birthday <height>
# paste the mnemonic when prompted
Always pass --birthday (any height at or before the wallet's first transaction). Without
it, the restore scans from the activation height of the wallet's earliest enabled pool
(Orchard/NU5 for the default Orchard-only config, Sapling activation when Sapling is
enabled): safe (it can never miss notes) but slow on mainnet. History reappears as the scan
progresses; do not trust balances until the scan and enhancement backlog finish ("synced"
readiness, which is the default, or /status showing fully_scanned at the tip and
pending_enhancements 0. The looser "scanned" and "connected" modes report ready before
that point.)
Non-interactive restore: set ZECD_MNEMONIC, or pass --mnemonic-file <path>
(ZECD_MNEMONIC takes precedence; stdin is the fallback). For init --encrypt, set
ZECD_WALLET_PASSPHRASE instead of answering the prompt.
Watch-only replica
Export the viewing key on the spending host with zecd export-ufvk, then
zecd init --ufvk "uview1..." --birthday <height> on the replica. A watch-only wallet is
fully reconstructable from UFVK + birthday; record both. The UFVK cannot spend but reveals
the wallet's entire transaction graph, so treat it as confidential.
Monitoring and alerting
zecd serves unauthenticated probes on a separate port (default 9233) when [health] enabled
(the default):
| Endpoint | Semantics |
|---|---|
GET /healthz | Liveness. 200 ok while the process runs. |
GET /readyz | Readiness, 200/503, gated by [health] readiness. |
GET /status | JSON snapshot: per-wallet sync state, active upstream endpoint, conn_state (down | syncing | ready), pending_enhancements, locked, and since 0.8.0 sync_totals and enhance_totals. |
Where sync time goes (since 0.8.0). /status carries two cumulative per-wallet objects, so
a slow restore can be attributed to the upstream or to this host from one read:
sync_totals, for the block scan:batches,blocks,bytes,txs,sapling_outputs,orchard_actions, and the time splitdownload_ms,tree_state_ms,scan_ms,transparent_ms,total_ms. Each batch also logs abatch completeline with the same split for that range.enhance_totals, for the memo drain that follows:passes,serviced, andrequests_ms(reading the request table),upstream_ms(waiting on fetches),apply_ms(storing the results),per_request_ms. The drain is bound by upstream round trips and the single writer, so it does not scale with this host's cores the way the scan does.
Readiness modes, strictest first:
"synced"(default): ready only once every wallet is connected, within[health] max_scan_lagblocks of the tip (default 4), and with an empty enhancement backlog. A from-birthday restore stays not-ready until it has scanned to its own funds and finished backfilling memos."scanned"(new in 0.6.4): connected and withinmax_scan_lagof the tip, without the empty-backlog term. Balances and note spendability come from the block scan and are current in this state; only history completeness lags. Choose it for a deployment that sends regularly from a wallet holding many transparent UTXOs, where the strict mode's backlog term flaps readiness after routine sends (see below)."connected": ready once the backend is connected and its tip is past the wallet's birthday. Does not wait for the scan at all, so readiness never flaps during a long catch-up; reads may lag the tip arbitrarily.
A 503 body carries a reason. Route alerts on it:
reason | Meaning | Action |
|---|---|---|
upstream_down | Zebra unreachable | Page someone. |
actor_down | A wallet's writer actor died | Restart the process. |
enhancing | Scanned to tip, still backfilling memos ("synced" mode only; "scanned" and "connected" stay ready) | Wait; watch pending_enhancements trend to zero. If it recurs after ordinary sends rather than after a restore, see the note below. |
syncing | Normal block catch-up | Wait. |
"Scanned to tip" is not "ready". Compact blocks carry no memos, so after the block scan
catches up, an enhancement pass fetches each transaction's full data from Zebra and decrypts
it to backfill memos. On a from-birthday restore of a busy wallet that is one fetch + decrypt
per transaction, which can take a long time after scan_progress hits 1.0 (since 0.8.0 it
runs [sync] enhance_concurrency fetches at once; a deployment that never reads memos can skip
it with [sync] fetch_memos = false). While the
backlog drains, conn_state stays syncing, getwalletinfo.scanning and
getblockchaininfo.initialblockdownload stay truthy, and "synced" readiness holds 503 with
reason="enhancing". Watch /status pending_enhancements; if it drains slowly, check that
Zebra's getrawtransaction is fast.
The backlog is not restore-only. A wallet holding many transparent UTXOs re-emits its recurring spend-search requests every time the chain tip advances past an unspent output's observed height, so
pending_enhancementsrises transiently after ordinary sends and new blocks, in steady state, on a wallet that has been synced for weeks. Under the default"synced"readiness that is enough to answer 503 withreason="enhancing"for as long as the drain takes, which on a busy address is long enough for an orchestrator to pull the node out of its service while every balance RPC is answering correctly.If you see readiness flapping after sends rather than after a restore, that is this, and
readiness = "scanned"is the answer: it keeps the height gate and drops the backlog term, so a node that is scanned to the tip stays in rotation while memos land behind it. Clamp any consumer that needs complete history topending_enhancementsreaching zero rather than to readiness.Fixed in 0.6.4, separately from the mode: the backlog count previously included duplicate requests, several thousand of them on a wallet with a reused transparent address, because the upstream query that generates them matched on transaction id alone. A count in the tens of thousands on such a wallet was mostly an artifact.
pending_enhancementsnow reports distinct outstanding requests, so figures from 0.6.3 and earlier are not comparable with figures from 0.6.4.
Bounding history completeness precisely. Since 0.7.0,
getwalletinfo reports a top-level
enhanced_through height, and pending_enhancements appears inside its scanning object. That
matters for two readers the health server does not serve: a library consumer running with
default-features = false has no health port at all, and anyone driving zecd purely over
JSON-RPC previously had to run one just for this number.
enhanced_through is the height below which history is complete, so a consumer replaying wallet
history as a log clamps its cursor to it rather than to readiness. It is null when not
currently determinable, which must be read as hold the cursor, never as "everything is
enhanced".
waitforsync (also 0.7.0) blocks until both the scan and
the backlog are done and returns all of these in one object, which is usually what a restore
script wants instead of a poll loop.
locked (top-level on both /readyz and /status, plus per-wallet) is true when a
passphrase-encrypted wallet needs a walletpassphrase before it can spend. It is reported
independently of readiness (a locked wallet can be ready: true), so a controller can drive
an unlock without mistaking it for a sync stall.
For load visibility, getrpcinfo returns active_commands: one entry per executing call
with method and duration (microseconds).
Logs: set [log] format = "json" for aggregation (Loki/CloudWatch/Elastic). Every RPC call
logs method, wallet, elapsed_ms: debug on success, except a call taking 2 s or more,
which logs rpc ok (slow) at info (since 0.8.0); errors log at info and add
code/message, except method-not-found (-32601), which logs at debug so a client probing
an absent method does not write one line per poll. Sync and connection lifecycle events log at info; connection failures at
warn.
Suggested alerts:
/readyz503 withreason=upstream_downfor more than 5 minutes./statussync lag (chain tip minus scanned height) not shrinking for 30 minutes.- Sustained HTTP 503 from the RPC port (work queue exhausted).
- Daemon restarts.
The health server starts after wallets load, so cover startup at boot with a
startupProbe / initialDelaySeconds. The port is unauthenticated by design and exposes
sync status only; keep it off the public internet anyway.
Structured logging
Reworked in 0.7.0. Text output looks much as it did; JSON output ([log] format = "json") is
now queryable without matching on message strings.
Context lives on spans, not in prose. Everything emitted while an RPC is handled, the
sanitized error detail lines included, is attributed to that call's method and wallet through an
rpc span. Every event from a wallet's actor carries the wallet name as a real field on a
wallet span, rather than a [name] prefix pasted onto the message text.
Numbers are fields. Durations, counts, and the rates on the send profile, the scan batches, and the periodic heartbeats used to be baked into sentences. They are now fields you can aggregate on.
The zecd::audit target
A stable tracing target carries the security-relevant events, so an operator routes them to a separate sink with a one-line filter instead of matching on message text:
- the RPC authentication mode chosen at startup, and per-request outcomes;
- the account-to-keys binding pin;
- seed unlock and relock, including the
walletpassphrasetimeout auto-lock; - transparent issuance past the recovery horizon;
- every process-hardening step that was a no-op or was opted out of;
- the loaded spending wallets, when
allow_multiple_spending_walletsis on.
Route it with a RUST_LOG directive, which overrides [log] level entirely when set:
RUST_LOG='warn,zecd::audit=info' # audit trail only, plus real problems
Two log levels moved
Both changes reduce steady-state volume, and both are worth knowing before you write an alert on the old behaviour:
- Per-request authentication success dropped from INFO to DEBUG, removing one line per authenticated request. Failures stay at WARN.
- Reconnect attempts during an upstream outage no longer stream WARNs. The first failure warns and names the demotion; the paced retries after it log at DEBUG with their attempt number and delay. An alert that counted reconnect WARNs will now fire once per outage rather than continuously, which is the intent.
Two more moved in 0.8.0:
- A slow successful RPC logs at INFO. A call that takes 2 seconds or more logs
rpc ok (slow)at INFO, where every success used to be DEBUG only. Seconds on a read means a wallet has outgrown a query, and this makes it visible at the default level. - Method-not-found dropped from INFO to DEBUG. A client whose dialect fallback polls a method zecd does not serve would otherwise write one INFO line per poll. Every other error code stays at INFO.
Each send's send complete line also carries tx_bytes, the built transaction's size, beside
est_tx_bytes, the estimate [spend] max_tx_bytes was checked against, and warns when the
estimate came in under the real size.
New TRACE events exist for one-per-downloaded-block and one-per-serviced-transaction-data request. They are off unless asked for, and are verbose enough that you want a narrow filter.
Startup and shutdown
One identifying line is logged at startup with the version, network, datadir, and upstream, which nothing did before. Shutdown warns when async operations are still unfinished.
Send semantics under failure
See Sending for the RPC surface; this is the operational contract.
sendtoaddressandsendmanyare synchronous and compute Orchard proofs, so a call holds the HTTP connection for a few seconds plus any queueing behind other sends (sends serialize per wallet). Set client-side send timeouts well above that. (z_sendmanyreturns an operation id immediately; see async operations.)- A client timeout is not a failure. The send may still complete on the server. Retrying
a send that actually succeeded pays twice, exactly as with bitcoind, but the longer proving
window makes it likelier. On timeout, reconcile with
listtransactions(orgettransaction) before retrying. - A send whose initial broadcast fails in transport still returns the txid. The transaction
is already committed to the wallet, its inputs are locked, and the rebroadcast loop
re-submits it (at most once per
[sync] rebroadcast_secs, default 60) while it is unmined and unexpired. Never retry a send that returned a txid. - Only an explicit upstream rejection (Zebra examined the tx and refused it) errors, with
-26. The tx's notes stay locked until its expiry height, then become spendable again; an immediate retry fails with-6rather than double-paying. - An expired unmined tx reports
confirmations: -1andabandoned: true. Treat it as failed and safe to re-send. - Rapid back-to-back sends exhaust spendable notes and return
-6until change confirms (freshly created shielded change is not spendable unmined). The-6message appends any balance awaiting confirmations, so "retry after the next block" is distinguishable from "the wallet needs funding".
Shutdown and in-flight sends
New in 0.8.0. z_sendmany returns an opid immediately and proves on a background task. A
stop signal arriving in that window used to discard the send: nothing had been broadcast, so no
funds were at risk, but the record of whether it happened was lost.
On shutdown the wallet now finishes the sends it has already accepted, bounded by [spend] shutdown_drain_secs (default 60). Set it below your supervisor's stop timeout, because that
is what actually bounds it:
| Supervisor | Default grace period | Setting to raise |
|---|---|---|
docker stop | 10 s | --time, or compose's stop_grace_period |
| Kubernetes | 30 s | terminationGracePeriodSeconds |
| systemd | 90 s | TimeoutStopSec |
Killed mid-drain, nothing is corrupted: a send that had not been stored is lost, as before the
drain existed. zecd config check warns when the value exceeds every common default, and 0
disables the drain. Operation status objects are in memory and do not survive a restart either
way, so reconcile by txid (gettransaction), not by opid.
Reorgs
zecd follows reorgs automatically: the scanner detects the fork, rewinds, and rescans the
replacement chain. Transactions in reorged-away blocks revert to unconfirmed
(confirmations: 0) until re-mined; confirmation thresholds keep doing their job. One
operator-visible consequence: a listsinceblock cursor pointing at a reorged-away block
returns -5 Block not found (zecd keeps no stale-header history to walk back through, unlike
bitcoind). A deep reorg is walked back in a few rounds: the rewind margin doubles while reorgs
keep coming (since 0.8.0). Treat -5 as "cursor invalid": re-baseline with a parameterless listsinceblock,
dedupe by txid, and store the fresh lastblock. See
Wallet: history & unspent.
Recovering a stuck sync
A sync that fails, retries, and fails again on the same block is reported with the cause rather than left as a bare error. There are two shapes, and they need different responses.
An unsupported network upgrade. At every connect zecd compares the upgrades the node reports against the consensus rules the build knows. An unknown pending upgrade logs a warning ahead of its activation height, which is your notice to upgrade zecd before then. An unknown active one logs an error naming it, and sync failures under it are attributed to the outdated build. The fix is to upgrade zecd; if you are already on the latest release, report it at https://forum.zcashcommunity.com.
A wallet database that cannot apply otherwise-valid blocks. If the upstream is serving blocks and the failure is in applying them (a commitment-tree conflict, say), no amount of retrying or upgrading will clear it, because the damage is local. Rebuild the database:
# Stop the daemon first: rescan takes the datadir lock and will refuse otherwise.
zecd --datadir ./data rescan --wallet default
That deletes the wallet database only. keys.toml and the seed are kept, and the next start
recreates the account from the seed and rescans from the wallet birthday, re-deriving every
balance and all history from the chain. Nothing is lost that a seed restore could not rebuild,
which is the same guarantee described in Stateless & recoverable;
the cost is the rescan time. Pass --yes to skip the confirmation prompt in automated
recovery.
Slow sends on a busy transparent address
A wallet holding many unspent outputs on a reused transparent address could see an ordinary
sendtoaddress block for a minute or more before answering, sometimes only to report
insufficient funds. Through 0.6.3 the cause was the spend-search path: the wallet asks the
chain for transactions involving an address once per unspent output it holds, which is how a
spend authored elsewhere gets noticed, but the address index answers each request with every
transaction in the range rather than only the unseen ones, and all of them were fetched and
re-stored. That work runs on the wallet's single writer, so it starved every command queued
behind it. Measured against an address paid in every block, a send waited 109 seconds.
Fixed in 0.6.4, which skips transactions already recorded as mined. Nothing about the configuration changes and no action is needed beyond upgrading. It matters most to deposit and payout addresses, which are exactly the ones that accumulate outputs on a single address.
If sends are still slow after upgrading, the cause is elsewhere: check that the node's
getrawtransaction is fast (the same dependency the enhancement backlog has), and check
/status for a wallet still catching up.
Upgrades
-
Check the new binary against your existing config, before stopping anything.
zecd config checkresolves the file with the exact build you are about to deploy and exits non-zero if that build would refuse it. It takes no datadir lock and writes nothing, so it is safe to run against a live deployment:./zecd-new config check --conf /etc/zecd/zecd.tomlThis matters because zecd rejects unknown config keys. That is what stops a typo'd knob from being silently ignored, but it also means a config valid for one build can be refused by another in either direction: an upgrade may not know a key yet, a rollback may have dropped one. Catching that here turns a failed restart into a no-op.
-
Diff the effective configuration to see which defaults the upgrade moves. Your file is only half the configuration; every key it leaves unset takes the binary's default:
diff <(./zecd-old config show --conf /etc/zecd/zecd.toml 2>/dev/null) \ <(./zecd-new config show --conf /etc/zecd/zecd.toml 2>/dev/null)To pin today's behaviour explicitly before upgrading, capture
zecd-old config show > effective.tomland deploy that: it is round-trippable TOML that zecd itself accepts. (Secrets come out as commented-out key names, so a captured file needs its credentials re-added.) -
Stop with SIGINT or SIGTERM (both are graceful: in-flight requests finish, new ones get 503, and since 0.8.0 the wallet finishes sends it had already accepted; see shutdown). The
stopRPC is regtest-only, so a stray RPC call cannot take down a production daemon. -
Replace the binary or pull the new image.
-
Start. Wallet DB migrations run automatically at open; the first start after a large librustzcash bump can take longer.
Upgrading onto 0.7.0 specifically, the first start also moves each wallet's databases into
a per-coin subdirectory. It is automatic and needs no configuration change, but read
wallet data layout first: a failure there is deliberately fatal rather
than a silent rebuild, and any backup script or volume mount that names <wallet>/data.sqlite
by hand needs its path updated. Step 1's config check reports a pending move as a warning, so
the dry run tells you it is coming.
Upgrading onto 0.8.0, three things change under an existing deployment:
- Building from source needs Rust 1.91 (0.7.x needed 1.88). Binary and package users are unaffected.
- The block cache is in memory, so 0.7.x's
blocks/andblockmeta.sqliteare left behind unused;zecd rescanremoves them, or delete them with the daemon stopped. [spend] orchard_action_limitnow counts a two-bundle send as the prover does, so a wallet holding pre-NU6.3 Orchard notes can have a send refused that 0.7.x accepted. Raise the cap and send SIGHUP, or consolidate withz_mergetoaddress.
Every 0.7.0 configuration key and response shape still resolves as it did, and every new key defaults to the previous behaviour.
Upgrading onto 0.9.0-rc1 (a release candidate, needed on testnet past NU7; see NU7): the wallet database gains two migrations on first start that 0.8.1 does not know, so a database 0.9.0-rc1 has opened cannot be reopened by 0.8.1. Copy the data directory first if you might need to go back. No configuration key, RPC or response shape changes.
Downgrades across DB migrations are not supported. If you need a rollback path, stop the daemon and snapshot the datadir first. The worst case of a lost datadir is a from-seed restore, not lost funds.
Steps 1 and 2 need zecd 0.6.0 or later on the new side; config show on the old side only
works if the old binary is also 0.6.0+, so the first upgrade onto 0.6.0 has nothing to diff
against.
Single-instance datadir lock
zecd takes an exclusive advisory lock on <datadir>/.lock while it owns the data directory
(the daemon for its whole lifetime, zecd init for the init). A second zecd run or
zecd init on the same datadir fails fast with Cannot lock data directory .... The lock is
an OS advisory lock the kernel releases when the process exits, including a crash or kill, so
there is never a stale lockfile to delete: if the error appears and no zecd is running, just
retry. Several commands are exempt because they never write the datadir: zecd export-ufvk
(read-only DB access, so you can export a UFVK while the daemon runs), zecd rpcauth, and,
since 0.6.0, zecd derive-address, zecd config check and zecd config show. Since 0.7.0
zecd chain-info and zecd licenses join them: chain-info opens no wallet database and
writes no cookie file, and licenses never reaches a datadir at all. All of them are safe to
run against a live deployment. config check deliberately does not mint a cookie
file either, which would otherwise invalidate the credential a running daemon already handed
out.
Mainnet checklist
-
zecd config check --conf <file> --strictpasses against the exact binary being deployed (--strictalso fails on warnings, which is the right setting for a CI gate). -
network = "main"and a real[rpc] password(the daemon refuses to start with theCHANGE-MEplaceholder). -
RPC bound to
127.0.0.1or a private network; TLS or a reverse proxy in front if it must cross a network boundary. RPC credentials are spend authority (see the threat model). -
Key custody chosen deliberately: for unattended sending, the age identity stored
outside the datadir (secrets manager, separate mount,
ZECD_AGE_IDENTITY); for human-operated wallets,zecd init --encryptso spending requireswalletpassphrasewith a timeout. See Key custody. - Mnemonic and birthday recorded offline; restore procedure tested on testnet.
-
Local Zebra full node configured (
server = "zebra"orzebra://host:port); Docker images pinned to verified releases. -
/readyzwired into the orchestrator with astartupProbecovering initial sync; alerts onupstream_down.
Conventions & wire format
zecd speaks Bitcoin Core's JSON-RPC dialect: the JSON-RPC 1.0 envelope, HTTP Basic/cookie authentication, Bitcoin Core's error codes, and its HTTP status mapping. This page defines the wire format shared by every method; the methods themselves are in the method index.
JSON-RPC envelope
Requests are POSTed as a JSON object (or an array of objects, for a batch):
{"jsonrpc": "1.0", "id": "curltest", "method": "getblockcount", "params": []}
methodis required; a missing or non-stringmethodis rejected with-32600.paramsis a positional array, as with Bitcoin Core. It may be omitted ornull(treated as empty). Handlers read positional arguments only, so pass an array; an object-shapedparamsis accepted at the framing level but yields zero positional arguments. Any other type is-32600.idis echoed back verbatim, including on errors (nullwhen it could not be parsed).- A call carrying more positional arguments than the method declares is rejected with
-1, matching Bitcoin Core's help-text error for over-arity calls.
Every response carries both result and error, one of them null (the JSON-RPC 1.0
behavior real Bitcoin clients such as python-bitcoinrpc parse):
{"result": 2500000, "error": null, "id": "curltest"}
{"result": null, "error": {"code": -32601, "message": "Method not found: no_such"}, "id": "curltest"}
HTTP transport
| Endpoint | Purpose |
|---|---|
POST / | RPC against the default wallet |
POST /wallet/<name> | RPC against wallet <name> (bitcoind multiwallet routing) |
The RPC port defaults to 8232 on mainnet and 18232 on testnet/regtest, bound to
[rpc] bind (see configuration). Responses are
Content-Type: application/json (overload/shutdown rejections are text/plain). zecd does
not validate the request Content-Type; send application/json as clients conventionally do.
Request bodies are capped at 2 MiB; oversize requests get HTTP 413 before auth or dispatch.
A /wallet/<name> request naming a wallet that is not configured and loaded fails every
wallet-routed method on it with -18 (Requested wallet does not exist or is not loaded: <name>), Bitcoin Core's RPC_WALLET_NOT_FOUND. Methods that never touch a wallet (uptime,
getnetworkinfo, ping, getrpcinfo, the fee estimators) ignore the path and still answer,
as in bitcoind. Each [wallets.<name>] section is an independent wallet; see
Wallet: addresses & keys for listwallets.
Authentication
Every RPC request requires HTTP Basic authentication. Accepted credentials are the union of:
-
[rpc] user+password(or--rpcuser/--rpcpassword): a single plaintext pair. Hashed at startup; verification only ever compares salted hashes. -
[rpc] authentries (or repeated--rpcauth): bitcoind-style salted credentials in the<user>:<salt>$<hmac-sha256 hex>format ofshare/rpcauth/rpcauth.py, so no plaintext password lives in the config. zecd ships the generator built in:zecd rpcauth alice # mints a random password, prints it once zecd rpcauth alice hunter2 # hashes a password you choseEither prints the
auth = ["alice:<salt>$<hash>"]line to drop into[rpc]. -
Cookie: when no user/password pair is set, zecd mints a random secret at startup and writes
__cookie__:<random>to[rpc] cookiefile(default<datadir>/.cookie), mode 0600, regenerated on every startup. This happens alongsideauthentries too, matching bitcoind's behavior wheneverrpcpasswordis empty. A local process reads the file and authenticates as__cookie__(howbitcoin-clitalks to a local node by default).
Credential checks are constant-time (the password HMAC is always computed, and every
configured user is checked without short-circuiting). A failed attempt gets HTTP 401 with
WWW-Authenticate: Basic realm="jsonrpc" after a 250 ms delay, the same anti-bruteforce
values as Bitcoin Core's httprpc.cpp. Failures are logged with the claimed username, peer
address, and X-Forwarded-For when a reverse proxy sets it.
RPC credentials are spend authority: any authenticated caller can reach sendtoaddress
unless the safelist removes it. See the
threat model.
HTTP status and error codes
The mapping is Bitcoin Core's (httprpc.cpp JSONErrorReply): -32600 is 400, -32601 is
404, and every other RPC error is 500 with the error object in the body. Clients must read
the body of non-200 responses.
| Condition | RPC code | HTTP |
|---|---|---|
| success | n/a | 200 |
| insufficient funds | -6 | 500 |
wallet locked (needs walletpassphrase) | -13 | 500 |
| tx rejected by network | -26 | 500 |
| bad/unknown address or txid | -5 | 500 |
| invalid parameter | -8 | 500 |
unknown /wallet/<name> | -18 | 500 |
| invalid request | -32600 | 400 |
| method not found (or safelisted out) | -32601 | 404 |
| parse error | -32700 | 500 |
| auth failure | n/a | 401 (+ WWW-Authenticate, 250 ms delay) |
| batch (any mix of outcomes) | per item | 200 |
| over work-queue / shutting down | n/a | 503 (text/plain body) |
| request body over 2 MiB | n/a | 413 |
Error numbering: Bitcoin Core's, not zcashd's
Error codes are Bitcoin Core's rpc/protocol.h values, because zecd's conformance target is
bitcoind, not zcashd. Two conventions carried over from Core:
-32602(RPC_INVALID_PARAMS) is never emitted by a method handler. A missing required argument is-1(Core answers with the method help text there) and a wrong-typed argument is-3(RPC_TYPE_ERROR).- Wallet, parameter, and verification codes are Core's numbers.
The -18 collision. zcashd numbers some codes differently in its own protocol.h. The
one divergence zecd actually emits is -18: in Bitcoin Core (and zecd) it means "wallet not
found" (an unknown /wallet/<name>), while in zcashd -18 is RPC_WALLET_BACKUP_REQUIRED.
Since zcashd has no multiwallet routing, a zcashd-derived client never triggers zecd's -18
in normal use, but tooling that hard-codes zcashd's numbering should be aware. zcashd's -11
(RPC_WALLET_ACCOUNTS_UNSUPPORTED, vs Core's "invalid label name") is never returned by zecd
at all: zecd is stateless and has no labels. The codes integrations branch on for the money
path (-4, -5, -6, -8, -13 through -15, -20, -26) are identical across Bitcoin
Core, zcashd, and zecd.
Amounts
Amounts are bare JSON numbers in decimal ZEC with exactly 8 decimal places (1 ZEC =
100,000,000 zatoshis), never strings and never floats internally. Serialization writes the
decimal digits directly (via serde_json's arbitrary_precision), and parsing is an exact
port of Bitcoin Core's ParseFixedPoint, so values round-trip with zero drift:
0.1 is exactly 0.10000000, and 21000000.00000000 survives untouched.
Use a client that decodes JSON numbers as exact decimals, not IEEE 754 doubles. In Python
that is python-bitcoinrpc's behavior, or plain json.loads(raw, parse_float=decimal.Decimal);
the conformance suite (scripts/conformance.py) asserts amount fields arrive as
Decimal. A client that parses amounts as float will see values like
0.30000000000000004 and misprice payments.
Batching
An array body is a batch. The response is always HTTP 200 with an array of envelopes in
request order; per-item failures ride in each item's error:
[
{"result": 2500000, "error": null, "id": 0},
{"result": null, "error": {"code": -32601, "message": "Method not found: no_such"}, "id": 1}
]
An empty batch ([]) is rejected with -32600. Batch items are processed sequentially, and
the whole batch consumes a single work-queue slot.
Work queue
zecd bounds concurrent in-flight requests like bitcoind's -rpcworkqueue: at most
[rpc] work_queue requests (default 100) are admitted; beyond that the server answers
HTTP 503 Work queue depth exceeded without doing any work (the bound is enforced before
authentication, so unauthenticated floods cannot starve real clients). During shutdown, new
requests get 503 Request rejected during server shutdown. Both 503 bodies are plain text,
not JSON.
Sends hold their slot for the whole call (a shielded send computes proofs for a few seconds),
so a burst of concurrent sends can exhaust the queue; see Sending for the
serialization semantics. getrpcinfo.active_commands shows what is executing right now.
Method safelist
[rpc] allowed_methods is an optional server-wide safelist. Empty (the default) serves every
implemented method. Non-empty serves only the listed methods; anything else, implemented or
not, is rejected with -32601 (HTTP 404), exactly as if it did not exist, so a locked-down
server discloses nothing about the surface it disabled. Names are validated against the
implemented method set at startup, so a typo is a fatal config error rather than a silently
dead entry. The safelist check runs before argument validation, so a disabled method never
leaks arity hints.
This is coarse and server-wide, not per-user. Its value is shrinking the blast radius of a
leaked credential: an invoicing integration can be limited to getnewaddress plus read
methods, keeping sendtoaddress and stop unreachable. The example config ships a
commented-out safelist grouped by use case.
Method index
Every implemented method, with its Bitcoin Core status and nearest zcashd equivalent,
is in the method index. Unimplemented bitcoind methods (including the
label methods, removed deliberately) return -32601.
Method index: zecd vs bitcoind vs zcashd
Every RPC method zecd dispatches (58 methods as of 0.8.0, the ALL_METHODS table in
src/rpc/mod.rs),
compared against Bitcoin Core master and zcashd. Each method name links to its full reference
entry.
Column legend:
- Bitcoin Core: ✓ = exists in current master with the semantics zecd mirrors; removed = no longer exists in current bitcoind (zecd keeps it for older clients); n/a = never existed there.
- zcashd: ✓ = same method name, compatible semantics; same name, differs = the name
exists but the semantics diverge (usually transparent-only in zcashd, with a
z_*method for shielded); n/a = no such method (nearest equivalent in parentheses). On chain and network rows a ✓ means zcashd serves the method with bitcoind's full-node semantics; zecd's wallet-scoped view (scanned heights, one Zebra "peer") is in the zecd column.
An [rpc] allowed_methods safelist, when set, answers -32601 for any method off the list,
indistinguishable from a method that does not exist. Multiwallet routing (/wallet/<name>)
is covered in Conventions & wire format.
| Method | Bitcoin Core | zcashd | zecd |
|---|---|---|---|
| Control | |||
| stop | ✓ | same name, differs (stops any network) | Regtest only; mainnet/testnet answer -32601. Stop a live node with SIGINT/SIGTERM |
| uptime | ✓ | n/a | Seconds since the daemon started |
| help | ✓ | ✓ | Static one-line summary; the method argument is ignored (see below) |
| getrpcinfo | ✓ | n/a | active_commands with elapsed microseconds; logpath empty (logs go to stderr) |
| Network | |||
| getnetworkinfo | ✓ | ✓ | zecd version/subversion; connections is 0 or 1 (the Zebra upstream is the only "peer") |
| getconnectioncount | ✓ | ✓ | 0 or 1 |
| getpeerinfo | ✓ | ✓ | At most one entry, describing the Zebra upstream, plus conn_state/syncing extensions |
| ping | ✓ | ✓ | No-op success; there is no P2P ping to measure |
| Blockchain | |||
| getblockchaininfo | ✓ | ✓ | blocks = fully scanned height, headers = tip; initialblockdownload true while scanning or enhancing |
| getblockcount | ✓ | ✓ | Fully scanned height, so getblockhash(getblockcount()) always answers |
| getbestblockhash | ✓ | ✓ | Hash at the fully scanned height |
| getblockhash | ✓ | ✓ | From the wallet's scanned blocks; pre-birthday or beyond-tip heights answer -8 |
| getblockheader | ✓ | ✓ | Verbose only, compact-block fields; verbose=false answers -8 |
| waitfornewblock | ✓ | ✓ | Blocks until the fully-scanned height advances; timeout in ms, 0 = indefinite; timing out is not an error |
| waitforblock | ✓ | ✓ | Blocks until the named hash is the scanned tip |
| waitforblockheight | ✓ | ✓ | Blocks until the scanned height reaches N; the correct "has the wallet caught up?" primitive |
| waitforsync | n/a | n/a | zecd extension (0.7.0). Blocks until the scan and the enhancement backlog are done; reports chain_tip, pending_enhancements and enhanced_through; timing out is not an error |
| Utility | |||
| validateaddress | ✓ | same name, differs (transparent-only; shielded via z_validateaddress) | Validates every Zcash address kind; adds isvalid_orchard and receiver_types extension fields |
| z_validateaddress | n/a | same name, differs (shielded and unified only, with key material) | Every address kind, with address_type, ismine, and receivers for a UA; no key material. New in 0.8.0 |
| z_listunifiedreceivers | n/a | ✓ | A UA split into per-receiver strings, the strings history reports. Key-free. New in 0.8.0 |
| settxfee | removed | same name, differs (functional in zcashd) | Always -8: fees are ZIP-317, never client-settable |
| estimatesmartfee | ✓ | n/a | Inert stub: conventional ZIP-317 rate (0.00001) plus a blocks echo |
| estimatefee | removed | n/a (removed in zcashd 5.6.0) | Same stub rate, kept for old clients |
| getmempoolinfo | ✓ | ✓ | Fixed shape with empty-mempool numbers (zecd holds no mempool of its own) |
| Raw transactions | |||
| getrawtransaction | ✓ (verbose JSON differs) | ✓ | Hex, or verbose JSON in zcashd's shape with shielded bundles; blockhash param rejected |
| sendrawtransaction | ✓ | ✓ | Broadcasts caller-built bytes through Zebra; maxfeerate ignored |
| Wallet: reads | |||
| getbalance | ✓ | same name, differs (transparent-only; z_getbalanceforaccount for shielded) | Spendable balance under the ZIP-315 confirmations policy; explicit minconf overrides it per call |
| getbalances | ✓ | n/a (z_getbalanceforaccount, z_gettotalbalance) | mine.trusted/untrusted_pending/immature plus a mine.coinbase extension (mature transparent coinbase, a subset of trusted) and lastprocessedblock; no watchonly object |
| getunconfirmedbalance | removed | same name, differs (transparent-only) | Incoming funds below the confirmations policy, including 0-conf via the mempool stream |
| getwalletinfo | ✓ | ✓ | bitcoind shape; scanning progress, unlocked_until when encrypted, private_keys_enabled:false when watch-only |
| getaddressinfo | ✓ | n/a (validateaddress / z_validateaddress) | ismine is cryptographic (viewing-key attribution); diversifier_index on own addresses; labels always []; iswatchonly always false, as in Core master |
| listtransactions | ✓ | same name, differs (transparent history only) | Core categories and fields; adds memo/memoStr; outgoing address is the single receiver actually paid |
| z_listtransactions | n/a | n/a (no equivalent; listtransactions is transparent-only) | zcashd-style per-output history vocabulary (no account arg) |
| listsinceblock | ✓ | same name, differs (transparent history only) | Cursor pattern; removed always []; a malformed cursor answers -5, a reorged-away cursor re-lists from the earliest scanned block |
| gettransaction | ✓ | same name, differs (z_viewtransaction for shielded detail) | amount/fee/confirmations/details/hex; foreign tx hex fetched from Zebra on demand |
| listunspent | ✓ | same name, differs (transparent UTXOs; z_listunspent for notes) | One entry per unspent note; synthesized txid/vout; address empty for change; transparent entries carry zcashd's generated flag and immature coinbase is omitted |
| getreceivedbyaddress | ✓ | same name, differs (transparent; z_listreceivedbyaddress for shielded) | Totals over diversified receiving addresses; change never counted |
| listreceivedbyaddress | ✓ | same name, differs (transparent) | listreceivedbyaddress 0 true enumerates every generated address; each entry's label is "" |
| listwallets | ✓ | n/a (single wallet) | Names from [wallets.<name>] config, plus loaded fleet wallets |
| listwalletdir | ✓ | n/a | Configured wallets plus fleet manifests; a warnings extension names unreadable manifests. New in 0.8.0 |
| createwallet | ✓ | n/a | Fleet only, experimental: a watch-only wallet from a ufvk and birthday in the options object; spending-wallet flags refused. New in 0.8.0 |
| loadwallet | ✓ | n/a | Fleet only, experimental: serve a provisioned fleet wallet. New in 0.8.0 |
| unloadwallet | ✓ | n/a | Fleet only, experimental: stop serving a fleet wallet; deletes nothing, and the shard keeps scanning it. New in 0.8.0 |
| Wallet: writes | |||
| getnewaddress | ✓ | same name, differs (deprecated, transparent-only; z_getaddressforaccount for UAs) | Fresh diversified UA; a label arg is rejected -8; address_type selects receivers within the enabled pools |
| sendtoaddress | ✓ | same name, differs (transparent-only) | Synchronous shielded send returning a txid; ZIP-317 fee; subtractfeefromamount/fee_rate answer -8; extra trailing memo param |
| sendmany | ✓ | same name, differs (transparent-only) | Same, multi-recipient; dummy "" first arg as in Core |
| walletpassphrase | ✓ | ✓ | Unlock with a timeout (capped at 100,000,000 seconds, as in Core); wrong passphrase -14, unencrypted wallet -15 |
| walletlock | ✓ | ✓ | Zeroizes the seed immediately, even mid-proof; unencrypted wallet -15 |
| signmessage | ✓ | same name, differs (transparent-only, same scheme) | Signs with an own transparent address's key; shielded addresses answer -3 |
| verifymessage | ✓ | ✓ | Recovers the signer's pubkey and compares the address; needs no wallet |
| Async operations | |||
| z_sendmany | n/a | ✓ | Async: returns an opid, proves/broadcasts in the background; fromaddress selects the funding source and must be the wallet's own (or ANY_TADDR); explicit fee answers -8 |
| z_shieldcoinbase | n/a | ✓ | Async: sweeps mature transparent coinbase into one shielded output, no change in any pool; toaddress must have a shielded receiver; explicit fee answers -8; nothing mature to shield answers -6 |
| z_mergetoaddress | n/a | ✓ | Async (0.7.0): consolidates many UTXOs or many notes into one output, no amount argument and no change; one source class per call, so a mixed t+z merge is -8; adds an ANY_ORCHARD wildcard; explicit fee answers -8 |
| z_getoperationstatus | n/a | ✓ | Non-destructive status objects; wallet-scoped |
| z_waitforoperation | n/a | n/a | zecd extension. Blocks until one operation finishes; adds a finished flag; timeout in seconds (default 120, clamped to 3600); non-destructive |
| z_getoperationresult | n/a | ✓ | Finished operations only; destructive one-shot reap, matching zcashd |
| z_listoperationids | n/a | ✓ | The wallet's operation ids; optional status filter |
| Address derivation | |||
| z_getaddressforaccount | n/a | ✓ | Derive a UA for the wallet's single account (account must be 0); shielded receiver types only; optional exact diversifier_index |
Deliberately absent method families
These answer method-not-found (-32601), the same as any unknown method.
- Label methods (
setlabel,getaddressesbylabel,listlabels,getreceivedbylabel,listreceivedbylabel): zecd keeps no off-chain label store, by the statelessness invariant. Embeddedlabel/labelsfields on other methods remain, always""/[]. - Key import/export (
dumpprivkey,importprivkey,importaddress,importpubkey,z_exportkey,z_importkey,z_exportviewingkey,z_importviewingkey): each wallet is one ZIP-32 account from one mnemonic; key material moves only through the CLI (zecd init,zecd export-ufvk), never over the RPC channel. See key custody and watch-only wallets. dumpwallet/backupwallet/importwallet: the backup is the mnemonic; everything else is rebuilt from seed plus chain, so there is no wallet file worth dumping.- Wallet encryption RPCs (
encryptwallet,walletpassphrasechange): at-rest encryption is set once atzecd init --encrypt, so the passphrase never crosses the network. - Raw transaction construction (
createrawtransaction,fundrawtransaction,signrawtransaction,decoderawtransaction,decodescript): shielded transactions cannot be assembled from public outpoints.sendrawtransactionstill broadcasts externally built bytes. - Mining (
getblocktemplate,submitblock,generate,getmininginfo): zecd is a wallet server, not a validator; mine against the Zebra node. - P2P management (
addnode,disconnectnode,setban,listbanned,getnettotals,getaddednodeinfo): zecd has no P2P stack; its only peer is one Zebra node over JSON-RPC.
The help introspection gap
help <method> ignores its argument and returns a static one-line blurb naming only a few
methods. bitcoind lists every command and returns per-method usage for help <method>, so
tooling that introspects via help gets nothing useful from zecd today. Use this index and
the per-category reference pages instead. (The blurb ends by pointing at the README for the
full list, which is now a stub that points here; treat this index as the list it means.)
Wallet: addresses & keys
Reference for the address-generation, address-inspection, wallet-metadata, and lock/unlock
methods. For the wire format, auth, and multiwallet /wallet/<name> routing, see
Conventions & wire format; for background on Unified Addresses, diversified
addresses, and pool configuration, see Addresses & shielded pools.
getnewaddress
getnewaddress ( "" address_type )
Returns a fresh receiving address for the wallet's account: a new diversified Unified Address (new diversifier, same account key), or a bare transparent address when requested. Works on watch-only wallets and on locked encrypted wallets (addresses derive from the viewing key, not the seed).
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | label | string | "" | Must be empty or omitted. zecd is stateless and stores no labels; a non-empty label is rejected with -8. Kept in Bitcoin Core's position so address_type stays at parameter 2. |
| 2 | address_type | string | wallet default | Per-call receiver override: empty, "unified", or "default" use the wallet's configured default_receivers; a single shielded pool name ("orchard", "sapling") or a comma-separated list ("sapling,orchard") builds a UA with exactly those receivers; "transparent" returns a bare t-address. |
With no address_type, the wallet's [pools] configuration decides: the default
(Orchard-only) config returns an Orchard-only UA; a wallet with transparent_default = true
returns a bare transparent address. Every requested shielded receiver must be a pool enabled
on the wallet. "transparent" requires [pools] transparent = true and cannot be combined
with shielded pool names (zecd hands out one receiver type at a time; ZIP-316 forbids a
transparent-only UA, so the transparent receiver is bare-encoded as t1.../tm...).
There is no "ironwood" receiver. Ironwood notes are received at ordinary Orchard addresses, so
an Orchard receiver is all a payer needs to send you ironwood funds. See
Addresses & shielded pools.
Transparent addresses come from the gap-limited external chain. Once the recovery window is
full of unfunded addresses, zecd by default issues past it with a loud log warning (such an
address may be unrecoverable from seed); with
[pools] transparent_allow_beyond_recovery_window = false it returns -4 instead. See
Transparent support.
Result: the address as a JSON string.
"u1v0qh8pw9qm4h2v0negtfzrwhtjzfhgh0jcs9tzkjxg7xkpxkfhz5c4tj0nzqyjrmzgcqnyu7q6cx"
Errors
| Code | When |
|---|---|
| -8 | Non-empty label argument |
| -5 | Unknown address_type token; "transparent" combined with shielded pool names; otherwise-invalid pool list |
| -8 | address_type names a shielded pool not enabled on this wallet |
| -8 | address_type is "transparent" but [pools] transparent is off |
| -4 | Transparent gap limit reached and transparent_allow_beyond_recovery_window = false |
The address_type syntax is validated before the wallet is resolved, so an unknown token is
-5 regardless of which wallet is targeted; pool enablement is checked per wallet.
vs Bitcoin Core: same signature (label, address_type) and the same -5 Unknown address type '...' for a bad type, but zecd rejects a non-empty label with -8 where Core
records it in the address book. The type values differ: pool names instead of
legacy/p2sh-segwit/bech32/bech32m.
vs zcashd: zcashd's getnewaddress is deprecated and only produces transparent
addresses; its shielded flow is z_getnewaccount + z_getaddressforaccount. zecd's
getnewaddress is the primary shielded path.
z_getaddressforaccount
z_getaddressforaccount account ( ["receiver_type",...] diversifier_index )
Derives an address for the wallet's account in zcashd's syntax, optionally at an exact index.
Unlike getnewaddress, the returned object includes the index, so a client can re-derive the
same address deterministically later.
It serves two distinct cases depending on receiver_types:
- Shielded (the default). A Unified Address carrying the requested shielded receivers, indexed by ZIP-32 diversifier index.
- Transparent (
["p2pkh"], new in 0.6.0). A bare t-address at a BIP 44 external child index. See transparent derivation below - the parameter is the same slot, but it means a different thing.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | account | number | required | Must be 0. zecd has one account per wallet; select another wallet via /wallet/<name> instead. |
| 2 | receiver_types | array of strings | wallet default | Either shielded pools for a UA ("sapling" and/or "orchard", each enabled on this wallet; empty/omitted uses the configured default_receivers), or exactly ["p2pkh"] (equivalently ["transparent"]) for a bare t-address. The two cannot be mixed. "p2sh" and unknown tokens are -8. |
| 3 | diversifier_index | number | next unused | For a shielded request: a non-negative integer within the 11-byte (2^88) diversifier space. For a transparent request: a BIP 44 external child index, so the hardened half (>= 2^31) is -8. Omitted picks the next unused index; given, it derives exactly that index. |
Re-deriving at the same index with the same receiver set is idempotent (byte-identical
response, zcashd's invariant). Requesting a different receiver set at an already-exposed
index is a -4 reuse error. Auto-selected shielded indices are not sequential (the
next-unused selection is clock-seeded; see
Addresses & shielded pools), so record the returned
diversifier_index if you need to re-derive. Transparent indices are sequential.
Transparent derivation at an explicit index
New in 0.6.0. On a wallet with [pools] transparent = true,
z_getaddressforaccount 0 ["p2pkh"] N returns the bare t-address at BIP 44 external child
index N.
Why the receiver set must be exactly ["p2pkh"]. ZIP-316 forbids a transparent-only
unified address, and zecd never mixes a transparent receiver into a UA, so there is no address
shape that could carry both. Asking for ["p2pkh", "orchard"] is therefore -8 rather than
something zecd could silently reinterpret.
It shares the exposure path with sequential issuance. Deriving here and calling
getnewaddress "" "transparent" run the same code, so the two agree by construction: the same
recovery-horizon classification, the same warnings, the same -4 when
transparent_allow_beyond_recovery_window = false would put the index out of restore range,
and the same refresh of the address matcher. That last one matters - without it a payment to a
directly addressed index would be silently dropped by the scanner. See
Transparent support for the two windows involved.
Together with getaddressinfo's address_index, this closes the loop for
an operator reconciling an issued range against the chain: ask for index N, and ask which
index an address was.
Result
{
"account": 0,
"diversifier_index": 1000000,
"receiver_types": ["orchard"],
"address": "u1v0qh8pw9qm4h2v0negtfzrwhtjzfhgh0jcs9tzkjxg7xkpxkfhz5c4tj0nzqyjrmzgcqnyu7q6cx"
}
Errors
| Code | When |
|---|---|
| -1 | account missing |
| -8 | account outside zcashd's range 0 <= account <= (2^31)-2, or not an integer |
| -4 | account in range but not 0 ("has not been generated"; zecd wallets have a single account) |
| -8 | receiver_types not an array; contains "p2sh" or an unknown token; names a pool not enabled on this wallet |
| -8 | "p2pkh"/"transparent" mixed with a shielded receiver, or requested on a wallet without [pools] transparent = true |
| -3 | A receiver_types element is not a string |
| -8 | diversifier_index fractional, negative, non-numeric, or beyond the 2^88 space ("too large"); for a transparent request, also >= 2^31 (the hardened half) |
| -4 | Index already exposed with different receiver types ("was already generated with different receiver types.") |
| -4 | No address derivable at the requested index for the requested receivers (e.g. an invalid Sapling diversifier): "no address at diversifier index N." |
| -4 | Transparent index at or beyond the recovery horizon while transparent_allow_beyond_recovery_window = false |
Example
# The t-address at BIP 44 external child index 7.
curl -u u:p -d '{
"jsonrpc": "1.0", "id": 1, "method": "z_getaddressforaccount",
"params": [0, ["p2pkh"], 7]
}' http://127.0.0.1:8232/
vs Bitcoin Core: no equivalent.
vs zcashd: same syntax and result shape, and the reuse/no-address error strings match
zcashd's wording under the same -4. Deliberate divergences: zcashd accepts any previously
generated account number, zecd only account 0; zcashd can return a UA that includes a
p2pkh receiver alongside shielded ones, while zecd treats ["p2pkh"] as a request for a
bare t-address and rejects the mixture, because it never puts a transparent receiver in a UA.
getaddressinfo
getaddressinfo "address"
Returns ownership and validity details for an address. ismine is cryptographic, not just a
lookup: after the recorded-address fast path, zecd attributes the address to the account's
incoming viewing key by decrypting its diversifier, so an address the account can derive but
never recorded (for example one handed out before a from-seed restore and never funded) still
reports ismine: true. Bare transparent addresses are recognized via recorded addresses only.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | address | string | required | The address to inspect. |
Result
{
"address": "u1v0qh8pw9qm4h2v0negtfzrwhtjzfhgh0jcs9tzkjxg7xkpxkfhz5c4tj0nzqyjrmzgcqnyu7q6cx",
"scriptPubKey": "",
"ismine": true,
"solvable": true,
"iswatchonly": false,
"isscript": false,
"iswitness": false,
"isvalid_orchard": true,
"receiver_types": ["orchard"],
"labels": []
}
scriptPubKey: the real hex script for transparent addresses; empty for shielded addresses, which have no script form.solvable: equalsismine, including on watch-only wallets (Core's definition ignores the lack of private keys; the wallet-level signal isgetwalletinfo.private_keys_enabled).iswatchonly: alwaysfalse, matching Core master where the field is deprecated.isvalid_orchard,receiver_types: zecd extensions mirroringvalidateaddress: whether the address carries an Orchard receiver, and the full list of pools it can receive into (transparent/sapling/orchard).labels: always[](zecd is stateless; the field is kept for shape conformance).receivers_consistent(optional, extension): present only for a multi-receiver UA whose consistency against this wallet's keys is computable.falseflags a hand-spliced UA (receivers from different diversifier indices, or one of ours mixed with a stranger's) that this wallet can never have issued.
Derivation fields for an own transparent address (new in 0.6.0)
On a bare t-address this wallet owns, three more fields report where it came from:
{
"address": "tmEjFVCkiVKmTPMHtnFHYJgvvyRJvpUZ4nD",
"ismine": true,
"hdkeypath": "m/44'/133'/0'/0/7",
"ischange": false,
"address_index": 7,
"receiver_types": ["transparent"]
}
hdkeypathandischangeare Bitcoin Core's fields, with Core's meaning: the full BIP 44 path, and whether the address is on the internal (change) chain rather than the external one.address_indexis a zecd extension carrying the BIP 44 child index on its own, so a caller does not have to parse it back out of the path string. It is the same indexz_getaddressforaccounttakes, which is what makes issuance and reconciliation a closed loop.
All three are absent for shielded addresses (a diversifier index is not a BIP 44 path) and for transparent addresses this wallet does not own.
diversifier_index (new in 0.8.0) is present on any address this wallet owns: the
shielded diversifier index for a Unified or Sapling address, or the BIP 44 child index for a
bare transparent one (the same number as address_index; ZIP 32 reuses it), so a caller need
not branch on the kind. It is the index received history entries
report, and the one z_getaddressforaccount takes, so storing it at issuance lets every later
receipt be matched by integer rather than by address string. A shielded index can reach 2^88,
so parse it as an arbitrary-precision integer.
Errors
| Code | When |
|---|---|
| -1 | address missing |
| -5 | Address does not decode on this network ("Invalid address"; validity reporting belongs to validateaddress) |
vs Bitcoin Core: same core fields and the same -5 on an undecodable address, plus
Core's hdkeypath/ischange on own transparent addresses. zecd still emits a subset
overall: no desc/parent_desc, no pubkey fields, no timestamp.
isvalid_orchard/receiver_types/receivers_consistent/address_index/diversifier_index
are additions.
vs zcashd: no equivalent; zcashd has only validateaddress/z_validateaddress, with
no ownership attribution for Unified Addresses in this shape.
getwalletinfo
getwalletinfo
Wallet metadata and balances. scanning reports sync progress and stays truthy while the
transaction-enhancement backlog drains (the wallet is at the tip but still backfilling memos
and full transaction data), not just during the block scan.
Result
{
"walletname": "default",
"walletversion": 169900,
"format": "sqlite",
"balance": 1.25000000,
"unconfirmed_balance": 0.10000000,
"immature_balance": 0.00000000,
"txcount": 12,
"keypoolsize": 1,
"keypoolsize_hd_internal": 0,
"paytxfee": 0.00000000,
"private_keys_enabled": true,
"avoid_reuse": false,
"scanning": { "duration": 0, "progress": 0.9731, "pending_enhancements": 84 },
"enhanced_through": 2912916,
"descriptors": false,
"unlocked_until": 1751629200
}
-
balance/unconfirmed_balance/immature_balance: decimal ZEC, 8 places, under the wallet's confirmations policy.immature_balancecarries transparent coinbase that has not yet reached the 100-block maturity. It is never counted as spendable and never appears inlistunspent; once mature the value becomes shieldable withz_shieldcoinbase. -
keypoolsizeis always1andkeypoolsize_hd_internalalways0: addresses are diversified on demand from the account key; there is no key pool. -
paytxfeeis always0(fees are ZIP-317, never client-settable). -
private_keys_enabled:falsefor a watch-only (imported UFVK) wallet; the wallet-level cannot-sign signal, as with Core'sdisable_private_keyswallets. -
scanning: an object (durationalways0,progressthe block-scan ratio in [0,1]) while scanning or while the enhancement backlog is nonzero;falsewhen idle. -
scanning.pending_enhancements(extension, 0.7.0): distinct outstanding transaction-data requests.progressis a [0,1] block-scan ratio and cannot express this open-ended work, and until 0.7.0 the count existed only on the health server's/status, which is unreachable for adefault-features = falseembedder and for anyone driving zecd purely over JSON-RPC.progressholds at1.0through the drain. -
enhanced_through(extension, 0.7.0): the height below which history is complete. It is the difference between "scanned to the tip" and "serving complete history", since a scanned but un-enhanced output still has a null memo.It is top-level rather than inside
scanningdeliberately: that object is Core's, and it is the literalfalseonce the wallet is idle, which is exactly the moment a consumer following wallet history as a log is ready to advance its cursor, and therefore exactly when it needs this. Nesting it there would make the field unreachable at the only time it matters.nullmeans "not currently determinable", which a consumer must read as hold the cursor, never as "everything is enhanced".waitforsyncblocks until the backlog is empty and returns the same fields. -
import_error(extension, 0.8.0): present only when a fleet wallet's import failed; seewaitforsync. -
fetch_memos(extension, 0.8.0): present, asfalse, only when[sync] fetch_memos = false.enhanced_throughis thennull, since it promises that memos at or below it are readable. -
descriptors: alwaysfalse. -
unlocked_until: present only for passphrase-encrypted wallets; the unix time the wallet auto-relocks, or0while locked. Absent on unencrypted and watch-only wallets. -
transparent(extension): present only when[pools] transparent = true, so a shielded-only wallet's shape is unchanged.{"enabled": true, "default": <bool>, "gap_limit": <n>, "coinbase_balance": <amount>, "recovery_horizon": <n>}, plus the lookahead fields below and, whentransparent_initial_scanis set, aninitial_syncobject{"exposed": <n>, "total": <n>, "complete": <bool>}for polling the address pre-exposure. See Transparent support. -
transparent.recovery_horizon/.lookahead_from/.lookahead_through/.restorable(extension): the two address windows, which are anchored differently and can disagree.recovery_horizon(transparent_initial_scan + transparent_gap_limit) is what a from-seed restore rediscovers within, anchored on funding, so handing an address out does not move it.lookahead_from/lookahead_throughare the forward lookahead the running wallet scans ahead of its recorded addresses, both inclusive and anchored on exposure, so issuance does move them. They describe forward reach only: every address with a database row is matched too, including indices belowlookahead_from.restorableislookahead_from <= recovery_horizon, andfalsemeans the wallet is crediting addresses a from-seed restore would not rediscover, which is the field to alert on. The lookahead fields are absent until the matcher is first built, andlookahead_through/restorableare omitted whengap_limitis0. See Two windows. -
transparent.coinbase_balance(extension): the unspent mature transparent coinbase value, the same number asgetbalances.mine.coinbase. It is already insidebalance, and is reported separately because no ordinary send can select it: consensus forbids a transaction spending transparent coinbase from having any transparent output.
vs Bitcoin Core: walletversion 169900 and format: "sqlite" match Core's values.
Core master has dropped the balance/unconfirmed_balance/immature_balance/paytxfee
fields from this method (balances live on getbalances); zecd still emits them, in the
older Core shape. zecd omits Core's external_signer, blank, birthtime, flags, and
lastprocessedblock (the latter appears on zecd's getbalances). The transparent block,
enhanced_through, and scanning.pending_enhancements are additions.
vs zcashd: zcashd's getwalletinfo keeps the old pre-0.19 Core shape plus its own
split (balance is transparent-only, with a separate shielded_balance), a real key pool
(keypoololdest), and a settable paytxfee; zecd follows modern Core instead.
listwallets
listwallets
Returns the names of all loaded wallets: every [wallets.<name>] in the config (plus the
default wallet) and, with a fleet enabled, every loaded fleet wallet.
Target a specific wallet with the /wallet/<name> URL path, as in Bitcoin Core; see
Conventions & wire format.
Result
["default", "watch1"]
vs Bitcoin Core: identical shape. Configured wallets are fixed at startup; only fleet wallets can be created, loaded and unloaded at runtime (below). At most one loaded wallet may hold spending keys.
vs zcashd: no equivalent (zcashd is single-wallet).
Fleet wallet management
New in 0.8.0, experimental. The four methods below manage fleet wallets:
watch-only, shielded-only wallets defined by a viewing key, sharing shard databases.
createwallet and loadwallet refuse with -4 unless [fleet] enabled = true, and these
methods may change in a patch release while the fleet is experimental.
They follow Bitcoin Core's dialect where a Zcash wallet allows it. The difference is forced:
Core creates a wallet that generates its own keys, while a monitored Zcash wallet is defined by
the viewing key it is given, so createwallet requires one.
createwallet
createwallet "wallet_name" ( disable_private_keys blank passphrase avoid_reuse descriptors load_on_startup external_signer {"ufvk":..,"birthday":..} )
Onboard a view wallet without restarting the daemon. It writes the wallet's manifest, places it
in a shard (opening a new one when none has room), and serves it immediately at
/wallet/<name>. Its account is imported on the shard's next connected pass; until then its
balances and history are empty and waitforsync reports imported: false.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | wallet_name | string | required | ASCII letters, digits, -, _ and .. Must not name a loaded wallet. |
| 2 | disable_private_keys | bool | true | Only true (or omitted) is accepted: a fleet wallet is watch-only. |
| 3 | blank | Accepted and ignored. | ||
| 4 | passphrase | null | Must be omitted or null: a fleet wallet holds no spending material. | |
| 5 | avoid_reuse | Accepted and ignored. | ||
| 6 | descriptors | Accepted and ignored. | ||
| 7 | load_on_startup | Accepted and ignored: the manifest is the startup list. | ||
| 8 | external_signer | null | Must be omitted or null. | |
| 9 | options | object | required | {"ufvk": "uview1...", "birthday": <height>}. Both required. |
Result
{
"name": "acct-00417",
"warning": "the wallet is loaded; its balance and history are empty until the shard scans from its birthday"
}
Errors
| Code | When |
|---|---|
| -8 | name missing; disable_private_keys false; a passphrase or external_signer; ufvk or birthday missing from the options; a birthday that is not a block height; a name that is not addressable as /wallet/<name>; a viewing key that does not decode for this network |
| -4 | the fleet is not enabled; a wallet of that name is already loaded; placing or starting the wallet failed |
vs Bitcoin Core: same name, result shape and positional flags, plus the options object carrying the viewing key and birthday. Every flag that would ask for a spending wallet is refused.
loadwallet
loadwallet "wallet_name"
Serve a fleet wallet that is provisioned but not loaded: one whose manifest was added while the
daemon ran, or one unloadwallet dropped. A reloaded wallet's history is intact, since its
account never left its shard.
Result
{ "name": "acct-00417", "warning": "" }
Errors
| Code | When |
|---|---|
| -8 | name missing; the name or the manifest's viewing key is invalid |
| -4 | the fleet is not enabled; the manifest directory cannot be read; a wallet of that name is already loaded |
| -18 | no readable manifest of that name (listwalletdir reports unreadable ones) |
unloadwallet
unloadwallet ( "wallet_name" load_on_startup )
Stop serving a fleet wallet. Nothing is deleted: the manifest and the account stay, and the
shard keeps scanning for the wallet, so unloading frees no scanning work and a restart or
loadwallet serves it again. To retire a wallet for good, delete its manifest and rebuild its
shard.
The wallet is named by the argument or by the /wallet/<name> endpoint. The arguments are
validated before the wallet is resolved.
Result
{
"name": "acct-00417",
"warning": "the wallet is no longer served; its account stays in its shard and is still scanned, so unloading frees no scanning work, and a restart or loadwallet serves this wallet again"
}
Errors
| Code | When |
|---|---|
| -3 | wallet_name is not a string, or load_on_startup is not a boolean |
| -8 | no wallet named by either the argument or the endpoint; the two name different wallets; load_on_startup is false (the manifest is the startup list, and unloading keeps it, so delete the manifest instead) |
| -4 | the wallet is a configured [wallets.<name>] entry, which stays loaded for the daemon's lifetime |
| -18 | no loaded wallet of that name |
vs Bitcoin Core: same shape and the same refusal when the endpoint and the argument
disagree. load_on_startup = false is refused rather than silently ignored, since ignoring it
would bring the wallet back at the next restart.
listwalletdir
listwalletdir
The wallets available on disk, loaded or not: the configured wallets plus every readable fleet manifest, sorted. Works with the fleet disabled, listing configured wallets only.
Result
{
"wallets": [{ "name": "acct-00417" }, { "name": "default" }],
"warnings": ["/var/lib/zecd/fleet/zec/wallets.d/acct-00999.toml: <reason>"]
}
warnings (zecd extension) names each manifest that could not be read, which is how an
operator learns that a file they wrote is not being served. It is absent when there are none,
so a healthy fleet gets Bitcoin Core's exact shape.
walletpassphrase
walletpassphrase "passphrase" timeout
Decrypts the seed of a passphrase-encrypted wallet into (mlocked) memory for timeout
seconds, after which it auto-relocks. Re-running it resets the timer; a timeout of 0
relocks almost immediately. Only wallets created with zecd init --encrypt are
passphrase-encrypted; there is no passphrase-setting or passphrase-changing RPC, so the
passphrase is chosen at init and never crosses the network in any other call. See
Key custody.
Before holding the seed unlocked, zecd verifies it derives the account's pinned UFVK; a
mismatch (a replaced keys.toml or wallet database) fails with -4 and the wallet stays
locked.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | passphrase | string | required | The wallet passphrase. Must be non-empty. |
| 2 | timeout | number | required | Seconds to stay unlocked. Non-negative integer; values above 100,000,000 (~3.17 years) are silently clamped, as in Bitcoin Core. |
Result: null.
Errors
| Code | When |
|---|---|
| -1 | passphrase missing |
| -3 | passphrase not a string |
| -8 | Empty passphrase; missing or non-integer timeout; negative timeout ("Timeout cannot be negative.") |
| -14 | Wrong passphrase ("Error: The wallet passphrase entered was incorrect.") |
| -15 | Wallet is not passphrase-encrypted (identity-file or watch-only wallets): "Error: running with an unencrypted wallet, but walletpassphrase was called." |
| -4 | Decrypted seed does not derive this wallet's account (binding mismatch); refuses to unlock |
Argument validation runs before the encryption-state check, so a negative timeout is -8
even on an unencrypted wallet.
Example
curl -u user:pass -d '{"jsonrpc":"1.0","id":1,"method":"walletpassphrase","params":["correct horse battery staple",600]}' http://127.0.0.1:8232/
vs Bitcoin Core: same semantics, the same 100,000,000-second clamp, and the same
-14/-15 messages. zecd unlocks a seed (scrypt-derived key over an age-encrypted
mnemonic) rather than a wallet.dat master key.
vs zcashd: same method and error codes, but zcashd has no timeout clamp, and its
wallet encryption (encryptwallet/walletpassphrasechange) is an experimental feature
disabled by default; zecd sets encryption once at init --encrypt.
walletlock
walletlock
Drops the decrypted seed immediately and cancels the pending relock. Subsequent sends fail
with -13 ("unlock needed") until the next walletpassphrase.
The zeroization takes a fast path: wallet commands normally serialize through the per-wallet
actor, so a lock queued behind a send that is mid-proof would wait out the whole proving
window. walletlock instead zeroizes the shared in-memory seed immediately, bypassing the
queue. The in-flight send already derived its spending key before proving, so it completes;
any queued send then fails -13 at key derivation, which is the correct post-lock behavior.
The actor still processes the lock command afterward as the authoritative writer of the
relock deadline and published status.
Result: null.
Errors
| Code | When |
|---|---|
| -15 | Wallet is not passphrase-encrypted: "Error: running with an unencrypted wallet, but walletlock was called." |
vs Bitcoin Core: same semantics and the same -15 on an unencrypted wallet.
vs zcashd: same method; zcashd locks its wallet.dat master key, zecd zeroizes the
in-memory seed.
Wallet: balances
Reference for the balance and received-by-address methods. All five are read-only: they run
on short-lived SQLite connections that bypass the wallet actor, so they never block on a sync
or an in-flight send. For the wire format, auth, and multiwallet /wallet/<name> routing, see
Conventions & wire format.
Balances aggregate every pool the wallet holds funds in: Orchard, Sapling, Ironwood (once NU6.3 activates; those notes arrive at ordinary Orchard addresses, so no extra receiver is involved), and (when transparent receiving is enabled) transparent UTXOs. Amounts are bare JSON numbers in decimal ZEC, 8 places, exact (no float drift).
getbalance
getbalance ( "*" minconf include_watchonly avoid_reuse )
Returns the wallet's spendable balance. With no minconf, spendability follows the wallet's
configured confirmations policy (ZIP-315 defaults: 3 confirmations for trusted notes, meaning
your own change and every output of a transaction the wallet authored, 10 for third-party
receipts; [spend] trusted_confirmations /
untrusted_confirmations in the configuration). The no-argument result
therefore always equals what a send can actually spend, and agrees with the -6 insufficient
funds accounting on the send methods.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | dummy | string | omitted | Legacy account argument. Must be excluded, null, or "*"; any other string is -32. |
| 2 | minconf | number | wallet policy | Overrides both policy bounds symmetrically: count a note spendable at minconf confirmations regardless of trust. Values below 1 (including 0) are served as 1: a shielded note is never spendable unmined. |
| 3 | include_watchonly | any | ignored | Accepted for Bitcoin Core arity compatibility, ignored. |
| 4 | avoid_reuse | any | ignored | Accepted for Bitcoin Core arity compatibility, ignored. |
Because the default policy is stricter than any single minconf, getbalance "*" 1 is always
greater than or equal to getbalance.
Result
1.25000000
Errors
| Code | When |
|---|---|
| -32 | dummy is a string other than "*" |
| -3 | dummy is a non-string, or minconf is not a number |
vs Bitcoin Core: same signature and the same -32 with the identical message for a bad
dummy. Core's minconf defaults to 0 and its no-argument result is the trusted balance;
zecd's default is the ZIP-315 policy, and minconf 0 is served as 1. include_watchonly and
avoid_reuse are ignored (Core master also ignores include_watchonly).
vs zcashd: zcashd's getbalance is transparent-only; its shielded balances live in
z_gettotalbalance / z_getbalanceforaccount. zecd's getbalance is the account-wide
spendable total across all pools, so it is closer to z_getbalanceforaccount than to zcashd's
getbalance. zcashd also accepts "" for the dummy and rejects a bad one with -8, and has
extra inZat / asOfHeight arguments that zecd does not.
getbalances
getbalances
Returns the Bitcoin Core 0.19+ balance object. Everything reports under mine, including on
a watch-only (UFVK) wallet: like Core's descriptor wallets, the
addresses are the wallet's own and only signing is impossible, so there is no watchonly
object.
Result
{
"mine": {
"trusted": 1.25000000,
"untrusted_pending": 0.10000000,
"immature": 0.05000000,
"coinbase": 0.00000000
},
"lastprocessedblock": {
"hash": "00000000012f2e9d7a9ba447d1da6a2c31ec26bd8d0a55a259d3ab1741e5cdcc",
"height": 2412345
}
}
trusted: spendable under the wallet's confirmations policy; equalsgetbalance.untrusted_pending: received but not yet spendable under the policy; equalsgetunconfirmedbalance. Incoming 0-conf payments seen by the mempool stream land here.immature: change awaiting confirmation; unconfirmed change from your own sends reports here. For transparent coinbase held below the 100-block maturity, seegetwalletinfo.immature_balanceand Transparent support.coinbase(extension): the unspent mature transparent coinbase value, a subset oftrustedby construction (see below).lastprocessedblock(Core 26+): the fully-scanned block the balances are anchored to, the same anchor asgetblockcount. Omitted while the wallet has not yet scanned a block.
Why coinbase is broken out. Mature transparent coinbase is spendable value and counts
toward trusted, but no ordinary send can select it: consensus requires a transaction that
spends transparent coinbase to have no transparent output at all, so the wallet's coin
selection skips those UTXOs. Without the field, getbalance could report funds that
sendtoaddress then refused to spend, with nothing in the API explaining the difference. The
same value is mirrored at
getwalletinfo.transparent.coinbase_balance, and the -6
insufficient-funds message on the send methods names the mature-coinbase amount and
points at shielding (z_shieldcoinbase) as the route that can move it.
vs Bitcoin Core: same shape minus Core master's mine.nonmempool and the optional
mine.used (zecd has no avoid-reuse flag), plus the coinbase extension. The Core triple still
totals the wallet (coinbase is a subset of trusted, not a fourth bucket), so a client reading
only trusted/untrusted_pending/immature is unaffected. Core's legacy watchonly object is
likewise gone from Core master; zecd never emits it.
vs zcashd: no equivalent. The nearest is z_gettotalbalance, which splits
transparent/private/total rather than trusted/pending.
getunconfirmedbalance
getunconfirmedbalance
Returns value received but not yet spendable under the wallet's confirmations policy, across
all pools. Identical to getbalances.mine.untrusted_pending. An incoming payment appears here
at 0 confirmations via the mempool stream, before its funding block is scanned.
Result
0.10000000
vs Bitcoin Core: removed in Core 30.0 (its release notes point callers at
getbalances.mine.untrusted_pending). zecd keeps it for older clients; prefer getbalances
in new code.
vs zcashd: exists, but returns the unconfirmed transparent balance only; zecd's spans shielded pools too.
getreceivedbyaddress
getreceivedbyaddress "address" ( minconf include_immature_coinbase )
Returns the total received by one of the wallet's own addresses, summed over transactions
with at least minconf confirmations. Internal change is not counted; a payment to one of
the wallet's own external addresses is.
Matching is by diversifier index (since 0.8.0; 0.7.3 on the 0.7 line). Any encoding of an
index the wallet owns answers with that index's receipts: the exact value getnewaddress
returned, a re-encoding with a different receiver subset, or a single receiver of it. Before,
matching was whole-string, and after a from-seed restore or zecd rescan the wallet's own
getnewaddress address could answer 0.00000000 while the balance was correct. A bare
transparent address is still its own key. A spliced UA (this wallet's receivers combined across diversifier indices, or mixed
with a stranger's) is rejected with -5 rather than silently treated as foreign.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | address | string | required | An address belonging to this wallet (UA or bare transparent). |
| 2 | minconf | number | 1 | Count only transactions with at least this many confirmations. 0 includes unmined receipts. Expired or conflicted transactions report -1 confirmations and are never counted at minconf >= 0. |
| 3 | include_immature_coinbase | bool | false | Also count transparent coinbase that has not reached the 100-block maturity. Shielded coinbase has no maturity rule and always counts. |
Unlike getbalance, minconf 0 is meaningful here: this method totals receipts, not
spendability.
Result
0.50000000
Errors
| Code | When |
|---|---|
| -5 | Address does not parse for this network |
| -5 | Spliced/inconsistent Unified Address |
| -4 | Valid address that does not belong to this wallet (Address not found in wallet) |
| -3 | Non-numeric minconf |
vs Bitcoin Core: same signature, same -4 Address not found in wallet for a foreign
address, and the same treatment of include_immature_coinbase: immature transparent coinbase
is left out of the total unless it is set.
vs zcashd: zcashd's getreceivedbyaddress covers transparent addresses only; shielded
receipts are enumerated per-note by z_listreceivedbyaddress (a list, not a total). zcashd's
extra inZat / asOfHeight arguments do not exist in zecd.
listreceivedbyaddress
listreceivedbyaddress ( minconf include_empty include_watchonly "address_filter" include_immature_coinbase )
Per-address received totals with the txids that paid them. With include_empty it also
lists every address the wallet has generated, which makes it the address-enumeration idiom:
zecd has no listaddresses, so listreceivedbyaddress 1 true is how you enumerate the
wallet's known addresses. The set is what this wallet database has recorded; after a
from-seed restore, handed-out addresses that were never funded are forgotten (zecd is
stateless).
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | minconf | number | 1 | Count only transactions with at least this many confirmations (same semantics as getreceivedbyaddress). |
| 2 | include_empty | bool | false | Also list generated addresses that have received nothing. |
| 3 | include_watchonly | any | ignored | Accepted, ignored (deprecated and unused in Core master too). |
| 4 | address_filter | string | none | Return only the entry for this exact address string. |
| 5 | include_immature_coinbase | bool | false | Also count transparent coinbase below the 100-block maturity, as in getreceivedbyaddress. |
Result
[
{
"address": "u1v0qh8pw9qm4h2v0negtfzrwhtjzfhgh0jcs9tzkjxg7xkpxkfhz5c4tj0nzqyjrmzgcqnyu7q6cx",
"amount": 0.50000000,
"confirmations": 4,
"label": "",
"txids": [
"1f5e1f7b9d0f0c2f0a3f4f4b8f9f6d3e2c1b0a998877665544332211ffeeddcc"
]
}
]
amount: total received by the address atminconf, decimal ZEC.confirmations: confirmations of the most recently counted payment (the minimum across the counted transactions); 0 for an empty entry.label: always""; zecd keeps no labels, the field is retained for Core shape.txids: the counted transactions,[]for an empty entry.
Errors
| Code | When |
|---|---|
| -3 | Non-numeric minconf |
vs Bitcoin Core: same parameter list and entry shape. address_filter is a plain string
match, not validated: a filter that matches nothing (including an address the wallet has
never seen) returns [], where Core rejects an invalid filter address with -4. label is
always empty, and the by-label variants (listreceivedbylabel, getreceivedbylabel) are not
implemented (-32601).
vs zcashd: zcashd's listreceivedbyaddress is transparent-only and rejects a non-default
addressFilter; per-address shielded receipts come from z_listreceivedbyaddress (one entry
per note, with memos). zecd folds all pools into the one Core-shaped method; for per-output
history with memos use z_listtransactions.
Example
curl -s --user u:p --data-binary \
'{"jsonrpc":"1.0","id":"1","method":"listreceivedbyaddress","params":[1,true]}' \
http://127.0.0.1:8232/
Wallet: history & unspent
Reference for the wallet history and unspent-output methods: listtransactions,
z_listtransactions, listsinceblock, gettransaction, and listunspent. All five are
read-only: they run on short-lived SQLite connections and never block on the sync loop.
Shared conventions
These apply to every method on this page.
-
Categories. Only
sendandreceiveare emitted. A self-transfer (a payment to one of the wallet's own external addresses) appears as Bitcoin Core's send + receive pair. True change (internal key scope) is hidden from history but still counted in balances andlistunspent. Core's coinbase categories (generate/immature/orphan) never appear. -
Confirmations are anchored to the wallet's fully-scanned height, the same height
getblockcountreports, sogetblockcount() - blockheight + 1agrees with the field. An expired unmined transaction reports-1(it can never confirm; Core's "conflicted" signal, so pollers terminate). -
time/timereceivedare the block time once mined. For an unmined transaction they are the wall-clock time the wallet first saw it in the mempool, held in a transient in-memory map (never persisted; see statelessness), falling back to the creation time for wallet-authored sends. After a restart, an unmined foreign transaction reports0until the mempool stream re-observes it or it mines. The two fields are always equal. -
memo/memoStrare extension fields beyond Bitcoin Core's set, using zcashd'sz_viewtransactionnames:memois the raw stored memo bytes in hex,memoStrthe decoded text when the memo is a valid ZIP-302 text memo. An output carrying no memo, or the canonical empty memo every memoless shielded output carries, adds neither field.memois emitted from the stored bytes and does not depend on the ZIP-302 parse succeeding; onlymemoStrdoes. The two are different questions: a memo whose lead byte is at or below0xF4is declared to be UTF-8 text, but nothing on the consensus side enforces that, so a protocol embedding arbitrary bytes in that lead-byte space produces a memo that fails the parse while still being perfectly good data. Through 0.6.3 the hex field was gated on that parse, so such a memo came back with nomemo, nomemoStrand no error, which is indistinguishable from an output that carried none. Fixed in 0.6.4: an absentmemofield now means "no memo", never "unparseable memo". If you built a workaround that fetches the raw transaction to recover memos this dropped, you can retire it. -
Outgoing
addressis the single receiver actually paid, not the full Unified Address the caller typed. A multi-receiver UA is sender-side metadata that never reaches the chain, so history reduces each outgoing output to the paid receiver (a baret/zsaddress, or a single-receiver UA for Orchard). This makes history identical on the authoring instance and after a restore-from-seed. Since 0.8.0 received entries follow the same rule, so a payer and a payee print the same string for one output (byte-identical to the issued address under the default Orchard-only receivers). See statelessness. -
diversifier_index(new in 0.8.0, zecd extension) is onreceiveentries inlisttransactions,listsinceblock,z_listtransactionsandgettransaction.details: the index of the wallet's own address the output landed on. Every encoding of an address shares it, and the scanner recovers it from the note, so it is identical after a from-seed restore. Store the index at issuance (z_getaddressforaccountreturns it,getaddressinforeads it back) and match receipts by integer. Send entries, including the send half of a self-transfer, never carry it: a recipient's index is theirs. A shielded index can reach 2^88, so parse it as an arbitrary-precision integer. -
With
[sync] fetch_memos = false(0.8.0),memoandmemoStrare omitted everywhere, not only where a fetch was skipped. See configuration. -
labelis always""andwalletconflictsalways[]: zecd keeps no address labels and tracks no conflict set.bip125-replaceableis always"no"(Zcash has no RBF). -
Amounts are bare JSON numbers in decimal ZEC, 8 places.
-
History ordering is total, and is a contract. For the four history methods (
listtransactions,z_listtransactions,listsinceblock,gettransaction, but notlistunspent, which builds its list from the note and UTXO sets and promises no order), results are ordered by height, then transaction id, then output pool, then output index. Height alone is not injective, since a block holds many wallet transactions, so through 0.6.3 two transactions mined at the same height came back in whatever order SQLite happened to produce. That was stable enough within one call and not stable across calls, which meant acount/frompage boundary landing inside a same-height tie could show a transaction twice or skip it entirely between adjacent pages. Fixed in 0.6.4. A consumer replaying history as a log therefore gets a stable(height, txid, pool, outindex)sequence and can resume from the last entry it processed. The transaction-id comparison is over the stored internal byte order, which is an arbitrary but stable permutation of display order; do not read it as alphabetical by displayed txid. Reversing the order (newest-first) reverses the height and txid keys together, leaving the within-transaction output order unchanged.
listtransactions
listtransactions ( "label" count skip include_watchonly )
The most recent wallet history entries, one entry per non-change output, oldest-to-newest. Covers shielded notes and (when enabled) transparent outputs in one list.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | label | string | "*" | "*" or omitted lists everything. Any other value keeps only entries whose label equals it; every zecd entry's label is "", so "" matches everything and any other string matches nothing. |
| 2 | count | numeric | 10 | Number of entries to return. |
| 3 | skip | numeric | 0 | Number of most-recent entries to skip before taking count. |
| 4 | include_watchonly | boolean | false | Accepted and ignored (deprecated in Core too). |
Result
[
{
"address": "u1a7pqnnzcdev3ka5jyv2q0kag0k8qvyw2s0z1erdhfmwzmp8dip5rk5632cxutlyf6jz062cu5qnkcs2857vy0mnhxen8993rvxmqedqu",
"category": "receive",
"amount": 1.25000000,
"label": "",
"vout": 0,
"confirmations": 12,
"txid": "8ab1c74952e723459d5e18b975bff21af07a90ba1eec368bcb2d3d6d7b0e0c17",
"bip125-replaceable": "no",
"memo": "696e766f6963652034322070616964",
"memoStr": "invoice 42 paid",
"blockhash": "0000000001d4f81c8494ba9cd02c0ea936f1ba52e6a186a538d3c3e2ab5b91f7",
"blockheight": 2914301,
"blockindex": 1,
"blocktime": 1751581200,
"walletconflicts": [],
"time": 1751581200,
"timereceived": 1751581200
},
{
"address": "u1v40svyy8lqhy4gyq5vysyz39yqwf4ypw9zvhqjmwlqk9vqvyfrgc6yz6e2spwwrjxpwyfwjt3u4nrpydp0hnzqge0ptr9y8yavgvpr7ux",
"category": "send",
"amount": -0.50000000,
"label": "",
"vout": 0,
"confirmations": 3,
"txid": "e37b006aa754e982f2c19152fbd80f26e6a3fe9c418b1ce3f5aab3ad4d7e9b52",
"bip125-replaceable": "no",
"abandoned": false,
"fee": -0.00015000,
"blockhash": "00000000023a1b6d81c62f1c22f0a3e9a83f6de960e60d357ce09b3c73ef14a8",
"blockheight": 2914310,
"blockindex": 2,
"blocktime": 1751583450,
"walletconflicts": [],
"time": 1751583450,
"timereceived": 1751583450
}
]
- Sends are negative (Core's sign convention);
fee(negative) andabandonedappear on send entries only.abandonedis true for an expired unmined send. - Mined entries carry
blockhash/blockheight/blockindex/blocktime; unmined entries carrytrustedinstead (true iff the wallet authored the transaction and it can still be mined).
Errors
| Code | When |
|---|---|
| -8 | Negative count or skip. |
| -3 | count/skip not a number. |
vs Bitcoin Core: same arguments and paging (count most recent after skipping skip
from the newest end, returned oldest-first). Entries omit wtxid and parent_descs;
abandoned appears only on send entries (Core master also puts it on receives); categories
are limited to send/receive; memo/memoStr are extensions.
vs zcashd: zcashd's listtransactions covers transparent activity only (shielded
receipts need per-address z_listreceivedbyaddress) and adds amountZat, status, and
expiryheight to each entry. zecd lists shielded and transparent activity in one Core-shaped
list; use z_listtransactions for the zcashd vocabulary.
z_listtransactions
z_listtransactions ( count from includeWatchonly )
A zecd extension (no such method exists in zcashd or Bitcoin Core): per-output wallet history
in zcashd's z_* vocabulary. Same content as listtransactions, different field names, plus
the value pool and zatoshi amounts. Pagination is identical (newest-first cursor,
oldest-first output).
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | count | numeric | 10 | Number of entries to return. |
| 2 | from | numeric | 0 | Number of most-recent entries to skip. |
| 3 | includeWatchonly | boolean | false | Accepted and ignored. |
There is no account or address argument: results span the wallet's single account.
Result
[
{
"txid": "e37b006aa754e982f2c19152fbd80f26e6a3fe9c418b1ce3f5aab3ad4d7e9b52",
"status": "mined",
"confirmations": 3,
"time": 1751583450,
"walletconflicts": [],
"pool": "orchard",
"category": "send",
"amount": -0.50000000,
"amountZat": -50000000,
"address": "u1v40svyy8lqhy4gyq5vysyz39yqwf4ypw9zvhqjmwlqk9vqvyfrgc6yz6e2spwwrjxpwyfwjt3u4nrpydp0hnzqge0ptr9y8yavgvpr7ux",
"outindex": 0,
"change": false,
"outgoing": true,
"blockhash": "00000000023a1b6d81c62f1c22f0a3e9a83f6de960e60d357ce09b3c73ef14a8",
"blockheight": 2914310,
"blockindex": 2,
"blocktime": 1751583450,
"expiryheight": 2914350,
"fee": -0.00015000,
"feeZat": -15000
}
]
poolistransparent,sapling,orchard, orironwood.ironwoodappears once NU6.3 activates (testnet). Those notes arrive at ordinary Orchard addresses, sopoollabels the note's bundle, not a distinct receiver. See Addresses & shielded pools.statusismined,waiting, orexpired. zcashd's fourth valueexpiringsoonis never emitted.amountZatis an integer (zatoshis), negative on sends;outgoingis true on the send side of a self-transfer pair.changeis alwaysfalse(change outputs are filtered before this point; the key is kept for shape compatibility with zcashd'swalletInternal/changeconvention).expiryheightappears when the transaction has a non-zero expiry;fee/feeZat(negative) on send entries only;memo/memoStron shielded outputs as elsewhere.
Errors
| Code | When |
|---|---|
| -8 | Negative count or from. |
| -3 | count/from not a number; includeWatchonly not a boolean. |
vs Bitcoin Core: no equivalent; this is the zcashd-vocabulary view of the same history
listtransactions serves.
vs zcashd: zcashd has no z_listtransactions. The entry shape borrows from
z_listreceivedbyaddress (pool/amount/amountZat/memo/memoStr/outindex/change/
block fields), z_viewtransaction (outgoing), and zcashd's per-transaction
status/expiryheight. Unlike z_listreceivedbyaddress it is not per-address and includes
sends; unlike z_viewtransaction it is a flat paged list, not a per-transaction
spends/outputs breakdown.
listsinceblock
listsinceblock ( "blockhash" target_confirmations include_watchonly include_removed )
The restart-safe payment poller: every wallet transaction in blocks after blockhash (plus
all unmined transactions), and a lastblock cursor to feed into the next call.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | blockhash | string | omitted | List activity since this block (exclusive). Omitted or "" lists everything. |
| 2 | target_confirmations | numeric | 1 | Which depth's block hash to return as lastblock (must be >= 1). Not a filter. |
| 3 | include_watchonly | boolean | false | Accepted and ignored. |
| 4 | include_removed | boolean | true | Accepted and ignored; removed is always []. |
Result
{
"transactions": [],
"removed": [],
"lastblock": "0000000001d4f81c8494ba9cd02c0ea936f1ba52e6a186a538d3c3e2ab5b91f7"
}
transactions entries have exactly the listtransactions shape (no label filter applies).
removed is always empty: reorged-away transactions are rescanned and re-reported by the
sync engine rather than tracked separately. lastblock is the hash of the block that
currently has target_confirmations confirmations, anchored to the fully-scanned height;
when the requested depth predates the wallet's scan range it falls back to the earliest
scanned block, and a wallet with nothing scanned returns the all-zero hash.
Cursor semantics after a reorg. zecd keeps only the current chain's scanned blocks, so it
cannot walk a stale cursor back to the fork point the way Bitcoin Core's
findCommonAncestor does. Instead, a well-formed 64-hex hash that is not among the
wallet's scanned blocks (a reorged-away cursor, or one below the wallet birthday) is treated
as "since the earliest scanned block": everything is listed. A lower cursor only ever
re-reports, never misses, so the poller self-heals instead of wedging. Only a malformed
hash, which can never be a cursor zecd handed out, is a -5 Block not found. Consequence for
integrators: process listsinceblock output idempotently, keyed by txid. A
target_confirmations of, say, 6 keeps re-reporting transactions until they reach 6
confirmations, which is the intended Core usage pattern and absorbs the re-baseline case for
free.
Errors
| Code | When |
|---|---|
| -5 | blockhash is not a 64-character hex string ("Block not found"). |
| -8 | target_confirmations is not an integer >= 1. |
vs Bitcoin Core: same cursor pattern and lastblock semantics. Core walks a stale hash
back to the fork point and can populate removed; zecd re-baselines to the earliest scanned
block and keeps removed always []. Core master's two extra positional arguments
(include_change, label) exceed zecd's four-argument arity and are rejected with -1.
vs zcashd: zcashd's listsinceblock is the inherited transparent-only Bitcoin method;
zecd's covers shielded activity too and adds the reorg re-baseline behavior above.
gettransaction
gettransaction "txid" ( include_watchonly verbose )
Detailed information on one wallet transaction: net amount, per-output details, and the
raw hex.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | txid | string | required | The transaction id (display hex). |
| 2 | include_watchonly | boolean | false | Accepted and ignored. |
| 3 | verbose | boolean | false | Accepted and ignored; no decoded field is ever emitted (use getrawtransaction <txid> 1). |
Result
{
"amount": -0.50000000,
"fee": -0.00015000,
"confirmations": 3,
"txid": "e37b006aa754e982f2c19152fbd80f26e6a3fe9c418b1ce3f5aab3ad4d7e9b52",
"bip125-replaceable": "no",
"details": [
{
"address": "u1v40svyy8lqhy4gyq5vysyz39yqwf4ypw9zvhqjmwlqk9vqvyfrgc6yz6e2spwwrjxpwyfwjt3u4nrpydp0hnzqge0ptr9y8yavgvpr7ux",
"category": "send",
"amount": -0.50000000,
"vout": 0,
"pool": "ironwood",
"label": "",
"abandoned": false,
"fee": -0.00015000
}
],
"hex": "050000800a27a726b4d0d6c2...",
"blockhash": "00000000023a1b6d81c62f1c22f0a3e9a83f6de960e60d357ce09b3c73ef14a8",
"blockheight": 2914310,
"blockindex": 2,
"blocktime": 1751583450,
"walletconflicts": [],
"time": 1751583450,
"timereceived": 1751583450
}
amountis fee-exclusive, per Core: for a wallet-funded transaction it is the negated sum of payments (the balance delta with the fee added back); for a pure receive it is the received amount; a self-transfer nets to0.fee(negative) appears only when the wallet funded the transaction. librustzcash records a derived fee even on pure receives, but zecd gates the field on the balance-delta signal so a deposit is never reported with a fee the wallet did not pay.detailshas one entry per non-change output and category, with thelisttransactionsentry shape minus the per-transaction fields (confirmations,txid, times), which sit at the top level.memo/memoStrappear per detail entry.details[].pool(extension, 0.7.0):"transparent","sapling","orchard"or"ironwood".voutis the output index within its own pool's bundle, not a transparent outpoint index, so on a transaction with outputs in more than one pool(txid, vout)does not identify an output.(txid, pool, vout)does, which is the key a consumer followinglistsinceblockas an incremental cursor reaches for. History entries already carriedpool; this bringsdetailsinto line with them.hexis the stored raw transaction when the wallet has it (wallet-authored sends, and receives stored via the mempool stream or the enhancement pass). For a transaction the wallet only ever saw as a compact block, the bytes are fetched on demand from the chain backend. The fetch is best-effort: an unreachable upstream yields"", not an error.- Mined/unmined block fields and
trustedfollow the shared conventions above.
Errors
| Code | When |
|---|---|
| -5 | Unknown or non-wallet txid ("Invalid or non-wallet transaction id", Core's message). |
vs Bitcoin Core: same top-level shape; omits wtxid, parent_descs, generated,
comment, and (since verbose is ignored) decoded. fee appears only on wallet-funded
transactions, as in Core. details entries add memo/memoStr and pool.
vs zcashd: zcashd's gettransaction reports the transparent parts and defers shielded
detail to z_viewtransaction. zecd's details cover shielded outputs (with memos) directly;
there is no separate z_viewtransaction.
listunspent
listunspent ( minconf maxconf ["address",...] include_unsafe query_options )
The wallet's unspent funds in Bitcoin Core's UTXO shape: every unspent shielded note (all enabled pools) plus, for transparent-enabled wallets, every unspent transparent UTXO.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | minconf | numeric | 1 | Minimum confirmations. 0 includes unconfirmed outputs fed by the mempool stream. |
| 2 | maxconf | numeric | 9999999 | Maximum confirmations. |
| 3 | addresses | array | none | Keep only outputs received on these addresses. Each entry must be a valid address (-5); duplicates are -8. |
| 4 | include_unsafe | boolean | true | Include outputs not safe to spend (see safe below). |
| 5 | query_options | object | none | Accepted and ignored: Core's minimumAmount/maximumAmount/etc. have no effect on the result. |
Result
[
{
"txid": "8ab1c74952e723459d5e18b975bff21af07a90ba1eec368bcb2d3d6d7b0e0c17",
"vout": 0,
"address": "u1a7pqnnzcdev3ka5jyv2q0kag0k8qvyw2s0z1erdhfmwzmp8dip5rk5632cxutlyf6jz062cu5qnkcs2857vy0mnhxen8993rvxmqedqu",
"amount": 1.25000000,
"confirmations": 12,
"spendable": true,
"solvable": true,
"safe": true
}
]
- Synthesized outpoints for notes. Shielded notes are not bitcoin-style outpoints, so
(txid, vout)is synthesized:voutis the note's index within its pool's bundle (the Sapling output index or the Orchard action index). It identifies the note stably but cannot be fed to raw-transaction spending. Transparent UTXOs carry their real(txid, vout)and a bare t-address. addressis the receiving diversified address when the wallet recorded one. Change and internal notes report"", which anaddressesfilter never matches, so a filtered call naturally excludes change.generatedrides on transparent entries, carrying zcashd's meaning:truewhen the output came from a coinbase transaction,falseotherwise. Shielded notes have no such field. Immature coinbase is not listed at all: a coinbase UTXO with fewer than 100 confirmations is excluded fromlistunspententirely, matching Bitcoin Core'sAvailableCoins, and its value is reported ingetwalletinfo.immature_balanceuntil it matures. A mature coinbase entry does appear, but consensus forbids spending it into any transparent output, so the only way to spend it isz_shieldcoinbase; the ordinary send paths never select it. See Transparent support.safeistruefor confirmed outputs and for unconfirmed outputs whose creating transaction the wallet itself authored (its own change or self-send); a foreign output surfaced at 0-conf by the mempool stream issafe: false.include_unsafe: falsehides those.spendableandsolvableare alwaystrue. They are nominal: whether a send can actually select an output is governed by the wallet's confirmations policy ([spend]trusted_confirmations/untrusted_confirmations, ZIP-315 defaults 3/10), so an entry with 1 confirmation can appear here while a send still returns-6until it reaches policy depth (see Sending). A transparent UTXO is additionally spendable only under theAllowFullyTransparentprivacy policy; under the default policy it is receive-only (see Transparent support).
Errors
| Code | When |
|---|---|
| -5 | Invalid address in the addresses filter. |
| -8 | Duplicated address in the filter. |
| -3 | minconf/maxconf not a number; addresses not an array of strings; include_unsafe not a boolean. |
vs Bitcoin Core: same arguments and filtering; entries omit label, scriptPubKey,
redeemScript, desc, and parent_descs (shielded notes have no script form).
query_options is accepted but has no effect, where Core applies minimumAmount and
friends. generated is an addition (Core carries it on gettransaction, not here), but the
immature-coinbase exclusion follows Core's AvailableCoins exactly. The synthesized note
outpoints are the largest semantic difference; see
Compatibility boundary.
vs zcashd: zcashd splits this surface into listunspent (transparent) and
z_listunspent (shielded, with pool/outindex/memo/change per note). zecd merges both
into one Core-shaped list; for pool and memo detail use
z_listtransactions. The generated flag on transparent entries is
zcashd's, with zcashd's meaning.
Sending
Reference for the synchronous send methods sendtoaddress and sendmany. For the
asynchronous zcashd-style methods (z_sendmany, z_shieldcoinbase, and the
operation-tracking trio), see async operations.
Send semantics
Everything in this section applies to both methods.
Synchronous. The call builds the transaction, computes the Orchard proof, commits it to
the wallet, and broadcasts it, all inside the HTTP request; the txid returns only after
broadcast is attempted. Unlike bitcoind's millisecond sends, the proof takes on the order of
seconds (far longer in debug builds), plus any queueing behind other sends. Set client-side
send timeouts well above that. A client that times out and blindly retries a send that
actually succeeded pays twice: on timeout, reconcile with
listtransactions before retrying. Once the transaction is committed,
a transport failure during initial relay does not surface as an error; the txid is returned
and a background loop rebroadcasts until the transaction mines or expires. Only an explicit
rejection by the Zebra node returns an error (-26), and the spent notes stay locked until
the transaction's expiry height.
Sends serialize per wallet. Each wallet is owned by a single-writer actor, the analog of
Bitcoin Core's cs_wallet: concurrent sends to one wallet are processed one at a time and
never select the same note, so there is no double-spend and no note-locking API to manage.
Queued sends hold their HTTP connection longer.
Fees are ZIP-317, never client-settable. The wallet computes the conventional fee;
there is no estimator and no fee knob. subtractfeefromamount (sendtoaddress) and
subtractfeefrom (sendmany) are rejected with -8 when engaged, because silently
ignoring them would move different amounts than the caller intended. fee_rate is rejected
with -8 for the same reason. These guards fire before any wallet access, so passing the
defaults (false, null, []) still works. conf_target and estimate_mode are
estimation hints and are silently ignored; settxfee always returns
-8.
Insufficient funds is self-diagnosing. Shielded change is unspendable until it
confirms (3 confirmations for trusted change by default, see
configuration), so rapid back-to-back sends exhaust spendable notes
and return -6 until a block arrives. The -6 message appends any balance awaiting
confirmations, so a client can tell "retry after the next block" from "the wallet needs
funding":
Insufficient funds: 0 zatoshis spendable, 10001000 required (including fee);
awaiting confirmations: 0 zatoshis incoming, 49990000 zatoshis change
Change is split into several notes. A send leaves up to [spend] target_note_count change
notes (default 4) rather than one, so the next send has several notes to spend in turn instead
of serializing on one note's confirmation depth. The split only happens above
[spend] min_split_output_value (default 0.1 ZEC), since splitting a small balance into dust
helps nobody. Both were hard-coded before 0.7.0 and are now configurable; the defaults are the
old hard-coded values, so nothing changes unless you set them. A deployment built on small
balances is the case that wants the floor lowered: below it, one change note comes back, and
back-to-back sends then hit the -6 above.
Consolidating the other direction. Many small notes and UTXOs accumulate on a wallet that
receives a lot of payments, and no send method gathers them up, because they all select inputs
to cover an amount you name. z_mergetoaddress,
new in 0.7.0, is the sweep that does.
Coinbase inputs are never selected here. Consensus forbids a transaction that spends a
transparent coinbase output from having any transparent output, change included, so the
transparent-to-transparent path (which always writes transparent outputs) skips coinbase
UTXOs entirely; selecting one would build a consensus-invalid transaction. Mature transparent
coinbase is spendable only by shielding it with
z_shieldcoinbase, which moves the whole selected
value into a single shielded output. Coinbase below the 100-block maturity is not spendable
at all: it is excluded from listunspent and reported in
getwalletinfo.immature_balance.
Because that value still counts toward getbalance, the -6 insufficient-funds message names
the mature-coinbase amount and points at z_shieldcoinbase, so a balance the wallet reports but
refuses to spend explains itself. The same amount is readable as
getbalances.mine.coinbase and
getwalletinfo.transparent.coinbase_balance.
Privacy policy is enforced per recipient. Under the wallet's configured
[spend] privacy_policy (default AllowRevealedRecipients), a recipient with no shielded
receiver (a bare t1/t3 address) is rejected up front with -8 when the policy is
FullPrivacy or AllowRevealedAmounts. FullPrivacy additionally rejects, on the built
proposal, any send that crosses a turnstile between two shielded pools (Sapling, Orchard and
Ironwood are three distinct pools). Neither method takes a
per-call policy argument; the config value applies. See the
privacy policy ladder.
Neither method selects a funding source. They have no fromaddress, so they spend the
account's shielded notes, with the one legacy exception below. Spending transparent UTXOs, and
shielding them, is z_sendmany's fromaddress.
Action limit. [spend] orchard_action_limit (default 50, 0 disables) caps the Orchard
actions of a single send to bound its memory and proving cost. The count is per bundle and
summed, as the builder proves them: since NU6.3 a send that spends legacy Orchard notes into
Ironwood outputs builds two bundles, so it can be refused with far fewer recipients than the
cap. A proposal that exceeds it returns -8 naming the count, and the two bundles when there
are two. Consolidate with z_mergetoaddress, or raise
the cap and send SIGHUP, which reloads it without a restart (since 0.8.0).
Common errors (both methods; verified in the handlers and src/error.rs):
| Code | When |
|---|---|
| -1 | Missing required argument; more positional arguments than the method accepts |
| -3 | Amount not a number or string; zero or unparseable amount (Invalid amount); negative or above 21,000,000 ZEC (Amount out of range); non-boolean verbose |
| -5 | Unparseable address, or an address for the wrong network |
| -6 | Insufficient spendable funds (see the enrichment above) |
| -8 | subtractfeefromamount/subtractfeefrom or fee_rate engaged; privacy-policy rejection of a transparent-only recipient; orchard_action_limit exceeded |
| -4 | Watch-only wallet (Error: Private keys are disabled for this wallet); other wallet-level build failures |
| -13 | Passphrase-encrypted wallet is locked (walletpassphrase first) |
| -18 | /wallet/<name> names no loaded wallet |
| -26 | The Zebra node examined the transaction and rejected it |
sendtoaddress
sendtoaddress "address" amount ( "comment" "comment_to" subtractfeefromamount replaceable conf_target "estimate_mode" avoid_reuse fee_rate verbose "memo" )
Pay one recipient from the wallet's shielded notes (or, under
AllowFullyTransparent with a transparent recipient, from transparent UTXOs; see
transparent). Returns the txid after proving and broadcast.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | address | string | required | Recipient: unified, Sapling, or transparent address |
| 2 | amount | number or string | required | Decimal ZEC, 8 places; zero is rejected (-3) |
| 3 | comment | string | omitted | Ignored: zecd persists no local metadata (see statelessness) |
| 4 | comment_to | string | omitted | Ignored, as above |
| 5 | subtractfeefromamount | boolean | false | Rejected with -8 if true (fees are ZIP-317, paid by the sender) |
| 6 | replaceable | boolean | omitted | Ignored (no RBF in Zcash) |
| 7 | conf_target | number | omitted | Ignored (no fee estimator; ZIP-317 buys next-block inclusion) |
| 8 | estimate_mode | string | omitted | Ignored |
| 9 | avoid_reuse | boolean | omitted | Ignored (shielded receiving addresses are diversified) |
| 10 | fee_rate | number | omitted | Rejected with -8 if set |
| 11 | verbose | boolean | false | Return an object with fee_reason instead of a bare txid |
| 12 | memo | string (hex) | omitted | zecd extension: hex-encoded ZIP-302 memo for the shielded recipient, at most 512 bytes |
Result
"85a13a0895c9ef2e26b1a29321581e19b6cb51b0e6b1e4f0d68f4d5cba1b7f4e"
With verbose = true (fee_reason is always the ZIP-317 conventional fee):
{
"txid": "85a13a0895c9ef2e26b1a29321581e19b6cb51b0e6b1e4f0d68f4d5cba1b7f4e",
"fee_reason": "ZIP 317"
}
Errors (beyond the common table)
| Code | When |
|---|---|
| -3 | memo present but not a string |
| -8 | memo is not valid hex (Invalid parameter, expected memo data in hexadecimal format.); memo longer than 512 bytes; memo paired with a transparent recipient (Memo cannot be used with a transparent recipient) |
vs Bitcoin Core
Parameter positions 1-11 match Core master's sendtoaddress exactly (verified against
src/wallet/rpc/spend.cpp), including the verbose result shape. Differences: comment/
comment_to are accepted but never stored; subtractfeefromamount and fee_rate are hard
-8 rejections instead of honored; replaceable/conf_target/estimate_mode/
avoid_reuse are ignored; position 12 (memo) does not exist in Core.
vs zcashd
zcashd retains sendtoaddress but as a transparent-only legacy method: it selects funds
exclusively from the transparent pool, its help says "THIS API PROVIDES NO PRIVACY", and it
takes only 5 arguments (through subtractfeefromamount, which it honors). The recommended
zcashd send is z_sendmany. zecd inverts this: sendtoaddress is the primary, shielded
send, and its memo parameter follows z_sendmany's conventions (hex, 512-byte cap). Unlike
z_sendmany, zecd's sendtoaddress keeps Core's rejection of zero amounts (-3), so a
memo-only send needs z_sendmany.
Example
curl -u u:p --max-time 120 -d '{
"jsonrpc": "1.0", "id": 1, "method": "sendtoaddress",
"params": ["u1abc...", 0.1, "", "", false, false, null, "", false, null, false,
"74616b652074686520686f626269747320746f2069736574686172"]
}' http://127.0.0.1:8232/
sendmany
sendmany "" {"address":amount,...} ( minconf "comment" ["address",...] replaceable conf_target "estimate_mode" fee_rate verbose )
Pay several recipients in one transaction: one ZIP-317 fee, one anchor. Recipients may mix shielded and transparent addresses (under the default policy a transparent recipient is paid from shielded notes, with shielded change).
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | dummy | string | "" | Legacy placeholder; zecd ignores it entirely (Core rejects a non-empty value) |
| 2 | amounts | object | required | {"address": amount, ...}; amounts are decimal ZEC, 8 places, number or string; zero is rejected (-3) |
| 3 | minconf | number | omitted | Ignored dummy value, as in Core master |
| 4 | comment | string | omitted | Ignored (not stored) |
| 5 | subtractfeefrom | array | omitted | Rejected with -8 if non-empty |
| 6 | replaceable | boolean | omitted | Ignored |
| 7 | conf_target | number | omitted | Ignored |
| 8 | estimate_mode | string | omitted | Ignored |
| 9 | fee_rate | number | omitted | Rejected with -8 if set |
| 10 | verbose | boolean | false | Return an object with fee_reason instead of a bare txid |
Duplicate recipient keys collapse silently. Recipients arrive as a JSON object, and
JSON parsing keeps only the last occurrence of a duplicated key before zecd sees it, so
listing the same address twice sends only the last amount. Bitcoin Core's
Invalid parameter, duplicated address error cannot be reproduced here. Do not list an
address twice; combine the amounts instead. (z_sendmany, whose recipients are an array,
does reject duplicates with -8.)
Transparent-to-transparent spends. sendmany has no per-call privacy argument, so its
only route to a fully transparent spend (transparent UTXOs in, transparent change) is the
[spend] privacy_policy = "AllowFullyTransparent" config knob, and it engages only when
every recipient is a bare transparent address. Under the default policy a wallet holding
only transparent funds gets -6. See transparent.
Result
Same shape as sendtoaddress: a bare txid string, or {"txid", "fee_reason"} with
verbose = true.
Errors (beyond the common table)
| Code | When |
|---|---|
| -3 | amounts present but not an object |
| -8 | amounts is an empty object (sendmany requires at least one recipient) |
vs Bitcoin Core
Parameter positions 1-10 match Core master exactly (verified against
src/wallet/rpc/spend.cpp); Core also treats minconf as an ignored dummy. Differences:
zecd never validates the dummy argument (Core returns -8 for a non-empty one), rejects
subtractfeefrom/fee_rate with -8 instead of honoring them, and does not store the
comment.
vs zcashd
zcashd's sendmany is a discouraged transparent-only legacy method ("Prefer to use
z_sendmany instead"); it takes 5 arguments, honors minconf, and honors
subtractfeefromamount. zecd's nearest zcashd equivalent for a multi-recipient shielded
send is z_sendmany, which zecd also implements (async operations)
with per-output memos, a per-call minconf and privacyPolicy, and zero-amount outputs.
Example
curl -u u:p --max-time 120 -d '{
"jsonrpc": "1.0", "id": 1, "method": "sendmany",
"params": ["", {"u1abc...": 0.05, "t1M72Sfpbz1BPpXFHz9m3CdqATR44Jvaydd": 0.02}]
}' http://127.0.0.1:8232/
Async operations
Reference for z_sendmany, z_shieldcoinbase, z_mergetoaddress, and the operation-tracking
trio z_getoperationstatus / z_getoperationresult / z_listoperationids. These six methods
adopt zcashd's asynchronous send model: they match zcashd's syntax, status shapes, and
state strings, so clients written for zcashd's z_sendmany work unchanged. For synchronous
sends in Bitcoin Core's dialect, see Sending.
A seventh method, z_waitforoperation, is a zecd extension with no
zcashd counterpart: it blocks until one operation finishes, so a client does not have to
write a poll-sleep loop.
The operation model
z_sendmany, z_shieldcoinbase and z_mergetoaddress validate their arguments, then return
an operation id (opid- followed by a UUID, identical to zcashd) immediately. The transaction is selected,
proved, and broadcast on a background task; its outcome is fetched later through the tracking
methods.
The background task still funnels through the wallet's single-writer actor, so an async send
cannot double-spend against a concurrent sendtoaddress (see
Architecture).
An operation moves through the zcashd state strings queued, executing, and then
success or failed. The cancelled state exists in the schema (and as a
z_listoperationids filter) for zcashd compatibility, but zecd has no cancellation path, so
no operation ever reports it.
Properties of the registry:
-
In-memory and transient. Operations are lost on restart, exactly as in zcashd. A send that was already committed to the wallet DB still broadcasts via the rebroadcast loop even if its status object is gone; only the tracking record is lost. This is one of the two deliberate transient exceptions to zecd's statelessness invariant.
-
Wallet-scoped. Each operation is tagged with the wallet that created it. The tracking methods, routed per-wallet via
/wallet/<name>, only ever see their own wallet's operations, even when an opid from another wallet is named explicitly (it is silently omitted). zcashd's queue is node-wide; zcashd only has one wallet. -
Poll, wait, or reap.
z_getoperationstatusis non-destructive: call it as often as you like.z_waitforoperationis also non-destructive, and blocks instead of returning immediately, which is usually what you want for a single send.z_getoperationresultis destructive and one-shot: it returns each finished operation's status once and removes it; a second call for the same opid returns nothing. This matches zcashd exactly. Waiting never reaps, so a wait followed by az_getoperationresultstill gets the result. -
Bounded. Two caps protect the daemon from an authenticated flood of
z_sendmany:- At most 1024 operations are retained. Past that, the oldest finished results are auto-evicted (logged at WARN). A client that never reaps cannot wedge the daemon; the only cost is that old unread status objects may be discarded (the transactions themselves already broadcast).
- At most 16 unfinished (queued + executing) operations per wallet. An in-flight
operation owns a real pending send and cannot be evicted, so past this cap new
async-send calls are rejected with
-4back-pressure until some finish. Finished operations never count toward this cap, so forgetting to reap never blocks new sends.
zcashd has neither cap. Sends serialize on the wallet actor regardless, so 16 in flight is far above any useful concurrency.
z_sendmany
z_sendmany "fromaddress" [{"address":..,"amount":..,"memo":..},...] ( minconf ) ( fee ) ( privacyPolicy )
Send to one or more recipients asynchronously. Returns an opid immediately; the outcome (txid or error) surfaces through the tracking methods.
fromaddress selects the funding source, as it does in zcashd:
fromaddress | Funds the send from |
|---|---|
| a unified or Sapling address of this wallet | the account's shielded notes |
| a bare transparent address of this wallet | that address's non-coinbase UTXOs only |
ANY_TADDR | any of the account's non-coinbase transparent UTXOs |
ANY_SAPLING | the account's Sapling notes only (new in 0.8.0) |
ANY_ORCHARD | the account's Orchard and Ironwood notes only (new in 0.8.0; a zecd extension, since zcashd's wildcards predate Orchard) |
A transparent source requires privacy policy AllowRevealedSenders or weaker, since spending
transparent inputs reveals the sender's addresses and amounts; see the
privacy policy ladder. Paired with a shielded recipient it is the
shielding (t-to-z) send: the payment and the change both land in the shielded pool, which is how
received transparent funds get shielded. Paired with an all-transparent recipient set it is a
fully transparent transaction and additionally needs AllowFullyTransparent.
One source funds one send. Shielded notes and transparent UTXOs are never mixed, so if the named
source cannot cover the payment the operation fails -6 rather than quietly topping up from the
other pool. Transparent coinbase is never selected here; it is
z_shieldcoinbase's alone, because consensus forbids a transparent output
in a transaction spending it.
Shielded coin control is per account, not per address: notes are account-scoped, so a shielded
fromaddress names the account and any of the wallet's shielded addresses selects the same
notes.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | fromaddress | string | required | One of this wallet's own addresses (unified, Sapling, or bare transparent), ANY_TADDR, ANY_SAPLING, or ANY_ORCHARD. Selects the funding source per the table above. A foreign, undecodable, or hand-spliced address is -5; ANY_SPROUT is -8. |
| 2 | amounts | array | required | Non-empty array of {"address":.., "amount":.., "memo":..} objects. amount is decimal ZEC, 8 places; zero is allowed (the memo-only pattern, shielded recipients only). memo is an optional hex-encoded ZIP-302 memo, at most 512 bytes, shielded recipients only. Unknown keys and duplicate recipient addresses are -8. |
| 3 | minconf | number | wallet policy | Only spend notes with at least this many confirmations, overriding both bounds of the wallet's confirmations policy symmetrically for this send. Omitted or null uses the configured ZIP-315 policy (3 trusted / 10 untrusted). Values below 1 are served as 1; a non-number is -3. |
| 4 | fee | null | null | Must be omitted or null. Fees are always ZIP-317, computed by the wallet; any explicit value (including 0) is -8. |
| 5 | privacyPolicy | string | LegacyCompat | Per-call override of [spend] privacy_policy. See the mapping below. |
privacyPolicy accepts every zcashd policy name and maps it onto zecd's
five-rung ladder:
| Value | Effect in zecd |
|---|---|
FullPrivacy | No shielded leak: a transparent recipient is -8 up front, and a proposal that crosses a turnstile between two shielded pools is rejected (Sapling, Orchard and Ironwood are three distinct pools). |
AllowRevealedAmounts | Turnstile crossing allowed (reveals the amount). A transparent recipient is still -8. |
AllowRevealedRecipients | Transparent recipients allowed, paid from shielded funds with shielded change. A transparent fromaddress is still -4. |
AllowRevealedSenders, AllowLinkingAccountAddresses | Additionally permits funding the send from transparent UTXOs, with the change shielded: the shielding send. AllowLinkingAccountAddresses collapses here because zecd spends from one account, so there are no accounts to link. |
AllowFullyTransparent, NoPrivacy | Additionally permits keeping the change transparent, making the whole transaction transparent (see Transparent support). |
LegacyCompat or omitted | The wallet's configured [spend] privacy_policy (default AllowRevealedRecipients). |
| anything else | -8 |
Since 0.6.1 AllowRevealedSenders is a rung of its own; earlier versions accepted the name and
treated it as AllowRevealedRecipients.
Result
"opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10"
Only argument validation fails synchronously. Everything downstream, including -6
insufficient funds, a locked wallet, the -4 "Private keys are disabled" refusal on a
watch-only wallet, proving failures, and broadcast rejection,
surfaces later in the operation's error object, never as an error on this call. So do the
two size bounds checked when the transaction is planned: a send over [spend] orchard_action_limit or [spend] max_tx_bytes fails the operation with -8, naming the bound
it hit, before anything is proved.
Errors (synchronous)
| Code | When |
|---|---|
| -1 | fromaddress missing or null |
| -3 | fromaddress, minconf, or a memo field is the wrong JSON type |
| -5 | fromaddress undecodable, not this wallet's, or a Unified Address with inconsistently spliced receivers |
| -8 | ANY_SPROUT; amounts missing or not an array; empty amounts; unknown key or missing address/amount in an entry; duplicate recipient; non-hex or over-512-byte memo; memo on a transparent recipient; explicit fee; unknown privacyPolicy; transparent recipient under FullPrivacy/AllowRevealedAmounts |
| -4 | transparent fromaddress/ANY_TADDR under a policy below AllowRevealedSenders; transparent source with an all-transparent recipient set below AllowFullyTransparent; the wallet already has 16 unfinished operations (back-pressure); or the payment set is not a valid transaction request |
vs Bitcoin Core: no equivalent; Core has no asynchronous RPC model. The synchronous
counterparts are sendtoaddress and sendmany.
vs zcashd: same signature, same opid model, same status shapes; this is the page where zecd tracks zcashd rather than Bitcoin Core. Differences:
fromaddressmust be this wallet's own address (ANY_TADDRaside); zcashd will also spend from an address in another of its accounts. Selection itself matches: a t-address funds from that address,ANY_TADDRfrom any of them, a shielded address from the account's notes.feemay be an explicit amount in zcashd (defaultnullmeans ZIP-317); zecd rejects any explicit fee with-8.minconfdefaults to 10 in zcashd (DEFAULT_NOTE_CONFIRMATIONS); zecd defaults to the wallet's configured ZIP-315 policy and clamps explicit values to at least 1.- zcashd's
LegacyCompatdefault resolves toFullPrivacywhen a Unified Address is involved andAllowFullyTransparentotherwise; zecd's resolves to the configured[spend] privacy_policy. - zecd's ladder is linear, so
AllowRevealedSendersalso permits a transparent recipient paid from shielded notes; zcashd treats the two disclosures as incomparable lattice points.AllowLinkingAccountAddresseshas no separate meaning here, since there is one account. - The zero-valued memo-only output is accepted by both.
Example
opid = rpc.z_sendmany(my_ua, [
{"address": dest_ua, "amount": 0.5,
"memo": "7a6563642070617965652072656631323334"},
])
status = rpc.z_waitforoperation(opid) # blocks; no poll loop
if not status["finished"]:
... # timed out, still running: call again
elif status["status"] == "failed":
raise RuntimeError(status["error"]["message"])
txid = status["result"]["txid"]
rpc.z_getoperationresult([opid]) # optional: reap it
Example: shielding received transparent funds
Move transparent coins into the wallet's own shielded pool. The source is transparent and the recipient is shielded, so this is the shielding send; the change shields too.
opid = rpc.z_sendmany("ANY_TADDR",
[{"address": rpc.z_getnewaddress(), "amount": 1.0}],
None, None, "AllowRevealedSenders")
status = rpc.z_waitforoperation(opid)
Drop the "AllowRevealedSenders" argument if [spend] privacy_policy already permits a
transparent source; passing it makes the call work whatever the wallet is configured for, since
the argument is a per-call override. Naming one of the wallet's own t1 addresses instead of
ANY_TADDR shields only that address's coins and leaves the rest untouched.
There is no "shield everything" amount: the ZIP-317 fee comes out of the inputs, so a sweep has
to ask for slightly less than the transparent total. Sum the transparent entries of
listunspent (they are the ones carrying a bare t-address, and
skip any with generated: true, which only
z_shieldcoinbase can spend) and leave room for the fee.
z_shieldcoinbase
z_shieldcoinbase "fromaddress" "toaddress" ( fee ) ( limit ) ( "memo" ) ( privacyPolicy )
Sweep the wallet's mature transparent coinbase UTXOs into a single shielded output. Returns
an opid immediately; the txid or error surfaces through the tracking methods, exactly as with
z_sendmany.
Why this method exists at all. Zcash consensus forbids a transaction that spends a transparent coinbase output from having any transparent output, change included. A coinbase spend therefore cannot pay a t-address and cannot keep transparent change: the whole selected value has to land in one shielded output. That is not a shape the ordinary send methods can produce, so shielding is the only way to spend transparent coinbase, and this is the method that does it. The regular transparent-to-transparent path never selects coinbase inputs for the same reason (see Sending).
No change, in any pool. The shielded payment is exactly input_total - fee. Emitting
shielded change instead would leak how much coinbase the wallet chose to sweep, so the
selected value is moved whole. Sweep in stages with limit if you do not want it all in one
note.
Maturity. Transparent coinbase must reach the standard 100-block maturity before it can
be shielded; the bound is enforced during input selection. Immature coinbase is excluded from
listunspent and reported in
getwalletinfo.immature_balance until it matures.
Shielded coinbase (ZIP-213), a block reward mined directly to a shielded address, needs none of this: it has no maturity rule and no spend restriction, so those notes spend as ordinary Orchard notes through the normal send methods.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | fromaddress | string | required | A transparent address of this wallet, or "*" for all of them. |
| 2 | toaddress | string | required | Where the swept value lands. Must have a shielded receiver; a transparent-only destination is -8, since a coinbase spend may not create a transparent output. |
| 3 | fee | null | null | Must be omitted or null. Fees are always ZIP-317, computed by the wallet; any explicit value is -8. |
| 4 | limit | number | 50 | Maximum UTXOs to shield in this transaction. 0 means no caller limit: shield as many as fit under the block-space cap. |
| 5 | memo | string (hex) | omitted | Hex-encoded ZIP-302 memo carried on the shielded output. |
| 6 | privacyPolicy | string | wallet policy | Same names as z_sendmany. Shielding necessarily reveals the transparent senders being swept, so a policy that forbids revealing senders is -8. |
Result
{
"remainingUTXOs": 12,
"remainingValue": 3.75000000,
"shieldingUTXOs": 50,
"shieldingValue": 15.62500000,
"opid": "opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10"
}
shieldingUTXOs/shieldingValue: what this operation is sweeping.remainingUTXOs/remainingValue: mature coinbase left over becauselimitor the block-space cap cut the selection short. Non-zero means call again once this operation finishes.opid: feed it toz_getoperationstatus/z_getoperationresult.
As with z_sendmany, only argument validation fails synchronously; proving and broadcast
failures surface in the operation's error object.
Errors (synchronous)
| Code | When |
|---|---|
| -1 | fromaddress or toaddress missing |
| -3 | An argument is the wrong JSON type |
| -5 | An address is undecodable, or for the wrong network |
| -6 | No mature coinbase to shield |
| -8 | toaddress has no shielded receiver; explicit fee; a privacyPolicy that forbids revealing senders, or an unknown one |
vs Bitcoin Core: no equivalent; Core has no shielded pool and no asynchronous RPC model.
vs zcashd: same signature, same response shape, same opid model. The one difference is
the fee: zcashd accepts an explicit fee amount, zecd rejects any explicit value with -8
and always charges the ZIP-317 conventional fee. The wallet-scoping and eviction properties
of the operation registry described above apply here as they do to z_sendmany.
Example
curl -u u:p -d '{
"jsonrpc": "1.0", "id": 1, "method": "z_shieldcoinbase",
"params": ["*", "u1abc..."]
}' http://127.0.0.1:8232/
z_mergetoaddress
z_mergetoaddress ["fromaddress", ...] "toaddress" ( fee ) ( transparent_limit ) ( shielded_limit ) ( "memo" ) ( privacyPolicy )
New in 0.7.0. The consolidation sweep: merge many UTXOs, or many notes, into one output at
toaddress, paying inputs - fee. There is no amount argument and no change in any pool.
A wallet that has received many small payments accumulates a long tail of UTXOs and notes. Nothing else in zecd gathers them up: the ordinary send methods select inputs to cover an amount you name, which is the opposite operation. This is zcashd's answer, with its signature and its response shape.
The selection counts return synchronously, so the merging* and remaining* numbers are
fixed before the call returns. Proving and broadcast run under the returned opid, exactly as
with z_sendmany and z_shieldcoinbase.
One source class per call. Sources are either transparent or shielded, never both:
fromaddresses entry | Selects |
|---|---|
"ANY_TADDR" | Every non-coinbase transparent UTXO the wallet holds. |
| an own t-address | That address's non-coinbase UTXOs. Several may be listed. |
"ANY_SAPLING" | The account's Sapling notes. |
"ANY_ORCHARD" | The account's Orchard and Ironwood notes. A zecd extension: zcashd's wildcard set predates Orchard, and post-NU6.3 an Orchard receiver holds Ironwood notes, so one wildcard spans the family. |
| an own shielded or unified address | The account's notes across every pool it can hold. Notes are account-scoped, so per-address shielded coin control does not exist here, exactly as with z_sendmany's fromaddress. |
Mixing a transparent and a shielded source in one call is -8. This is the one documented
divergence from zcashd, whose t+z merge predates zecd's one-source-per-send model; two calls
achieve the same consolidation. ANY_TADDR together with individual t-addresses is also -8,
as is a shielded wildcard together with an individual shielded address, and so is a duplicated
entry. "ANY_SPROUT" is -8: there is no Sprout support at all.
Transparent coinbase is never selected. It cannot be: consensus forbids a transaction
spending transparent coinbase from having any transparent output, so sweeping it is
z_shieldcoinbase's job alone.
Privacy is the existing ladder, unchanged. See Privacy policy:
- a transparent source needs
AllowRevealedSenders; - a fully transparent merge (transparent inputs paying a transparent destination) needs
AllowFullyTransparent; - paying a transparent destination out of shielded notes needs
AllowRevealedRecipients.
Call it repeatedly. The per-call limits are real, and the remaining* fields say what a
follow-up would pick up. A large wallet consolidates over several calls, waiting for each
operation to finish before starting the next (sends serialize per wallet regardless).
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | fromaddresses | array of string | required | Non-empty; see the source table above. A missing argument is -1; a non-array is -8. |
| 2 | toaddress | string | required | Any address, own or foreign, transparent or shielded. A TEX destination is -8: paying one is a two-transaction ZIP-320 proposal, and zecd rejects multi-transaction proposals everywhere. |
| 3 | fee | null | null | Must be omitted or null. Fees are ZIP-317, computed by the wallet; any explicit value is -8. |
| 4 | transparent_limit | number | 50 | Maximum UTXOs to merge. 0 means as many as fit under the block-space cap and [spend] max_tx_bytes. Accepted and ignored for a shielded source, as in zcashd. |
| 5 | shielded_limit | number | 200 | Maximum notes to merge. 0 means no caller limit by count; Selections are additionally clamped by [spend] orchard_action_limit and, since 0.8.0, by [spend] max_tx_bytes: a merge selects as many notes as fit under both rather than failing, and reports what it left in the remaining* fields. Accepted and ignored for a transparent source. |
| 6 | memo | string (hex) | omitted | ZIP-302 memo on the output. Shielded destinations only; with a transparent toaddress it is -8. |
| 7 | privacyPolicy | string | wallet policy | Per-call override of [spend] privacy_policy, same names as z_sendmany. |
Result
{
"remainingUTXOs": 12,
"remainingTransparentValue": 3.75000000,
"remainingNotes": 0,
"remainingShieldedValue": 0.00000000,
"mergingUTXOs": 50,
"mergingTransparentValue": 15.62500000,
"mergingNotes": 0,
"mergingShieldedValue": 0.00000000,
"opid": "opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10"
}
merging*: what this operation is sweeping, fixed at call time.remaining*: what the limits or the block-space cap left behind. Non-zero means call again once this operation finishes.opid: feed it toz_getoperationstatus/z_getoperationresult.
The pair that does not match your source class is reported as zero, not omitted.
Errors (synchronous)
| Code | When |
|---|---|
| -1 | fromaddresses or toaddress missing |
| -3 | memo is not a string |
| -4 | The wallet already has 16 unfinished operations |
| -5 | A fromaddresses entry is undecodable, for the wrong network, or not this wallet's |
| -6 | Nothing to merge, or insufficient value to cover the fee |
| -8 | Empty or non-array fromaddresses; a duplicated entry; mixed source classes; ANY_TADDR with explicit t-addresses; a shielded wildcard with an explicit shielded address; ANY_SPROUT; an unparseable or TEX toaddress; an explicit fee; a negative or non-integer limit; a memo with a transparent destination or over 512 bytes; a privacy policy that forbids the shape, or an unknown one |
| -13 | The wallet is locked |
Note the split: an undecodable fromaddresses entry is -5, while an undecodable toaddress
is -8 (unknown address format). That asymmetry is zcashd's, and zecd reproduces it.
Only argument validation and the selection fail synchronously; proving and broadcast failures
surface in the operation's error object.
vs Bitcoin Core: no equivalent.
vs zcashd: same signature, same response shape, same opid model. Three differences. zecd
refuses to mix transparent and shielded sources in one call (see above). An explicit numeric
fee is -8 rather than being applied. And ANY_ORCHARD is an addition, covering the
Orchard and Ironwood notes zcashd's wildcard set has no name for.
Example
Sweep up to 50 transparent UTXOs into a shielded address, then check what is left:
curl -u u:p -d '{
"jsonrpc": "1.0", "id": 1, "method": "z_mergetoaddress",
"params": [["ANY_TADDR"], "u1abc...", null, 0, null, "", "AllowRevealedSenders"]
}' http://127.0.0.1:8232/
Consolidate the account's shielded notes into a single note at one of its own addresses:
curl -u u:p -d '{
"jsonrpc": "1.0", "id": 1, "method": "z_mergetoaddress",
"params": [["ANY_ORCHARD"], "u1own..."]
}' http://127.0.0.1:8232/
z_getoperationstatus
z_getoperationstatus ( ["operationid", ...] )
Status objects for this wallet's async operations, all of them when no array is given. Non-destructive: operations stay in memory.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | operationid | array | all operations | Array of opid strings. A malformed opid (or a non-string element, or a non-array argument) is -8; a well-formed but unknown opid is silently omitted. |
Result (sorted by creation_time, ascending)
[
{
"id": "opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10",
"method": "z_sendmany",
"params": {
"fromaddress": "u1v0m9...",
"amounts": [{"address": "u1x7pq...", "amount": 0.5}],
"minconf": 1
},
"status": "success",
"creation_time": 1751600000,
"result": {
"txid": "5f8de306fcd7e716f9c39ea55c30d97a5a80439b7c8ec24b3decd80d20f0f1a8"
},
"execution_secs": 3
}
]
method/paramsecho the originating call (zcashd's context info). The echoedminconfis the raw argument, shown as1when it was omitted; the effective default when omitted is the wallet's configured policy.statusis one ofqueued,executing,success,failed(cancellednever occurs in zecd).- On
failed, anerrorobject{"code": .., "message": ..}replacesresult; a-6insufficient-funds send lands here with the same enriched message the synchronous sends return. resultandexecution_secs(whole seconds of wall-clock execution) appear only onsuccess.
Errors
| Code | When |
|---|---|
| -8 | argument is not an array; an element is not a string; an opid is malformed |
vs Bitcoin Core: no equivalent.
vs zcashd: same shape and sort order. zcashd's view is node-wide and includes its other
async operation types (the Sapling migration among them); zecd only ever has z_sendmany,
z_shieldcoinbase and z_mergetoaddress operations, scoped to the routed wallet. zcashd silently
ignores a malformed opid string; zecd rejects it with -8. zcashd reports execution_secs as
a fractional number; zecd reports whole seconds.
z_waitforoperation
z_waitforoperation "opid" ( timeout )
Block until one operation reaches a terminal state, then return its status object. A zecd
extension, in the same vein as sendtoaddress's memo argument and listunspent's pool
field: zcashd has no equivalent, so a client that must also work against zcashd should keep
using z_getoperationstatus.
This exists because z_sendmany and z_shieldcoinbase return an opid the caller has to
poll, so every client ends up reimplementing the same poll-sleep-check loop. One call
replaces it.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | opid | string | required | A single opid, as a bare string. Note this is not the array the tracking trio takes; an array is -3. |
| 2 | timeout | number | 120 | Seconds to wait. Clamped to 3600 rather than rejected, so an over-large value waits an hour instead of erroring. 0 returns the current status immediately, which is the single-operation, non-destructive read z_getoperationstatus only offers as an array. Negative or non-integer is -8. |
The 3600-second ceiling exists because a blocking call holds one [rpc] work_queue permit
for its whole duration. Without a bound, a few clients waiting forever would starve the queue
and every other request would start returning 503. Size [rpc] work_queue with that in mind
if many clients wait concurrently; see Configuration.
Result: the same status object z_getoperationstatus returns per
operation, plus a finished boolean.
{
"id": "opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10",
"method": "z_sendmany",
"params": { "fromaddress": "u1v0m9...", "amounts": [{"address": "u1x7pq...", "amount": 0.5}], "minconf": 1 },
"status": "success",
"creation_time": 1751600000,
"result": { "txid": "5f8de306fcd7e716f9c39ea55c30d97a5a80439b7c8ec24b3decd80d20f0f1a8" },
"execution_secs": 3,
"finished": true
}
finished and status together name all four outcomes, so a caller never has to know which
status strings are terminal:
finished | status | Meaning |
|---|---|---|
true | success | Done. The txid is in result. |
true | failed | The operation ran and failed. The send's -6/-4/-25 is in error, not an error on this call. |
true | cancelled | Terminal in the schema; zecd never cancels, so this does not occur. |
false | queued or executing | The wait gave up, not the operation. Either the timeout elapsed or the daemon began shutting down while the operation was still running. Call again to keep waiting. |
Timing out is not an error. The current queued/executing status object comes back
instead, mirroring Bitcoin Core's waitforblock family and the
last iteration of the loop this replaces. Callers therefore branch on finished/status
rather than on two different failure shapes. Daemon shutdown ends the wait the same way, so a
long wait cannot hold a work-queue slot through a graceful stop.
Non-destructive. The operation stays in the registry;
z_getoperationresult remains the only reader that reaps.
Errors
| Code | When |
|---|---|
| -1 | no opid given, or too many arguments |
| -3 | opid is not a string (it takes one bare id, not the trio's array) |
| -8 | malformed opid; negative or non-integer timeout; or a well-formed opid this wallet has no operation for |
| -18 | unknown /wallet/<name> |
That last -8 is deliberately not z_getoperationstatus's silent omission: an opid this
wallet never issued, another wallet's, or one already reaped has nothing to wait for, so
silently returning would leave the caller blocked on a fiction.
vs Bitcoin Core: no equivalent (Core has no async operation model), though the
timeout-is-not-an-error contract is taken from Core's waitforblock family.
vs zcashd: no equivalent. zcashd clients poll z_getoperationstatus.
Example
curl -u u:p -d '{
"jsonrpc": "1.0", "id": 1, "method": "z_waitforoperation",
"params": ["opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10", 300]
}' http://127.0.0.1:8232/
z_getoperationresult
z_getoperationresult ( ["operationid", ...] )
Like z_getoperationstatus, but returns only finished operations (success or failed)
and removes them from memory. Destructive and one-shot: each result is returned exactly
once, and a repeat call for the same opid returns an empty array. Still-running operations
are neither returned nor removed. Reaping results promptly is good hygiene but never
required; unreaped results are auto-evicted past the 1024-operation cap.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | operationid | array | all finished operations | Array of opid strings; same validation as z_getoperationstatus. |
Result: the same status-object array as z_getoperationstatus, restricted to finished
operations, sorted by creation_time.
Errors
| Code | When |
|---|---|
| -8 | argument is not an array; an element is not a string; an opid is malformed |
vs Bitcoin Core: no equivalent.
vs zcashd: identical semantics, including the destructive removal; the scoping and
malformed-opid differences noted under z_getoperationstatus apply here too.
z_listoperationids
z_listoperationids ( "status" )
The opid strings of this wallet's operations, sorted by creation time.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | status | string | none | Filter by state: queued, executing, success, failed, or cancelled. An unrecognized filter matches nothing and returns an empty list, matching zcashd. |
Result
["opid-9c2f0d61-1c2b-4f3e-9a3e-2d4b8c7a5e10"]
vs Bitcoin Core: no equivalent.
vs zcashd: same signature and filter behavior; zecd's list is wallet-scoped and sorted
by creation time, and cancelled never matches anything because zecd never cancels an
operation.
Blockchain
Reference for the chain-state methods. They answer from the wallet's sync status and its
scanned-blocks table, not from a validator's block index: zecd is a wallet server in front of
a Zebra node, so its heights are wallet-scan heights. The five
read-only methods are followed by the three blocking
waitfor* methods. For the wire format,
auth, and multiwallet /wallet/<name> routing, see
Conventions & wire format.
Two height conventions run through this page:
blocks/getblockcountis the fully-scanned height: the height up to which balances and history are accurate.headersis the Zebra chain tip zecd knows about.
A syncing wallet therefore reports blocks < headers, exactly as bitcoind does during IBD.
getbestblockhash and getblockhash(getblockcount()) both describe the fully-scanned block,
so the classic poller pattern getblockhash(getblockcount()) always answers and always agrees
with getbestblockhash (asserted by the conformance suite). With multiwallet routing, each
wallet reports its own scan height.
getblockchaininfo
getblockchaininfo
Chain and sync overview for the routed wallet.
Result
{
"chain": "main",
"blocks": 2913000,
"headers": 2913004,
"bestblockhash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
"difficulty": 1.0,
"time": 1751599123,
"mediantime": 1751598700,
"verificationprogress": 0.999998,
"initialblockdownload": false,
"size_on_disk": 0,
"pruned": false,
"warnings": ""
}
chain:main,test, orregtest.blocks: fully-scanned height (0 before anything is scanned).headers: Zebra's chain tip as last seen; equalsblocksif no tip is known yet.bestblockhash: hash of theblocksblock; empty string in the brief window before anything has been scanned.difficulty: stub, always1.0. zecd never validates proof of work.time/mediantime: the best scanned block's time and the median time past over the last up-to-11 scanned blocks (mediantimefalls back totimenear the wallet birthday; both fall back to 0 before anything is scanned).verificationprogress: scan progress in[0, 1].initialblockdownload:truewhile the block scan is behind the tip or the post-scan transaction-enhancement backlog is nonzero. A wallet that has scanned to the tip but is still backfilling memos and full transaction data reportstrue; only a wallet ready to serve full history reportsfalse.size_on_disk: stub, always0.pruned: alwaysfalse.warnings: always"".
vs Bitcoin Core: same field names and types for everything emitted. Core master
additionally emits bits, target, chainwork, and prune details, which have no light
wallet equivalent; Core master also returns warnings as an array of strings unless
-deprecatedrpc=warnings is set, while zecd keeps the classic string form. Semantics differ:
Core's blocks is validated chain height, zecd's is the wallet's scanned height, and
initialblockdownload covers the enhancement backlog as well as the scan.
vs zcashd: zcashd has no initialblockdownload field; it emits the inverted
initial_block_download_complete plus estimatedheight, and Zcash-specific
commitments, valuePools, upgrades, and consensus blocks that zecd does not. zecd
keeps Bitcoin Core's shape instead.
getblockcount
getblockcount
The fully-scanned height: the height at which balances and history are accurate. Returns 0 before anything has been scanned.
Result
2913000
vs Bitcoin Core: same shape; Core returns the validated chain height, zecd the wallet's
scanned height. getblockhash(getblockcount()) holds on both.
vs zcashd: same as the Core comparison; zcashd's getblockcount is the validator height.
getbestblockhash
getbestblockhash
The hash of the getblockcount block, in display (byte-reversed) hex.
Result
"0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc"
Errors
| Code | When |
|---|---|
| -1 | Nothing has been scanned yet ("best block hash not yet known (still syncing)") |
vs Bitcoin Core: identical shape; the block it names is the wallet's fully-scanned block, not the validator tip.
vs zcashd: same as the Core comparison.
getblockhash
getblockhash height
The hash of the block at height, answered from the wallet's scanned-blocks table. The
not-yet-scanned chain tip is also answerable (from the sync status), so a poller that jumps
to headers still gets a hash. Any other height outside the wallet's range is -8: heights
below the wallet birthday were never scanned (a light wallet holds no blocks there), and
heights between the scanned height and the tip, or beyond the tip, are not yet known.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | height | number | required | Block height. Must be an integer in the wallet's scanned range (or the known tip). |
Result
"0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc"
Errors
| Code | When |
|---|---|
| -1 | height omitted |
| -3 | height is not an integer ("Block height must be an integer") |
| -8 | height is negative, above the representable range, below the wallet birthday, or beyond the known tip ("Block height out of range") |
vs Bitcoin Core: same signature, same error taxonomy (missing arg -1, wrong type -3,
out of range -8 with Core's exact message). Core answers any height from 0 to the chain
tip; zecd answers only the wallet's scanned range plus the tip, so pre-birthday heights that
Core would serve are -8 here.
vs zcashd: zcashd matches Core's behavior (full range from genesis); the same scan-range restriction applies against it.
getblockheader
getblockheader "blockhash" ( verbose )
Header information for a scanned block, verbose form only. zecd stores compact blocks, which
carry no serialized 80-byte-style header, so only the fields a compact block provides are
present and verbose=false is rejected rather than fabricated. The common poller pattern
(walk nextblockhash from a checkpoint, read height/confirmations/time) works.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | blockhash | string | required | Block hash, 64 hex characters (display order). |
| 2 | verbose | boolean | true | Must be true (or omitted). false is -8. |
Result
{
"hash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
"confirmations": 4,
"height": 2912997,
"time": 1751598912,
"mediantime": 1751598500,
"previousblockhash": "00000000027f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a1908ddeeff",
"nextblockhash": "00000000039e8d7c6b5a49382716059e8d7c6b5a49382716059e8d7c6b112233"
}
confirmationscounts from the fully-scanned height (the tip header reports 1).mediantimeis the median time past over the last up-to-11 scanned blocks.previousblockhash/nextblockhashappear only when the neighbor is in the wallet's scan range;nextblockhashis absent on the scanned tip (Core likewise omitspreviousblockhashon genesis andnextblockhashon the tip).
Errors
| Code | When |
|---|---|
| -8 | blockhash is not 64 characters or not hex (Core's ParseHashV messages) |
| -8 | verbose is false ("verbose=false is not supported: a light wallet does not store serialized block headers") |
| -3 | verbose is not a boolean |
| -5 | Unknown hash, or a block outside the wallet's scan range ("Block not found") |
vs Bitcoin Core: same signature, same -8/-5 errors, and the emitted fields are a
subset of Core's with matching names and semantics. Missing: version, versionHex,
merkleroot, nonce, bits, target, difficulty, chainwork, nTx (a compact-block
wallet never sees them), and the verbose=false serialized-header form is rejected. Core
reports confirmations: -1 for a block off the active chain; zecd never serves fork blocks
at all (they are -5).
vs zcashd: zcashd's header additionally carries finalsaplingroot, solution, and the
Equihash nonce; the same subset relationship and the same verbose=false difference apply.
waitfornewblock, waitforblock, waitforblockheight
waitfornewblock ( timeout )
waitforblock "blockhash" ( timeout )
waitforblockheight height ( timeout )
Block until the wallet reaches a chain state, then return it. All three exist in Bitcoin Core with these signatures, so this is a conformance gap closed as much as a convenience.
They wait on the fully-scanned height, not the chain tip. That is the whole point. "Has
the wallet caught up to height N?" is answered by blocks/getblockcount, not by headers,
and you previously had to know that from reading the source, so every consumer reinvented a
poll loop against the wrong field or the right one by luck.
Do not poll a balance instead. The mempool stream credits an incoming payment at 0 confirmations, so a balance is satisfied before the confirming block is scanned. Any height-dependent field read next -
confirmations,blockhash,listsinceblock- may not be written yet. That shape has cost real CI failures. Wait on the height, then read.
Parameters
| Method | # | Name | Type | Default | Description |
|---|---|---|---|---|---|
| all | last | timeout | number | 0 | Milliseconds to wait, as in Core. 0 or omitted waits indefinitely. |
waitforblock | 1 | blockhash | string | required | Wait until this block is the scanned tip. |
waitforblockheight | 1 | height | number | required | Wait until the fully-scanned height is at least this. Already satisfied returns immediately. |
waitfornewblock waits for the scanned height to advance past whatever it is when the call
arrives.
Result
{
"hash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
"height": 2913000
}
Timing out is not an error, exactly as in Core: the current {hash, height} comes back,
so a caller compares height against what it asked for rather than branching on an error.
z_waitforoperation follows the same contract.
How the wait works. It is event-driven, waking on the sync status the wallet actor
already publishes, with a one-second backstop re-check that bounds a missed publish. It ends
promptly on daemon shutdown, so a no-timeout call cannot hold an [rpc] work_queue slot
through a graceful stop. As with z_waitforoperation, a blocking call occupies a work-queue
permit while it waits - size [rpc] work_queue accordingly if many clients wait at once.
Errors
| Code | When |
|---|---|
| -8 | blockhash is not 64 characters or not hex; height is negative |
| -3 | an argument is the wrong JSON type |
vs Bitcoin Core: same three signatures, same millisecond timeout, same {hash, height}
result, same timeout-is-not-an-error contract. The difference is what "the tip" means: Core
waits on its validated chain tip, zecd on the wallet's fully-scanned height, which is the
useful one for a wallet client and is strictly behind the node's tip while syncing.
vs zcashd: zcashd has waitfornewblock, waitforblock and waitforblockheight as
hidden/debug RPCs with the same shapes.
Example
# Mine or await a payment, then read history safely.
tip = rpc.getblockcount()
rpc.waitforblockheight(tip + 6, 120000) # 6 confirmations, 120s cap
rpc.listsinceblock() # heights are now written
waitforsync
waitforsync ( timeout )
New in 0.7.0, and a zecd extension: neither Bitcoin Core nor zcashd has it. Block until the wallet is fully caught up, meaning the block scan has finished and the transaction-enhancement backlog has drained, then return that state.
It exists because the three waitfor* methods above answer "has the wallet scanned to height
N?", which is not the same question as "is this wallet serving complete history?". Compact
blocks carry no memos, so after the scan reaches the tip an enhancement pass is still fetching
full transaction data. A caller that waits on height alone and then reads
gettransaction can find a memo missing that will appear a
minute later. See the operations runbook for
what that backlog is and why it is not restore-only.
The call nudges a sync pass before it starts waiting, so the wait measures a pass that is
already beginning rather than one that is up to [sync] interval_secs away.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | timeout | number | 0 | Milliseconds, following the waitfor* convention rather than z_waitforoperation's seconds. 0 or omitted waits indefinitely. |
Result
{
"hash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
"height": 2913000,
"chain_tip": 2913004,
"synced": true,
"pending_enhancements": 0,
"enhanced_through": 2913000,
"imported": true
}
height/hash: the fully-scanned height, the same valuegetblockcountreturns.chain_tip: the upstream's tip, so a caller can render "scanned H of TIP" without opening its own connection to ask.heightalone cannot express progress, because what it is being measured against is exactly this.nulluntil the first tip is known, before the first successful connect.synced: the predicate the call waits on. Never true whileimportedis false.imported(0.8.0): whether the wallet's own account exists in its database yet. Alwaystruefor a conventional wallet by the time it can be read;falsefor a fleet wallet that has been onboarded but not yet imported, whose reads are legitimately empty.import_error(0.8.0): present only when a fleet wallet's import failed. The failure is terminal, so the call returns at once rather than waiting out its timeout.pending_enhancements: distinct outstanding transaction-data requests still to drain.enhanced_through: the height below which history is complete.nullmeans "not currently determinable", which a consumer must read as hold the cursor, never as "everything is enhanced".
Timing out is not an error. The current state comes back with synced: false, so a caller
branches on that field rather than catching an exception. That boolean is load-bearing: without
it, "the wait gave up" and "the wallet is ready" would be distinguishable only by re-deriving
the predicate from the other fields.
A blocking call occupies an [rpc] work_queue permit while it waits, and ends promptly on
daemon shutdown, exactly as the waitfor* family does.
Errors
| Code | When |
|---|---|
| -1 | timeout is negative |
| -3 | timeout is not an integer |
Example
# Restore a wallet, then block until its history is actually complete.
st = rpc.waitforsync(600000) # 10 minute cap
if not st["synced"]:
raise TimeoutError(f'scanned {st["height"]} of {st["chain_tip"]}, '
f'{st["pending_enhancements"]} enhancements pending')
rpc.listsinceblock() # memos are now populated
Raw transactions
Reference for getrawtransaction and sendrawtransaction. getrawtransaction serves any
transaction by txid, from the wallet's own store when it has the raw bytes and otherwise from
the Zebra upstream; its verbose form is zcashd's TxToJSON
shape, not Bitcoin Core's. sendrawtransaction broadcasts
caller-built bytes through Zebra. For the wire format, auth, and multiwallet /wallet/<name>
routing, see Conventions & wire format; for building and sending transactions
from the wallet itself, see Sending.
getrawtransaction
getrawtransaction "txid" ( verbose "blockhash" )
Returns the raw transaction with the given txid: a hex string by default, a decoded JSON
object when verbose is truthy. Lookup order: the wallet DB's stored raw bytes first
(present for transactions the wallet created or has enhanced), then a fetch from Zebra. So
any transaction Zebra can serve is retrievable, not only wallet transactions. The third
Bitcoin Core parameter, blockhash, is rejected: a light client has no block index to scope
the lookup to.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | txid | string | required | Transaction id, 64 hex characters (display order). |
| 2 | verbose | boolean or number | false | Bitcoin Core passes a boolean, zcashd an integer; both are accepted. Any nonzero integer means verbose. |
| 3 | blockhash | string | must be unset | Rejected with -8 if present and non-null. |
Result (verbose omitted or false)
"050000800a27a726b4d0d6c2000000006df32c00..."
The conformance suite asserts this equals gettransaction's hex field for wallet
transactions (see Wallet: history).
Result (verbose) for a mined v5 transaction with an Orchard bundle (hex strings
truncated here with ...; real responses carry full values):
{
"txid": "3d21f0b1a9c8e7d6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2",
"authdigest": "8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b",
"size": 4180,
"overwintered": true,
"version": 5,
"versiongroupid": "26a7270a",
"locktime": 0,
"expiryheight": 2913040,
"vin": [],
"vout": [],
"valueBalance": 0.00000000,
"valueBalanceZat": 0,
"vShieldedSpend": [],
"vShieldedOutput": [],
"orchard": {
"actions": [
{
"cv": "2f8e...",
"nullifier": "c41a...",
"rk": "77b2...",
"cmx": "0e5d...",
"ephemeralKey": "a93c...",
"encCiphertext": "f012...",
"outCiphertext": "5be7...",
"spendAuthSig": "d84f..."
}
],
"valueBalance": 0.00010000,
"valueBalanceZat": 10000,
"flags": {
"enableSpends": true,
"enableOutputs": true
},
"anchor": "31d6...",
"proof": "9a02...",
"bindingSig": "6cc1..."
},
"hex": "050000800a27a726b4d0d6c2...",
"height": 2912990,
"confirmations": 11,
"blockhash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
"time": 1751598912,
"blocktime": 1751598912
}
Field notes:
- Core fields
txid,size,version,locktime,vin,vout,hexare as in Bitcoin Core. The segwit-onlyhash/vsize/weightare absent (no Zcash equivalent). authdigest,overwinteredalways present;versiongroupidandexpiryheightonly on Overwinter+ (v3+) transactions.vinentries:txid,vout,scriptSig{asm, hex},sequence; a coinbase input is{coinbase, sequence}. Signature pushes inscriptSig.asmrender with their sighash type decoded (<sig>[ALL]), as in zcashd.voutentries:value(decimal ZEC, 8 places),valueZatandvalueSat(zcashd's two zatoshi aliases),n,scriptPubKey{asm, hex, type}plusreqSigs/addressesfor standard scripts (absent fornulldata/nonstandard, matching zcashd).- The Sapling section (
valueBalance,valueBalanceZat,vShieldedSpend,vShieldedOutput, andbindingSigwhen a bundle exists) is present on v4+ transactions, empty-with-zero-balance when the transaction carries no Sapling bundle, and omitted below v4. Spend/output descriptions carry the zcashd field set (cv,anchor,nullifier,rk,proof,spendAuthSig;cmu,ephemeralKey,encCiphertext,outCiphertext). orchardis present on v5 transactions (emptyactionswith zero balance when there is no bundle). A positivevalueBalanceis net value leaving the pool; for a fully-shielded Orchard-to-Orchard send with no transparent outputs it equals the ZIP-317 fee (a transparent recipient adds its amount on top).heightandconfirmationsappear when the mined height is known (from the wallet record or from Zebra);confirmationscounts from the wallet's fully-scanned height.blockhash/time/blocktimecome from the wallet's scanned-blocks table and are omitted when the block is outside the wallet's scan range. An unmined mempool transaction carries none of these fields.
Errors
| Code | When |
|---|---|
| -1 | txid omitted |
| -8 | txid is not 64 hex characters (Core's ParseHashV messages), or blockhash is set |
| -3 | verbose is neither boolean nor integer |
| -5 | Neither the wallet nor Zebra knows the txid ("No such mempool or blockchain transaction") |
| -22 | The raw bytes fail to parse as a transaction ("TX decode failed: ...", verbose only) |
vs Bitcoin Core: Core master's second parameter is verbosity (0/1/2, with 2 adding fee
and prevout data); zecd has only the hex/verbose split and no level 2. Core's blockhash
parameter is rejected here. The verbose shape is zcashd's, not Core's: shielded bundle
fields, valueZat/valueSat, height, and authdigest are additive; hash, vsize,
weight, and in_active_chain are absent. Core without -txindex only serves mempool
transactions; zecd serves anything in its wallet store plus anything Zebra returns. zecd's
-5 message is the bare No such mempool or blockchain transaction (zcashd's exact line);
Core master varies the base text by -txindex state and always appends . Use gettransaction for wallet transactions., which zecd does not.
vs zcashd: the verbose object is zcashd's TxToJSON shape, field for field.
Differences: zcashd supports the blockhash argument and zecd rejects it; zcashd's
verbose is an integer while zecd also accepts a boolean; zcashd's block fields come from
its full block index, zecd's from the wallet's scan range.
sendrawtransaction
sendrawtransaction "hexstring" ( maxfeerate )
Broadcasts caller-built raw transaction bytes to the network through Zebra and returns the
txid. The bytes are parsed first (an undecodable transaction is -22, and parsing yields the
txid to return). Resubmission of a transaction already in Zebra's mempool succeeds
idempotently, as in Bitcoin Core. Unlike wallet sends, a caller-supplied transaction that
does not spend the wallet's own notes is not backed by zecd's rebroadcast loop, so every
failure (transport or rejection) surfaces as an error rather than being retried silently.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | hexstring | string | required | The serialized transaction, hex-encoded. |
| 2 | maxfeerate | any | ignored | Accepted for Bitcoin Core arity compatibility, ignored: fees are ZIP-317 and a shielded transaction's fee is not computable from its serialization alone. |
Result
"3d21f0b1a9c8e7d6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2"
Errors
| Code | When |
|---|---|
| -1 | hexstring omitted; or the upstream is unreachable / the broadcast fails in transport |
| -22 | The hex does not decode to a transaction ("TX decode failed") |
| -26 | Zebra examined and rejected the transaction ("transaction rejected (code N): reason") |
| -27 | The transaction is already mined ("Transaction outputs already in utxo set", Core's exact message) |
vs Bitcoin Core: same signature and result. Core enforces maxfeerate (default 0.10
BTC/kvB) and rejects high-fee transactions with -25; zecd ignores the parameter entirely.
The -22/-26/-27 mapping and the already-in-mempool-is-success behavior match Core's
contract.
vs zcashd: zcashd's second parameter is allowhighfees (boolean); zecd's second
positional slot accepts it but ignores it either way. Result and error family are the same.
Example
curl -u user:pass -d '{"jsonrpc": "1.0", "id": "z", "method": "sendrawtransaction",
"params": ["050000800a27a726b4d0d6c2..."]}' http://127.0.0.1:8232/
Network
zecd has no P2P layer: its only network relationship is the single chain backend it derives chain data from, a local Zebra node by default or a lightwalletd server in light mode. The four network RPCs exist for client compatibility and report that upstream as if it were the node's one peer. Envelope, auth, and error conventions are on the RPC conventions page.
getnetworkinfo
getnetworkinfo
Returns zecd's version and identity in Bitcoin Core's getnetworkinfo shape. The P2P-specific fields are present but inert.
Result
{
"version": 100,
"subversion": "/zecd:0.1.0/",
"protocolversion": 170100,
"localservices": "0000000000000000",
"localservicesnames": [],
"localrelay": false,
"timeoffset": 0,
"networkactive": true,
"connections": 1,
"connections_in": 0,
"connections_out": 1,
"networks": [],
"relayfee": 0.00001000,
"incrementalfee": 0.00001000,
"localaddresses": [],
"warnings": ""
}
version: zecd's own version in Core's numeric encoding (major*10000 + minor*100 + patch, derived from the crate version;0.1.0encodes to100).subversion:/zecd:<version>/.protocolversion: a hardcoded value (170100). zecd does not speak the P2P protocol, so this is a static snapshot, not a live number; it does not track zcashd's currentPROTOCOL_VERSION(170150).connections/connections_out:1while the Zebra upstream is reachable, else0.connections_inis always0.relayfee/incrementalfee: the ZIP-317 marginal fee (0.00001 ZEC), as decimal ZEC.localservices,localservicesnames,localrelay,timeoffset,networks,localaddresses: fixed inert values (no P2P stack behind them).networkactiveis alwaystrue.warnings: always the empty string.
vs Bitcoin Core: same field set and types, but every P2P-derived value is synthetic: connections* count the single upstream, networks/localaddresses are empty, and warnings uses the legacy string form (Core master returns an array unless started with -deprecatedrpc=warnings). Core's version/subversion describe bitcoind; zecd reports its own.
vs zcashd: zcashd's getnetworkinfo reports a real P2P node (peer counts, per-network reachability, proxy settings). Same method name, so version-probing clients work unchanged against zecd.
getconnectioncount
getconnectioncount
Returns 1 while the Zebra upstream is reachable, 0 otherwise. Always agrees with the length of getpeerinfo.
Result
1
vs Bitcoin Core: identical shape; Core counts P2P peers, zecd counts its one chain upstream.
vs zcashd: same as Core: a real P2P connection count.
getpeerinfo
getpeerinfo
Returns the Zebra upstream as the single "peer", or an empty array while it is unreachable (bitcoind's shape for a node with no peers).
Result
[
{
"id": 0,
"addr": "zebra-rpc 127.0.0.1:8234",
"inbound": false,
"conn_state": "ready",
"syncing": false
}
]
addr: the resolved[backend] serverendpoint, rendered aszebra-rpc <host>:<port>.conn_state(zecd extension): the upstream connection state,syncingorready. (The third state,down, never appears here: a down upstream yields the empty array instead. All three states also ride on the/statushealth endpoint.)syncing(zecd extension):truewhile the block scan is behind the tip or the post-scan transaction-enhancement backlog is still draining, so it agrees withconn_stateand withgetblockchaininfo.initialblockdownload.
vs Bitcoin Core: Core emits several dozen fields per peer (pingtime, bytessent, version handshake data, ban score, and so on); zecd emits only the five above. id/addr/inbound keep their Core meaning; conn_state and syncing are extensions.
vs zcashd: zcashd returns its real P2P peer list. No shielded-specific equivalent exists; monitor zecd's sync progress via getpeerinfo.syncing, getwalletinfo, or the health endpoints.
ping
ping
A liveness no-op. There is no P2P peer to ping; the call succeeds immediately with a null result.
Result
null
vs Bitcoin Core: Core queues a protocol ping to every peer and reports the round-trip in getpeerinfo.pingtime; the null result is identical. zecd measures nothing.
vs zcashd: same as Core (real P2P ping). Use zecd's ping only as an "is the RPC server up" probe; /healthz is the better tool for that.
Utility & control
Address validation, message signing, the fee-probe stubs (Zcash fees are ZIP-317, never client-settable), and the daemon control surface. Envelope, auth, and error conventions are on the RPC conventions page.
validateaddress
validateaddress "address"
Validates any Zcash address kind against the daemon's configured network: transparent P2PKH/P2SH (t1/t3, tm/t2 on testnet), Sapling (zs), and Unified Addresses (u1/utest1). An address encoded for a different network is reported invalid.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | address | string | required | The address to validate |
Result (valid address)
{
"isvalid": true,
"address": "utest12r53eljnr7kev8ychw3ahzjgm6fwxm7fd8vfay7hn9uylj05x0pxxhze800h9dcgyr8hkc7kz3s2crnrhjcy2p90yfce2vl8mq667zw0",
"scriptPubKey": "",
"isscript": false,
"iswitness": false,
"isvalid_orchard": true,
"receiver_types": ["orchard"]
}
Result (invalid address)
{
"isvalid": false,
"error_locations": [],
"error": "Invalid or unsupported address format"
}
scriptPubKey: the real hex output script for transparent addresses (76a914...88acP2PKH,a914...87P2SH). Shielded addresses have no script form, so the field is the empty string.isscript:truefor P2SH.iswitness: alwaysfalse(Zcash has no segwit).isvalid_orchard(zecd extension): whether the address can receive into the Orchard pool.receiver_types(zecd extension): the pools the address can receive into, in canonical order (transparent,sapling,orchard). For a Unified Address this enumerates its receivers, so a client can see what au1...actually carries; a bare t-addr is["transparent"].receivers_consistent(zecd extension, sometimes present): for a UA with at least two shielded receivers, whether all of them belong to the routed wallet's account at one diversifier index.truemeans a well-formed UA this wallet could have issued;falseflags a hand-spliced UA (receivers stapled together from different indices, or one of the wallet's mixed with a stranger's). Absent when not computable: a foreign UA (the diversifier index is the owner's secret) or a single-receiver address.- On invalid input,
error_locationsis always the empty array (no per-character diagnosis).
Ownership is not reported here; use getaddressinfo for ismine.
Errors
| Code | When |
|---|---|
| -1 | address argument missing |
| -3 | address argument present but not a string |
vs Bitcoin Core: same base shape, including the error/error_locations fields on invalid input. Core additionally emits witness_version/witness_program for segwit addresses (never applicable here) and populates scriptPubKey for every valid address (zecd leaves it empty for shielded). isvalid_orchard, receiver_types, and receivers_consistent are zecd extensions.
vs zcashd: zcashd splits validation in two: its validateaddress accepts only transparent addresses (and mixes in wallet fields like ismine/iswatchonly), while z_validateaddress handles shielded and Unified Addresses with an address_type field and per-pool key material. zecd's single validateaddress covers every kind, so a valid UA gets isvalid: true.
Since 0.8.0 zecd also implements z_validateaddress, for zcashd-lineage
tooling that asks it whether an address is the wallet's own.
z_validateaddress
z_validateaddress "address"
New in 0.8.0. Validate an address of any kind and report whether it is this wallet's own.
It exists for ismine: zcashd-lineage tooling uses z_validateaddress to decide whether an
address belongs to the wallet before a self-send or a consolidation, and validateaddress
deliberately carries no ownership signal.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | address | string | required | Any address: transparent, Sapling, Unified or TEX. |
Result (valid Unified Address)
{
"isvalid": true,
"address": "u1...",
"address_type": "unified",
"ismine": true,
"receivers": ["orchard"]
}
address_type:p2pkh,p2sh,sapling,unifiedortex.ismine: whether the routed wallet owns the address. The wallet is resolved strictly, sofalsealways means "not this wallet's", never "no wallet to ask".receivers: Unified Addresses only, in zcashd's vocabulary (p2pkh,sapling,orchard).- An address that does not decode on this network is
{"isvalid": false}and nothing else.
Errors
| Code | When |
|---|---|
| -1 | address argument missing |
| -3 | address argument present but not a string |
| -18 | the routed wallet does not exist |
vs zcashd: zcashd's z_validateaddress accepts only shielded and Unified Addresses and
returns per-pool key material for a Sapling address. zecd accepts
every kind, since answering "invalid" about an address the wallet will pay would be worse than
the divergence, names which in address_type, and does not return key material.
z_listunifiedreceivers
z_listunifiedreceivers "unified_address"
New in 0.8.0. Take a Unified Address apart into its receivers, each encoded on its own, in zcashd's shape. History names every output by the single receiver it paid, so a consumer that handed out a multi-receiver address needs exactly these strings to match a history entry back to it. Key-free: any valid Unified Address on this network is accepted, owned or not.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | unified_address | string | required | A Unified Address. |
Result
{
"p2pkh": "t1...",
"sapling": "zs1...",
"orchard": "u1..."
}
One field per receiver present (p2pkh, p2sh, sapling, orchard). Orchard has no bare
encoding, so its receiver comes back as a single-receiver Unified Address, which is also how
history reports an Orchard or Ironwood output.
Errors
| Code | When |
|---|---|
| -1 | address argument missing |
| -3 | address argument present but not a string |
| -5 | not a valid address on this network |
| -8 | a valid address that is not a Unified Address (a bare address is already its one receiver) |
| -18 | the routed wallet does not exist |
To match receipts to an address you issued without comparing strings at all, use
diversifier_index, which received history entries and
getaddressinfo carry.
signmessage
signmessage "t-address" "message"
Sign message with the private key of a transparent address this wallet owns, returning
a base64 signature in Bitcoin Core's shape. Ported from zallet's implementation so the two
agree byte for byte.
The digest is Zcash's, not Bitcoin's. Each of the magic string "Zcash Signed Message:\n"
and the caller's message is CompactSize-length-prefixed, the two are concatenated, and the
result is double-SHA256 hashed (zcashd's rpc/misc.cpp). The magic prefix is what stops a
signature over user text from being replayed as a signature over a transaction. A Bitcoin
verifier will therefore not validate a zecd signature, and vice versa, even though the
encoding is identical.
The signature is a recoverable ECDSA signature over that digest, serialized as a 65-byte
[header][r||s] blob with header = 31 + recovery_id (the compressed-pubkey form), then
base64-encoded.
Shielded addresses cannot sign. There is no equivalent operation for a Sapling or Orchard address, and no unified-address form; this is transparent-only in zecd, zcashd and Core alike.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | t-address | string | required | A bare transparent P2PKH address this wallet owns. |
| 2 | message | string | required | The message to sign, verbatim. |
Result
"H9L5yLFjti0QTHhPyFrZCT1V/MMnBtXKmoiKDZ78NDBjERki6ZTQZdSMCtkgoNmp3RTMPMWfnAeQBQdMHhJ4CjA="
Errors
| Code | When |
|---|---|
| -1 | address or message missing |
| -5 | address does not decode as a transparent address on this network |
| -3 | the address is a P2SH (t3/t2) script address ("Address does not refer to key"), or a shielded address |
| -4 | the wallet does not own the address, or the wallet is watch-only (no private keys) |
| -13 | the wallet is encrypted and locked (unlock with walletpassphrase) |
The address is validated before the seed is touched, so a malformed address answers -5/-3
regardless of lock state.
vs Bitcoin Core: same signature, same result encoding, same error taxonomy. The digest differs (Zcash's magic string, so signatures are not interchangeable), and zecd signs only with transparent keys it derived from the wallet seed - there is no imported-key case.
vs zcashd: same method, same digest, same encoding; interoperable.
verifymessage
verifymessage "t-address" "signature" "message"
Check a signature against a transparent address. Stateless: it recovers the signer's public key from the recoverable signature, derives the transparent address that key implies, and compares. No wallet key material is used and the address need not be the wallet's, so this verifies a signature produced by anyone. Only the wallet's network parameters are consulted, to decode the address.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | t-address | string | required | The transparent address the signature claims to be from. |
| 2 | signature | string | required | Base64, as returned by signmessage. |
| 3 | message | string | required | The message the signature covers, verbatim. |
Result
true
false means the signature is well-formed but does not match: a wrong address, a tampered
message, a wrong-length blob, or an unrecoverable signature. Only malformed input raises an
error, so an attacker cannot distinguish failure modes from the error code.
Errors
| Code | When |
|---|---|
| -1 | an argument is missing |
| -3 | the address does not decode ("Invalid address"), is a P2SH script address ("Address does not refer to key"), or the signature header says uncompressed key ("Uncompressed key signatures are not supported.") |
| -5 | the signature is not valid base64 ("Malformed base64 encoding") |
vs Bitcoin Core: same signature, same true/false contract, same distinction between a
mismatching signature (false) and a malformed one (an error). Core still accepts
uncompressed-key signature headers (27-30); zecd rejects them, as zallet does.
vs zcashd: same method and semantics; interoperable.
estimatesmartfee
estimatesmartfee conf_target ( estimate_mode )
An inert probe-compatibility stub. Zcash fees follow ZIP-317 and are computed at transaction-build time; there is no fee estimator. Returns a stable conventional rate so fee-probing clients succeed.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | conf_target | numeric | 6 | Echoed back as blocks; has no effect |
| 2 | estimate_mode | string | ignored | Accepted for arity compatibility, ignored |
Result
{
"feerate": 0.00001000,
"blocks": 2
}
feerate is always 0.00001 ZEC (the ZIP-317 marginal fee, as decimal ZEC per the Core convention); blocks echoes conf_target.
vs Bitcoin Core: Core runs a real estimator and may return an errors array with no feerate; zecd always returns feerate. Same success shape.
vs zcashd: no equivalent: current zcashd serves neither estimatesmartfee nor estimatefee.
estimatefee
estimatefee ( nblocks )
The legacy single-number fee probe; same inert stub as estimatesmartfee. The optional argument is ignored.
Result
0.00001000
vs Bitcoin Core: removed from Core master (only estimatesmartfee remains); zecd keeps it because old clients still call it.
vs zcashd: no equivalent in current zcashd.
settxfee
settxfee amount
Always fails. Fees follow ZIP-317 and are never client-settable; an explicit fee instruction gets a self-diagnosing -8 (the same treatment as fee_rate/subtractfeefromamount on the send RPCs) rather than a silently ignored true.
Errors
| Code | When |
|---|---|
| -8 | always: "settxfee is not supported: fees follow ZIP-317 (computed at transaction-build time) and are never client-settable" |
vs Bitcoin Core: removed from Core master. Historic Core set a wallet-wide fee rate and returned true.
vs zcashd: zcashd still carries settxfee, deprecated but enabled by default (it is not in zcashd's default-deny deprecated set); it sets the legacy pre-ZIP-317 paytxfee.
getmempoolinfo
getmempoolinfo
Returns a fixed empty-mempool shape. zecd keeps no mempool of its own (it is a wallet server, not a relay node); mempool visibility for the wallet's transactions comes from the Zebra mempool poller and surfaces through the wallet RPCs instead. This stub satisfies client preflight checks.
Result
{
"loaded": true,
"size": 0,
"bytes": 0,
"usage": 0,
"total_fee": 0.00000000,
"maxmempool": 300000000,
"mempoolminfee": 0.00001000,
"minrelaytxfee": 0.00001000
}
Every value is constant: an empty but "loaded" pool with the conventional ZIP-317 fee floors and Core's default 300 MB maxmempool.
vs Bitcoin Core: same first eight fields; Core master adds more (incrementalrelayfee, unbroadcastcount, and newer policy/cluster fields) and reports live numbers.
vs zcashd: zcashd's getmempoolinfo reports its real mempool with only size/bytes/usage (plus a regtest-only fullyNotified). Query the upstream node directly for actual Zcash mempool contents.
stop
stop
Requests graceful shutdown: in-flight requests finish, new ones get HTTP 503, and the reply reaches the client before exit. Regtest only. On mainnet and testnet the method reports method-not-found (-32601), so a stray stop cannot take down a production daemon over RPC. Stop a live node with a signal instead (SIGINT/SIGTERM; the systemd unit from the .deb does this).
Result
"zecd stopping"
Errors
| Code | When |
|---|---|
| -32601 | called on mainnet or testnet (HTTP 404) |
vs Bitcoin Core: Core's stop works on every network, returns "Bitcoin Core stopping", and accepts a hidden wait (milliseconds) test argument; zecd takes no arguments and restricts the method to regtest.
vs zcashd: available on every network, returns "Zcash server stopping".
uptime
uptime
Seconds since the daemon started.
Result
86400
vs Bitcoin Core: identical.
vs zcashd: no equivalent (zcashd does not implement uptime).
help
help ( "command" )
Returns a static one-line orientation string naming a handful of methods and pointing at the reference documentation. The command argument is accepted but ignored: there are no per-method help pages, so help getbalance returns the same generic blurb as help. Tooling that introspects the RPC surface via help (as some Bitcoin libraries do) learns nothing useful from zecd; use the method index instead.
Result
"zecd: a Bitcoin-Core-style JSON-RPC server for Orchard-only Zcash. Supported methods include getblockchaininfo, getnetworkinfo, getwalletinfo, getnewaddress, z_getaddressforaccount, getbalance, sendtoaddress, sendmany, listtransactions, gettransaction, validateaddress. See the README for the full list and limits."
vs Bitcoin Core: Core's help lists every registered command grouped by category, and help <command> returns that method's full usage text. This is the one deliberately weak point in zecd's conformance surface.
vs zcashd: same behavior as Core (full listing plus per-method help).
getrpcinfo
getrpcinfo
Reports the currently-executing RPC commands, Core's load-visibility RPC. Useful for spotting what is holding the work queue during an overload.
Result
{
"active_commands": [
{
"method": "sendtoaddress",
"duration": 2417093
},
{
"method": "getrpcinfo",
"duration": 12
}
],
"logpath": ""
}
active_commands: one entry per in-flight command;durationis the elapsed running time in microseconds (Core's unit; easy to misread as milliseconds). The call always lists itself.logpath: always empty. zecd logs to stderr viatracing, not to adebug.logfile.
vs Bitcoin Core: identical shape and semantics; Core's logpath is the absolute path to debug.log.
vs zcashd: no equivalent.
Embedding zecd as a library
Since 0.7.0 zecd is usable as a Rust library: a host process can bring up the wallet actors, sync, and async operations in-process and dispatch any RPC without an HTTP socket in between. The crate is published on crates.io.
[dependencies]
zecd = { version = "0.8", default-features = false }
default-features = false drops the two feature gates below, so neither axum nor clap enters
your dependency tree. Everything on this page works in that build.
Maturity. The embedding surface is new in 0.7.0 and has so far been exercised only by this project's own tests. The RPC surface is not affected by that caveat: it is the same code path, with the same conformance suite behind it, and has been stable across the whole 0.x line. If you are integrating over the network, read RPC conventions instead; this page is for callers who want to skip the socket.
Cargo features
| Feature | Default | What it gates |
|---|---|---|
server | on | The axum JSON-RPC server and the health server (/healthz, /readyz, /status). |
cli | on | The clap surface, the printing shells around each subcommand, and daemon::init_tracing. |
Both are on by default, so the shipped binary, the Docker images, and the release artifacts are built exactly as they were before the split. A library consumer turns both off.
Two requirements come with the node rather than with the features:
- A multi-thread tokio runtime. The scan and proving paths use
block_in_place, which panics on a current-thread runtime. - No process-wide policy is installed for you. The library never sets a tracing subscriber,
never changes panic behaviour beyond an idempotent hook, and never applies the core-dump and
ptrace lockdown that
hardening::harden_processdoes for the binary. Those are the host application's decisions, so make them deliberately.
The node
node::NodeBuilder builds a running node from a resolved configuration; node::Node is the
handle. Node::call dispatches any RPC in-process with wire-identical semantics: the same
method table, the same [rpc] allowed_methods safelist, the same arity checks, and the same
Bitcoin Core error codes an HTTP client would get. A worked example ships as
examples/embedded.rs.
Resolve configuration without clap via config::AppConfig::resolve_overrides and
config::ConfigOverrides.
Node::reload_config (new in 0.8.0) applies a freshly resolved configuration to a running
node: what the binary's SIGHUP handler does. Only [spend] orchard_action_limit and
[spend] max_tx_bytes take effect; the returned config::ReloadReport lists every other
changed key as needing a restart, for the caller to log.
The typed client
typed::Client is one Rust method per RPC, borrowed from a node with node::Node::wallet and
bound to that wallet. Every wrapper builds the same positional parameters a JSON caller would
send and rides through Node::call, so the typed surface cannot drift away from the wire
contract; a lockstep test fails the build if any dispatched method lacks a wrapper, or if a
wrapper names a method that does not exist.
Two differences from decoding JSON yourself:
- Amounts are exact zatoshis (integers), not the decimal-ZEC numbers on the wire. This sidesteps the float hazard described under Amounts entirely.
- Response structs are
#[non_exhaustive], so a later release adding a field is not a breaking change for you. Match with..and construct with the provided constructors.
Sending
node::Node::send with node::SendOptions builds, proves, and broadcasts from a
zip321::TransactionRequest the caller already holds, instead of rendering one into
z_sendmany's JSON for zecd to parse straight back. Below the entry point it is the RPC send
path unchanged: the same privacy ladder, the same ZIP-317 fee, the same
serialization behaviour described in Sending.
It accepts duplicate recipients, which the RPC refuses by default for zcashd parity, so one
address can be paid by several memo-carrying payments in a single transaction. Over the wire
the same thing is available behind
[rpc] allow_duplicate_shielded_recipients.
SendOptions carries the in-process spelling of z_sendmany's arguments: minconf,
privacy, and source, a wallet::SendSource that names the funding source as
fromaddress does (shielded notes, one shielded pool family, or transparent UTXOs). Its
default is the plain "pay this request from shielded notes" call.
zip321 and TxId are re-exported from the crate root, so requests are built against exactly
the versions zecd links rather than against a second copy that happens to have the same
version number.
Errors
error::RpcError and error::codes are the Bitcoin Core taxonomy call returns.
RpcError::details carries structured data for the errors that have it: today
error::InsufficientFunds on a -6, so a caller reads the shortfall and the value still
awaiting confirmations as numbers rather than parsing them back out of a message string.
This is in-process only. The wire error object stays exactly Bitcoin Core's code plus
message, and a test pins that, so the amounts never appear on the network. They are also
optional: the change-strategy paths genuinely do not know them, and reporting zero there would
be indistinguishable from a real zero.
The datadir-lock error is downcastable rather than only recognizable by its message text, and carries the lock path.
Reading wallet history
wallet::read is the read side of the wallet database: wallet::read::TxQuery and
wallet::read::query_transactions, returning the wallet::read::TxRecord and
wallet::read::TxOutputRecord shapes that the RPC handlers are themselves built from. It is
for an embedder with its own data model, which would otherwise be reconstructing structs from
JSON that was serialized from these.
query_transactions documents the total order its results come in: (mined_height, txid),
with outputs in (pool, output_index) order. That order is what a consumer replaying wallet
history as a log needs in order to paginate and resume deterministically. It is the same fix
that made listtransactions paging stable, and for the same reason:
height alone is not injective, so a page boundary landing inside a same-height tie was
previously resolved arbitrarily.
Since 0.8.0 these functions take an engine_dir path and an account scope:
query_transactions(engine_dir, scope, &query). One database can hold several accounts (a
fleet shard does), so every read that reports a wallet's own money, history or addresses names
the account it means. For a loaded wallet, get both from Node::wallet_location, which
returns a node::WalletLocation with engine_dir, account (an AccountUuid, or None
before the account exists) and scope (a wallet::read::AccountScope). It is the only
supported route to a fleet member's files, and the scope is reported rather than left to be
derived because the two None cases scope differently. An unknown wallet is -18.
Without a node, compute the directory with config::engine_dir or
config::WalletEntry::engine_dir and never by joining the components yourself, since the
layout is per-coin and per-engine (see wallet data layout),
and pass AccountScope::Any, which is exact for a database holding one account.
Chain queries before a wallet exists
chain_probe::account_birthday and chain_probe::tip_status are the two chain queries that
have to happen before the wallet they are for exists, which is why no Node method can serve
them: a birthday must be chosen before the account is created, and a node needs that account to
start.
account_birthday is the same function zecd init builds its birthday with, so an embedder
pinning a birthday alongside a seed it generated itself records exactly what init would have.
Both take a chain::ChainSource the caller supplies, which is what makes them usable over a
transport zecd does not configure: dial a tonic::Channel yourself and wrap it with
chain::lwd::LwdSource::connect. (A node itself proxies through [backend] proxy since
0.8.0.)
chain_probe::probe is the same thing the zecd chain-info
subcommand prints, and the only supported way to reach the chain without a wallet at all.
A narrow, supported slice of chain goes with them: ChainSource::latest_block,
ChainSource::tree_state, ChainSource::server_info, the ChainTip and ServerInfo return
types, and lwd::LwdSource::connect. The rest of that trait (block streaming, the mempool,
transparent evidence) stays internal and is reshaped as backends and coins are added. Do not
build on it.
The CLI cores
Every subcommand is a data-returning function behind its printing shell, so an embedder runs the same code the command line does without capturing stdout:
| Function | Subcommand |
|---|---|
init::init_wallet | zecd init |
init::rescan_wallet | zecd rescan |
init::export_ufvk_string | zecd export-ufvk |
derive_address::derive | zecd derive-address |
config_check::check, config_check::inspect | zecd config check |
config_show::render | zecd config show |
chain_probe::probe | zecd chain-info |
server::auth::generate_rpcauth | zecd rpcauth |
example_config::EXAMPLE_CONFIG | zecd example-config |
licenses::THIRD_PARTY_LICENSES | zecd licenses |
Key material
wallet::keys::seed_with_identity and wallet::keys::pinned_ufvk return the seed and viewing
key of an on-disk wallet, for protocol-layer key derivation such as an application signing its
own payloads with a BIP 44 child key.
These are deliberately not RPCs. zecd exports no key material over the wire, and signing a caller-supplied digest with a wallet key would be a spend oracle, because that digest can be a transaction sighash. In-process is a different question: the caller already holds the datadir and the age identity file, so these add no reach it did not have. They only give it a supported spelling. See Key custody.
Several spending wallets in one process
The daemon enforces one loaded spending wallet, because an RPC credential is spend authority for whichever wallet a request routes to, and two loaded spenders leave no single answer to the question of which keys a credential can spend.
An embedded node has no RPC credentials. The host application is the authorization boundary and
names the wallet on every call, so the rule has nothing to protect there.
[keys] allow_multiple_spending_wallets lifts it for that case. It is off by default and
refused by the daemon: zecd config check reports it as an error for the binary, naming
what it is for. When it is on, the loaded spenders are written to the zecd::audit target.
Logging when embedded
The library emits events; your subscriber receives them. That includes the rpc and wallet
spans and the zecd::audit target described in the
operations runbook.
Two things belong to daemon::init_tracing rather than to the library, so a
default-features = false build has neither: the [log] level / [log] format / RUST_LOG
handling, and subscriber installation. Configure your own.
Architecture
How zecd is put together: a single-writer actor per wallet database, a read path that bypasses it, a sync engine sliced into batches so the actor stays responsive, the background loops (enhancement, mempool, rebroadcast) that hang off the sync loop, and one shared upstream connection per endpoint that every actor talks through. The Zebra backend and statelessness pages cover the upstream interface and the persistence invariant separately.
Component diagram
RPC client (python-bitcoinrpc, curl, ...)
|
v HTTP Basic / cookie auth
+---------------------------+ +------------------+
| axum RPC server | | health server |
| auth gate -> work-queue | | /healthz /readyz |
| semaphore -> dispatch | | /status |
+------------+--------------+ +---------^--------+
| | SyncStatus (watch channel)
per-wallet WalletHandle |
| | |
| reads | writes |
v v |
+-----------------+ mpsc command channel |
| short-lived | (oneshot reply each) |
| SQLite conns | | |
| (WAL snapshots) | v |
+--------+--------+ +------+----------------------+
| | WalletActor (single writer) |
| | owns WalletDb (data.sqlite)|
+----------->| sync loop / enhance / |
same DB | mempool / rebroadcast / |
file | sends (prove + broadcast) |
+------+----------------------+
| HubSource (a ChainSource)
v
+------+----------------------+
| ChainHub (one per endpoint) |
| shared by every actor |
+------+----------------------+
| AnySource -> ZebraSource / light source
v
zebrad JSON-RPC (zebra://host:port) or lightwalletd
A fleet shard is one more actor of the same kind: one database holding many watch-only accounts, scanned in one pass.
The single-writer actor
zcash_client_sqlite::WalletDb is Send but not Sync, and wallet writes (note selection,
scan application, stores) must not interleave. zecd therefore gives each configured wallet one
actor task (src/wallet/actor.rs) that owns the WalletDb and is the only writer. It is the
analog of Bitcoin Core's cs_wallet mutex (src/wallet/wallet.h in the Core tree): every
state-changing operation serializes through one queue, so two concurrent sendtoaddress calls
cannot select the same notes and double-spend. The actor also runs the background sync loop,
so scans and sends contend for the same writer by construction.
RPC handlers talk to the actor through a clonable WalletHandle over a bounded tokio mpsc
channel (capacity 64); each WalletCommand carries a oneshot reply sender the handler
awaits. The command set is small: GetNewAddress, GetAddressForAccount, Send, GetRawTx,
Broadcast, Unlock, Lock. Everything else is a read.
Read-only RPCs (getbalance, listtransactions, listunspent, getwalletinfo, ...) never
enter the queue. The wallet DB runs in WAL mode, so src/wallet/read.rs serves them from
short-lived read connections with consistent snapshots. Reads keep working during a long scan
or proof, and a wedged writer cannot block balance queries. Two more things bypass the queue
deliberately: sync state is published on a watch channel (SyncStatus), read lock-free by
the blockchain RPCs and the health server; and walletlock zeroizes the shared seed directly
from the handle, so the seed does not linger behind a queued long send.
Command handling and mempool ingestion are panic-isolated (catch_unwind): a poison
transaction or a librustzcash edge case fails that one command instead of killing the actor,
which would silently stop all writes while reads kept answering.
The actor's main loop, in order per pass: drain any finished pipelined sends, drain all queued
commands (writers are never starved by sync), then run one sync batch. When the batch reports
no more work (caught up), it runs the rebroadcast pass, one enhancement batch, and (re)opens
the mempool subscription. Idle, it sits in a select! over shutdown, commands, send
completions, the relock deadline, the poll tick ([sync] interval_secs, default 20), and the
mempool stream.
The sync engine
src/sync/engine.rs is the compact-block scan loop, ported from zcash-devtool and refactored
into a one-batch-per-call driver: sync_one_batch downloads and scans up to [sync] batch_size compact blocks (default 10,000), then returns to the actor loop so queued commands
run between batches. Since 0.8.0 the batch is held in memory (sync/memcache.rs) rather than
written one file per block, and for shielded-only wallets the next range downloads while the
current one scans. A monolithic
run-until-caught-up loop (librustzcash ships one behind its sync feature) would hold the
writer for the whole initial sync; it also leaves the RequestedRewindInvalid reorg case
unhandled, which is why zecd keeps its own driver.
Reorg detection is librustzcash's (a continuity error from scan_cached_blocks); recovery is
caller-side by upstream design. zecd's perform_rewind truncates below the conflict and
retries at a shallower height when the requested rewind is invalid, so young wallets survive
reorgs near their birthday. Since 0.8.0 the rewind margin starts at 10 blocks and doubles for
each consecutive reorg, up to one batch, so a deep rollback is walked back in a few rounds
rather than dozens; it returns to 10 after the first clean batch. Compact blocks themselves are derived from full zebrad blocks; see
the Zebra backend. For transparent-enabled wallets the same pass matches
each scanned block's transparent outputs against the wallet's exposed-address set; see
transparent support.
Transaction enhancement
Compact blocks carry no memos and no full transaction data, so the block scan records a
received note with a NULL memo. enhance_step backfills this by servicing librustzcash's
transaction_data_requests: for each request it fetches the full transaction from zebrad and
runs decrypt_and_store_transaction, recovering received memos and (via the sender's OVK) the
wallet's own outgoing memos. Without it, any transaction the wallet only ever saw as a compact
block (every receive during initial sync or a restore) would never show its memo.
On a from-birthday restore the backlog is one upstream fetch per transaction: potentially tens
of thousands of requests after the block scan already reached the tip. So enhance_step is
bounded like the scan: one pass per call, [sync] enhance_concurrency requests (default 16),
with commands serviced and the shrinking backlog republished between passes. Since 0.8.0 a
pass fetches its requests concurrently, reads the request table once, and commits once, so a
pass records everything it fetched or none of it. [sync] fetch_memos = false skips the memo
fetches entirely (see configuration). The count rides on
SyncStatus.pending_enhancements and is an observable readiness signal: while it is non-zero
the wallet reports getwalletinfo.scanning: true, /readyz returns 503 with
reason: "enhancing" in synced mode, and /status shows the number. "Scanned to tip" is not
"ready to serve full history"; see operations for monitoring it.
The count is of distinct outstanding requests. The upstream query that generates the
recurring transparent spend-search requests joins its per-UTXO queue to received outputs on
transaction id alone, so a transaction with k unspent wallet outputs yields k identical
requests per queue row; deduplicating is what keeps the published count meaningful and stops
the drain servicing the same request repeatedly within one batch. Because those requests
re-emit whenever the tip passes an output's observed height, the backlog also rises in steady
state on a wallet holding many transparent UTXOs, not only during a restore, which is what
readiness = "scanned" exists for.
Mempool poller (0-conf)
Once caught up, the actor subscribes to the upstream mempool stream. Since 0.8.0 there is one
stream per upstream, owned by the chain hub and fanned out to every actor, rather than one per
wallet: a 2-second getrawmempool poller that closes itself when getbestblockhash changes, which doubles as
the "new block, sync now" signal. Every mempool transaction is processed twice over: it is
trial-decrypted against the wallet's keys (decrypt_and_store_transaction is a no-op for
unrelated transactions), and its transparent outputs are matched against the exposed-address
set. Incoming payments of either kind therefore appear at 0 conf in getunconfirmedbalance,
listtransactions, and listunspent minconf=0, as in bitcoind. The subscription is
best-effort: a stream error just drops it until the next caught-up pass. The actor also stamps
a transient in-memory first-seen time for unmined transactions here (surfaced as
time/timereceived); it is never persisted, per the statelessness
invariant.
Rebroadcast loop
On caught-up passes, at most once per [sync] rebroadcast_secs (default 60), the actor
re-submits wallet transactions that are still unmined and unexpired. Only transactions that
spend this wallet's own notes or UTXOs qualify: nobody else can spend them, so they were
necessarily authored here, and foreign unmined transactions the mempool stream stored are the
sender's to retransmit. A node that already holds the transaction rejects the duplicate, which
is logged at debug and harmless. This is what makes sendtoaddress safe to return a txid even
when the initial relay fails: the inputs are locked in the DB until expiry and the loop keeps
retrying.
The spend path
All sends (sendtoaddress, sendmany, z_sendmany) funnel through the actor's do_send.
Three details matter to operators:
sync_to_tip_for_send. librustzcash sets a transaction's target height (and thus its
expiry) and its spend anchor from the wallet DB's recorded chain tip. If the sync loop has
starved under load, that tip can lag Zebra's real tip past the expiry delta, and the send is
rejected upstream as already expired (-25, intermittently). Bumping only the tip pointer is
worse: the anchor then falls in an unscanned range and get_wallet_summary zeroes the entire
shielded balance, turning the failure into -6 ("0 spendable"). So before building, do_send
refreshes the tip and drives sync_step until the tip captured at entry is scanned. Normally
a no-op; best-effort (an unreachable upstream falls back to the last-scanned height).
Cached Orchard proving key. With [spend] cache_proving_key (on by default), sends run
through the PCZT roles with an orchard::circuit::ProvingKey shared by Arc across all
actors. Since 0.8.0 the Zakura Common crates also cache the keys process-wide on the fused
path (flag off), so neither path rebuilds a key per transaction; the flag now chooses only
whether the keys are warmed at startup and whether pipeline_proving can engage. Key
generation takes well under a second, and is skipped at startup when no loaded wallet can
spend. Proving runs under tokio::task::block_in_place, so it does not stall the async
runtime, but it does hold the actor.
Since 0.6.0 the key is built off the startup critical path. daemon::run kicks off the
build and carries straight on to spawn wallet actors and bind the listeners; the first send
awaits the handle, which on any real deployment resolves immediately because the build
finished long before. The handle is a tokio::sync::OnceCell, so concurrent senders wait on
the one in-flight build rather than starting a second, and a send arriving before the
background task ran simply drives the build itself. A keygen panic now fails that send rather
than the whole daemon. The Orchard and Ironwood keygens are independent, so they run on
separate threads.
[spend] pipeline_proving (default off). By default the whole send (select, build, prove,
sign, store, broadcast) runs on the actor, so a long proof freezes background sync for its
duration. With pipelining on, the send splits: phase A (note selection + PCZT build, a
milliseconds-scale DB read) stays on the actor, phase B (prove + sign) runs on a blocking
thread, and phase C (extract + store + broadcast) returns to the actor. Sends still serialize:
only one PCZT is ever uncommitted, so there is no double-spend surface; a send arriving
mid-proof queues (up to 64, then -4 back-pressure) and starts when the in-flight one
commits. It improves liveness, not multi-send throughput. Engages only on the cached-Orchard
PCZT path. Every send logs a phase profile (select+build / prove+sign / store / broadcast
milliseconds plus input and action counts).
Datadir lock
The single-writer invariant holds within one process; a second zecd on the same datadir would
still corrupt the wallet DB. src/lock.rs takes an exclusive advisory lock on
<datadir>/.lock (via fmutex, as zcashd does). zecd (the daemon) and zecd init
take it and hold it for their lifetime; a second writer fails to start with "Cannot lock data
directory ... Another zecd is already running". zecd rpcauth (no datadir access) and
zecd export-ufvk (read-only) deliberately do not take it, so the UFVK stays exportable while
the daemon runs.
Module map
Module (src/) | What lives there |
|---|---|
main.rs, daemon.rs | CLI shim; wiring: datadir lock, proving-key build, actor spawn, RPC + health servers, shutdown |
config.rs, pools.rs | TOML + CLI resolution; pool sets and receiver selection (see configuration) |
server/ | axum router, Basic/cookie auth, work-queue semaphore, JSON-RPC 1.0 framing |
rpc/ | dispatch table and method handlers (see the RPC reference) |
wallet/mod.rs | WalletHandle, WalletCommand, SyncStatus, the multiwallet registry |
wallet/actor.rs | the single-writer actor: sync/enhance/mempool/rebroadcast loops, sends, proving |
wallet/read.rs | read-only queries over short-lived WAL connections |
wallet/open.rs, store.rs, keys.rs, binding.rs | DB open/init + WAL, keys.toml, seed custody, account-to-keys binding |
chain/ | the ChainSource trait, ZebraSource, the light-mode source, and hub.rs, the shared upstream connection (see Chain backends) |
wallet/shard.rs, fleet.rs | fleet scan domains and manifests (see Fleet) |
sync/engine.rs, sync/memcache.rs | one-batch-per-call scan driver, reorg recovery; the in-memory batch |
operations.rs | the async-operation registry behind z_sendmany |
health.rs | /healthz, /readyz, /status on a separate port |
error.rs, amount.rs, address.rs | Bitcoin Core error codes + HTTP mapping; exact fixed-point amounts; address parsing |
lock.rs, hardening.rs, backoff.rs, state.rs | datadir lock; core-dump/mlock hardening; reconnect backoff; AppState |
Stateless & recoverable
zecd persists no off-chain data that a from-seed restore plus a full chain sync could not rebuild. This page defines that invariant, explains why the wallet database is a cache rather than authoritative state, and walks through its consequences: no labels, disposable data directories, functional (not bitwise) recovery, and deterministic history across restores.
The invariant
Everything zecd writes to disk is either recoverable from the seed and the chain, or a cache of
such data. The invariant is unconditional: there is no config flag, no side-table, and no way to
turn statefulness on. It is about persistence, not memory; transient in-memory caches are fine
(see the exceptions below), but nothing lands on disk that zecd init --restore followed by a
sync would not reproduce.
The practical payoff: a wallet's seed phrase (or, for a watch-only wallet, its UFVK) is the complete backup. There is no wallet.dat to snapshot, no label store to export, no dump/import cycle on migration.
The wallet database is a cache
The on-disk state is the librustzcash wallet DB (data.sqlite, at <wallet>/zec/lrz/ since
0.7.0). Every row in it is derivable:
- Balances, notes, and transparent UTXOs are rebuilt by re-scanning the chain: note trial-decryption with the account's viewing key for shielded funds, the block-scan transparent-output matcher for transparent ones.
- Addresses are re-derived from the seed. The shielded diversifier cursor (which index
getnewaddresshands out next) is clock-derived, and the transparent gap chain is sequential; both are caches of on-chain-recoverable data. Any address that ever received funds is recovered from the note (or UTXO) itself during the scan, so payments to previously issued addresses are detected after a restore. - An issued-but-never-funded address is simply forgotten on restore. For shielded addresses this is harmless (a later payment to it is still detected, because detection is by trial-decryption, not by address lookup). For transparent addresses it is bounded by the gap window; see below.
The one security-relevant exception in data.sqlite is which account the daemon serves:
getnewaddress derives from the DB account's UFVK, so a swapped or planted database would
silently divert deposits to a foreign key. zecd defends this by pinning the account's UFVK into
keys.toml at init and verifying the DB against the pin on every startup
(wallet/binding.rs::verify_or_pin_account); every seed exposure additionally verifies that the
seed derives the pinned UFVK. The pin itself is seed-derivable data (a UFVK is a function of the
seed), so it respects the invariant. Details in key custody.
Consequence: no labels
Address labels are the one kind of state with no on-chain source (they are supplied out-of-band) that is also persistent by nature. zecd therefore keeps none:
- The five label-dedicated methods are removed from the dispatch table entirely. Calling
setlabel,getaddressesbylabel,listlabels,getreceivedbylabel, orlistreceivedbylabelreturns method-not-found (-32601, HTTP 404), exactly like any unknown method. getnewaddressrejects a non-emptylabelargument with-8("labels are not supported (zecd is stateless); call getnewaddress without a label").- The embedded
label/labelsfields on the general history and address RPCs (getaddressinfo,listtransactions,listsinceblock,gettransactiondetails,listreceivedbyaddress) are retained for Bitcoin Core shape conformance but are always""or[]. Alisttransactionslabel filter other than"*"or""therefore matches nothing.
Keep your address-to-customer mapping in your own database, where it belongs in a payment system anyway.
Consequence: disposable data directories
Because the datadir holds only caches (plus keys.toml, which holds the age-encrypted seed and
the UFVK pin), a zecd deployment can treat it as expendable. A container with no persistent
volume, rebuilt from the seed on each start, loses nothing an operator depends on; it just pays
the rescan cost. In practice you keep the datadir for speed and keep the seed as the backup.
Restore and rescan mechanics (including --birthday to bound the scan) are covered in
operations.
Consequence: functional, not bitwise, recovery
A restore reproduces the wallet's funds and history, not its exact prior state:
- The sequence of addresses
getnewaddresshands out is not reproduced. Shielded diversifier indexes are clock-derived (librustzcash starts at a Unix-time-based index and increments past collisions), so a restored instance issues different fresh addresses than the original would have. All of them belong to the same account, and any that get funded are recovered. - Track the addresses you hand out yourself. zecd remembers an address only once it has
received funds; an issued-but-unfunded address disappears from
listreceivedbyaddress-style views after a restore. Keeping your own record of issued addresses avoids accidentally reusing one, which is a privacy/linkability leak, never a loss of funds. (getaddressinfo.isminestill resolves an unrecorded shielded address cryptographically via the viewing key, so you can always check whether an address is yours.)
The transient exceptions (in-memory only)
Three pieces of state live only in memory. None are written to disk, none survive a restart, and so none break the invariant:
| State | What it is | On restart |
|---|---|---|
| Tx first-seen times | Wall-clock stamp when the mempool stream first stores a pending tx (wallet::FirstSeen), surfaced as time/timereceived until a block time supersedes it | Rebuilt as the mempool stream re-observes still-pending txs; a mined tx uses its block time. A foreign unmined tx not yet re-observed reports time 0 until then |
| Async-operation registry | z_sendmany operation IDs and results (async operations) | Lost, matching zcashd's behavior; broadcast transactions are unaffected |
| Orchard proving key | ProvingKeyCache, built once on a background task at startup and shared across wallets (not warmed when no loaded wallet can spend; it then builds on the first send) | Rebuilt at startup (a pure performance cache) |
An unmined transaction has no block time yet; that is expected, not an off-chain gap, which is why first-seen is the deliberate exception rather than a violation. The rule for future development is the same: a transient in-memory cache is fine, but persisting anything the seed cannot rebuild breaks the invariant and needs an explicit design decision.
One persisted marker was such a decision. Since 0.8.0 (and 0.7.1) zecd marks each transaction
it authors as trusted when it stores it, so a payment to the wallet's own address waits the
trusted confirmation depth rather than the untrusted one. A from-seed restore does not
re-derive the marker, so a restored wallet is briefly more conservative than the instance
that sent, never less safe. [spend] trust_own_transactions = false turns the marker off, for
deployments that need an authoring instance and a restore to report identical balances at
every depth.
Recovery breadth: shielded vs transparent
Shielded funds are unconditionally recoverable from the seed. Detection is note trial-decryption with the viewing key, which needs no prior knowledge of which addresses were issued; every note the account ever received is found by scanning.
Transparent funds (opt-in, off by default) are recoverable only within the configured window:
the gap window is anchored at the wallet's issuance frontier (the highest of the last funded
index, the highest index handed out, and the transparent_initial_scan floor), and a restore
knows neither the funded nor the handed-out indices until it scans. Its horizon is therefore
transparent_initial_scan + transparent_gap_limit, extended as scanning finds funded indices.
Transparent change consumes the internal gap chain under the same limit. This is the standard
HD-wallet gap limitation, made sharper by statelessness (zecd does not persist an issued-address
high-water mark for you), which is why depth belongs to transparent_initial_scan rather than
to an inflated gap limit. Sizing guidance and the full mechanism are in the
transparent guide.
Restore-deterministic outgoing history
A Unified Address can carry several receivers (one per pool), but a transaction pays exactly one of them on-chain. The full multi-receiver UA you typed is sender-side metadata that never reaches the chain: librustzcash caches it only on the instance that authored the send, and a restore recovers only the single receiver actually paid.
Rather than show history that silently changes shape after a restore, zecd's history RPCs
(listtransactions, gettransaction details, listsinceblock, z_listtransactions) reduce
every outgoing output's address to that single paid receiver
(address::single_receiver_for_pool): a bare t/zs address, or a single-receiver UA for
Orchard (which has no standalone encoding). The reduction is idempotent, so a bare or
single-receiver recipient displays as itself. Since 0.8.0 it applies to incoming outputs as
well, so payer and payee print the same string for one output. The result is history that is
identical on the authoring instance and after a restore, where zcashd echoes the
stored UA on the authoring instance and degrades to the single receiver after a restore.
The trade-off: a multi-receiver UA you issued does not appear verbatim in history. Match by
diversifier_index instead, which received entries and getaddressinfo carry: it is the
identity every encoding of an address shares, and the scanner recovers it from the note, so it
survives a restore. z_listunifiedreceivers splits a UA into the receiver strings history
reports. zecd keeps no recipient-side mapping itself, consistent with everything above.
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.
Privacy policy
Every zecd send is governed by a privacy policy: a five-rung ladder that decides what a
transaction may reveal on-chain. This page explains the leaks a Zcash send can cause, what each
rung permits and rejects (with error codes), where the policy is configured and overridden, how
zcashd's privacyPolicy names map onto it, and how it is enforced.
What a Zcash send can reveal
zecd holds funds as shielded notes by default (optionally Sapling notes and, opt-in, transparent UTXOs; see addresses and transparent support). With NU6.3 active those notes are Ironwood notes, received at ordinary Orchard receivers - the address is unchanged, the value pool is not. Orchard V2 remains a real pool that a wallet can still hold and spend from, which is why a send out of it into Ironwood is a genuine pool crossing rather than a relabelling.
A fully shielded send within one pool reveals nothing about amount, sender, or recipient. Four things break that, and they are independent:
- A transparent recipient. Paying a bare
t-address forces a transparent output, which is a Bitcoin-style output: the recipient and the amount paid are public forever. - Crossing a shielded turnstile. When value moves between shielded pools in one
transaction, the net value entering or leaving each pool is published in the transaction's
valueBalancefield (consensus requires it). The recipient stays hidden, but the transferred amount is public. Sapling, Orchard and Ironwood are three distinct pools here, so this covers an Ironwood-funded send paying a Sapling address, and equally a send that drains legacy Orchard V2 notes into Ironwood. - Funding a send from transparent UTXOs. Spending the wallet's own t-address coins puts them in the transaction as inputs, which publishes the sender's addresses and the amounts held at them, and links them to each other. This is true even when the change shields: a shielding (t-to-z) send hides where the value went, not where it came from.
- Keeping the change transparent. A send funded from transparent UTXOs that also pays a transparent recipient never touches a shielded pool at all: inputs, outputs, amounts, and change are all public, exactly as in Bitcoin.
Because the leaks are independent, the policy cannot be a boolean. A caller who opts into revealing amounts (leak 2) has not thereby opted into revealing recipients (leak 1); neither opt-in implies a willingness to reveal the sender (leak 3); and revealing the sender in order to move transparent funds into the shielded pool is a very different act from spending them straight back out in the clear (leak 4).
The five rungs
SendPrivacy (src/config.rs) has five variants, strictest first. Each rung permits everything
the rung above it permits, plus one more disclosure.
| Policy | Transparent recipient | Shielded pool crossing | Transparent-funded spend | Kept-transparent change |
|---|---|---|---|---|
FullPrivacy | rejected, -8 | rejected, -8 | no | no |
AllowRevealedAmounts | rejected, -8 | allowed | no | no |
AllowRevealedRecipients (default) | allowed | allowed | no | no |
AllowRevealedSenders | allowed | allowed | yes (change shields) | no |
AllowFullyTransparent | allowed | allowed | yes | yes |
Details per rung:
FullPrivacy: only fully shielded sends confined to a single shielded pool. A recipient with no shielded receiver is-8at the RPC layer; a proposal whose inputs, outputs, or change would touch a transparent component or more than one shielded pool is-8from the actor, with a message naming the policy and the config knob to change. Sapling, Orchard and Ironwood are three distinct pools here: ironwood notes are received at ordinary Orchard addresses, but they are a separate value pool, so an ironwood-to-Orchard send crosses the turnstile exactly as an ironwood-to-Sapling one does.AllowRevealedAmounts: permits the turnstile crossing (revealing the amount viavalueBalance) but still rejects a transparent recipient with-8. This rung is the reason the ladder exists: collapsing it ontoAllowRevealedRecipientssilently pays transparent recipients under a policy chosen to forbid exactly that.AllowRevealedRecipients(the default): permits transparent recipients and crossings. This matches the Bitcoin-RPC promise of "send to any valid address". A transparent recipient is still paid from shielded notes, and the change stays shielded, so the sender side leaks nothing. A wallet holding only transparent funds still cannot spend under this policy: the shielded input selector sees zero spendable and the send fails-6("Insufficient funds").AllowRevealedSenders: additionally permits funding a send from the wallet's transparent UTXOs, which is whatz_sendmany'sfromaddressselects. The change of such a send is shielded, so with a shielded recipient this is the shielding (t-to-z) send: it is how received transparent funds move into the shielded pool. What it discloses is the sender side, and only that. A transparentfromaddressunder any weaker policy, the default included, is refused with-4before the send is queued.AllowFullyTransparent: additionally permits keeping the change transparent, which makes the whole transaction transparent. This is the only policy under which transparent change is possible. It engages when every recipient of a send is a bare transparent address and the funding source is transparent; a shielded recipient in the request routes back to the shielding build instead. See transparent support for the spend mechanics.
Where the policy is set
The wallet-wide policy is [spend] privacy_policy in the config file
(see configuration):
[spend]
# "FullPrivacy" | "AllowRevealedAmounts" | "AllowRevealedRecipients"
# | "AllowRevealedSenders" | "AllowFullyTransparent"
privacy_policy = "AllowRevealedRecipients"
The five names are case-sensitive; anything else (including zcashd-only names such as
NoPrivacy or AllowLinkingAccountAddresses) is a startup error, not an RPC error.
Note that AllowRevealedSenders is a rung in its own right as of 0.6.1. Earlier versions
accepted the name and treated it as AllowRevealedRecipients, on the reasoning that a wallet
with no transparent funding source had no sender to reveal. A config carrying that name from an
older deployment therefore gains the ability to fund sends from transparent UTXOs; write
AllowRevealedRecipients to keep the previous behaviour.
Only one RPC can override it per call: z_sendmany's fifth positional argument,
privacyPolicy (see async operations). sendtoaddress and
sendmany have no per-call argument and always use the configured policy
(see sending). An omitted privacyPolicy, or the value LegacyCompat,
falls back to the configured policy; an unknown string is -8
("Unknown privacy policy: ...").
zcashd policy-name mapping
zcashd's PrivacyPolicy (src/wallet/wallet.h, seven policies forming the lattice described in
zcash/zcash#6240) distinguishes sender-side
disclosures that only matter for a wallet spending from user-visible transparent source
addresses. zecd has such a source as of 0.6.1, so AllowRevealedSenders now carries its zcashd
meaning rather than collapsing. AllowLinkingAccountAddresses still collapses onto it: zecd
spends from a single account, so there are no separate accounts to link.
z_sendmany's privacyPolicy accepts every zcashd name
(wallet_methods::privacy_from_policy):
zcashd privacyPolicy | zecd rung |
|---|---|
omitted, LegacyCompat | the configured [spend] privacy_policy |
FullPrivacy | FullPrivacy |
AllowRevealedAmounts | AllowRevealedAmounts |
AllowRevealedRecipients | AllowRevealedRecipients |
AllowRevealedSenders | AllowRevealedSenders |
AllowLinkingAccountAddresses | AllowRevealedSenders |
AllowFullyTransparent | AllowFullyTransparent |
NoPrivacy | AllowFullyTransparent |
| anything else | -8 |
AllowFullyTransparent and NoPrivacy are the two zcashd policies that permit keeping the
change transparent, so both map to zecd's top rung.
One difference from zcashd's lattice is worth stating plainly: zecd's ladder is linear, so
each rung implies every rung below it. AllowRevealedSenders therefore also permits a
transparent recipient (paid from shielded notes, as AllowRevealedRecipients does), where
zcashd treats the sender-side and recipient-side disclosures as incomparable points in a
lattice. The strictly-transparent combination, a transparent recipient paid from transparent
funds, is the one zcashd also separates out, and it is AllowFullyTransparent in both.
Enforcement: two halves
The leaks are checked at different times because they are knowable at different times. The recipient-side and sender-side checks need only the request, so they run synchronously at the RPC layer; whether a send crosses a shielded turnstile depends on which notes fund it, which is not known until the proposal is built.
Half 1: the per-recipient pre-check (RPC layer). wallet_methods::build_payment runs for
every recipient of every send RPC, before anything reaches the wallet actor. If the policy does
not allow transparent recipients (SendPrivacy::allows_transparent_recipient()), a recipient
address with no shielded receiver (address::has_shielded_receiver) is rejected immediately:
-8: Privacy policy AllowRevealedAmounts rejects tmXXXX...: it has no shielded receiver,
so paying it would reveal the amount and recipient on-chain. Use privacyPolicy
"AllowRevealedRecipients" (or set [spend] privacy_policy) to permit this.
This check is cheap (address parsing only) and needs no wallet state. For z_sendmany it runs
synchronously, so a policy-rejected recipient fails with -8 before an operation id is ever
returned.
The same pass checks the funding source. A transparent fromaddress (or ANY_TADDR) under a
policy that does not permit transparent inputs (SendPrivacy::allows_transparent_inputs()) is
-4, naming the rung that would allow it:
-4: Insufficient privacy policy to allow transparent sender: AllowRevealedRecipients does
not permit funding a send from transparent UTXOs (which reveals the sender's addresses and
amounts). Use privacyPolicy "AllowRevealedSenders" or weaker to allow this transaction to
proceed.
and a transparent source paired with an all-transparent recipient set under anything short of
AllowFullyTransparent gets the companion refusal for the fully transparent case. Both are
re-checked authoritatively on the actor before the build, so the RPC-layer copy is a fast path
rather than the only guard, and the two cannot drift.
Half 2: the proposal check (wallet actor). Whether a send crosses the turnstile depends on
which notes fund it, and that is unknown until librustzcash builds the transfer proposal
(librustzcash has no privacy-policy concept of its own). So the actor's send path
(actor::build_proposal_and_pczt / do_send_fused) enforces the single-pool rule on the built
proposal, and only for FullPrivacy: enforce_full_privacy walks every proposal step with
Step::involves and rejects with -8 if any step touches a transparent component or more than
one shielded pool. Inputs, payment outputs, and change all count. The rule is stated over the
pool count rather than as a list of forbidden pairs, so a fourth pool is covered without
another edit; an earlier form that named only Sapling and Orchard let ironwood crossings through
(fixed in 0.5.2). AllowRevealedAmounts and above skip this check, since crossing
is exactly what that rung opts into. For z_sendmany this half runs on the background operation,
so the failure surfaces in z_getoperationstatus/z_getoperationresult rather than as a
synchronous error.
Source selection is a third decision point in actor::do_send, but it is a routing choice rather
than a rejection: the resolved source maps onto librustzcash's spend policy, with the shielded
pool set left empty for a transparent source so a shortfall is -6 instead of a silent top-up
from the other pool. Keeping the change transparent is the narrow case within that, taken only
under AllowFullyTransparent and only when every recipient is a bare transparent address.
Why the rungs must not collapse
An earlier zecd version reduced the policy to a boolean and mapped AllowRevealedAmounts onto
AllowRevealedRecipients. The result: a caller who set the policy specifically to keep
recipients private could still pay a transparent address, silently. The ladder fixes that class
of bug structurally, and the unit tests (full_privacy_rejects_transparent_recipients,
privacy_from_policy_maps_every_case in src/rpc/wallet_methods.rs) plus the funded regtest
tier guard it. When extending the ladder, add a rung; never fold two rungs together.
AllowRevealedSenders is the worked example of both halves of that rule. It was a collapsed
alias for as long as zecd had no transparent funding source, which was defensible while true;
when coin control made it false, the fix was to give the name its own rung rather than to leave
it pointing at a weaker one. Collapsing it would have meant a wallet configured for
AllowRevealedRecipients could suddenly spend its transparent coins in the clear.
Lineage
The ladder is zcashd's privacy-policy design
(zcash/zcash#6240) reduced to the disclosures zecd
can actually cause. zcashd models seven policies as a lattice with a meet operation
(PrivacyPolicyMeet); zecd keeps the four that are distinguishable for a wallet whose shielded
sends are always funded from shielded notes, and enforces FullPrivacy on the built proposal.
Reproducible builds
zecd's release binaries, Docker images, and packages are built so that an independent party can rebuild them bit-for-bit from the source tree. This page explains why, how each artifact is made deterministic, and how to verify a release yourself.
Why
zecd holds spend authority: the daemon has (or can decrypt) the seed that signs transactions. An operator who runs a prebuilt binary is trusting whoever built it. Reproducible builds replace that trust with a check: rebuild the same source, compare hashes, and any discrepancy (a compromised build machine, a tampered artifact, a supply-chain injection between source and binary) is detectable by anyone. For a wallet daemon this is not a nicety; it is the only way a third party can confirm that the published binary is the audited source.
Two properties are involved, and zecd's two Docker builds sit at different points:
- Determinism: the same inputs always produce the same bytes. Both builds have this.
- Toolchain trust: how much you must trust the compiler and base images that produced those bytes. Only the amd64 StageX build has the full-source-bootstrap story.
amd64: the StageX build (Dockerfile)
The primary image is a multi-stage build on StageX base images:
- Every base image (
stagex/pallet-rust,stagex/user-protobuf,stagex/user-abseil-cpp) is full-source-bootstrapped and pinned by digest in the Dockerfile. There is no upstream binary toolchain to trust; the toolchain itself is rebuilt from source. - The binary is statically linked against musl (
x86_64-unknown-linux-musl,-C target-feature=+crt-static), so the runtime image carries no libc. - Determinism flags:
SOURCE_DATE_EPOCH=1,CARGO_INCREMENTAL=0,-C codegen-units=1, and-C link-arg=-Wl,--build-id=none. Dependencies are pinned by the committedCargo.lock(cargo fetch --locked,cargo install --frozen). - The runtime stage is a bare
scratchimage: the staticzecdbinary, empty/var/lib/zecdand/tmpskeleton dirs, user10001:10001, a CA bundle at/etc/ssl/certs/ca-certificates.crtwithSSL_CERT_FILEpointed at it, and the license texts (LICENSE-MIT,LICENSE-APACHE,THIRD-PARTY-LICENSES.txt) under/usr/share/doc/zecd/, since the binary statically links its dependencies. Nothing else. The bundle is there for light mode, which dials lightwalletd over TLS and trusts the OS store by default (tls_roots = "native"); a full-node deployment never reads it, since that connection is plaintext HTTP to a local node. It comes from a digest-pinned source on both targets (a StageXcore-ca-certificatesstage on amd64, the pinned Alpine builder base on arm64), so it is a pinned input like every other and does not weaken the bit-for-bit guarantee. - The build enables
--features mimalloc-secure. musl's default allocator (malloc-ng) contends under Orchard proving's multi-threaded (rayon) allocation churn: roughly 80x more futex syscalls than mimalloc, costing about 10% per shielded send on bare metal and several times that in syscall-expensive sandboxes (gVisor, nested virtualization, some CI). mimalloc restores glibc-level performance; the-securevariant (MI_SECURE: guard pages, canary free-lists) adds back the heap-exploitation mitigations that replacingmalloc-ngwould otherwise drop, for under 4% on the proving path. Native glibc dev builds leave the feature off.
.dockerignore is an allowlist (Cargo.toml, Cargo.lock, rust-toolchain.toml, src,
zecd.example.toml, and the three license files), so the build context, and therefore the
build inputs, are exactly the files the build needs.
The export stage
Every stage before runtime is shared with an export stage that contains only the binary
at the image root. Extract it without running a container:
docker build --target export -o ./out . # ./out/zecd
This is exactly how the release workflow obtains the binaries it publishes (below), so a local export is directly comparable to a released one.
arm64: the pinned Alpine build (Dockerfile.arm64)
StageX publishes amd64 images only, so the full-source-bootstrapped build is amd64-only for
now. For ARM, Dockerfile.arm64 produces the same output shape (a static
aarch64-unknown-linux-musl binary in a bare scratch runtime, same user, datadir, ports,
and entrypoint) from the musl-native rust:alpine official image, with everything pinned:
- the base image by digest (
rust:1.96.0-alpine3.24@sha256:...); - the C/C++/protoc toolchain to exact apk versions (
gcc,g++,musl-dev,binutils,make,protoc,protobuf-dev), so apk cannot silently resolve a newer compiler that changes the emitted machine code; - the Rust toolchain via
RUSTUP_TOOLCHAIN=1.96.0, overridingrust-toolchain.toml's floatingchannel = "stable"; - the same determinism knobs as amd64 (
SOURCE_DATE_EPOCH=1,CARGO_INCREMENTAL=0,codegen-units=1,+crt-static,--build-id=none, fixed build path) and--features mimalloc-secure.
The result is deterministic and independently rebuildable bit-for-bit. What it is not is StageX-grade trust: the compiler and base image are upstream binary artifacts (a Docker official image plus Alpine packages), not bootstrapped from source. Released images are one multi-arch manifest on GHCR, so the same tag pulls the amd64 or the arm64 build.
Maintenance caveat: Alpine garbage-collects superseded package versions from its CDN, so the
apk pins go stale. When the arm64 build starts failing with "package not found", the base
image digest and the apk pins must be refreshed together (keeping RUSTUP_TOOLCHAIN in
lockstep with the image tag). See the MAINTENANCE note in Dockerfile.arm64.
Release artifacts (release.yml)
Pushing a v* tag runs the Release workflow. For each Linux target
(x86_64-unknown-linux-musl via Dockerfile on an amd64 runner,
aarch64-unknown-linux-musl via Dockerfile.arm64 natively on an arm64 runner) it:
- Builds the Dockerfile's
exportstage and extracts the binary. The published binaries therefore inherit the reproducible image pipeline; there is no separatecargo buildthat could diverge from the images. - Packages a reproducible
.tar.gz:tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@1", thengzip -9n(no embedded name or timestamp). - Builds a reproducible
.debviascripts/build-deb.sh, which wraps the pre-built binary without reintroducing nondeterminism: every file's mtime is clamped toSOURCE_DATE_EPOCH(1),dpkg-deb --root-owner-grouppins ownership to root:root, the changelog is compressed withgzip -n, and dpkg-deb (1.18.11 or later) honorsSOURCE_DATE_EPOCHfor the ar member timestamps. The output has been verified bit-for-bit across independent builds. The package carries the systemd unit and maintainer scripts inline; see the deployment guide for what it installs. - Gathers every architecture's artifacts, writes one
SHA256SUMSover them (bare filenames, sorted, so the file is itself byte-stable and verifies against downloads sitting in a single directory), and attaches everything to a draft GitHub release (a human reviews and publishes).
Artifacts are named with the Debian/Go architecture token - zecd-<version>-linux-amd64.tar.gz
beside zecd_<version>_amd64.deb - rather than the Rust target triple, so one release
listing spells each architecture exactly one way. Through 0.5.2 the tarballs used triples
and each artifact carried its own .sha256 sidecar.
Separate docker and docker-arm64 jobs in the same workflow push each architecture's image
by digest (the amd64 push uses rewrite-timestamp=true and forced compression so the pushed
layers are deterministic too, and attaches SBOM and provenance attestations), and a manifest
job combines the two digests into one multi-arch manifest tagged <version> and
<major>.<minor>, with no per-architecture suffix. A crates-io job checks that
Cargo.toml names the tagged version and publishes the crate. The workflow also has a
workflow_dispatch trigger with a version input for dry-running the packaging without a
tag; manual runs skip the GHCR push unless push_images is set and always produce a draft
release.
No patched dependencies
Reproducibility was validated empirically with clean double-builds, which once surfaced a
nondeterministic dependency: the fl! localization proc-macro in i18n-embed-fl 0.9 (pulled
in by age, which encrypts the wallet mnemonic; see key custody)
emitted fluent message arguments in randomly seeded HashMap order, so one call site flipped
its argument order on a per-build coin flip. Through 0.7.x the repo vendored a fixed 0.9.4 as
its one [patch.crates-io] entry. Since 0.8.0 age 0.12 depends on i18n-embed-fl 0.10,
which carries the fix, and Cargo.toml has no patch entries and no git dependencies.
Every dependency comes from crates.io, but not all are stable releases: the Zakura Common
wallet layer (zakura-client-backend, zakura-client-sqlite, zakura-pczt) is published at
release-candidate versions. Cargo.lock pins them exactly like any other dependency.
Verifying a release
To check a published binary against the source it claims to be built from:
git clone https://github.com/zecrocks/zecd && cd zecd
git checkout v<version>
# amd64
docker build --target export -o ./out .
sha256sum out/zecd
# arm64 (on an arm64 host)
docker build -f Dockerfile.arm64 --target export -o ./out .
sha256sum out/zecd
Compare the hash against the binary inside the released .tar.gz. Note what each check
covers: SHA256SUMS covers the archive, which proves you downloaded what was published,
while the rebuild above proves the binary inside it is the one this source produces. Both
are worth doing, and only the second is a reproducibility claim. To verify a .deb, rebuild
it from your extracted binary and compare the whole file:
./scripts/build-deb.sh out/zecd <version> amd64 .
sha256sum zecd_<version>_amd64.deb # must match the released .deb
To verify an image rather than a binary, rebuild the runtime stage and compare the zecd
binary it contains (extracted via the export stage as above) against the one in the GHCR
image. The build fetches pinned dependencies from crates.io (Cargo.lock), so it needs
network access; everything else (base images, toolchain, flags) is pinned in the Dockerfiles.
Treat any mismatch as a red flag and report it.
Threat model & trust boundaries
What zecd protects, what it trusts, which adversaries it defends against, and which it deliberately does not. Read this before deploying with real funds; the custody mechanics live in key custody.
Assets
Ordered by blast radius:
| Asset | What it grants | Where it lives |
|---|---|---|
| Seed / mnemonic | Spend authority over all funds, forever. The root secret. | Age-encrypted in keys.toml; decrypted into process memory when unlocked. |
| RPC credentials | Spend authority on an unlocked wallet. Anyone who can call sendtoaddress can move funds; treat the RPC password, rpcauth secrets, and the .cookie file exactly like the seed while the daemon runs unlocked. | [rpc] config, ZECD_RPC_PASSWORD, <datadir>/.cookie (mode 0600). |
| UFVK (Unified Full Viewing Key) | Full view access: every incoming and outgoing transaction, amounts, memos, addresses. Cannot spend, but its leak is a permanent transaction-graph privacy compromise. | Wallet DB; pinned in keys.toml; printed by zecd export-ufvk. |
| Wallet datadir | The wallet DB (data.sqlite), keys.toml, the cookie, and (in the default custody model) the age identity file. See the datadir-theft row below. | --datadir. |
| Zebra RPC credentials | Access to the node whose answers zecd trusts for its entire chain view. | [zebra] config (cookie or user/password). |
Trust boundaries
RPC client zecd zebrad
(your app) ---HTTP------> [RPC :8232] (self-hosted)
Basic/cookie | |
auth, JSON-RPC | actor / wallet DB |
| |
no auth -----> [health :9233] |
(sync status) | |
+---plaintext JSON-RPC---->+
| (local-only by design)
v
disk: <datadir>/
keys.toml, identity.txt,
data.sqlite, .cookie, .lock
RPC client to zecd. Authenticated (HTTP Basic: rpcuser/rpcpassword, rpcauth
entries, or the generated cookie). The transport is plaintext HTTP, same as bitcoind: the hop
is assumed to be a trusted network segment (loopback, or a private segment fronted by
TLS/reverse proxy). Authentication proves identity; it does not encrypt the wire.
zecd to the chain backend. The backend is fully trusted for the chain view: balances, confirmation counts, incoming payments, and mempool visibility are whatever it serves. zecd validates response shapes, not consensus. It never sees key material, so a compromised backend cannot steal funds, only lie about the chain. See chain backends.
Which backend you run changes the rest of this hop:
-
Full node (default). Plaintext local JSON-RPC to a Zebra you run yourself, so the trust above is trust in your own machine. Note the hop is not metadata-free even here: shielded scanning pulls whole blocks and filters locally, but a transparent-enabled wallet also queries the node's address index by address. That is a non-issue when the node is yours, and it is the same traffic that becomes an exposure in light mode. Credentials over cleartext to a non-local host are refused unless deliberately overridden.
-
Light mode (0.6.x and later). gRPC to a lightwalletd, which may be someone else's server. The chain-view trust is now trust in a third party, and there is a second, distinct exposure: the server learns which addresses this wallet cares about, because transparent spend detection queries it per funded address. TLS is the wire mitigation and can be pinned to a certificate fingerprint or a private CA; plaintext to a globally routable host is refused rather than silently downgraded, precisely because what it leaks is the address set. But TLS protects the hop, not the metadata: the server itself still sees every query.
Be specific about how much it sees. Those address queries are currently issued as a single call spanning the address's funding height through to the chain tip, rather than as the sequence of narrow windows the protocol provides for decorrelation. That is the right call against an always-on index you run yourself, and it is what makes a deep restore finish in reasonable time, but it is not gated on who the backend is. Against a third-party lightwalletd it means the server learns each funded address's full history range in one query. A wallet whose address set is sensitive should run its own node, which is the standing recommendation for transparent-enabled wallets on other grounds too.
-
Through a SOCKS5 proxy (0.8.0 and later).
[backend] proxyhides the wallet host's network address from the backend and from the path to it, and the proxy resolves the destination, so no DNS query leaves the machine. It does not change what the backend learns from the queries themselves. A TLS lightwalletd session stays end to end through the proxy; a zebra JSON-RPC session is plaintext on the proxy's hop to the node. See chain backends.
zecd to disk. The datadir holds the encrypted seed, the wallet DB, and the RPC cookie. Filesystem permissions are the boundary; zecd sets 0600 on the cookie and the identity file but otherwise trusts the OS user model.
Health port. /healthz, /readyz, /status on a separate port (default
127.0.0.1:9233) are unauthenticated by design and expose sync status only, no balances or
addresses. Still keep it off the public internet: sync state and upstream reachability are
reconnaissance.
Adversaries and mitigations
| Adversary | Can attempt | Mitigations |
|---|---|---|
| Network attacker on the RPC hop | Sniff or brute-force credentials, issue spends. | Default bind 127.0.0.1; front remote access with TLS or a reverse proxy. Cookie auth (fresh random secret, file mode 0600) or rpcauth salted HMAC-SHA256 (no plaintext password in config). Constant-time credential comparison with no username short-circuit. 250 ms delay on every 401, matching bitcoind's anti-bruteforce delay. On mainnet, zecd refuses to start while the RPC password is the example placeholder CHANGE-ME. |
| Holder of a leaked RPC credential | Full RPC surface, including sends on an unlocked wallet. | [rpc] allowed_methods safelist: a non-empty list serves only those methods, everything else returns -32601 (indistinguishable from nonexistent), shrinking the blast radius to what the deployment actually needs. Server-wide, not per-user. For structural containment, run the exposed instance watch-only so no credential on it is spend authority. Passphrase custody (init --encrypt) keeps the wallet locked between sends. |
| Malicious or compromised Zebra | Serve a wrong chain view: fake confirmations, hidden incoming payments, stale tip. Cannot steal keys (it never sees them). | Run your own node; that is the deployment model, not an option. The cleartext-credential gate refuses to send [zebra] credentials to a globally-routable host over plaintext (loopback and private ranges allowed by default; [backend] rfc1918_is_local = false tightens, allow_remote_cleartext = true opts out for an out-of-band-secured hop). |
| Datadir thief (backup leak, stolen disk, snapshot access) | Read keys.toml, data.sqlite, the cookie. | The seed in keys.toml is age-encrypted. Caveat for the default custody model: the identity file defaults to <datadir>/identity.txt, so whoever reads the whole datadir has the seed. Mitigate by storing the identity outside the datadir (ZECD_AGE_IDENTITY, a secrets manager, a separate mount) or by using passphrase custody, where no on-disk file can decrypt the seed. Either way the thief still gets the UFVK and full history (a privacy loss). Details in key custody. |
DB planter (swaps or plants data.sqlite to divert deposits to their key) | Make getnewaddress derive addresses from a foreign account. | Account-to-keys binding: init pins the account's UFVK into keys.toml; every startup verifies the DB account against the pin, and every seed exposure (startup auto-unlock, walletpassphrase) verifies the seed derives that UFVK. A mismatch is fatal for the whole daemon (treated as tampering evidence); walletpassphrase returns -4 and stays locked. |
Memory scraper (swap file, core dump, another process reading /proc/<pid>/mem) | Capture the decrypted in-memory seed passively. | Best-effort hardening at startup: the seed buffer is mlocked (never swapped), core dumps are disabled (RLIMIT_CORE=0), and the process is non-dumpable (PR_SET_DUMPABLE=0, which also blocks ptrace by other non-root processes). ZECD_ALLOW_CORE_DUMPS=1 opts out for debugging. Each step warns and continues if denied. This is not a defense against code execution inside zecd, which can read the seed directly; for that, run zecd watch-only and keep spend authority in a separate signer. |
| Authenticated DoS (credentialed flood) | Exhaust the daemon with requests or queued sends. | Work-queue semaphore ([rpc] work_queue, default 100): excess concurrent requests get 503, like bitcoind. The async-operation registry is capped at 1024 retained operations (oldest finished results evicted) and 16 unfinished operations per wallet (further z_sendmany rejected with -4 back-pressure). |
| Concurrent writer (second zecd on the same datadir) | Corrupt the wallet DB. | Exclusive advisory lock on <datadir>/.lock, taken by both the daemon and zecd init and held for their lifetime; a second writer refuses to start. Kernel-released on exit, so no stale lockfile. Read-only export-ufvk is exempt. |
Residual risks and non-goals
Residual risks (real, accepted, mitigate operationally):
- Zebra is a single point of trust and availability. No cross-checking against a second source. A lying node lies successfully until you notice; a dead node stalls sync and sends (reads keep answering from the local DB).
mlockcovers the seed buffer only. Transient key copies made inside librustzcash during derivation and proving are not individually locked. Back swap with an encrypted device to cover the residue.- No built-in TLS on the RPC port. Same posture as bitcoind; anything beyond loopback needs a proxy in front.
- No per-user RPC permissions. Every accepted credential has the same authority;
allowed_methodsis one server-wide gate. - The health port leaks operational state (sync progress, upstream reachability) to anyone who can reach it. Keep it private.
- Transparent funds have a bounded recovery window on a from-seed restore (gap limit / initial scan); a lost datadir plus an undersized window loses sight of sparsely-funded high addresses. See transparent addresses.
Explicit non-goals:
- Code execution inside the zecd process. An attacker running code in-process reads the unlocked seed. The supported isolation is the watch-only split, not in-process containment.
- A hostile host. Root, the hypervisor, and anyone who can ptrace as root are outside
the model.
PR_SET_DUMPABLE=0stops other non-root processes, nothing more. - Zcash protocol or librustzcash vulnerabilities. Report those upstream per the Zcash ecosystem security policy, not against zecd.
- Hiding metadata from your own Zebra node. zecd fetches full blocks and polls the mempool from a node you run; the node necessarily learns the wallet's sync pattern. Metadata exposure to a third-party backend is a different matter and is not a non-goal: see the light-mode paragraph in Trust boundaries above for what such a server learns.
Supply-chain integrity of the shipped binaries is addressed separately by the reproducible build pipeline. To report a vulnerability in zecd itself, use GitHub's private vulnerability reporting on the repository; do not open a public issue.
Key custody
How zecd stores the wallet seed at rest, when it is decrypted into memory, how that memory is hardened, and how the daemon proves the keys it holds actually match the wallet database it serves. Read Threat model first for what these mechanisms do and do not defend against.
Custody models
A spending wallet's 24-word mnemonic lives age-encrypted in keys.toml (created mode 0600).
There are two at-rest models for it, selected once at zecd init, plus watch-only as the
no-keys deployment:
| Model | At rest | Startup state | Passphrase RPCs |
|---|---|---|---|
| Identity file (default) | Mnemonic age-encrypted to the recipient of identity.txt | Unlocked (with default auto_unlock = true) | -15 |
Passphrase (init --encrypt) | Mnemonic age-encrypted with a passphrase (scrypt) | Locked; sends -13 | walletpassphrase / walletlock |
Watch-only (init --ufvk) | No seed anywhere; seedless keys.toml | n/a | -15; sends -4 |
Identity file (default)
zecd init generates an age X25519 identity at [keys] age_identity (default
<datadir>/identity.txt, created mode 0600; a reused identity whose permissions have been
widened is refused) and encrypts the mnemonic in keys.toml to it. With the default
[keys] auto_unlock = true, startup decrypts the seed into a zeroizing in-memory secret so
sends run unattended. walletpassphrase and walletlock return -15, matching bitcoind with
an unencrypted wallet.
The co-location caveat: with identity.txt inside the datadir, the at-rest encryption only
protects a leak of keys.toml alone. Anyone who can read the whole datadir has the seed. For
an unattended mainnet wallet, store the identity outside the datadir (a secrets-manager mount,
a separate volume) and point zecd at it via ZECD_AGE_IDENTITY, --age-identity, or
[keys] age_identity.
Do not set auto_unlock = false on an identity wallet: it starts locked, sends fail -13,
and walletpassphrase cannot unlock it (-15, there is no passphrase). zecd warns loudly at
startup about this dead end. If you want a manually unlocked wallet, use the passphrase model.
Passphrase (zecd init --encrypt)
zecd init --encrypt wraps the mnemonic with a passphrase instead (age scrypt; minimum 12
characters, confirmed twice on stdin, or supplied via ZECD_WALLET_PASSPHRASE for
non-interactive init). No identity file can decrypt it. keys.toml carries an
encryption = "passphrase" marker; this is the only model with a runtime lock state, and the
only one where getwalletinfo.unlocked_until appears.
The wallet starts locked and follows Bitcoin Core's state machine:
- Sends while locked fail with
-13("Please enter the wallet passphrase with walletpassphrase first."). walletpassphrase "<pass>" <timeout>decrypts the seed fortimeoutseconds. A wrong passphrase is-14. The timeout is a required non-negative integer; values above 100,000,000 seconds are silently clamped, as in Core. Re-running resets the timer; a timeout of 0 relocks immediately. The scrypt derivation is deliberately slow (about a second) and runs off the async runtime.- The wallet auto-relocks at the deadline.
getwalletinfo.unlocked_untilreports the relock unix time (0 when locked); the field appears only for passphrase-encrypted wallets, like Core. walletlockzeroizes the seed immediately and cancels the pending relock.
Encryption is set once at init. There is no encryptwallet or walletpassphrasechange RPC
(both -32601), so the passphrase never crosses the network. To change it, re-run
zecd init --restore --encrypt from the mnemonic in a fresh data directory.
Watch-only: no keys on the box
The strongest custody posture is to not hold spending keys at all: run the RPC-facing zecd
watch-only (zecd export-ufvk on the spending wallet, zecd init --ufvk on the serving one)
and keep the spending wallet on isolated infrastructure. Addresses, balances, and history all
work; sends return -4. See Watch-only wallets.
Memory hardening
Once unlocked, the seed is resident in process memory in every spending model. zecd hardens it against passive capture at startup. Every step is best-effort: a failure logs a warning and the daemon keeps serving, never refuses to start.
mlockon the seed buffer. The pages holding the decrypted seed are pinned into RAM so they are never written to swap, and the bytes are zeroized on lock/relock/shutdown. The lock is targeted at the seed buffer, notmlockall(which would have to fit the whole RSS, proving keys included, underRLIMIT_MEMLOCKand typically fails in containers). A deniedmlock(for example an unprivileged container withRLIMIT_MEMLOCK=0) warns once and leaves the seed usable but swappable; raise the memlock limit to fix it. Transient key copies made deeper in librustzcash during derivation and proving are not individually locked; back swap with an encrypted device to cover that residue.- Core dumps disabled (
RLIMIT_CORE = 0), so a crash cannot spill the seed into a core file.ZECD_ALLOW_CORE_DUMPS=1(the exact value1; anything else keeps hardening on) opts out for crash debugging. The opt-out does not affect the seedmlock. - Non-dumpable (
PR_SET_DUMPABLE = 0, Linux only), which also blocksptraceattach and/proc/<pid>/memreads by other non-root processes.
This defends passive disclosure (swap, core dumps, another process reading zecd's memory). It does not defend an attacker with code execution inside zecd, who can read the seed directly. For that isolation, split the deployment watch-only as above.
Account-to-keys binding
The wallet database (data.sqlite) is a rebuildable cache of on-chain data, but one datum in
it is security-critical and has no on-chain check: which account the daemon serves.
getnewaddress derives receive addresses from the database account's UFVK, so a planted or
swapped database silently diverts every future deposit to whoever holds that account's keys.
zecd pins the account to keys.toml (the operator-controlled root of trust) and verifies the
pin in four layers:
zecd initrefuses a wallet database that already contains an account.zecd initrecords the new account's Unified Full Viewing Key inkeys.toml(theufvkfield, written in all custody models including watch-only). The UFVK is derivable from the seed, so the pin is a cache of seed-derivable data and respects the statelessness invariant.- Every startup compares the database account's UFVK against the pin. A mismatch is the typed
BindingMismatcherror and is fatal for the whole daemon: tampering evidence, unlike ordinary per-wallet startup failures, which merely skip the wallet. Akeys.tomlfrom before the pin existed is backfilled trust-on-first-use. - Every seed exposure re-verifies that the decrypted seed actually derives the account's
UFVK: the identity auto-unlock at startup (mismatch is fatal, since an unattended wallet
has no later unlock where it could surface) and every
walletpassphrase(mismatch returns-4and the wallet stays locked). This retroactively validates a trust-on-first-use pin and catches akeys.tomland database pair swapped in together.
Deliberately not covered: tampering with non-key rows (notes, history, scan state). Once the account keys are verified, planted notes cannot be spent and balances are rebuildable from seed plus chain. Error messages abbreviate the UFVK to its first 24 characters, since the full encoding is itself a viewing capability.
Secrets outside the config file
Every secret can be sourced from the environment or a mounted file instead of the (ConfigMap-bound) TOML:
| Secret | Sources (highest precedence first) |
|---|---|
| RPC password | --rpcpassword / ZECD_RPC_PASSWORD, then [rpc] password_file (a mounted file; configured-but-unreadable is fatal), then inline [rpc] password |
keys.toml location | ZECD_KEYS_FILE / --keys-file / keys_file (global for the default wallet, or per [wallets.<name>]) |
| age identity | ZECD_AGE_IDENTITY / --age-identity / [keys] age_identity |
Mnemonic (init --restore) | ZECD_MNEMONIC, then --mnemonic-file, then interactive stdin |
Passphrase (init --encrypt) | ZECD_WALLET_PASSPHRASE, then interactive stdin (entered twice) |
Prefer the env var or file over --rpcpassword on the command line: argv is world-readable
via ps and /proc/<pid>/cmdline, and zecd warns at startup when it detects the flag there.
With [keys] bootstrap_from_keys (default on), a wallet whose keys.toml is present but
whose data directory has no account is rebuilt on boot: the account is recreated from the seed
(immediately for identity/auto-unlock wallets, at the first walletpassphrase for encrypted
ones) and the wallet rescans from its birthday. The data directory becomes a disposable cache
and the Kubernetes shape is "mount one Secret, start with an empty PVC". See
Operations for the minimal runtime file set and the bootstrap
procedure.
RPC credentials are spend authority
Anyone with RPC access to an unlocked wallet can spend from it. Treat the RPC credential with the same care as the seed material above:
- Credentials follow bitcoind:
rpcuser/rpcpassword, saltedrpcauthentries ([rpc] auth = ["<user>:<salt>$<hmac-sha256>"], generated with the built-inzecd rpcauth <user> [password], no externalrpcauth.pyneeded), or the generated cookie file (<datadir>/.cookie, mode 0600) when no user/password pair is set. Preferrpcauthor the cookie over a bare shared password. - On mainnet, zecd refuses to start while the password is the example placeholder
(
CHANGE-ME). - zecd serves plaintext HTTP. Bind to
127.0.0.1(the default) or front it with a TLS-terminating proxy; zecd warns at startup about a bare password on a non-loopback bind. [rpc] allowed_methodsshrinks the blast radius of a leaked credential to a chosen method subset. See RPC overview and Threat model.
Compatibility boundary
zecd targets generic Bitcoin-RPC compatibility, not bug-for-bug bitcoind emulation. This page defines what that boundary covers and the edges where a shielded-first light wallet necessarily behaves differently from bitcoind. Intentional per-method divergences are in the method index.
What compatibility means
Any integration that drives a coin purely through Bitcoin Core RPC works: request a deposit
address with getnewaddress, hand it to the payer, poll listtransactions /
gettransaction / getbalance for the payment and its confirmations. Method names, response
field names and types, the JSON-RPC 1.0 envelope, HTTP Basic/cookie auth, decimal 8-place
amounts, and error codes all match Bitcoin Core (see conventions and wire
format). The conformance suite drives a live daemon with the same client logic
python-bitcoinrpc uses, so an unmodified AuthServiceProxy client works out of the box (see
testing and conformance).
If your integration polls a height, Core's
waitfornewblock / waitforblock / waitforblockheight
work here too, and block on the wallet's fully-scanned height, which is the one that makes
history and confirmations safe to read. Do not substitute a balance poll for them: an incoming
payment is credited at 0 confirmations from the mempool, so a balance is satisfied before the
confirming block is scanned and the height-dependent fields you read next may not be written
yet.
Edges
Behaviors an integrator should design around. Each follows from being a shielded-first light wallet.
Spending needs confirmations
An incoming mempool payment is visible immediately: getunconfirmedbalance,
listtransactions, and listunspent with minconf=0 all show it at 0 confirmations, fed by
zecd's getrawmempool poller. But a received note must mine and reach the confirmation
minimum before it is spendable. The default policy is ZIP
315's: 3 confirmations for the wallet's own change and
for every output of a transaction it authored, 10 for third-party payments (roughly 12.5 minutes at 75-second blocks). [spend] trusted_confirmations / untrusted_confirmations tune it wallet-wide (see
configuration).
A parameterless getbalance reports what is spendable under that policy; funds below the
threshold show in getunconfirmedbalance and getbalances.mine.untrusted_pending meanwhile.
An explicit minconf (getbalance "*" 1) overrides the policy symmetrically and counts
everything at that depth, as in Bitcoin Core. minconf 0 is served as 1: a shielded note is
never spendable unmined. See wallet balances.
Fees are never client-settable
Fees follow ZIP 317: a deterministic formula (5,000 zatoshis
times max(2, logical actions); a typical send pays 0.0001 ZEC) computed at build time. There
is no fee market to outbid, so client fee instructions are meaningless. zecd rejects them
with -8 rather than silently ignoring them:
subtractfeefromamount(sendtoaddress) andsubtractfeefrom(sendmany): would change who pays the fee.fee_rateonsendtoaddress/sendmany: an explicit fee instruction.settxfee: always-8.
Estimation hints are safely ignored: conf_target and estimate_mode on sends, and
maxfeerate on sendrawtransaction (the conventional fee already buys next-block
inclusion). estimatesmartfee/estimatefee remain as inert probe-compat stubs returning a
stable conventional rate (feerate 0.00001). The exact fee actually paid is reported after
the fact in gettransaction.fee. See sending and utility and
control.
Addresses are Unified Addresses
getnewaddress returns a shielded Unified Address (u1... on mainnet, utest1... on
testnet). Clients that treat addresses as opaque strings are fine; clients that parse the
address as a transparent Bitcoin address (base58 checks, script construction) are not.
validateaddress validates every Zcash address kind and reports what a given address can
receive via its receiver_types array. See addresses and shielded
pools.
Sends that leave a single shielded pool reveal information
A transparent recipient reveals the recipient and the amount on-chain; crossing a turnstile
between two shielded pools (spending one, paying the other) reveals the crossed amount via
valueBalance. Sapling, Orchard and Ironwood are three distinct pools for this purpose, so a
post-NU6.3 wallet paying a Sapling or Orchard recipient from ironwood notes is crossing one.
Both leaks are permitted under the default policy, AllowRevealedRecipients.
The [spend] privacy_policy setting (and z_sendmany's per-call privacyPolicy) is a
five-rung ladder that lets you forbid either leak, or opt in to the two sender-side ones:
funding a send from transparent UTXOs, and additionally keeping its change transparent. See
privacy policy for the rungs and where each is enforced.
Memos are extensions
Shielded memos (ZIP 302) sit beyond Bitcoin Core's surface, so zecd exposes them as extensions that dialect-pure clients never trip over:
sendtoaddresstakes a hex-encoded memo as an extra trailing parameter, afterverbose. At most 512 bytes; non-hex or oversized memos are-8(zcashd's messages); a memo paired with a transparent recipient is-8.- History entries (
listtransactions,gettransaction.details,z_listtransactions) carrymemo(hex) andmemoStr(decoded text) fields when an output has one; entries without a memo omit the fields entirely.memois the stored bytes and does not depend on the memo parsing as ZIP-302 text, so an absentmemomeans "no memo" rather than "unparseable" (see wallet history; this was not true before 0.6.4). z_sendmanypermits a zero-valued output, zcashd's memo-only-send pattern (a shielded recipient,amount: 0, and amemo). The Bitcoin-Core-dialectsendtoaddress/sendmanykeep rejecting a zero amount with-3 Invalid amount, as Core does.
See sending and async operations.
Partial reads during initial sync
During initial sync or a post-restore rescan, read RPCs serve whatever has been scanned so
far: getbalance on a half-synced wallet is a partial number, not an error. (Bitcoin Core
rejects every RPC with a warm-up error, -28, while it loads at startup.) Gate automation on
GET /readyz with [health] readiness = "synced", or on getwalletinfo.scanning / getblockchaininfo.initialblockdownload,
before trusting balances.
These signals stay busy until the wallet can serve full history, not just until the block
scan reaches the tip. Compact blocks carry no memos, so after the scan catches up a
per-transaction enhancement pass fetches each transaction's full data from Zebra to backfill
memos; on a from-birthday restore that backlog can run long after scan_progress hits 1.0.
Since 0.8.0 the drain fetches concurrently ([sync] enhance_concurrency), and a deployment
that never reads memos can skip it with [sync] fetch_memos = false.
The backlog is surfaced as pending_enhancements on GET /status (a count of distinct
outstanding requests since 0.6.4; earlier releases counted duplicates and reported figures
several times higher on wallets with reused transparent addresses), scanning and
initialblockdownload stay truthy, and "synced" readiness holds /readyz at 503 with
reason="enhancing" until it drains to zero. The backlog is not restore-only, so under
"synced" this recurs after ordinary sends on a wallet holding many transparent UTXOs;
readiness = "scanned" (0.6.4) keeps the scan gate without it. See the operations
runbook.
sendmany collapses duplicate recipients
sendmany recipients arrive as a JSON object, and JSON parsing collapses duplicate keys
(last one wins) before zecd sees them, so Bitcoin Core's -8 Invalid parameter, duplicated address cannot be reproduced. Do not list the same address twice; combine the amounts into
one entry. z_sendmany takes an array of recipient objects instead, so it does detect and
reject duplicates with -8.
Since 0.7.0, [rpc] allow_duplicate_shielded_recipients (off by default) permits a repeated
shielded address on z_sendmany, for callers deliberately paying one address from several
memo-carrying outputs in a single transaction. Repeated transparent recipients stay refused
either way, and sendmany is unaffected, since its duplicates are gone before zecd sees them.
An embedded caller gets the same thing unconditionally through
Node::send.
listsinceblock cursors do not survive reorgs
zecd keeps only the current chain's scanned block hashes (a light wallet has no stale-header
index), so if a listsinceblock cursor block is reorged away, or is below the wallet
birthday, listsinceblock <hash> returns -5 Block not found. Bitcoin Core instead walks
back to the common ancestor and includes transactions from the fork point onward. Treat -5
as "cursor invalid": re-baseline with a parameterless listsinceblock and dedupe by txid
(idempotent payment processing is required for reorg safety anyway). See wallet
history.
Testing & conformance
How zecd is tested, layer by layer, and how to run the conformance suite against your own instance. The layers run cheapest first: offline unit tests, a wire-format conformance suite, stdlib smoke scripts, a full regtest end-to-end harness in CI, and manual live testnet.
The coverage bar: every RPC method in the dispatch table is asserted somewhere in the regtest
tier, either by scripts/conformance.py or by a harness test. Intentional divergences from
Bitcoin Core are listed in Compatibility.
Offline unit and integration tests
cargo test # offline unit + HTTP integration tests (over 200)
cargo test -- --include-ignored # also the slower ignored tests (actor spawn, prover load)
No network required. Coverage: amount conversion (decimal boundaries, no float drift),
auth (Basic, constant-time compare, cookie, bitcoind-style rpcauth salted HMAC), JSON-RPC 1.0
framing (single, batch, envelope, id), backend URL resolution, the Zebra client against an
in-process fake zebrad (every RPC mapping, real-block to CompactBlock conversion checked against
block-explorer ground truth, mempool poller dedupe), the full HTTP path via tower::oneshot
(401 on bad auth, 404 for method-not-found, batch as a 200 array, 503 when the work queue is
exhausted), and black-box CLI acceptance tests (tests/cli.rs).
Conformance suite: scripts/conformance.py
The "is it identical enough to bitcoind" proof, over 250 wire-format checks. It drives a running daemon
with the same client logic python-bitcoinrpc's AuthServiceProxy uses:
- HTTP Basic auth and the JSON-RPC 1.0 envelope (
{"result","error","id"}) - amounts decoded as
decimal.Decimal, asserting exact round-trips with no float drift - errors raised as
JSONRPCExceptionwith the expected Bitcoin Core code - batching (one POST, an array of responses)
It runs live in CI on every PR: the Regtest E2E workflow's funded test (regtest_funded.rs)
executes it against a real, funded regtest daemon, so conformance additions are exercised
end-to-end without testnet access. The original 49 checks were additionally validated against
the public testnet. With --passphrase (the funded e2e supplies its own) it also drives the
lock/unlock state machine (walletpassphrase/walletlock round-trips), leaving the wallet as
it was found.
Smoke scripts
scripts/rpc_smoke.py is a stdlib-only (no third-party dependencies) end-to-end check of the
wire format, amounts, and error codes over HTTP. scripts/rpc_send_smoke.py is a manual
spending smoke test: it needs two wallets with the default one funded, and validates the
walletlock/walletpassphrase gate, sendtoaddress, and sendmany by broadcasting real
transactions.
Regtest end-to-end harness
regtest-harness/ (a separate crate) brings up a real regtest node and drives the compiled
zecd binary over JSON-RPC. The Regtest E2E workflow runs the standard tier on every PR and
push to main as a matrix of node and upstream:
| Leg | Node | zecd's upstream | Tests |
|---|---|---|---|
zebra, zecd | zebrad | the node's JSON-RPC (zebra://) | the full list below |
zakura, zecd | zakurad | the node's JSON-RPC (zebra://) | the full list below |
zebra, zecd-lwd | zebrad | a lightwalletd in front of it | regtest_lwd plus the funded, transparent, shielding and merge binaries rerun in light mode |
Each node runs a pinned image on PRs and pushes; the weekly schedule runs every leg against
both the pinned image and latest as an upstream canary. Funding comes from a pinned
released zecd. The same workflow runs tests/embedded_regtest.rs, the
library end to end through Node::call.
Standard tier (always runs):
regtest_funded.rs: the funded flows. 0-conf mempool-stream receive (visible ingetunconfirmedbalance/listtransactions/listunspent minconf=0before the funding tx mines), a received ZIP-302 memo plus a send-memo round-trip, an enhancement guard (a from-birthday restore recovers the received memo purely via the enhancement step, since compact blocks carry no memos; see Architecture),sendtoaddressthrough confirmation, a two-outputsendmany, manualsendrawtransaction, outage and expiry sends with the health endpoints checked through the outage, the encryption state machine, the busy-server burst, and finallyconformance.pyagainst the live daemon.regtest_e2e.rs,regtest_binding.rs,regtest_sapling.rs,regtest_hang.rs: the base receive/spend/confirm cycle, account-to-keys binding, a two-pool (Sapling + Orchard) wallet including a tri-pool mixed-recipientsendmany, and recovery from an upstream that hangs without dying (SIGSTOP).regtest_migration.rs(the 0.7.0 data-directory layout migration on a funded wallet),regtest_ironwood.rs(NU6.3 pool structure), andregtest_orchard_v2_spend.rs(a note received in the Orchard pool before NU6.3 activates and spent after it).- The transparent binaries (see Transparent addresses):
regtest_transparent.rs(0-conf and confirmed t-address receive),regtest_transparent_t2t.rs(fully-transparent spend underAllowFullyTransparent, change stays transparent, default policy still refuses with-6),regtest_transparent_gap.rs(gap-limit andtransparent_initial_scanrecovery semantics on a from-seed restore),regtest_transparent_offline_restore.rs(a restore that never saw a receive and its spend live recovers both),regtest_transparent_preexpose_responsive.rs(read RPCs stay responsive during a deep initial-scan pre-exposure), andregtest_transparent_recovery_window.rs(beyond-gap issuance policy: warn-only vs fail-closed-4). regtest_shielding.rs(z_sendmanyfromaddresscoin control and the t->z shielding send),regtest_mergetoaddress.rs(consolidating a fragmented wallet),regtest_coinbase.rs(spending transparent and shielded coinbase), andregtest_fleet.rs(many view wallets in shards, each seeing only its own funds, history and addresses, across a restart).
Extended tier (ZECD_REGTEST_EXTENDED=1; weekly and on workflow dispatch, skipped in
seconds on PRs): a live reorg (zecd rewinds and follows the replacement chain), multiwallet
(/wallet/<name> routing, the removed label methods, one spending wallet alongside watch-only
replicas), watch-only UFVK wallets, graceful stop plus init --restore --birthday (same
first address, no phantom funds), the larger z_mergetoaddress cases, and on the light-mode
leg regtest_multibackend.rs (one daemon with a zebra-backed spending wallet beside a
lightwalletd-backed watch-only replica).
Stress tier (ZECD_REGTEST_STRESS=1; monthly cron or manual dispatch only): builds a large
note-fragmented wallet (default 256 notes) and asserts background sync stays live during a long
send with pipeline_proving on.
Live testnet
The final, manual layer and the only check against the real public network: fund a testnet wallet's Unified Address with TAZ, then verify the receive, send, and encryption flows as in the regtest tier, plus a funds-bearing restore (the regtest restore test is fundless; it proves the mnemonic round-trip via address determinism).
Running conformance against your own instance
Point the scripts at your daemon's RPC endpoint and credentials:
# Unit + offline tests (amount conversion, auth, JSON-RPC framing, HTTP status codes):
cargo test
# Also run the slower ignored tests (e.g. actor-spawn tests that load the bundled prover):
cargo test -- --include-ignored
# Conformance suite against a running daemon:
python3 scripts/conformance.py --url http://127.0.0.1:18232/ --user u --password p
# Stdlib-only smoke test of the wire format, amounts, and error codes over HTTP:
python3 scripts/rpc_smoke.py --url http://127.0.0.1:18232/ --user u --password p
# Spending smoke test (manual; needs two wallets, the default one funded):
python3 scripts/rpc_send_smoke.py --send-timeout 180
Add --passphrase <pass> to conformance.py for an encrypted wallet to exercise the
lock/unlock state machine. Exit codes are non-zero on any failed check. See
RPC overview for the envelope, auth, and error-code contract these scripts
assert.
Known limitations
Current limitations and their workarounds, plus the future work each one points at. Intentional design boundaries (what zecd will never do) are on the compatibility boundary page; this page is about gaps that may close.
listunspent outpoints are synthesized
Shielded notes are not bitcoin-style outpoints, so listunspent reports each unspent note
with a synthesized (txid, vout) identifying the shielded action that created it, and no
transparent scriptPubKey. The address field is the diversified address the note was
received on when recorded, and empty for change/internal notes. Treat the pair as a stable
opaque identifier for dedupe, not as something you can feed to transparent-UTXO tooling. See
wallet history and unspent.
Shielding is explicit, never automatic
Since 0.6.1 a received transparent UTXO can be moved into the shielded pool, by naming a
transparent source on z_sendmany under the
AllowRevealedSenders privacy policy. What is still missing:
- No auto-shielding. Nothing sweeps transparent receives into the shielded pool on its own;
every shield is a call an operator or an integration makes. Wiring librustzcash's
propose_shieldinginto a caught-up sync pass is the planned path to a background sweep. Since 0.7.0 the bulk call exists at least:z_mergetoaddresswith["ANY_TADDR"]and a shielded destination sweeps up to 50 non-coinbase UTXOs per call into one note. It is still something you have to call. Coinbase keeps its own route,z_shieldcoinbase, because consensus leaves it no other one: a transaction spending transparent coinbase may not have any transparent output, change included. - No mixed inputs. Transparent UTXOs and shielded notes cannot fund a single send together.
One source funds one send, so a shortfall on the named source is
-6rather than a top-up from the other pool. - No per-address shielded coin control. Notes are account-scoped, so a shielded
fromaddressnames the account rather than selecting that address's notes. Per-address selection works on the transparent side only.
Under the default policy a transparent-only wallet's sendtoaddress/sendmany still
returns -6: those methods take no fromaddress, so they only ever spend shielded notes.
No transparent receive reconciliation pass
Transparent receive discovery is a forward-only block-scan matcher, bounded by which
addresses are exposed at scan time. A receive on an address exposed only after its funding
block was scanned (out-of-order funding within the gap, with a small transparent_gap_limit)
is missed until a from-seed rescan. The planned follow-up is a periodic reconciliation pass
that batches all exposed addresses into Zebra's always-on transparent address index
(getaddressbalance/getaddressutxos) to cross-check the scanned balance and backfill
anything missed, kept off the per-block hot path. Workaround today: set [pools] transparent_initial_scan to your issuance high-water mark so the whole issued range is
pre-exposed before scanning, and keep transparent_gap_limit small, covering only the addresses
you have handed out that are still unfunded. Depth belongs to the initial scan, not the gap: the
gap window is re-derived on every recorded receive, so a large one stalls the scan (zecd warns
above 1000 and logs an error above 10000). See transparent support.
One account per wallet
Each wallet surfaces exactly one ZIP-32 account (the first in its database, or for a
fleet wallet its own account in a shared shard database);
multi-account-per-seed is not exposed, and Bitcoin Core's legacy string-account API is not
implemented. Workaround: use multiwallet. Each [wallets.<name>] entry is an independent
seed, database, and directory, addressed bitcoind-style at POST /wallet/<name> (see
multiwallet routing). Note the constraint that at most one loaded wallet may
hold spending keys; the rest must be watch-only.
Per-wallet send throughput is one actor
Sends to one wallet serialize on its single-writer actor (the cs_wallet analog), so
per-wallet throughput is one core's worth of Orchard proving. [spend] pipeline_proving
(default off) addresses the liveness half only: it runs a send's prove-and-sign off the
actor, so a long send no longer freezes background sync, reads of status, and mempool
processing for its whole duration. Sends still serialize (at most one uncommitted transaction
at a time), so it does not raise multi-send throughput. It engages only on the cached-Orchard
PCZT proving path (cache_proving_key = true, the default). True concurrent sends
(disjoint-note selection across in-flight sends) remain a design proposal, not something zecd
implements. Workaround: shard the hot float across multiple wallets; K actors already overlap
their proofs across cores with no shared state.
No -rpcthreads worker pool
bitcoind processes RPC on a configurable thread pool (-rpcthreads, default 16) in front of
a bounded queue (-rpcworkqueue, default 64). zecd does not replicate the pool model;
requests run on the async runtime, and the [rpc] work_queue semaphore (default 100)
provides the same user-visible bound: beyond it the server returns HTTP 503 Work queue depth exceeded, as bitcoind does when its queue fills. There is no thread-count knob to tune. See
conventions and wire format.
help introspection is a stub
help returns a static one-line summary and ignores its optional command argument, where
bitcoind lists every command and returns per-method usage for help <method>. Tooling that
discovers a node's surface by introspecting help gets nothing useful from zecd. Workaround:
the method index is the authoritative surface list, and probing a
method directly distinguishes implemented (any non--32601 response) from absent (-32601,
HTTP 404). One caveat: with an [rpc] allowed_methods safelist configured, a method blocked by
the safelist also returns -32601, so probing cannot tell a disabled method from an absent one
(the safelist deliberately discloses nothing about the surface it hides).
PostgreSQL wallet backend is blocked upstream
The wallet store is SQLite only (zcash_client_sqlite, published as zakura-client-sqlite
since 0.8.0). The one structural coupling blocking
an alternative backend is in reorg recovery: perform_rewind in src/sync/engine.rs must
match the concrete SqliteClientError::RequestedRewindInvalid error to retry a truncation at
a shallower bound, because zcash_client_backend's WalletWrite trait has no portable
"rewind invalid" error contract. Until upstream grows one (the TODO(upstream) on
perform_rewind tracks it), a PostgreSQL WalletDb backend cannot be wired in without
losing correct reorg recovery. No workaround; scale reads via the WAL-mode short-lived read
connections zecd already uses (see architecture).