Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-bitcoinrpc uses (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 --restore recovers 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/.deb packages. 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_sendmany plus 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 getpeerinfo reports 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

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, plus keys.toml holding 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.

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:

  1. CLI flag (some flags read an environment variable as a fallback; see Environment variables)
  2. TOML key
  3. 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

KeyTypeDefaultDescription
networkstring"test"Chain to run on: "main"/"mainnet", "test"/"testnet", or "regtest". Overridden by --network, --testnet, --regtest.
datadirpath"./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_walletstring"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.

KeyTypeDefaultDescription
dirpath<datadir>/<name>Directory holding this wallet's data.sqlite, keys.toml, and blocks/.
keys_filepath<dir>/keys.tomlLocation 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.
poolsarray of stringglobal [pools] enabledOverride of the enabled shielded pools for this wallet.
default_receiversarray of stringsee belowOverride 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.
transparentboolglobal valueOverride of [pools] transparent.
transparent_defaultboolglobal valueOverride of [pools] transparent_default.
transparent_gap_limitintegerglobal valueOverride of [pools] transparent_gap_limit.
transparent_initial_scanintegerglobal valueOverride of [pools] transparent_initial_scan.
transparent_allow_beyond_recovery_windowboolglobal valueOverride of [pools] transparent_allow_beyond_recovery_window.
transparent_gap_warn_thresholdintegerglobal valueOverride 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.

KeyTypeDefaultDescription
serverstring"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_secsinteger10Per-attempt dial timeout (seconds); clamped to at least 1.
reconnect_base_secsinteger1Reconnect backoff base delay (seconds); clamped to at least 1. Backoff is exponential with full jitter.
reconnect_max_secsinteger60Reconnect backoff cap (seconds); clamped to at least reconnect_base_secs.
rfc1918_is_localbooltrueTreat 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_cleartextboolfalseEscape 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.

KeyTypeDefaultDescription
rpc_userstringunsetRPC username for zebrad.
rpc_passwordstringunsetRPC password for zebrad.
rpc_cookiepathunsetPath 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).

KeyTypeDefaultDescription
bindstring (IP)"127.0.0.1"Listen address. Overridden by --rpcbind.
portinteger8232 main / 18232 test+regtestListen port. Overridden by --rpcport.
userstringunsetHTTP Basic auth username. Overridden by --rpcuser.
passwordstringunsetHTTP 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_filepathunsetRead 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.
autharray 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.
cookiefilepath<datadir>/.cookieWhere 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_queueinteger100Max concurrent in-flight requests before returning HTTP 503 (Bitcoin Core's -rpcworkqueue); clamped to at least 1.
allowed_methodsarray 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).

KeyTypeDefaultDescription
age_identitypath<datadir>/identity.txtage identity file used to decrypt the wallet seed for unattended sending (the identity-file custody model). Overridden by --age-identity / ZECD_AGE_IDENTITY.
auto_unlockbooltrueDecrypt the seed at startup so sends need no walletpassphrase (identity-file wallets only; passphrase-encrypted wallets always start locked).
keys_filepathunsetLocation 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_keysbooltrueWhen 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.

KeyTypeDefaultDescription
enabledarray 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_receiversarray of string= enabledReceivers 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).
transparentboolfalseAllow bare transparent (t1…/tm…) receiving addresses via getnewaddress "" "transparent". Off keeps zecd shielded-only (address_type = "transparent" is rejected with -8).
transparent_defaultboolfalseMake a bare transparent address the no-argument getnewaddress default. Requires transparent = true (validated at startup).
transparent_gap_limitinteger20External 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_scaninteger0Initial 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_windowbooltrueWhat 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_thresholdinteger5Warn 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]

KeyTypeDefaultDescription
interval_secsinteger20How often to poll Zebra for new blocks (seconds); clamped to at least 1.
rebroadcast_secsinteger60How 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.

KeyTypeDefaultDescription
trusted_confirmationsinteger3Confirmations before the wallet's own outputs (change) are spendable (ZIP 315 default). Clamped to at least 1.
untrusted_confirmationsinteger10Confirmations 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_policystring"AllowRevealedRecipients"What sends may reveal on-chain: "FullPrivacy", "AllowRevealedAmounts", "AllowRevealedRecipients", or "AllowFullyTransparent". z_sendmany's per-call privacyPolicy overrides it.
orchard_action_limitinteger50Cap 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_keybooltrueBuild 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_provingboolfalseRun 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.

KeyTypeDefaultDescription
enabledbooltrueServe /healthz, /readyz, /status.
bindstring (IP)"127.0.0.1"Probe listen address (0.0.0.0 to expose off-host).
portinteger9233Probe listen port (all networks).
readinessstring"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_laginteger4Maximum chain_tip - fully_scanned gap at which /readyz reports ready. Only consulted in "synced" mode.

[log]

KeyTypeDefaultDescription
levelstring"info"Default tracing filter; overridden entirely by RUST_LOG when set.
formatstring"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.

FlagOverridesDescription
--conf <FILE>file locationPath to the TOML config (default <datadir>/zecd.toml).
--datadir <DIR>datadirData directory. Falls back to ZECD_DATADIR, then the file, then ./zecd-data.
--testnetnetworkUse testnet.
--regtestnetworkUse regtest (a local Zebra regtest chain). Wins over --testnet and --network.
--network <NET>network"main", "test", or "regtest".
--rpcbind <ADDR>[rpc] bindRPC bind address.
--rpcport <PORT>[rpc] portRPC port.
--rpcuser <USER>[rpc] userRPC username.
--rpcpassword <PASS>[rpc] password / password_fileRPC 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] authAdditional rpcauth credential; may be repeated.
--server <SERVER>[backend] serverChain upstream: zebra or zebra://host:port.
--age-identity <FILE>[keys] age_identityage identity file; also readable from ZECD_AGE_IDENTITY.
--keys-file <FILE>[keys] keys_fileDefault wallet's keys.toml path; also readable from ZECD_KEYS_FILE. An explicit [wallets.<name>] keys_file still wins.
--versionPrint 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.

SubcommandFlagsDescription
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.
runRun the JSON-RPC daemon (the default when no subcommand is given).

Environment variables

VariableUsed byDescription
ZECD_DATADIRdaemon + subcommandsData directory. Precedence: --datadir > ZECD_DATADIR > file datadir > ./zecd-data.
ZECD_RPC_PASSWORDdaemonRPC password; equivalent to --rpcpassword and wins over [rpc] password_file and inline password. Preferred over the flag (not visible in ps).
ZECD_KEYS_FILEdaemon + initDefault wallet's keys.toml path; equivalent to --keys-file.
ZECD_AGE_IDENTITYdaemon + initage identity file path; equivalent to --age-identity.
ZECD_MNEMONICinit --restoreThe seed phrase for a non-interactive restore. Takes precedence over --mnemonic-file and stdin.
ZECD_WALLET_PASSPHRASEinit --encryptThe at-rest passphrase for a non-interactive encrypted init; otherwise prompted twice on stdin.
ZECD_ALLOW_CORE_DUMPSdaemon + subcommandsSet 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_LOGdaemon + subcommandsStandard 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_sendmany plus the operation-tracking trio z_getoperationstatus / z_getoperationresult / z_listoperationids: zcashd's asynchronous send pattern, kept so opid-based client code keeps working.
  • z_listtransactions: per-output history with pool, 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

zcashdzecd
Validator + wallet in one process: zcashd validates the chain, indexes it, speaks P2P, and serves the walletWallet 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 poolsOrchard 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 worksZIP-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_REQUIREDBitcoin 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, backupwalletSeed-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 echoStateless: 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

zcashdzecdNotes
z_getnewaccountnot supportedzecd is one account per wallet, created at zecd init. Need more accounts → more wallets ([wallets.<name>], one spending wallet max)
z_getaddressforaccountz_getaddressforaccountSame 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)getnewaddressReturns 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), listaddresseslistreceivedbyaddress 0 trueinclude_empty=true enumerates every address the wallet has generated, with received totals
z_listunifiedreceiversnot supportedDecode the UA client-side with any ZIP-316 library; zecd keeps no recipient-side UA bookkeeping

Balances

zcashdzecdNotes
z_gettotalbalance (deprecated)getbalance / getbalancesWallet-level totals; getbalances splits trusted / untrusted_pending / immature
z_getbalanceforaccountgetbalancesOne account per wallet, so the wallet totals are the account totals
z_getbalance (deprecated; per-address)getreceivedbyaddresszecd has no per-address balance (all diversified addresses fund one account); per-address received totals exist
z_getbalanceforviewingkeywatch-only walletzecd export-ufvk on the spender, zecd init --ufvk elsewhere, then getbalance there. See Watch-only wallets
getbalancegetbalanceSpendable under the ZIP-315 policy; explicit minconf overrides per call
getunconfirmedbalancegetunconfirmedbalanceIncludes 0-conf mempool receives

History and unspent

zcashdzecdNotes
listtransactionslisttransactionsCore shape plus memo/memoStr; label fields always ""
z_viewtransactiongettransaction / z_listtransactionsgettransaction is the Core shape extended with memo fields; z_listtransactions carries zcashd's per-output vocabulary (pool, amountZat, outindex, …)
z_listreceivedbyaddresslistreceivedbyaddress / z_listtransactionsCore totals per address, or per-output entries with memos
z_listunspentlistunspentOne entry per unspent note with synthesized (txid, vout); address empty for change
listsinceblocklistsinceblockCursor semantics; removed always []
z_getnotescountnot supported

Sending

zcashdzecdNotes
z_sendmanyz_sendmanySame 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, sendmanysendtoaddress, sendmanySynchronous bitcoind-style sends: build, prove, broadcast, return the txid. Extra trailing hex memo parameter on sendtoaddress. See Sending
z_getoperationstatus / z_getoperationresult / z_listoperationidssameSame semantics, including destructive one-shot z_getoperationresult. Wallet-scoped and in-memory (lost on restart, as in zcashd)
z_shieldcoinbasenot supportedNo auto-shielding path yet: a transparent receive can only be spent transparently (opt-in) or left in place. See Known limitations
z_mergetoaddressnot supported
z_setmigration / z_getmigrationstatusnot supportedThe Sapling-migration machinery has no zecd counterpart
z_converttexnot supported

Keys, backup, and wallet management

zcashdzecdNotes
backupwallet, z_exportwallet, z_importwalletnot supportedThe 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, importpubkeynot supportedNo per-address key import/export by design; all addresses derive from the seed
z_exportviewingkeyzecd export-ufvk (CLI)Prints the wallet's Unified Full Viewing Key; not an RPC
z_importviewingkeyzecd init --ufvk <key> (CLI)Creates a watch-only wallet
encryptwallet, walletpassphrasechangenot supportedEncryption is set once at zecd init --encrypt; the passphrase never crosses the network
walletpassphrase, walletlocksameBitcoin Core semantics (-13 locked send, -14 wrong passphrase, -15 unencrypted)
walletconfirmbackupnot supportedzcashd's -18 "backup required" flow does not exist
getrawchangeaddress, addmultisigaddress, signmessage, keypoolrefill, lockunspent, listlockunspentnot supported-32601
settxfeedispatched, always -8Fees 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.

  1. Set up the target: a synced Zebra node, then zecd init (record the mnemonic offline) and start the daemon; see the Quickstart.

  2. 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…).

  3. On zcashd, send everything to that UA with z_sendmany. Note zcashd's default privacyPolicy is LegacyCompat, which treats any transaction involving a UA as FullPrivacy, 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).

  4. 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 init created.
  • 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/scanning state after the block scan reaches the tip. Watch getwalletinfo.scanning and the /readyz health endpoint (Operations runbook).
  • Sends take a few seconds. Every shielded send builds a zero-knowledge proof, so sendtoaddress/sendmany hold the HTTP connection for a few seconds; raise client timeouts accordingly. z_sendmany keeps 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:

  1. 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.
  2. Addresses: replace z_getnewaccount + z_getaddressforaccount (or z_getnewaddress) with getnewaddress; expect a UA. Drop any label arguments: getnewaddress rejects them with -8, and the label methods are gone (-32601).
  3. Balances: replace z_gettotalbalance / z_getbalanceforaccount with getbalance / getbalances. Keep parsing amounts as exact decimals (e.g. Python Decimal): they are bare JSON numbers with 8 decimal places, never floats.
  4. Fees: delete every fee argument. Pass null (or omit) for z_sendmany's fee; an explicit number is -8. Remove settxfee, subtractfeefromamount, and fee_rate usage.
  5. 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").
  6. Async sends: opid flows keep working, but budget for the per-wallet cap of 16 unfinished operations (-4 beyond it) and remember z_getoperationresult consumes each result exactly once.
  7. Timeouts: raise HTTP client timeouts for sendtoaddress / sendmany (proving takes seconds).
  8. Confirmation assumptions: zecd's default spend policy is ZIP-315 (3 trusted / 10 untrusted) rather than a flat minconf=10; pass minconf explicitly where your logic depends on it.
  9. 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.ismine recognizes 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 sapling and orchard (a future ironwood pool will slot in as a third name).
  • The default ([pools] omitted entirely) is Orchard-only.
  • default_receivers must be a subset of enabled; naming a disabled pool is a startup error. default_receivers omitted defaults to enabled.
  • 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:

CallReturns
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:

CodeWhen
-5Unknown 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)
-8Requested shielded receiver set is not a subset of the wallet's enabled pools
-8"transparent" requested on a wallet without [pools] transparent = true
-8Non-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 sapling to 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... or zs... 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 for sendtoaddress/sendmany, which take no per-call policy argument; or
  • a z_sendmany privacyPolicy of AllowFullyTransparent (or zcashd's NoPrivacy, 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's transparent.initial_sync object, {"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): getnewaddress issues 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 below transparent_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 -4 with 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-ufvk is deliberately exempt from the exclusive datadir lock that zecd init and 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
  • --ufvk conflicts with --restore and --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 --ufvk needs 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.toml with 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.

SurfaceBehavior on a watch-only wallet
getwalletinfo.private_keys_enabledfalse. This is the watch-only signal, as in Core. (unlocked_until is absent: the wallet is not encrypted, there is nothing to lock.)
getnewaddressWorks: 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.
getaddressinfoUnchanged: 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:

  1. 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 --ufvk instead. Watch-only inits are exempt: any number are allowed.
  2. 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.0 tag is an example. Pin to a release you have verified; Zebra's flags can vary between versions. (Zebra tags have no v prefix.)
  • 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 musl zecd, deterministic flags (SOURCE_DATE_EPOCH=1, codegen-units=1, --build-id=none), and a bare scratch runtime. 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 via RUSTUP_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 -arm64 suffixed 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:

PropertyValue
Binary/usr/local/bin/zecd (static musl, no shell or libc in the image)
Entrypointzecd, default args --datadir /var/lib/zecd
User10001:10001 (unprivileged, non-root)
Workdir / datadir/var/lib/zecd (writable by the runtime user)
Exposed ports8232, 18232 (JSON-RPC mainnet/testnet), 9233 (health)
Basescratch: 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 plus README.md, CHANGELOG.md, both license files, and zecd.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_EPOCH anchored; 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 runs zecd --datadir /var/lib/zecd as the zecd user 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

PortServiceProtocolNotes
8232zecd JSON-RPC (mainnet)HTTP, Basic/cookie authBitcoin-convention port; spend authority, keep private
18232zecd JSON-RPC (testnet/regtest)HTTP, Basic/cookie authAlso used for mainnet in the compose stack (config choice)
9233zecd healthHTTP, unauthenticated/healthz, /readyz, /status
8234Zebra JSON-RPC (mainnet)HTTPWhat server = "zebra" expects; set rpc.listen_addr here
18234Zebra JSON-RPC (testnet/regtest)HTTPTestnet 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 with ready, locked, a per-wallet map, and (when not ready) a reason of actor_down, upstream_down, enhancing, or syncing.
  • 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, within max_scan_lag blocks of the tip (default 4), and its transaction-enhancement backlog has drained. Strict: a from-birthday restore stays not-ready for hours (reason distinguishes syncing from enhancing). 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.

ArtifactWhereWhat it protects
24-word mnemonicshown once by zecd initThe funds. Record offline (paper/HSM). Loss of the server without it is loss of funds.
Birthday heightinside keys.toml; also record it with the mnemonicMakes 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 pointsThe 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.txtDecrypts 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>:

PathRoleShip it?
<dir>/keys.tomlSecret: encrypted seed + birthday/networkYes. Mount as a Secret; relocate with keys_file / ZECD_KEYS_FILE.
identity.txtSecret: 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>/.cookieEphemeral RPC cookie, minted at startup, removed on clean shutdownNo.

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 > inline password). Prefer the env var or password_file: a password on the command line is visible to any local user via ps, and zecd warns at startup when it is passed that way.
  • keys.toml location: 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 /status reports locked: true. The rebuild runs at the first walletpassphrase, 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 with zecd init --ufvk against 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):

EndpointSemantics
GET /healthzLiveness. 200 ok while the process runs.
GET /readyzReadiness, 200/503, gated by [health] readiness.
GET /statusJSON 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_lag blocks 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:

reasonMeaningAction
upstream_downZebra unreachablePage someone.
actor_downA wallet's writer actor diedRestart the process.
enhancingScanned to tip, still backfilling memos ("synced" mode only)Wait; watch pending_enhancements trend to zero.
syncingNormal block catch-upWait.

"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:

  • /readyz 503 with reason=upstream_down for more than 5 minutes.
  • /status sync 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.

  • sendtoaddress and sendmany are 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_sendmany returns 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 (or gettransaction) 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 -6 rather than double-paying.
  • An expired unmined tx reports confirmations: -1 and abandoned: true. Treat it as failed and safe to re-send.
  • Rapid back-to-back sends exhaust spendable notes and return -6 until change confirms (freshly created shielded change is not spendable unmined). The -6 message 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

  1. Stop with SIGINT or SIGTERM (both are graceful: in-flight requests finish, new ones get 503). The stop RPC is regtest-only, so a stray RPC call cannot take down a production daemon.
  2. Replace the binary or pull the new image.
  3. 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 the CHANGE-ME placeholder).
  • RPC bound to 127.0.0.1 or 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 --encrypt so spending requires walletpassphrase with a timeout. See Key custody.
  • Mnemonic and birthday recorded offline; restore procedure tested on testnet.
  • Local Zebra full node configured (server = "zebra" or zebra://host:port); Docker images pinned to verified releases.
  • /readyz wired into the orchestrator with a startupProbe covering initial sync; alerts on upstream_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": []}
  • method is required; a missing or non-string method is rejected with -32600.
  • params is a positional array, as with Bitcoin Core. It may be omitted or null (treated as empty). Handlers read positional arguments only, so pass an array; an object-shaped params is accepted at the framing level but yields zero positional arguments. Any other type is -32600.
  • id is echoed back verbatim, including on errors (null when 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

EndpointPurpose
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] auth entries (or repeated --rpcauth): bitcoind-style salted credentials in the <user>:<salt>$<hmac-sha256 hex> format of share/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 chose
    

    Either 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 alongside auth entries too, matching bitcoind's behavior whenever rpcpassword is empty. A local process reads the file and authenticates as __cookie__ (how bitcoin-cli talks 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.

ConditionRPC codeHTTP
successn/a200
insufficient funds-6500
wallet locked (needs walletpassphrase)-13500
tx rejected by network-26500
bad/unknown address or txid-5500
invalid parameter-8500
unknown /wallet/<name>-18500
invalid request-32600400
method not found (or safelisted out)-32601404
parse error-32700500
auth failuren/a401 (+ WWW-Authenticate, 250 ms delay)
batch (any mix of outcomes)per item200
over work-queue / shutting downn/a503 (text/plain body)
request body over 2 MiBn/a413

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.

MethodBitcoin Corezcashdzecd
Control
stopsame name, differs (stops any network)Regtest only; mainnet/testnet answer -32601. Stop a live node with SIGINT/SIGTERM
uptimen/aSeconds since the daemon started
helpStatic one-line summary; the method argument is ignored (see below)
getrpcinfon/aactive_commands with elapsed microseconds; logpath empty (logs go to stderr)
Network
getnetworkinfozecd version/subversion; connections is 0 or 1 (the Zebra upstream is the only "peer")
getconnectioncount0 or 1
getpeerinfoAt most one entry, describing the Zebra upstream, plus conn_state/syncing extensions
pingNo-op success; there is no P2P ping to measure
Blockchain
getblockchaininfoblocks = fully scanned height, headers = tip; initialblockdownload true while scanning or enhancing
getblockcountFully scanned height, so getblockhash(getblockcount()) always answers
getbestblockhashHash at the fully scanned height
getblockhashFrom the wallet's scanned blocks; pre-birthday or beyond-tip heights answer -8
getblockheaderVerbose only, compact-block fields; verbose=false answers -8
Utility
validateaddresssame name, differs (transparent-only; shielded via z_validateaddress)Validates every Zcash address kind; adds isvalid_orchard and receiver_types extension fields
settxfeeremovedsame name, differs (functional in zcashd)Always -8: fees are ZIP-317, never client-settable
estimatesmartfeen/aInert stub: conventional ZIP-317 rate (0.00001) plus a blocks echo
estimatefeeremovedn/a (removed in zcashd 5.6.0)Same stub rate, kept for old clients
getmempoolinfoFixed 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
sendrawtransactionBroadcasts caller-built bytes through Zebra; maxfeerate ignored
Wallet: reads
getbalancesame name, differs (transparent-only; z_getbalanceforaccount for shielded)Spendable balance under the ZIP-315 confirmations policy; explicit minconf overrides it per call
getbalancesn/a (z_getbalanceforaccount, z_gettotalbalance)mine.trusted/untrusted_pending/immature plus lastprocessedblock; no watchonly object
getunconfirmedbalanceremovedsame name, differs (transparent-only)Incoming funds below the confirmations policy, including 0-conf via the mempool stream
getwalletinfobitcoind shape; scanning progress, unlocked_until when encrypted, private_keys_enabled:false when watch-only
getaddressinfon/a (validateaddress / z_validateaddress)ismine is cryptographic (viewing-key attribution); labels always []; iswatchonly always false, as in Core master
listtransactionssame name, differs (transparent history only)Core categories and fields; adds memo/memoStr; outgoing address is the single receiver actually paid
z_listtransactionsn/an/a (no equivalent; listtransactions is transparent-only)zcashd-style per-output history vocabulary (no account arg)
listsinceblocksame 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
gettransactionsame name, differs (z_viewtransaction for shielded detail)amount/fee/confirmations/details/hex; foreign tx hex fetched from Zebra on demand
listunspentsame name, differs (transparent UTXOs; z_listunspent for notes)One entry per unspent note; synthesized txid/vout; address empty for change
getreceivedbyaddresssame name, differs (transparent; z_listreceivedbyaddress for shielded)Totals over diversified receiving addresses; change never counted
listreceivedbyaddresssame name, differs (transparent)listreceivedbyaddress 0 true enumerates every generated address; each entry's label is ""
listwalletsn/a (single wallet)Names from [wallets.<name>] config
Wallet: writes
getnewaddresssame 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
sendtoaddresssame name, differs (transparent-only)Synchronous shielded send returning a txid; ZIP-317 fee; subtractfeefromamount/fee_rate answer -8; extra trailing memo param
sendmanysame name, differs (transparent-only)Same, multi-recipient; dummy "" first arg as in Core
walletpassphraseUnlock with a timeout (capped at 100,000,000 seconds, as in Core); wrong passphrase -14, unencrypted wallet -15
walletlockZeroizes the seed immediately, even mid-proof; unencrypted wallet -15
Async operations
z_sendmanyn/aAsync: returns an opid, proves/broadcasts in the background; fromaddress must be the wallet's own (ANY_TADDR rejected -5); explicit fee answers -8
z_getoperationstatusn/aNon-destructive status objects; wallet-scoped
z_getoperationresultn/aFinished operations only; destructive one-shot reap, matching zcashd
z_listoperationidsn/aThe wallet's operation ids; optional status filter
Address derivation
z_getaddressforaccountn/aDerive 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. Embedded label/labels fields 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 at zecd 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. sendrawtransaction still 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

#NameTypeDefaultDescription
1labelstring""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.
2address_typestringwallet defaultPer-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

CodeWhen
-8Non-empty label argument
-5Unknown address_type token; "transparent" combined with shielded pool names; otherwise-invalid pool list
-8address_type names a shielded pool not enabled on this wallet
-8address_type is "transparent" but [pools] transparent is off
-4Transparent 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

#NameTypeDefaultDescription
1accountnumberrequiredMust be 0. zecd has one account per wallet; select another wallet via /wallet/<name> instead.
2receiver_typesarray of stringswallet defaultShielded 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.
3diversifier_indexnumbernext unusedNon-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

CodeWhen
-1account missing
-8account outside zcashd's range 0 <= account <= (2^31)-2, or not an integer
-4account in range but not 0 ("has not been generated"; zecd wallets have a single account)
-8receiver_types not an array; contains "p2pkh", "p2sh", or an unknown token; names a pool not enabled on this wallet
-3A receiver_types element is not a string
-8diversifier_index fractional, negative, non-numeric, or beyond the 2^88 space ("too large")
-4Index already exposed with different receiver types ("was already generated with different receiver types.")
-4No 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

#NameTypeDefaultDescription
1addressstringrequiredThe 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: equals ismine, including on watch-only wallets (Core's definition ignores the lack of private keys; the wallet-level signal is getwalletinfo.private_keys_enabled).
  • iswatchonly: always false, matching Core master where the field is deprecated.
  • isvalid_orchard, receiver_types: zecd extensions mirroring validateaddress: 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. false flags 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

CodeWhen
-1address missing
-5Address 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.
  • keypoolsize is always 1 and keypoolsize_hd_internal always 0: addresses are diversified on demand from the account key; there is no key pool.
  • paytxfee is always 0 (fees are ZIP-317, never client-settable).
  • private_keys_enabled: false for a watch-only (imported UFVK) wallet; the wallet-level cannot-sign signal, as with Core's disable_private_keys wallets.
  • scanning: an object (duration always 0, progress the block-scan ratio in [0,1]) while scanning or while the enhancement backlog is nonzero; false when idle.
  • descriptors: always false.
  • unlocked_until: present only for passphrase-encrypted wallets; the unix time the wallet auto-relocks, or 0 while 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, when transparent_initial_scan is set, an initial_sync object {"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

#NameTypeDefaultDescription
1passphrasestringrequiredThe wallet passphrase. Must be non-empty.
2timeoutnumberrequiredSeconds to stay unlocked. Non-negative integer; values above 100,000,000 (~3.17 years) are silently clamped, as in Bitcoin Core.

Result: null.

Errors

CodeWhen
-1passphrase missing
-3passphrase not a string
-8Empty passphrase; missing or non-integer timeout; negative timeout ("Timeout cannot be negative.")
-14Wrong passphrase ("Error: The wallet passphrase entered was incorrect.")
-15Wallet is not passphrase-encrypted (identity-file or watch-only wallets): "Error: running with an unencrypted wallet, but walletpassphrase was called."
-4Decrypted 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

CodeWhen
-15Wallet 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

#NameTypeDefaultDescription
1dummystringomittedLegacy account argument. Must be excluded, null, or "*"; any other string is -32.
2minconfnumberwallet policyOverrides 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.
3include_watchonlyanyignoredAccepted for Bitcoin Core arity compatibility, ignored.
4avoid_reuseanyignoredAccepted 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

CodeWhen
-32dummy is a string other than "*"
-3dummy 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; equals getbalance.
  • untrusted_pending: received but not yet spendable under the policy; equals getunconfirmedbalance. 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 as getblockcount. 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

#NameTypeDefaultDescription
1addressstringrequiredAn address belonging to this wallet (UA or bare transparent).
2minconfnumber1Count 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.
3include_immature_coinbaseanyignoredAccepted for Bitcoin Core arity compatibility, ignored.

Unlike getbalance, minconf 0 is meaningful here: this method totals receipts, not spendability.

Result

0.50000000

Errors

CodeWhen
-5Address does not parse for this network
-5Spliced/inconsistent Unified Address
-4Valid address that does not belong to this wallet (Address not found in wallet)
-3Non-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

#NameTypeDefaultDescription
1minconfnumber1Count only transactions with at least this many confirmations (same semantics as getreceivedbyaddress).
2include_emptyboolfalseAlso list generated addresses that have received nothing.
3include_watchonlyanyignoredAccepted, ignored (deprecated and unused in Core master too).
4address_filterstringnoneReturn only the entry for this exact address string.
5include_immature_coinbaseanyignoredAccepted, ignored.

Result

[
  {
    "address": "u1v0qh8pw9qm4h2v0negtfzrwhtjzfhgh0jcs9tzkjxg7xkpxkfhz5c4tj0nzqyjrmzgcqnyu7q6cx",
    "amount": 0.50000000,
    "confirmations": 4,
    "label": "",
    "txids": [
      "1f5e1f7b9d0f0c2f0a3f4f4b8f9f6d3e2c1b0a998877665544332211ffeeddcc"
    ]
  }
]
  • amount: total received by the address at minconf, 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

CodeWhen
-3Non-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 send and receive are 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 and listunspent. Core's coinbase categories (generate/immature/orphan) never appear.
  • Confirmations are anchored to the wallet's fully-scanned height, the same height getblockcount reports, so getblockcount() - blockheight + 1 agrees with the field. An expired unmined transaction reports -1 (it can never confirm; Core's "conflicted" signal, so pollers terminate).
  • time / timereceived are 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 reports 0 until the mempool stream re-observes it or it mines. The two fields are always equal.
  • memo / memoStr are extension fields beyond Bitcoin Core's set, using zcashd's z_viewtransaction names: memo is the raw ZIP-302 memo bytes in hex, memoStr the decoded text when the memo is valid UTF-8 text. Empty or absent memos add neither field.
  • Outgoing address is 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 bare t/zs address, 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.
  • label is always "" and walletconflicts always []: zecd keeps no address labels and tracks no conflict set. bip125-replaceable is 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

#NameTypeDefaultDescription
1labelstring"*""*" 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.
2countnumeric10Number of entries to return.
3skipnumeric0Number of most-recent entries to skip before taking count.
4include_watchonlybooleanfalseAccepted 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) and abandoned appear on send entries only. abandoned is true for an expired unmined send.
  • Mined entries carry blockhash/blockheight/blockindex/blocktime; unmined entries carry trusted instead (true iff the wallet authored the transaction and it can still be mined).

Errors

CodeWhen
-8Negative count or skip.
-3count/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

#NameTypeDefaultDescription
1countnumeric10Number of entries to return.
2fromnumeric0Number of most-recent entries to skip.
3includeWatchonlybooleanfalseAccepted 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
  }
]
  • pool is transparent, sapling, or orchard.
  • status is mined, waiting, or expired. zcashd's fourth value expiringsoon is never emitted.
  • amountZat is an integer (zatoshis), negative on sends; outgoing is true on the send side of a self-transfer pair.
  • change is always false (change outputs are filtered before this point; the key is kept for shape compatibility with zcashd's walletInternal/change convention).
  • expiryheight appears when the transaction has a non-zero expiry; fee/feeZat (negative) on send entries only; memo/memoStr on shielded outputs as elsewhere.

Errors

CodeWhen
-8Negative count or from.
-3count/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

#NameTypeDefaultDescription
1blockhashstringomittedList activity since this block (exclusive). Omitted or "" lists everything.
2target_confirmationsnumeric1Which depth's block hash to return as lastblock (must be >= 1). Not a filter.
3include_watchonlybooleanfalseAccepted and ignored.
4include_removedbooleantrueAccepted 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

CodeWhen
-5blockhash is not a 64-character hex string ("Block not found").
-8target_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

#NameTypeDefaultDescription
1txidstringrequiredThe transaction id (display hex).
2include_watchonlybooleanfalseAccepted and ignored.
3verbosebooleanfalseAccepted 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
}
  • amount is 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 to 0.
  • 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.
  • details has one entry per non-change output and category, with the listtransactions entry shape minus the per-transaction fields (confirmations, txid, times), which sit at the top level. memo/memoStr appear per detail entry.
  • hex is 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 trusted follow the shared conventions above.

Errors

CodeWhen
-5Unknown 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

#NameTypeDefaultDescription
1minconfnumeric1Minimum confirmations. 0 includes unconfirmed outputs fed by the mempool stream.
2maxconfnumeric9999999Maximum confirmations.
3addressesarraynoneKeep only outputs received on these addresses. Each entry must be a valid address (-5); duplicates are -8.
4include_unsafebooleantrueInclude outputs not safe to spend (see safe below).
5query_optionsobjectnoneAccepted 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: vout is 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.
  • address is the receiving diversified address when the wallet recorded one. Change and internal notes report "", which an addresses filter never matches, so a filtered call naturally excludes change.
  • safe is true for 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 is safe: false. include_unsafe: false hides those.
  • spendable and solvable are always true. 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 -6 until it reaches policy depth (see Sending). A transparent UTXO is additionally spendable only under the AllowFullyTransparent privacy policy; under the default policy it is receive-only (see Transparent support).

Errors

CodeWhen
-5Invalid address in the addresses filter.
-8Duplicated address in the filter.
-3minconf/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):

CodeWhen
-1Missing required argument; more positional arguments than the method accepts
-3Amount 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
-5Unparseable address, or an address for the wrong network
-6Insufficient spendable funds (see the enrichment above)
-8subtractfeefromamount/subtractfeefrom or fee_rate engaged; privacy-policy rejection of a transparent-only recipient; orchard_action_limit exceeded
-4Watch-only wallet (Error: Private keys are disabled for this wallet); other wallet-level build failures
-13Passphrase-encrypted wallet is locked (walletpassphrase first)
-18/wallet/<name> names no loaded wallet
-26The 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

#NameTypeDefaultDescription
1addressstringrequiredRecipient: unified, Sapling, or transparent address
2amountnumber or stringrequiredDecimal ZEC, 8 places; zero is rejected (-3)
3commentstringomittedIgnored: zecd persists no local metadata (see statelessness)
4comment_tostringomittedIgnored, as above
5subtractfeefromamountbooleanfalseRejected with -8 if true (fees are ZIP-317, paid by the sender)
6replaceablebooleanomittedIgnored (no RBF in Zcash)
7conf_targetnumberomittedIgnored (no fee estimator; ZIP-317 buys next-block inclusion)
8estimate_modestringomittedIgnored
9avoid_reusebooleanomittedIgnored (shielded receiving addresses are diversified)
10fee_ratenumberomittedRejected with -8 if set
11verbosebooleanfalseReturn an object with fee_reason instead of a bare txid
12memostring (hex)omittedzecd 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)

CodeWhen
-3memo present but not a string
-8memo 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

#NameTypeDefaultDescription
1dummystring""Legacy placeholder; zecd ignores it entirely (Core rejects a non-empty value)
2amountsobjectrequired{"address": amount, ...}; amounts are decimal ZEC, 8 places, number or string; zero is rejected (-3)
3minconfnumberomittedIgnored dummy value, as in Core master
4commentstringomittedIgnored (not stored)
5subtractfeefromarrayomittedRejected with -8 if non-empty
6replaceablebooleanomittedIgnored
7conf_targetnumberomittedIgnored
8estimate_modestringomittedIgnored
9fee_ratenumberomittedRejected with -8 if set
10verbosebooleanfalseReturn 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)

CodeWhen
-3amounts present but not an object
-8amounts 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_getoperationstatus is non-destructive: call it as often as you like. z_getoperationresult is 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_sendmany calls are rejected with -4 back-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

#NameTypeDefaultDescription
1fromaddressstringrequiredOne 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.
2amountsarrayrequiredNon-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.
3minconfnumberwallet policyOnly 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.
4feenullnullMust be omitted or null. Fees are always ZIP-317, computed by the wallet; any explicit value (including 0) is -8.
5privacyPolicystringLegacyCompatPer-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:

ValueEffect in zecd
FullPrivacyNo shielded leak: a transparent recipient is -8 up front, and a proposal that crosses the Sapling/Orchard turnstile is rejected.
AllowRevealedAmountsTurnstile crossing allowed (reveals the amount). A transparent recipient is still -8.
AllowRevealedRecipients, AllowRevealedSenders, AllowLinkingAccountAddressesTransparent 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, NoPrivacyAdditionally permits a fully transparent spend: funding the send from transparent UTXOs with kept-transparent change (see Transparent support).
LegacyCompat or omittedThe 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)

CodeWhen
-1fromaddress missing or null
-3fromaddress, minconf, or a memo field is the wrong JSON type
-5fromaddress is ANY_TADDR, undecodable, not this wallet's, or a Unified Address with inconsistently spliced receivers
-8amounts 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
-4the 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:

  • fromaddress must be this wallet's own address and only gates ownership; zcashd selects funds from that specific address or account, and accepts ANY_TADDR to sweep non-coinbase transparent UTXOs across the wallet (zecd rejects it with -5).
  • fee may be an explicit amount in zcashd (default null means ZIP-317); zecd rejects any explicit fee with -8.
  • minconf defaults 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 LegacyCompat default resolves to FullPrivacy when a Unified Address is involved and AllowFullyTransparent otherwise; zecd's resolves to the configured [spend] privacy_policy. The sender-side policies are accepted but collapse onto AllowRevealedRecipients.
  • 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

#NameTypeDefaultDescription
1operationidarrayall operationsArray 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/params echo the originating call (zcashd's context info). The echoed minconf is the raw argument, shown as 1 when it was omitted; the effective default when omitted is the wallet's configured policy.
  • status is one of queued, executing, success, failed (cancelled never occurs in zecd).
  • On failed, an error object {"code": .., "message": ..} replaces result; a -6 insufficient-funds send lands here with the same enriched message the synchronous sends return.
  • result and execution_secs (whole seconds of wall-clock execution) appear only on success.

Errors

CodeWhen
-8argument 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

#NameTypeDefaultDescription
1operationidarrayall finished operationsArray 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

CodeWhen
-8argument 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

#NameTypeDefaultDescription
1statusstringnoneFilter 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 / getblockcount is the fully-scanned height: the height up to which balances and history are accurate.
  • headers is 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, or regtest.
  • blocks: fully-scanned height (0 before anything is scanned).
  • headers: Zebra's chain tip as last seen; equals blocks if no tip is known yet.
  • bestblockhash: hash of the blocks block; empty string in the brief window before anything has been scanned.
  • difficulty: stub, always 1.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 (mediantime falls back to time near the wallet birthday; both fall back to 0 before anything is scanned).
  • verificationprogress: scan progress in [0, 1].
  • initialblockdownload: true while 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 reports true; only a wallet ready to serve full history reports false.
  • size_on_disk: stub, always 0.
  • pruned: always false.
  • 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

CodeWhen
-1Nothing 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

#NameTypeDefaultDescription
1heightnumberrequiredBlock height. Must be an integer in the wallet's scanned range (or the known tip).

Result

"0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc"

Errors

CodeWhen
-1height omitted
-3height is not an integer ("Block height must be an integer")
-8height 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

#NameTypeDefaultDescription
1blockhashstringrequiredBlock hash, 64 hex characters (display order).
2verbosebooleantrueMust be true (or omitted). false is -8.

Result

{
  "hash": "0000000001a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819aabbcc",
  "confirmations": 4,
  "height": 2912997,
  "time": 1751598912,
  "mediantime": 1751598500,
  "previousblockhash": "00000000027f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a1908ddeeff",
  "nextblockhash": "00000000039e8d7c6b5a49382716059e8d7c6b5a49382716059e8d7c6b112233"
}
  • confirmations counts from the fully-scanned height (the tip header reports 1).
  • mediantime is the median time past over the last up-to-11 scanned blocks.
  • previousblockhash / nextblockhash appear only when the neighbor is in the wallet's scan range; nextblockhash is absent on the scanned tip (Core likewise omits previousblockhash on genesis and nextblockhash on the tip).

Errors

CodeWhen
-8blockhash is not 64 characters or not hex (Core's ParseHashV messages)
-8verbose is false ("verbose=false is not supported: a light wallet does not store serialized block headers")
-3verbose is not a boolean
-5Unknown 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

#NameTypeDefaultDescription
1txidstringrequiredTransaction id, 64 hex characters (display order).
2verboseboolean or numberfalseBitcoin Core passes a boolean, zcashd an integer; both are accepted. Any nonzero integer means verbose.
3blockhashstringmust be unsetRejected 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, hex are as in Bitcoin Core. The segwit-only hash/vsize/weight are absent (no Zcash equivalent).
  • authdigest, overwintered always present; versiongroupid and expiryheight only on Overwinter+ (v3+) transactions.
  • vin entries: txid, vout, scriptSig{asm, hex}, sequence; a coinbase input is {coinbase, sequence}. Signature pushes in scriptSig.asm render with their sighash type decoded (<sig>[ALL]), as in zcashd.
  • vout entries: value (decimal ZEC, 8 places), valueZat and valueSat (zcashd's two zatoshi aliases), n, scriptPubKey{asm, hex, type} plus reqSigs/addresses for standard scripts (absent for nulldata/nonstandard, matching zcashd).
  • The Sapling section (valueBalance, valueBalanceZat, vShieldedSpend, vShieldedOutput, and bindingSig when 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).
  • orchard is present on v5 transactions (empty actions with zero balance when there is no bundle). A positive valueBalance is 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).
  • height and confirmations appear when the mined height is known (from the wallet record or from Zebra); confirmations counts from the wallet's fully-scanned height. blockhash/time/blocktime come 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

CodeWhen
-1txid omitted
-8txid is not 64 hex characters (Core's ParseHashV messages), or blockhash is set
-3verbose is neither boolean nor integer
-5Neither the wallet nor Zebra knows the txid ("No such mempool or blockchain transaction")
-22The 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

#NameTypeDefaultDescription
1hexstringstringrequiredThe serialized transaction, hex-encoded.
2maxfeerateanyignoredAccepted 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

CodeWhen
-1hexstring omitted; or the upstream is unreachable / the broadcast fails in transport
-22The hex does not decode to a transaction ("TX decode failed")
-26Zebra examined and rejected the transaction ("transaction rejected (code N): reason")
-27The 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.0 encodes to 100).
  • 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 current PROTOCOL_VERSION (170150).
  • connections / connections_out: 1 while the Zebra upstream is reachable, else 0. connections_in is always 0.
  • 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). networkactive is always true.
  • 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] server endpoint, rendered as zebra-rpc <host>:<port>.
  • conn_state (zecd extension): the upstream connection state, syncing or ready. (The third state, down, never appears here: a down upstream yields the empty array instead. All three states also ride on the /status health endpoint.)
  • syncing (zecd extension): true while the block scan is behind the tip or the post-scan transaction-enhancement backlog is still draining, so it agrees with conn_state and with getblockchaininfo.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

#NameTypeDefaultDescription
1addressstringrequiredThe 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...88ac P2PKH, a914...87 P2SH). Shielded addresses have no script form, so the field is the empty string.
  • isscript: true for P2SH. iswitness: always false (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 a u1... 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. true means a well-formed UA this wallet could have issued; false flags 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_locations is always the empty array (no per-character diagnosis).

Ownership is not reported here; use getaddressinfo for ismine.

Errors

CodeWhen
-1address argument missing
-3address 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

#NameTypeDefaultDescription
1conf_targetnumeric6Echoed back as blocks; has no effect
2estimate_modestringignoredAccepted 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

CodeWhen
-8always: "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

CodeWhen
-32601called 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; duration is 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 via tracing, not to a debug.log file.

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.rsCLI shim; wiring: datadir lock, proving-key build, actor spawn, RPC + health servers, shutdown
config.rs, pools.rsTOML + 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.rsWalletHandle, WalletCommand, SyncStatus, the multiwallet registry
wallet/actor.rsthe single-writer actor: sync/enhance/mempool/rebroadcast loops, sends, proving
wallet/read.rsread-only queries over short-lived WAL connections
wallet/open.rs, store.rs, keys.rs, binding.rsDB open/init + WAL, keys.toml, seed custody, account-to-keys binding
chain/the ChainSource trait and ZebraSource (see Zebra backend)
sync/engine.rsone-batch-per-call scan driver, reorg recovery, block-cache cleanup
operations.rsthe async-operation registry behind z_sendmany
health.rs/healthz, /readyz, /status on a separate port
error.rs, amount.rs, address.rsBitcoin Core error codes + HTTP mapping; exact fixed-point amounts; address parsing
lock.rs, hardening.rs, backoff.rs, state.rsdatadir 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 getnewaddress hands 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, or listreceivedbylabel returns method-not-found (-32601, HTTP 404), exactly like any unknown method.
  • getnewaddress rejects a non-empty label argument with -8 ("labels are not supported (zecd is stateless); call getnewaddress without a label").
  • The embedded label/labels fields on the general history and address RPCs (getaddressinfo, listtransactions, listsinceblock, gettransaction details, listreceivedbyaddress) are retained for Bitcoin Core shape conformance but are always "" or []. A listtransactions label 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 getnewaddress hands 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.ismine still 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:

StateWhat it isOn restart
Tx first-seen timesWall-clock stamp when the mempool stream first stores a pending tx (wallet::FirstSeen), surfaced as time/timereceived until a block time supersedes itRebuilt 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 registryz_sendmany operation IDs and results (async operations)Lost, matching zcashd's behavior; broadcast transactions are unaffected
Orchard proving keyProvingKeyCache, built once at startup and shared across walletsRebuilt 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):

OperationZebra JSON-RPC
latest_block, server_infogetblockchaininfo (height, best hash, chain)
compact_block_rangegetblock verbosity=0 + getblock verbosity=1 (see below)
tree_statez_gettreestate (finalState hex, repackaged as the protobuf TreeState)
subtree_rootsz_getsubtreesbyindex (per pool, from index 0)
broadcast_txsendrawtransaction
fetch_txgetrawtransaction verbose=1
transparent_txidsgetaddresstxids (batched addresses, height range)
get_address_utxosgetaddressutxos (batched addresses)
subscribe_mempoolgetrawmempool + 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 = false for a strict loopback-only posture.
  • Any other hostname fails closed. The gate does no DNS lookup, so a name like zebra.example.com is 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 (default false). 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:

  1. 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.
  2. 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 valueBalance field (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.
  3. 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.

PolicyTransparent recipientSapling/Orchard crossingTransparent-funded (t-to-t) spend
FullPrivacyrejected, -8rejected, -8no
AllowRevealedAmountsrejected, -8allowedno
AllowRevealedRecipients (default)allowedallowedno
AllowFullyTransparentallowed (see caveat)allowedyes

Details per rung:

  • FullPrivacy: only fully shielded sends confined to a single shielded pool. A recipient with no shielded receiver is -8 at the RPC layer; a proposal whose inputs, outputs, or change would touch a transparent component or both shielded pools is -8 from the actor, with a message naming the policy and the config knob to change.

  • AllowRevealedAmounts: permits the turnstile crossing (revealing the amount via valueBalance) but still rejects a transparent recipient with -8. This rung is the reason the ladder exists: collapsing it onto AllowRevealedRecipients silently 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_recipients gates the dispatch to do_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 AllowFullyTransparent permits 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 for AllowRevealedRecipients, so build_payment rejects a transparent-only recipient with -8 even under AllowFullyTransparent, before the actor's do_send_transparent dispatch is reached. This is a known regression against the design documented here; treat the table's AllowFullyTransparent transparent-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 privacyPolicyzecd rung
omitted, LegacyCompatthe configured [spend] privacy_policy
FullPrivacyFullPrivacy
AllowRevealedAmountsAllowRevealedAmounts
AllowRevealedRecipientsAllowRevealedRecipients
AllowRevealedSendersAllowRevealedRecipients
AllowLinkingAccountAddressesAllowRevealedRecipients
AllowFullyTransparentAllowFullyTransparent
NoPrivacyAllowFullyTransparent
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 committed Cargo.lock (cargo fetch --locked, cargo install --frozen).
  • The runtime stage is a bare scratch image: the static zecd binary, empty /var/lib/zecd and /tmp skeleton dirs, user 10001: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 -secure variant (MI_SECURE: guard pages, canary free-lists) adds back the heap-exploitation mitigations that replacing malloc-ng would otherwise drop, for under 4% on the proving path. Native glibc dev builds leave the feature off. Measurements are in benchmarks/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, overriding rust-toolchain.toml's floating channel = "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:

  1. Builds the Dockerfile's export stage and extracts the binary. The published binaries therefore inherit the reproducible image pipeline; there is no separate cargo build that could diverge from the images.
  2. Packages a reproducible .tar.gz: tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@1", then gzip -9n (no embedded name or timestamp).
  3. Builds a reproducible .deb via scripts/build-deb.sh, which wraps the pre-built binary without reintroducing nondeterminism: every file's mtime is clamped to SOURCE_DATE_EPOCH (1), dpkg-deb --root-owner-group pins ownership to root:root, the changelog is compressed with gzip -n, and dpkg-deb (1.18.11 or later) honors SOURCE_DATE_EPOCH for 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.
  4. Writes a .sha256 sidecar 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:

AssetWhat it grantsWhere it lives
Seed / mnemonicSpend authority over all funds, forever. The root secret.Age-encrypted in keys.toml; decrypted into process memory when unlocked.
RPC credentialsSpend 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 datadirThe 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 credentialsAccess 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

AdversaryCan attemptMitigations
Network attacker on the RPC hopSniff 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 credentialFull 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 ZebraServe 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).
  • mlock covers 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_methods is 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=0 stops 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:

ModelAt restStartup statePassphrase RPCs
Identity file (default)Mnemonic age-encrypted to the recipient of identity.txtUnlocked (with default auto_unlock = true)-15
Passphrase (init --encrypt)Mnemonic age-encrypted with a passphrase (scrypt)Locked; sends -13walletpassphrase / walletlock
Watch-only (init --ufvk)No seed anywhere; seedless keys.tomln/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 for timeout seconds. 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_until reports the relock unix time (0 when locked); the field appears only for passphrase-encrypted wallets, like Core.
  • walletlock zeroizes 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.

  • mlock on 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, not mlockall (which would have to fit the whole RSS, proving keys included, under RLIMIT_MEMLOCK and typically fails in containers). A denied mlock (for example an unprivileged container with RLIMIT_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 value 1; anything else keeps hardening on) opts out for crash debugging. The opt-out does not affect the seed mlock.
  • Non-dumpable (PR_SET_DUMPABLE = 0, Linux only), which also blocks ptrace attach and /proc/<pid>/mem reads 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:

  1. zecd init refuses a wallet database that already contains an account.
  2. zecd init records the new account's Unified Full Viewing Key in keys.toml (the ufvk field, 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.
  3. Every startup compares the database account's UFVK against the pin. A mismatch is the typed BindingMismatch error and is fatal for the whole daemon: tampering evidence, unlike ordinary per-wallet startup failures, which merely skip the wallet. A keys.toml from before the pin existed is backfilled trust-on-first-use.
  4. 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 -4 and the wallet stays locked). This retroactively validates a trust-on-first-use pin and catches a keys.toml and 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:

SecretSources (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 locationZECD_KEYS_FILE / --keys-file / keys_file (global for the default wallet, or per [wallets.<name>])
age identityZECD_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, salted rpcauth entries ([rpc] auth = ["<user>:<salt>$<hmac-sha256>"], generated with the built-in zecd rpcauth <user> [password], no external rpcauth.py needed), or the generated cookie file (<datadir>/.cookie, mode 0600) when no user/password pair is set. Prefer rpcauth or 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_methods shrinks 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) and subtractfeefrom (sendmany): would change who pays the fee.
  • fee_rate on sendtoaddress/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:

  • sendtoaddress takes a hex-encoded memo as an extra trailing parameter, after verbose. 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) carry memo (hex) and memoStr (decoded text) fields when an output has one; entries without a memo omit the fields entirely.
  • z_sendmany permits a zero-valued output, zcashd's memo-only-send pattern (a shielded recipient, amount: 0, and a memo). The Bitcoin-Core-dialect sendtoaddress/sendmany keep 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 JSONRPCException with 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 in getunconfirmedbalance/listtransactions/listunspent minconf=0 before 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), sendtoaddress through confirmation, a two-output sendmany, manual sendrawtransaction, outage and expiry sends with the health endpoints checked through the outage, the encryption state machine, the busy-server burst, and finally conformance.py against 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 the zebra:// upstream, account-to-keys binding, both proving paths, a two-pool (Sapling + Orchard) wallet including a tri-pool mixed-recipient sendmany, 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 under AllowFullyTransparent, change stays transparent, default policy still refuses with -6), regtest_transparent_sendmany_t2t.rs (the same spend driven through sendmany, two transparent recipients in one tx), regtest_transparent_gap.rs (gap-limit and transparent_initial_scan recovery semantics on a from-seed restore), regtest_transparent_preexpose_responsive.rs (read RPCs stay responsive during a deep initial-scan pre-exposure), and regtest_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_shielding exists 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).