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:
shielded-first (Orchard by default, 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.
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 there is no lightwalletd and no zaino to operate. See
A Zebra-only backend.
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. The default wallet is Orchard-only; Sapling
receivers and transparent (t-address) receiving/spending are enabled per wallet via
[pools]config. 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 single self-hosted Zebra upstream. One local zebrad over JSON-RPC; no lightwalletd, no zaino, no trusted third-party servers. See A Zebra-only backend.
- 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>. 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, 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_listtransactions,z_getaddressforaccount). 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.
- 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.
Build from source
zecd is not yet published on crates.io. Build from source, use the
release tarballs / .deb packages, or use the
Docker stack below.
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.
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/data.sqlite, pluskeys.tomlholding the age-encrypted mnemonic.
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.
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.
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. The
zecd.example.toml file in the repository root is a fully commented starting point.
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> | Directory holding this wallet's data.sqlite, keys.toml, and blocks/. |
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. |
At most one loaded wallet may hold spending keys; any number of watch-only (UFVK) wallets may run alongside it; see Watch-only wallets.
[backend]
The chain upstream: a single self-hosted Zebra node's JSON-RPC. See A Zebra-only backend for the deployment model and the cleartext-credential gate.
| Key | Type | Default | Description |
|---|---|---|---|
server | string | "zebra" | Upstream endpoint. "zebra" means a local zebrad at 127.0.0.1:8234 (mainnet) or 127.0.0.1:18234 (testnet/regtest); set zebrad's rpc.listen_addr accordingly. Any explicit zebra://host:port (or bare host:port) works. 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. |
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. |
allow_remote_cleartext | bool | false | Escape hatch: allow [zebra] credentials to travel in plaintext to a globally-routable host. Only set this when the hop is secured out-of-band (SSH/WireGuard tunnel, private overlay). |
[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. |
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. |
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. |
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 last funded receiving address a from-seed restore keeps scanning. Unlike shielded funds (always recoverable by trial decryption), transparent funds are only rediscovered within this window. Must be at least 1. |
transparent_initial_scan | integer | 0 | Initial scan depth: pre-expose external transparent indices 0..N at startup/restore so the receive scan covers all of them, independent of the (small) steady-state 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. |
[spend]
Send policy: confirmations, privacy, and the proving pipeline. See Privacy policy for the four-rung ladder and its enforcement points.
| Key | Type | Default | Description |
|---|---|---|---|
trusted_confirmations | integer | 3 | Confirmations before the wallet's own outputs (change) are spendable (ZIP 315 default). 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", or "AllowFullyTransparent". z_sendmany's per-call privacyPolicy overrides it. |
orchard_action_limit | integer | 50 | Cap on Orchard actions (max(inputs, outputs)) a single send may build; bounds memory/proving cost and yields a clean -8 for oversized sends. 0 disables the cap. |
cache_proving_key | bool | true | Build the Orchard proving key once at startup and prove sends through the PCZT path, instead of rebuilding the key (~seconds of keygen) on every transaction. 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 | "connected" | What /readyz gates on: "connected" (backend connected and its tip past the wallet's birthday; does not wait for scanning) or "synced" (additionally scanned to within max_scan_lag blocks of the tip). |
max_scan_lag | integer | 4 | Maximum chain_tip - fully_scanned gap at which /readyz reports ready. Only consulted in "synced" mode. |
[log]
| Key | Type | Default | Description |
|---|---|---|---|
level | string | "info" | Default tracing filter; overridden entirely by RUST_LOG when set. |
format | string | "text" | "text" (human-readable) or "json" (structured, for log aggregation). Logs go to stderr. |
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. |
--server <SERVER> | [backend] server | Chain upstream: zebra or zebra://host:port. |
--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. The global flags above
are accepted on every invocation and must precede the subcommand (zecd --datadir ./data --testnet init). init and export-ufvk honor the datadir/network/keys flags; the RPC
flags are inert for them. rpcauth runs before config resolution and ignores all of them.
| 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). |
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. | |
run | Run the JSON-RPC daemon (the default when no subcommand is given). |
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_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 full node: zecd is wallet-only and talks JSON-RPC to a self-hosted Zebra node (zebra://host:port, local-only plaintext). 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 | Orchard by default; 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 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 by default). 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_listunifiedreceivers | not supported | Decode the UA client-side with any ZIP-316 library; zecd keeps no recipient-side UA bookkeeping |
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 and async opid flow. Differences: fromaddress must be one of this wallet's own addresses (ANY_TADDR or a foreign address → -5); explicit numeric fee → -8 (pass null or omit); privacyPolicy maps onto zecd's four-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 | not supported | No auto-shielding path yet: a transparent receive can only be spent transparently (opt-in) or left in place. See Known limitations |
z_mergetoaddress | not supported | |
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, signmessage, keypoolrefill, lockunspent, listlockunspent | not supported | -32601 |
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 A Zebra-only backend 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, shielding
(
z_shieldcoinbase/z_mergetoaddress), migration, and label RPCs; replace with the alternatives in the mapping table or remove.
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(a future ironwood pool will slot in as a third name). - 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) and applies only to outgoing outputs; received and self-transfer entries show your own recorded address. 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 payment back to a multi-receiver UA you issued, deconstruct that UA into its per-pool receivers client-side and compare against the reported receiver; zecd keeps no recipient-side bookkeeping. 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.
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.
Spending: fully-transparent only, strictly opt-in
A received transparent UTXO can be spent to a transparent recipient with the change kept transparent (a normal bitcoin-style t→t send that never touches a shielded pool), but only under the top rung of the privacy policy ladder:
[spend] privacy_policy = "AllowFullyTransparent"in config, the only route forsendtoaddress/sendmany, which take no per-call policy argument; or- a
z_sendmanyprivacyPolicyofAllowFullyTransparent(or zcashd'sNoPrivacy, which maps onto the same rung).
This is the most revealing kind of send (recipient, amount, and funding inputs all public), hence
the explicit opt-in. Under the default policy (AllowRevealedRecipients) a transparent-only
wallet's send still fails with -6 (insufficient funds): transparent UTXOs are never selected
as inputs. (Paying to a transparent recipient from shielded funds works under the default
policy, with shielded change; FullPrivacy and AllowRevealedAmounts reject transparent
recipients with -8.)
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).
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.
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). On restore, librustzcash pre-exposes external indices 0..gap_limit; each funded
index found extends the window to funded_index + gap_limit; a run of gap_limit consecutive
unfunded indices ends the chain. A payment to index N is recovered iff N is exposed.
[pools] transparent_gap_limit (default 20, applied only to transparent-enabled wallets;
librustzcash's own default is 10) sets the external window. If you hand out addresses ahead of
funding (one per invoice, most never paid), size it to at least your maximum number of
outstanding-unfunded addresses, or a restore can silently miss a later payment to a high,
sparsely-funded index. Transparent change consumes the internal chain and is recovered via
the internal gap (librustzcash's default internal window; zecd only varies the external limit).
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 every funded address forever, so an exchange that assigns 10 000
addresses and sizes the gap to match scans 10 000 addresses past each 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.
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 to widen transparent_gap_limit /
transparent_initial_scan (or get a lower index funded) before addresses land outside it.
Not implemented
- Auto-shielding. Received transparent UTXOs are not automatically shielded into Orchard, and
a transparent receive cannot fund a shielded send. Transparent funds can be spent
transparently (under
AllowFullyTransparent) or left in place. - Mixed inputs. Transparent UTXOs and shielded notes cannot fund a single send together.
- Address-index reconciliation. No periodic cross-check of exposed addresses against Zebra's transparent address index to backfill receives the forward-only scan missed.
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 Zebra upstream 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.
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.
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's
zfnd/zebra:5.0.0tag is an example. Pin to a release you have verified; Zebra's flags can vary between versions. (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. Released images carry-arm64suffixed tags.
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: no CA bundle (the Zebra upstream is plaintext-local, no outbound TLS) |
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 target (x86_64-unknown-linux-musl and
aarch64-unknown-linux-musl, both static):
zecd-<version>-<target>.tar.gz+.sha256: the binary plusREADME.md,CHANGELOG.md, both license files, andzecd.example.toml. The tar is reproducible (sorted entries, fixed mtime, root-owned,gzip -n).zecd_<version>_<amd64|arm64>.deb+.sha256: a reproducible Debian package (scripts/build-deb.sh: fixed mtimes,--root-owner-group,SOURCE_DATE_EPOCHanchored; verified bit-for-bit).
Verify the checksum, then install:
sha256sum -c zecd_<version>_amd64.deb.sha256
sudo apt install ./zecd_<version>_amd64.deb # or _arm64.deb on ARM
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, 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's docker jobs push the GHCR images: amd64 under bare semver tags
(<major>.<minor>.<patch> and <major>.<minor>), arm64 under the same tags with an
-arm64 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:
"connected"(default): 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, so RPC clients can reach zecd while it catches up and readiness never flaps during a long sync. Reads may lag the tip."synced": 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 for hours (reasondistinguishessyncingfromenhancing). Use it when clients must not see stale balances or incomplete history.
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" # or "connected" (default)
max_scan_lag = 4 # only applies in "synced" mode
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
Give the startup probe headroom: with the default [spend] cache_proving_key = true,
zecd builds the Orchard proving key at startup, before the health listener binds. The
clean keygen costs about 4.5 s single-threaded (see docs/PROVING_KEY_CACHE.md), so
/healthz is not answerable for the first seconds of process life. After a restore or an
upgrade with a long offline gap, prefer readiness = "connected" or a generous
readiness budget; in "synced" mode a catching-up wallet is 503 until it reaches the tip.
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. Mechanism and A/B numbers are in benchmarks/orchard-libc-bench/FINDINGS.md. 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. |
Do not back up data.sqlite or blocks/. They are caches 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>/data.sqlite (+ -wal/-shm) | Cache: account, scan progress, balances, history. Rebuilt from keys.toml plus a rescan. | No. |
<dir>/blocks/ | Cache: downloaded compact blocks. Can grow large; fully re-derivable. | No. Exclude from every snapshot. |
<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.
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, or /status showing fully_scanned at the tip and pending_enhancements 0; the
default "connected" readiness reports ready long before that).
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. |
Readiness modes:
"connected"(default): ready once Zebra is connected and its tip is past the wallet's birthday. Does not wait for the scan, so readiness never flaps during a long catch-up; reads may lag the tip."synced": 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.
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) | Wait; watch pending_enhancements trend to zero. |
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, potentially hours of work after scan_progress hits 1.0. 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.
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; errors log at info and add
code/message). 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 prover init at boot with a
startupProbe / initialDelaySeconds. The port is unauthenticated by design and exposes
sync status only; keep it off the public internet anyway.
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".
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). Treat -5 as "cursor invalid": re-baseline with a parameterless listsinceblock,
dedupe by txid, and store the fresh lastblock. See
Wallet: history & unspent.
Upgrades
- Stop with SIGINT or SIGTERM (both are graceful: in-flight requests finish, new ones get
503). 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.
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.
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. Two 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) and zecd rpcauth.
Mainnet checklist
-
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 (43 methods, 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 |
| Utility | |||
| validateaddress | ✓ | same name, differs (transparent-only; shielded via z_validateaddress) | Validates every Zcash address kind; adds isvalid_orchard and receiver_types extension fields |
| 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 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); 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 |
| 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 |
| 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 |
| Async operations | |||
| z_sendmany | n/a | ✓ | Async: returns an opid, proves/broadcasts in the background; fromaddress must be the wallet's own (ANY_TADDR rejected -5); explicit fee answers -8 |
| z_getoperationstatus | n/a | ✓ | Non-destructive status objects; wallet-scoped |
| 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.
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...).
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 a Unified Address for the wallet's account in zcashd's syntax, optionally at an exact
diversifier index. Unlike getnewaddress, the returned object includes the diversifier index,
so a client can re-derive the same address deterministically later.
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 | Shielded pools for the UA: "sapling" and/or "orchard", each enabled on this wallet. Empty/omitted uses the configured default_receivers. "p2pkh"/"p2sh" and unknown tokens are rejected: this method never exposes a transparent receiver. |
| 3 | diversifier_index | number | next unused | Non-negative integer within the 11-byte (2^88) diversifier space. 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.
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 "p2pkh", "p2sh", or an unknown token; names a pool not enabled on this wallet |
| -3 | A receiver_types element is not a string |
| -8 | diversifier_index fractional, negative, non-numeric, or beyond the 2^88 space ("too large") |
| -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." |
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. Two deliberate divergences: zcashd accepts any
previously generated account number, zecd only account 0; and zcashd's default (and
accepted) receiver set includes p2pkh, while zecd is shielded-only here and rejects it
with -8. Use getnewaddress "" "transparent" for a t-address.
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.
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, but zecd
emits a fixed subset: no desc/parent_desc, no HD key path or pubkey fields, no
ischange/timestamp. isvalid_orchard/receiver_types/receivers_consistent 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 },
"descriptors": false,
"unlocked_until": 1751629200
}
balance/unconfirmed_balance/immature_balance: decimal ZEC, 8 places, under the wallet's confirmations policy.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.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>}, plus, whentransparent_initial_scanis set, aninitial_syncobject{"exposed": <n>, "total": <n>, "complete": <bool>}for polling the address pre-exposure. See Transparent support.
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
is an addition.
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). 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. zecd has no createwallet/loadwallet/
unloadwallet: the wallet set is fixed by configuration at startup, and at most one loaded
wallet may hold spending keys.
vs zcashd: no equivalent (zcashd is single-wallet).
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, 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 such as
your own change, 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
},
"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 (zecd has no mining, so this is not coinbase maturity as in Core; unconfirmed change from your own sends reports here).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.
vs Bitcoin Core: same shape minus Core master's mine.nonmempool and the optional
mine.used (zecd has no avoid-reuse flag). 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 whole-string equality on the address, not receiver-level: round-tripping the
exact value getnewaddress returned always works and sums receipts across all of that UA's
receivers (they share one diversifier index). A different UA that merely shares a receiver,
or a re-encoding with a different receiver subset, is a different string and contributes
nothing. 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 | any | ignored | Accepted for Bitcoin Core arity compatibility, ignored. |
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. include_immature_coinbase is ignored (zecd wallets hold no coinbase).
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 | any | ignored | Accepted, ignored. |
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 ZIP-302 memo bytes in hex,memoStrthe decoded text when the memo is valid UTF-8 text. Empty or absent memos add neither field.- 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. Received and self-transfer entries keep the wallet's own recorded address. See statelessness. 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.
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, ororchard.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,
"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.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 Zebra upstream. 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.
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.safeistruefor confirmed outputs and for unconfirmed outputs whose creating transaction the wallet itself authored (its own change); 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. 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.
Sending
Reference for the synchronous send methods sendtoaddress and sendmany. For the
asynchronous zcashd-style send (z_sendmany 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
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 the Sapling to Orchard turnstile. Neither method takes a
per-call policy argument; the config value applies. See the
privacy policy ladder.
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. A proposal that exceeds it
returns -8 naming whether inputs or outputs overflowed.
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 and the operation-tracking trio z_getoperationstatus /
z_getoperationresult / z_listoperationids. These four 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.
The operation model
z_sendmany validates its arguments, then returns 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 vs reap.
z_getoperationstatusis non-destructive: call it as often as you like.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. -
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
z_sendmanycalls 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. zecd spends from its single account,
so fromaddress is an ownership check, not a fund selector: any of the wallet's own
addresses works and selects the same funds.
Parameters
| # | Name | Type | Default | Description |
|---|---|---|---|---|
| 1 | fromaddress | string | required | One of this wallet's own addresses (unified, Sapling, or bare transparent). A foreign, undecodable, or hand-spliced address is -5. zcashd's ANY_TADDR sentinel is rejected with -5. |
| 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
four-rung ladder:
| Value | Effect in zecd |
|---|---|
FullPrivacy | No shielded leak: a transparent recipient is -8 up front, and a proposal that crosses the Sapling/Orchard turnstile is rejected. |
AllowRevealedAmounts | Turnstile crossing allowed (reveals the amount). A transparent recipient is still -8. |
AllowRevealedRecipients, AllowRevealedSenders, AllowLinkingAccountAddresses | Transparent recipients allowed, paid from shielded funds with shielded change. zcashd's sender-side rungs collapse here because zecd's shielded sends have no transparent sender to reveal. |
AllowFullyTransparent, NoPrivacy | Additionally permits a fully transparent spend: funding the send from transparent UTXOs with kept-transparent change (see Transparent support). |
LegacyCompat or omitted | The wallet's configured [spend] privacy_policy (default AllowRevealedRecipients). |
| anything else | -8 |
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.
Errors (synchronous)
| Code | When |
|---|---|
| -1 | fromaddress missing or null |
| -3 | fromaddress, minconf, or a memo field is the wrong JSON type |
| -5 | fromaddress is ANY_TADDR, undecodable, not this wallet's, or a Unified Address with inconsistently spliced receivers |
| -8 | 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 | 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 and only gates ownership; zcashd selects funds from that specific address or account, and acceptsANY_TADDRto sweep non-coinbase transparent UTXOs across the wallet (zecd rejects it with-5).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. The sender-side policies are accepted but collapse ontoAllowRevealedRecipients. - 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"},
])
while True:
status = rpc.z_getoperationstatus([opid])[0]
if status["status"] in ("success", "failed"):
break
time.sleep(1)
rpc.z_getoperationresult([opid]) # reap it
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 (z_shieldcoinbase, z_mergetoaddress, the Sapling migration); zecd
only ever has z_sendmany 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_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. All five are read-only and 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. 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.
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 Zebra upstream it derives chain data from. 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, 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.
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 Zebra 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.
Architecture
How zecd is put together: a per-wallet single-writer actor that owns the wallet database, a read path that bypasses it, a sync engine sliced into batches so the actor stays responsive, and the background loops (enhancement, mempool, rebroadcast) that hang off the sync loop. 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) |
+------+----------------------+
| ChainSource (AnySource -> ZebraSource)
v
zebrad JSON-RPC (zebra://host:port)
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 10,000 compact
blocks, then returns to the actor loop so queued commands run between batches. 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. 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, hours of work after the block scan already reached the tip. So
enhance_step is bounded like the scan: at most 16 requests per call (ENHANCE_BATCH), with
commands serviced and the shrinking backlog republished between batches. 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.
Mempool poller (0-conf)
Once caught up, the actor subscribes to the upstream mempool stream: 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 built once in daemon::run and
shared by Arc across all actors. The fused librustzcash path (flag off) rebuilds the proving
key inline on every transaction, about 4.5 s of key generation single-threaded (on the order
of 1 s on a fast multicore node). Analysis and benchmarks: docs/PROVING_KEY_CACHE.md in the
repo. Proving runs under tokio::task::block_in_place, so it does not stall the async
runtime, but it does hold the actor.
[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 and ZebraSource (see Zebra backend) |
sync/engine.rs | one-batch-per-call scan driver, reorg recovery, block-cache cleanup |
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). 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 at startup and shared across wallets | 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.
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:
a from-seed restore rediscovers a transparent receive only if its address index falls within
transparent_gap_limit of the last funded index, or is pre-exposed by
transparent_initial_scan. 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). 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, and it applies only to outgoing outputs; received
and self-transfer entries keep your own recorded address. 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: to match a payment back to a multi-receiver UA you issued, deconstruct that UA into its per-pool receivers and compare against the displayed receiver. zecd keeps no recipient-side mapping itself, consistent with everything above.
A Zebra-only backend
zecd talks to exactly one upstream: a self-hosted Zebra full node, over its stock JSON-RPC. There is no lightwalletd and no zaino in the stack. This page explains why, what zecd derives from the node itself, and the connection and security model of that one hop.
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. AnySource is today a single-variant enum holding ZebraSource; a
future backend (an embedded zaino service, say) is one more variant and one more impl, with no
changes above the trait.
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.
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: 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.
Each wallet actor dials the endpoint itself. 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: the actor drops the client and the next operation
reconnects. 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.
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.
Privacy policy
Every zecd send is governed by a privacy policy: a four-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 Orchard notes by default (optionally Sapling notes and, opt-in, transparent UTXOs; see addresses and transparent support). A fully shielded send within one pool reveals nothing about amount, sender, or recipient. Three 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 the Sapling and Orchard 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. Under the default Orchard-only configuration this happens when an Orchard-funded send pays a Sapling address. - Funding a send directly from transparent UTXOs. A t-to-t send with kept-transparent change never touches a shielded pool: 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), and neither opt-in implies a willingness to spend transparently (leak 3).
The four rungs
SendPrivacy (src/config.rs) has four variants, strictest first. Each rung permits everything
the rung above it permits, plus one more disclosure.
| Policy | Transparent recipient | Sapling/Orchard crossing | Transparent-funded (t-to-t) spend |
|---|---|---|---|
FullPrivacy | rejected, -8 | rejected, -8 | no |
AllowRevealedAmounts | rejected, -8 | allowed | no |
AllowRevealedRecipients (default) | allowed | allowed | no |
AllowFullyTransparent | allowed (see caveat) | allowed | 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 both shielded pools is-8from the actor, with a message naming the policy and the config knob to change. -
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"). -
AllowFullyTransparent: additionally permits the fully transparent spend. When (and only when) every recipient of a send is a bare transparent address, the actor funds it directly from the wallet's transparent UTXOs and keeps the change transparent (actor::transparent_only_recipientsgates the dispatch todo_send_transparent). Any shielded recipient in the request, or any weaker policy, falls through to the shielded proposal path. See transparent support for the spend mechanics. There is no transparent-to-shielded auto-shielding; see limitations.Caveat (current build): the ladder's design is that
AllowFullyTransparentpermits a bare transparent recipient (that is the whole point of the t-to-t path). The shipping code does not yet honor this at the RPC pre-check:SendPrivacy::allows_transparent_recipient()(src/config.rs) returns true only forAllowRevealedRecipients, sobuild_paymentrejects a transparent-only recipient with-8even underAllowFullyTransparent, before the actor'sdo_send_transparentdispatch is reached. This is a known regression against the design documented here; treat the table'sAllowFullyTransparenttransparent-recipient cell as the intended behavior, not the current one.
Where the policy is set
The wallet-wide policy is [spend] privacy_policy in the config file
(see configuration):
[spend]
# "FullPrivacy" | "AllowRevealedAmounts" | "AllowRevealedRecipients" | "AllowFullyTransparent"
privacy_policy = "AllowRevealedRecipients"
The four names are case-sensitive; anything else (including zcashd-only names such as
NoPrivacy or AllowRevealedSenders) is a startup error, not an RPC error.
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 (AllowRevealedSenders, AllowLinkingAccountAddresses) that only matter for a
wallet spending from user-visible transparent source addresses. zecd's shielded proposal path
spends shielded notes, so those rungs have no sender to reveal and collapse onto
AllowRevealedRecipients. 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 | AllowRevealedRecipients |
AllowLinkingAccountAddresses | AllowRevealedRecipients |
AllowFullyTransparent | AllowFullyTransparent |
NoPrivacy | AllowFullyTransparent |
| anything else | -8 |
AllowFullyTransparent and NoPrivacy are the two zcashd policies that permit funding a send
from transparent UTXOs with kept-transparent change, so both map to zecd's fourth rung.
Enforcement: two halves
The two shielded leaks are checked at different times because they are knowable at different times.
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.
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 both
shielded pools (single_pool_violated: transparent || (sapling && orchard)). Inputs, payment
outputs, and change all count. 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.
The AllowFullyTransparent dispatch is a third decision point in actor::do_send, but it is a
routing choice, not a rejection: the transparent-funded build path is taken only under that
policy 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 four-rung 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.
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, nothing else. No CA bundle is needed because zecd's only upstream is a local Zebra node over plaintext HTTP; the daemon makes no outbound TLS connections. - 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. Measurements are inbenchmarks/orchard-libc-bench/FINDINGS.md.
.dockerignore is an allowlist (Cargo.toml, Cargo.lock, rust-toolchain.toml, src,
vendor), 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 arm64 images
carry -arm64 suffixed tags on GHCR.
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. - Writes a
.sha256sidecar for each artifact and attaches everything to a draft GitHub release (a human reviews and publishes).
Separate docker and docker-arm64 jobs in the same workflow push the GHCR images (the
amd64 push uses rewrite-timestamp=true and forced compression so the pushed layers are
deterministic too, and attaches SBOM and provenance attestations). 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.
The vendored i18n-embed-fl patch
Reproducibility was validated empirically with clean double-builds, which surfaced one
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)
emits fluent message arguments in std HashMap iteration order. That order is randomly
seeded per rustc process, so one reachable call site in age's error formatting flipped its
argument order (about 26 bytes of .text) on a per-build coin flip.
The fix landed upstream in i18n-embed-fl 0.10 (kellpossible/cargo-i18n#151), but age (up
to 0.11.3, the latest) requires 0.9, which cargo cannot bump across semver. So the repo
vendors the released 0.9.4 with that fix backported at vendor/i18n-embed-fl, applied via
the repo's only [patch.crates-io] entry in Cargo.toml. All librustzcash crates stay on
released crates.io versions; this is the single patched dependency, and it is removed once an
age release depends on i18n-embed-fl 0.10+.
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 (whose .sha256 sidecar
covers the archive itself, so also compare the extracted zecd). 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 Zebra. Plaintext local JSON-RPC. Zebra is fully trusted for the chain view: balances, confirmation counts, incoming payments, and mempool visibility are whatever Zebra serves. zecd validates response shapes, not consensus. This is why the deployment model is self-hosted-only: you point zecd at a node you run, not a public endpoint. See the Zebra backend. Zebra never sees key material; a compromised node cannot steal funds, only lie about the chain.
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.
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).
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, 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 the
Sapling to Orchard turnstile (spending one pool, paying the other) reveals the crossed amount
via valueBalance. Both are permitted under the default policy, AllowRevealedRecipients.
The [spend] privacy_policy setting (and z_sendmany's per-call privacyPolicy) is a
four-rung ladder that lets you forbid either leak, or additionally opt in to fully
transparent spends. 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. 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 take hours after scan_progress hits 1.0.
The backlog is surfaced as pending_enhancements on GET /status, scanning and
initialblockdownload stay truthy, and "synced" readiness holds /readyz at 503 with
reason="enhancing" until it drains to zero. 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.
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 zebrad and drives the compiled
zecd binary over JSON-RPC. The Regtest E2E workflow runs the standard tier on every PR and
push to main; a weekly schedule reruns everything against both the pinned Zebra image and
zfnd/zebra:latest as an upstream canary.
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_proving_cache.rs,regtest_sapling.rs,regtest_hang.rs: the base receive/spend/confirm cycle against thezebra://upstream, account-to-keys binding, both proving paths, a two-pool (Sapling + Orchard) wallet including a tri-pool mixed-recipientsendmany, and recovery from an upstream that hangs without dying (SIGSTOP).- 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_sendmany_t2t.rs(the same spend driven throughsendmany, two transparent recipients in one tx),regtest_transparent_gap.rs(gap-limit andtransparent_initial_scanrecovery semantics on a from-seed restore),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).
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, and graceful stop plus init --restore --birthday (same
first address, no phantom funds).
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.
Transparent spending is fully-transparent only
With transparent support enabled, a transparent UTXO can be spent to
a transparent recipient with the change kept transparent, but only under the explicit
AllowFullyTransparent privacy policy. Two directions are not
implemented:
- No auto-shielding. Received transparent UTXOs are not automatically shielded into
Orchard, so a transparent receive cannot feed a shielded send. librustzcash's
propose_shieldingexists and wiring it into a caught-up sync pass is the planned path. - No mixed inputs. Transparent UTXOs and shielded notes cannot fund a single send together.
Until then, treat transparent as a receive-only on-ramp (funds stay put until you spend them
transparently), or opt in to AllowFullyTransparent for t-to-t spends. Under the default
policy, a transparent-only wallet's sendtoaddress/sendmany returns -6.
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 size transparent_gap_limit to your maximum
outstanding-unfunded address count. See transparent support.
One account per wallet
Each wallet surfaces exactly one ZIP-32 account (the first in its 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 in
docs/CONCURRENT_SENDS.md. 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). 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).