A shielded-first wallet server built on librustzcash - Orchard by default, with opt-in transparent (t-address) support - exposed through bitcoind's RPC dialect for developer ease of use. It uses the same method names, response shapes, auth, and error codes as Bitcoin Core, so that many existing Bitcoin RPC clients can use Zcash with little or no changes.
This project is intentionally not backwards-compatible with zcashd. Instead it is compatible with
other librustzcash-powered wallets, notably Zodl (iOS,
Android), which is built by much of the Zcash Core
team. To migrate funds from zcashd to zecd, the only supported path is to send everything on-chain
to addresses generated by zecd's getnewaddress.
It is not recommended to ever share seed phrases between apps. However by design, if something goes badly wrong with zecd, its seed phrases can be entered into any other librustzcash wallet to access funds.
zecd is a light client, for quick scalability. It syncs compact blocks in the background and never speaks P2P or indexes the chain itself. It connects to a self-hosted Zcash full node - a local zebra over its JSON-RPC.
All zecd funds are recoverable from the seed phrase - shielded funds unconditionally, transparent funds within the configured gap limit (see Transparent support). Importing private keys per-address is not supported; all addresses derive from the wallet seed.
flowchart LR
app["your app /<br>Bitcoin RPC client"] -->|JSON-RPC| zecd
zecd -->|JSON-RPC| zebra["zebra<br>(full node)"]
zecd points straight at a local zebrad's JSON-RPC. The default [backend] server = "zebra" is shorthand for zebra://127.0.0.1:8234 on mainnet / zebra://127.0.0.1:18234 on
testnet (set zebrad's rpc.listen_addr to that port - zebra ships with RPC disabled, and
8232/18232 are zecd's own RPC ports); any explicit zebra://host:port works too. zecd
derives compact blocks, tree state, and mempool visibility from the node RPCs itself, so
there is no lightwalletd to operate.
Run the node yourself. zecd holds spend authority over real funds; its entire view of the
chain - balances, confirmations, incoming payments - is whatever zebra serves it. The
endpoint is deliberately local-only (plaintext HTTP) - never expose a zebra RPC port to the
network. The Docker stack below runs the full zebra → zecd
pipeline with one compose file.
zecd keeps no off-chain state that a seed-only restore couldn't rebuild. Everything it needs
to reconstruct a wallet's balance and history lives in the librustzcash DB (data.sqlite), and
that DB is itself derivable from the seed: zecd init --restore (optionally --birthday) recovers
all funds, notes, and history by decrypting the chain with the account's viewing key. The recovery
is functional, not bit-for-bit - e.g. the exact sequence of addresses getnewaddress hands out
isn't reproduced (the clock-derived diversifier cursor is a cache, not authoritative state), but any
address that ever received funds is recovered from the note itself during the scan, so a payment
to a previously-issued address is still detected after a restore.
Statelessness is about persistence - zecd writes no off-chain data to disk that a restore couldn't rebuild. The one kind of data with no on-chain source is address labels: supplied out-of-band, never reconstructible, and persistent by nature. So zecd does not keep labels at all - there is no label store, no toggle, and no way to turn statefulness on:
- The label-dedicated methods
setlabel,getaddressesbylabel,listlabels,getreceivedbylabel, andlistreceivedbylabelare not implemented - calling them is method-not-found (-32601), like any unknown method. getnewaddressrejects alabelargument (-8); the address itself derives from the seed and is unaffected.- The embedded
label/labelsfields on the general history/address RPCs (getaddressinfo,listtransactions,z_listtransactions,listsinceblock,gettransaction,listreceivedbyaddress) are retained for Bitcoin Core shape conformance but are always empty.
Transaction first-seen times are a different case. An unmined transaction has no block time
yet - that's expected, not an off-chain gap - so zecd stamps the wall clock when the mempool stream
first sees a pending tx and surfaces it as gettransaction.timereceived / listtransactions.time
(Bitcoin Core's nTimeReceived) until a block time supersedes it. This is held in memory only
and never persisted (exactly like the async-operation registry), so it doesn't break the stateless
invariant: a restart rebuilds it as the mempool stream re-observes still-pending txs, and a tx that
mines gets its recoverable block time. A foreign unmined tx seen before a restart (and not yet
re-observed) simply reports time 0 until then.
This makes a zecd data directory disposable: a container with no persistent volume, rebuilt from the seed on each start, loses nothing an operator depends on. Track the addresses you hand out yourself - zecd remembers an address only once it has received funds (recovered from the chain), so keep your own record of issued-but-unfunded addresses to avoid reusing one (a privacy/linkability leak, never a loss of funds).
zecd is not yet published on crates.io - build from source (or use the Docker stack / release tarballs).
zecd expects a local zebrad (server = "zebra" → zebra://127.0.0.1:18234 on testnet; point
zebrad's rpc.listen_addr at that port). Then:
# 1. Initialize a testnet wallet (generates an age identity + 24-word mnemonic, creates an account).
cargo run --release -- --datadir ./data --testnet \
init --wallet default --account-name primary
# 2. Run the daemon (syncs in the background, serves JSON-RPC).
cargo run --release -- --datadir ./data --testnet \
--rpcuser zec --rpcpassword secret --rpcbind 127.0.0.1 --rpcport 18232Then talk to it like bitcoind:
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/from bitcoinrpc.authproxy import AuthServiceProxy
rpc = AuthServiceProxy("http://zec:secret@127.0.0.1:18232")
print(rpc.getblockchaininfo())
addr = rpc.getnewaddress("invoice-1") # a u1... Orchard Unified Address
print(rpc.getbalance())
print(rpc.listtransactions("*", 20))Without --rpcuser/--rpcpassword, zecd writes a bitcoind-style cookie file to
<datadir>/.cookie and authenticates against that.
CLI flags override the TOML config (default <datadir>/zecd.toml). See zecd.example.toml.
network = "test" # "main" | "test"
datadir = "./data"
default_wallet = "default"
[wallets.default]
dir = "./data/default"
[backend]
server = "zebra" # a local zebrad's JSON-RPC ("zebra" = zebra://127.0.0.1:8234
# main / :18234 test - point zebrad's rpc.listen_addr there).
# Or an explicit "zebra://host:port".
connect_timeout_secs = 10 # per-attempt dial timeout (so a hung endpoint can't stall sync)
reconnect_base_secs = 1 # reconnect backoff: base delay (doubles, full jitter)
reconnect_max_secs = 60 # reconnect backoff: ceiling
[zebra] # credentials for the zebra:// endpoint (omit when zebrad has
# rpc_cookie = "/path/.cookie" # `enable_cookie_auth = false`); a cookie wins over user/password
# rpc_user = "user"
# rpc_password = "pass"
[rpc]
bind = "127.0.0.1"
port = 18232 # mainnet default 8232, testnet 18232
user = "zec"
password = "secret"
# cookiefile = "./data/.cookie" # used when user/password are unset
work_queue = 100 # max in-flight requests before HTTP 503 (= bitcoind -rpcworkqueue)
[keys]
age_identity = "./data/identity.txt"
auto_unlock = true # decrypt the seed at startup so sends need no walletpassphrase
[sync]
interval_secs = 20
rebroadcast_secs = 60 # max spacing of unmined-tx re-broadcast passes
[spend]
trusted_confirmations = 3 # depth before the wallet's own change is spendable
untrusted_confirmations = 10 # depth before third-party payments are spendable (>= trusted)
privacy_policy = "AllowRevealedRecipients" # "FullPrivacy" (only single-shielded-pool sends - no
# transparent recipients, no Sapling<->Orchard crossing), or
# "AllowFullyTransparent" (also permits a t->t send funded from
# transparent UTXOs with kept-transparent change - see below)
orchard_action_limit = 50 # cap on Orchard actions per send (0 disables); like Zallet's
# builder.limits.orchard_actions - too many recipients -> -8
[log]
level = "info" # tracing filter; RUST_LOG overrides
format = "text" # "text" | "json" (structured, for log aggregation)
[health]
enabled = true
bind = "127.0.0.1" # set 0.0.0.0 for Kubernetes/LB probes
port = 9233
readiness = "connected" # "connected" (ready when the backend is live, past birthday)
# or "synced" (ready only once scanned to near the tip)
max_scan_lag = 4 # "synced" mode: max chain_tip - fully_scanned block gapzecd logs via tracing. The level comes from [log] level, overridden by RUST_LOG (e.g.
RUST_LOG=zecd=debug,zcash_client_backend=info). Each RPC call emits a structured event: debug
on success (method, wallet, elapsed_ms), info on error (adds code, message). Sync and
connection lifecycle events log at info. [log] format = "json" emits JSON lines for
Loki/CloudWatch/Elastic.
Each wallet is owned by a single-writer actor, so sends serialize per wallet, the same guarantee
Bitcoin Core gets from cs_wallet. Concurrent sendtoaddress/sendmany calls are processed one
at a time and never select the same note, so there is no double-spend; queued sends block their
HTTP call until complete. Unlike bitcoind's millisecond sends, a shielded send computes Orchard
proofs, so the call holds the HTTP connection for a few seconds (plus any queueing behind other
sends): set client-side send timeouts well above that. A client that times out and retries a send
that actually succeeded will pay twice - exactly as with bitcoind, but the longer window makes it
worth calling out: on timeout, reconcile with listtransactions before retrying. Because freshly-created change is unconfirmed (not yet spendable), rapid
back-to-back sends exhaust spendable notes and return RPC_WALLET_INSUFFICIENT_FUNDS (-6) until
confirmations arrive, the same code bitcoind returns for spent/locked funds. The -6 message
reports any balance awaiting confirmations, so a client can tell "retry after the next block" from
"the wallet needs funding".
Overload protection matches bitcoind's work queue: at most [rpc] work_queue requests (default
100, like -rpcworkqueue) are in flight; beyond that the server returns HTTP 503 Work queue depth exceeded. During shutdown it returns 503 Request rejected during server shutdown.
HTTP status and error codes match Bitcoin Core (rpc/protocol.h, httprpc.cpp):
| Condition | RPC code | HTTP |
|---|---|---|
| success | n/a | 200 |
| insufficient funds | -6 |
500 |
wallet locked (needs walletpassphrase) |
-13 |
500 |
| tx rejected by network | -26 |
500 |
| bad/unknown address or txid | -5 |
500 |
| invalid parameter | -8 |
500 |
| invalid request | -32600 |
400 |
| method not found | -32601 |
404 |
| parse error | -32700 |
500 |
| auth failure | n/a | 401 (+ WWW-Authenticate, 250 ms delay) |
| over work-queue / shutting down | n/a | 503 |
Batches always return HTTP 200 with per-item errors in the array.
Numbering is Bitcoin Core's, not zcashd's. These integers are Bitcoin Core's rpc/protocol.h
values. The one that collides numerically with zcashd/Zallet (which use Zcash's own
protocol.h) and that zecd actually emits is -18 "wallet not found" (an unknown
/wallet/<name>), where zcashd means "backup required" (zecd's -19, "wallet not specified", is
unused by zcashd). It is only returned by multiwallet, which zcashd lacks, so a zcashd client never
observes the mismatch - but tooling that hard-codes Zcash's numbering should be aware. (zecd is
stateless and has no labels, so the label-only -11 "invalid label name" is never returned.) The
codes integrations branch on for the money path (-4/-5/-6/-8/-13–-17/-20/-26) are
identical across all three.
For visibility under load, getrpcinfo returns active_commands: one entry per executing call
with method and duration (microseconds). Combine with getwalletinfo (txcount, balances, scanning),
listtransactions/gettransaction (per-tx confirmations), and the /status health endpoint.
With [health] enabled (default), zecd serves unauthenticated probes on a separate port
(default 9233):
-
GET /healthz: liveness.200 okwhile the process is running. -
GET /readyz: readiness.200/503, gated by[health] readiness:"connected"(default): ready as soon as the backend is connected and its chain tip is past the wallet's birthday height (a sanity check that we're on the right, live network). It does not wait for the wallet to finish scanning, so RPC clients can reach zecd while it catches up and readiness doesn't flap during a long sync - reads may lag the tip until caught up."synced": ready only once every wallet is connected and within[health] max_scan_lagblocks of the chain tip. Strict - a from-birthday restore stays not-ready until it has scanned to its own funds.
Body is JSON with per-wallet detail; when not ready it carries a
reason("upstream_down"vs"syncing") so alerting can tell an unreachable zebra apart from normal catch-up. -
GET /status: JSON snapshot of per-wallet sync state, including the activeserverendpoint andconn_state(down|syncing|ready).getpeerinforeflects the same active upstream.
Set [health] bind = "0.0.0.0" for Kubernetes/LB probes. The health server starts after wallets
load, so cover the brief prover-init at boot with a startupProbe / initialDelaySeconds.
deploy/docker-compose.yml runs the self-hosted stack (zebra → zecd, testnet by default;
zecd talks straight to zebra's JSON-RPC). Dockerfile builds
the zecd image as a reproducible StageX build: every base image is
full-source-bootstrapped and pinned by digest, and zecd is a statically linked musl
binary in a from-scratch runtime image, so independent builders can reproduce the binary
bit-for-bit. (vendor/i18n-embed-fl carries a two-line upstream-merged determinism fix that
this depends on - see the comment on [patch.crates-io] in Cargo.toml.) The export stage
extracts the static binaries without running a container:
docker build --target export -o ./out . # ./out/zecdARM (arm64) hosts: StageX publishes amd64 base images only, so the full-source-
bootstrapped reproducible build is amd64-only right now. For ARM, Dockerfile.arm64
builds the same binary with the same output shape - a statically linked musl binary in a
from-scratch runtime - and the same runtime contract (user, datadirs, ports, entrypoint),
from the musl-native rust:alpine image with a fully pinned toolchain (base image by
digest, the C/C++/protoc toolchain to exact apk versions, Rust by version, determinism
flags). It is deterministic and independently rebuildable bit-for-bit, just without StageX's
bootstrapped-toolchain trust story. Released images carry -arm64 suffixed tags:
docker build -f Dockerfile.arm64 -t zecd .Prebuilt release binaries: pushing a v* tag runs the Release workflow, which extracts
the binary from each Dockerfile's export stage - so the published binaries inherit the same
reproducible pipeline as the images - and attaches them to a draft GitHub release. The same
workflow can be run manually (Actions → Release → Run workflow) with a version input to
dry-run the packaging without cutting a tag; the GHCR image push is opt-in for those runs. Each Linux
target (x86_64-unknown-linux-musl and aarch64-unknown-linux-musl, both static) ships
a reproducible .tar.gz and a reproducible .deb (scripts/build-deb.sh: fixed-mtime,
root-owned, SOURCE_DATE_EPOCH-anchored; verified bit-for-bit), each with a .sha256 sidecar.
The .deb installs zecd to /usr/bin plus a (not-enabled) zecd.service systemd unit:
sudo apt install ./zecd_<version>_amd64.deb # or _arm64.deb on ARM
sudo systemctl enable --now zecd # optional: run as a servicecd deploy
docker compose up -d zebra # let it sync first
docker compose run --rm zecd init --wallet default
docker compose up -d
curl localhost:9233/readyz
curl --user zec:CHANGE-ME --data-binary '{"method":"getblockchaininfo","id":1}' localhost:18232/Mainnet: add -f docker-compose.mainnet.yml to every command to swap each service onto its
mainnet config (zebrad.mainnet.toml, zecd.mainnet.toml):
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 -dImage tags in the compose are examples; pin zebra to a release you've verified. Set a
real RPC password in zecd.toml / zecd.mainnet.toml before exposing the port.
The table compares each method against Bitcoin Core (current master) and Zallet (the
zcashd wallet replacement). Bitcoin Core column: ✓ = exists upstream with the semantics zecd
mirrors; removed = no longer exists in current bitcoind (zecd keeps it for older clients).
Zallet column: ✓ = Zallet serves the same method name; - = it does not (nearest z_*
equivalent in parentheses). Zallet is wallet-only - chain, network, mempool, and fee RPC are
the validator's job there - so those rows are all - .
| Method | Bitcoin Core | Zallet | What to expect from zecd |
|---|---|---|---|
| Wallet | |||
getnewaddress |
✓ | - (z_getaddressforaccount) |
Fresh diversified UA of the wallet's single account; a label argument is rejected -8 (zecd is stateless - see below); address_type is a per-call receiver override (unified/sapling/orchard/sapling,orchard, or transparent for a bare t-address when [pools] transparent = true), constrained to the wallet's enabled pools; unknown types rejected -5 |
getbalance |
✓ | - (z_gettotalbalance) |
Spendable balance under the ZIP-315 confirmations policy; explicit minconf overrides it per call (minconf=0 is treated as 1 - a shielded note is never spendable unmined) |
getbalances |
✓ | - (z_getbalances, per-account) |
mine.trusted/untrusted_pending/immature + lastprocessedblock; no watchonly object |
getunconfirmedbalance |
removed | - | Incoming funds below the confirmation policy (incl. 0-conf via mempool stream) |
getwalletinfo |
✓ | ✓ (several fields stubbed) | bitcoind shape; keypoolsize:1, descriptors:false, scanning progress, unlocked_until when encrypted, private_keys_enabled:false when watch-only |
getaddressinfo |
✓ | - | ismine is cryptographic (viewing-key attribution across both shielded pools, so an unrecorded/unfunded own address still resolves after a restore)/solvable; labels always [] (zecd is stateless); scriptPubKey empty (shielded); iswatchonly always false (deprecated in Core; the watch-only signal is getwalletinfo.private_keys_enabled); isvalid_orchard + receiver_types extension fields report the address's receivers |
setlabel, getaddressesbylabel, listlabels |
✓ | - | Not implemented (method-not-found, -32601): zecd is stateless and keeps no off-chain label store |
listtransactions |
✓ | - (z_listtransactions, different shape) |
Core categories/fields (fee on sends, count/skip); the label filter is accepted but matches only the empty label (no labels are kept); each entry's label is ""; adds memo/memoStr; outgoing address is the single receiver actually paid, not the multi-receiver UA (deterministic across restore - see Outgoing addresses in history) |
z_listtransactions |
(extension) | ✓ (zecd matches its shape, no account arg) |
zcashd's per-output history vocabulary: pool/category/amount/amountZat/address/outindex/outgoing/status, memo/memoStr, fee/feeZat on sends; [count] [from] [includeWatchonly] paging like listtransactions |
listsinceblock |
✓ | - | Cursor pattern; removed always []; reorged/unknown cursor → -5, re-baseline (no fork-point walk-back) |
gettransaction |
✓ | - (z_viewtransaction, different shape) |
amount/fee/confirmations/details/hex; foreign tx hex fetched from zebra on demand; outgoing details[].address is the single receiver actually paid (see Outgoing addresses in history) |
listunspent |
✓ | - (z_listunspent, different shape) |
One entry per unspent Orchard note; synthesized txid/vout; address empty for change |
getreceivedbyaddress, listreceivedbyaddress |
✓ | - | Core shapes over diversified receiving addresses; change never counted; each entry's label is "" (stateless). There is no listaddresses (Core has none either) - listreceivedbyaddress 0 true is the enumeration: include_empty unions every address the wallet has generated (used or not), each with its received total. include_watchonly is accepted but ignored (watch-only is wallet-level). The *bylabel pair is not implemented (-32601, stateless) |
sendtoaddress |
✓ | - (z_sendmany is async, returns an operation id) |
Synchronous: builds, proves, broadcasts, returns txid; ZIP-317 fee; subtractfeefromamount/fee_rate → -8; extra trailing memo hex param |
sendmany |
✓ | - (z_sendmany) |
Same; dummy "" first arg as in Core |
z_sendmany |
✓ (Orchard-only) | ✓ | Async: returns an opid, proves/broadcasts on a background task; spends from the wallet's Orchard account (fromaddress must be one of its own addresses; ANY_TADDR/foreign → -5); zcashd amounts array with per-recipient memo; ZIP-317 fee (explicit fee → -8); minconf honored; privacyPolicy mapped onto [spend] privacy_policy (unknown → -8); too many recipients (Orchard actions over [spend] orchard_action_limit, default 50) → -8; a wallet may have at most 16 unfinished operations in flight at once - beyond that, new calls are rejected with -4 (back-pressure) until some finish |
z_getoperationstatus |
✓ | ✓ | Status objects for the wallet's operations, non-destructive; per-wallet scoped; unknown opid omitted, malformed opid → -8 |
z_getoperationresult |
✓ | ✓ | Like z_getoperationstatus, but returns only finished operations and removes them from memory - destructive and one-shot, so each result is returned only once (use z_getoperationstatus to poll without consuming). Reaping is optional: unread finished results are auto-evicted once the registry exceeds its cap (the transaction still broadcasts; only the status object is dropped) |
z_listoperationids |
✓ | ✓ | The wallet's operation ids; optional status filter (queued/executing/success/failed/cancelled) |
z_getaddressforaccount |
- | ✓ (shielded-only) | Derive a Unified Address for the wallet's single account: z_getaddressforaccount account ( ["receiver_type", ...] diversifier_index ). account must be 0 (in-range but other → -4; out-of-range/non-integer → -8). receiver_types empty/omitted uses the wallet's default_receivers; only shielded pools (sapling/orchard) are valid - p2pkh/transparent/unknown → -8, and the result is always a shielded-only UA. diversifier_index omitted picks the next unused index; given, it re-derives that exact index idempotently (different receivers at an exposed index → -4; index with no valid address → -4; beyond the ~2^88 space → -8). Returns {account, diversifier_index, receiver_types, address} |
walletpassphrase |
✓ | ✓ | Unlock with a timeout in seconds (capped at 100,000,000 - about 3 years, same cap as Bitcoin Core), auto-relock when it expires; locked send → -13, wrong passphrase -14, unencrypted wallet -15 |
walletlock |
✓ | ✓ | Core semantics |
listwallets |
✓ | - (one wallet per instance) | Names from [wallets.<name>] config |
| Raw transactions | |||
getrawtransaction |
✓ (verbose JSON differs) | ✓ | Hex, or verbose JSON in zcashd's TxToJSON shape with shielded bundles - matches Zallet, not bitcoind; blockhash param rejected; wallet store first, upstream fallback |
sendrawtransaction |
✓ | - (planned) | Broadcasts caller-built bytes through the upstream; maxfeerate ignored |
| Blockchain | |||
getblockchaininfo |
✓ | - | blocks = fully-scanned height, headers = tip, initialblockdownload = scanning; difficulty/size_on_disk stubs |
getblockcount |
✓ | - | Fully-scanned height, so getblockhash(getblockcount()) always answers |
getbestblockhash |
✓ | - | Hash at the fully-scanned height |
getblockhash |
✓ | - | From the wallet's scanned blocks; pre-birthday or beyond-tip heights → -8 |
getblockheader |
✓ | - | Verbose only, compact-block fields (hash/confirmations/height/time/mediantime/prev/next); verbose=false → -8 |
| Network | |||
getnetworkinfo |
✓ | - | zecd version/subversion; connections is 0 or 1 (the chain upstream is the only "peer") |
getconnectioncount |
✓ | - | 0 or 1 |
getpeerinfo |
✓ | - | At most one entry, describing the zebra upstream, plus conn_state/syncing extensions |
ping |
✓ | - | No-op success (no P2P ping to measure) |
| Utility | |||
validateaddress |
✓ | ✓ (transparent-only: a valid UA gets isvalid:false) |
Validates every Zcash address kind; valid UA → isvalid:true, scriptPubKey empty, plus extension fields isvalid_orchard and a receiver_types array (transparent/sapling/orchard) enumerating what the address can receive |
estimatesmartfee |
✓ | - | Inert stub: conventional ZIP-317 rate (0.00001) + blocks echo |
estimatefee |
removed | - | Same stub rate, for old clients |
getmempoolinfo |
✓ | - | Fixed shape with empty-mempool numbers (a light client holds no mempool) |
settxfee |
removed | - | Always -8: fees are ZIP-317, never client-settable |
| Control | |||
stop |
✓ | ✓ (regtest-only) | Graceful shutdown, regtest only (mainnet/testnet → -32601, matching Zallet); returns "zecd stopping". Stop a live node with a signal (SIGINT/SIGTERM) |
uptime |
✓ | - | Seconds since start |
help |
✓ | ✓ | Static one-line summary only; the optional command argument is ignored (see below) |
getrpcinfo |
✓ | - | active_commands (each in-flight method with its elapsed time in microseconds); logpath empty (logs go to stderr) |
Known gaps in the table worth fixing:
help <method>ignores its argument and returns a generic blurb that names only a few methods. bitcoind lists every command and returns per-method usage forhelp <method>; tooling that introspects viahelpgets nothing useful from zecd today.estimatefee,settxfee, andgetunconfirmedbalancewere removed from Bitcoin Core master; zecd keeps them deliberately (zcashd-era and older bitcoind clients still call them), but don't model new integrations on them.
Multiwallet is addressed bitcoind-style via POST /wallet/<name>; the default wallet is used at
POST /.
getnewaddress returns a fresh Unified Address (u1... / utest1...) on every call. These are
diversified addresses of a single account, not new derivation paths: the wallet has one ZIP-32
account (m/32'/coin_type'/account'), and each address is a different diversifier index of that
account's keys. librustzcash advances to the next unused diversifier and persists it, so each call
yields a new, unused address. All of them receive into the same account and are spendable by the
same key (ZIP-316 + ZIP-32 diversification).
zecd is shielded-first (transparent receiving/spending is opt-in - see the next section). Each
wallet declares which shielded pools it uses and which receivers its Unified Addresses include, via
the [pools] config section (global default) 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 outSupported pools are sapling and orchard (a future ironwood pool will slot in here). The
default - [pools] omitted - is Orchard-only, preserving zecd's historical behaviour.
default_receivers must be a subset of enabled; naming a disabled pool is a startup error.
Change is sent to the strongest enabled pool (Orchard if enabled); inputs are spent from any pool.
getnewaddress's address_type argument is a per-call receiver override, constrained to the
wallet's enabled pools (else -8):
getnewaddress "" # the wallet's configured default_receivers
getnewaddress "" "unified" # same (alias: "default")
getnewaddress "" "sapling" # a UA with a Sapling receiver only
getnewaddress "" "sapling,orchard" # a UA with both shielded receivers
getnewaddress "" "transparent" # a bare t-address (only if [pools] transparent = true)
Transparent support is off by default - a wallet is shielded-only until you enable it. It is an
additive capability that coexists with the shielded pools, not a mode switch: enabled/default_receivers
remain shielded-only (sapling/orchard), and transparent = true is a separate flag that
adds the ability to also hand out - and, opt-in, spend from - bare transparent addresses. So a
wallet can be Orchard-only plus transparent, Sapling+Orchard plus transparent, etc. Enable it
globally or per wallet:
[pools]
enabled = ["orchard"] # shielded pools (unchanged; transparent is NOT listed here)
default_receivers = ["orchard"]
transparent = true # additionally allow bare t-addresses (receive; opt-in spend)
transparent_default = false # if true, no-arg getnewaddress returns a t-address instead of a UA
# transparent_gap_limit = 20 # restore-recovery window (see the caveat below)
# transparent_initial_scan = 0 # A18: 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 remainWith transparent = true, getnewaddress "" "transparent" returns a bare transparent address
(t1... mainnet / tm... test/regtest); the shielded getnewaddress forms above
(""/"unified"/"sapling"/"orchard"/"sapling,orchard") keep working unchanged. Each call
yields one address kind: a bare t-address or a shielded UA - a transparent receiver is never
mixed into a Unified Address (ZIP-316 forbids a transparent-only UA, so zecd derives a compliant UA
and bare-encodes just the transparent receiver). transparent_default = true makes a t-address the
no-argument getnewaddress default (the shielded forms remain available by passing address_type).
Requesting "transparent" on a wallet without the flag is rejected -8.
Funds received at a t-address are discovered by scanning blocks (the way zcashd and zallet do
it), since compact blocks omit transparent data and librustzcash's shielded scan can't see them:
zecd already fetches and parses each full block for the shielded compact-block conversion, so it
matches that block's transparent outputs against the wallet's own addresses at no extra request -
O(outputs-per-block) with a constant-time set lookup, independent of how many addresses the wallet
holds (so an exchange tracking ~100k addresses pays no per-address cost). An incoming transparent
payment shows at 0-conf the moment it hits the mempool (the mempool poller matches transparent
outputs too) and is confirmed by the block scan once mined. Received transparent funds are reported
across getbalance/listunspent/getaddressinfo/getreceivedbyaddress.
Spending received transparent funds - fully-transparent only, opt-in. A 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
[spend] privacy_policy = "AllowFullyTransparent"(or az_sendmanyprivacyPolicyofAllowFullyTransparent/NoPrivacy) - the most revealing send, so it is strictly opt-in. librustzcash's transfer builder funds payments from shielded notes only and won't select transparent UTXOs as inputs, and its change accounting has no transparent-change form, so zecd builds the transaction itself (greedy ZIP-317 coin selection over the wallet's transparent UTXOs, transparent recipient + change outputs). Change is routed to the wallet's internal change chain, so it is recovered on a from-seed restore (via the internal gap) and is hidden from transaction history as change - distinct from a deliberate self-send to your own receive address, which stays visible. Under the defaultAllowRevealedRecipients, a transparent-only wallet'ssendtoaddress/sendmanystill returns-6"insufficient funds" - there is no auto-shielding of received transparent funds into Orchard (planned), and transparent + shielded inputs cannot be mixed in one send. Sending to a transparent recipient from shielded funds works under the default policy and is rejected underFullPrivacy.
Statelessness caveat - the gap limit. Shielded funds are unconditionally recoverable on a
from-seed restore (note trial-decryption needs no scan range). Transparent funds are not: a
stateless rebuild rediscovers them only within the external transparent gap limit - how far
past the last funded receiving address the block scan keeps exposing (and therefore matching)
receiving addresses. zecd
exposes this as [pools] transparent_gap_limit (default 20, above librustzcash's built-in 10).
If you hand out addresses far ahead of funding (e.g. pre-generate one per invoice, most never
paid), set it to at least your maximum number of outstanding-unfunded addresses - otherwise a
restore can miss a later payment to a high-index address that was unfunded at rebuild time. This is
the standard HD-wallet gap-limit limitation, made sharper by statelessness (zecd doesn't persist a
keypool to fall back on). It does not apply to shielded receiving.
Large pre-generated runs - transparent_initial_scan (A18). A big gap limit is the wrong tool
when you hand out many addresses ahead of funding (e.g. an exchange assigns 10 000, only a high
one is funded): the gap is a sliding window kept gap_limit past every funded address forever, so
sizing it to 10 000 means scanning 10 000 addresses past each receive. Instead set [pools] transparent_initial_scan = N to pre-expose external indices 0..N once at startup/restore - so
the receive scan covers all of them - while keeping transparent_gap_limit small for steady-state.
Set N to your issuance high-water mark. When transparent receiving is on, getwalletinfo reports a
transparent block (enabled/default/gap_limit) and the daemon logs the effective gap limit and
initial scan depth at startup, so you can audit coverage against that high-water mark.
At the edge of the window - transparent_allow_beyond_recovery_window / transparent_gap_warn_threshold.
librustzcash refuses to allocate a transparent receiving address once the recovery window is full
(the gap limit reached with everything unfunded), precisely because a from-seed restore could not
rediscover funds sent there. By default (transparent_allow_beyond_recovery_window = true) zecd
issues the address anyway and logs a loud warning that funds received there may be unrecoverable
from seed; set it false to instead fail the getnewaddress call with an actionable error
(fail-closed). Independently, zecd warns as you approach the limit - once fewer than
transparent_gap_warn_threshold (default 5) in-window address slots remain - and on startup if a
wallet is already at/over the window, so you can widen transparent_gap_limit /
transparent_initial_scan (or get a lower index funded) before addresses start landing outside it.
Balances, listtransactions, listunspent, getreceivedbyaddress, and friends report notes and
transparent UTXOs across all enabled pools. validateaddress/getaddressinfo report each address's receivers
via the isvalid_orchard boolean and the receiver_types array
(transparent/sapling/orchard).
zecd is built entirely on librustzcash and reuses every transparent primitive it offers; the wallet-level policy and the pieces librustzcash deliberately omits are zecd's own. librustzcash's high-level wallet API is shielded-first by design: it "does not provide any functionality that allows users to directly spend transparent funds except by sending them to a shielded internal address" - so transparent receive discovery and the fully-transparent spend are zecd-implemented, on top of librustzcash's lower-level building blocks.
| Concern | librustzcash provides | zecd implements (on top) |
|---|---|---|
| Transparent key derivation (BIP-44 external/internal, secp256k1) | ✓ AccountPrivKey/AccountPubKey, derive_secret_key, ZIP-32 scopes |
uses it for signing + change-address derivation |
| Transparent UTXO storage & spendability query | ✓ transparent_received_outputs table, put_received_transparent_utxo, get_spendable_transparent_outputs (coinbase-maturity + confirmations + dust) |
uses it; no extra UTXO store |
| Per-scope gap chains + restore recovery | ✓ external and internal gap chains seeded at account creation, with_gap_limits |
sets transparent_gap_limit; adds the A18 transparent_initial_scan pre-exposure |
| Transparent receive discovery | ✗ - the shielded scan never sees transparent I/O; transaction_data_requests only finds spends of held UTXOs |
zecd owns it: block scan matches each block's transparent outputs against the wallet's address set (engine::owned_transparent_output), at no extra fetch |
| Transparent input signing | ✓ TransparentSigningSet, PCZT sign_transparent |
uses it via the zcash_primitives Builder |
| Spend transparent UTXOs to an arbitrary recipient | ✗ - propose_transfer funds from shielded notes only (transparent handling is ZIP-320 ephemeral outputs) |
zecd builds the tx itself: greedy ZIP-317 coin selection + exact fee + Builder |
| Persistent transparent change (kept-transparent) | ✗ - ChangeValue has only shielded + ephemeral-transparent variants |
zecd sizes change and routes it to the internal change chain |
| Change recognized as change in history | partial - recipient_key_scope is recorded, but is_change is hard-coded 0 for all transparent outputs |
read::is_internal_change keys on the scope (internal ⇒ change), so change is hidden while an external self-send stays visible |
| Recording a wallet-built tx (lock inputs, rebroadcast) | ✓ store_transactions_to_be_sent (SentTransaction + utxos_spent); auto-records a wallet-owned transparent output |
uses it; rides the existing rebroadcast loop |
| 0-conf transparent receives | ✗ - the shielded mempool trial-decrypt never matches a transparent output | zecd matches mempool txs' transparent outputs against the address set (actor::record_tx_transparent_receives), recording them unmined |
| Auto-shielding transparent → Orchard | propose_shielding exists |
not wired (planned) - transparent is spend-transparently-or-leave |
The invariant: zecd persists no off-chain data that a from-seed restore + full chain sync couldn't rebuild. Everything on disk is either recoverable from the seed and the chain, or it is a cache of such data - never authoritative state. The one kind of state with no on-chain source and that is persistent by nature - address labels - zecd keeps none of (the label RPCs are removed; see Stateless by design above). This holds for the transparent feature too.
What is on disk is the librustzcash wallet DB (data.sqlite), which is a cache:
- Balances, notes, and transparent UTXOs - rebuilt by re-scanning the chain (note trial-decryption for shielded; the block-scan transparent-output matcher for transparent).
- Addresses (shielded diversifiers and transparent external/internal gap addresses) - re-derived
from the seed. The shielded diversifier cursor is clock-derived and the transparent gap chain is
sequential; both are caches of on-chain-recoverable data. An unused handed-out address that was
never funded is simply forgotten on restore - harmless, except for transparent, where recovery
is bounded by the gap limit (see the caveat above): a transparent address only recovers if it
is funded within
transparent_gap_limitof the last funded index, or pre-exposed bytransparent_initial_scan. Transparent change lands on the internal gap chain and is governed by the same limit.
Caches that are in-memory only (never written to disk, rebuilt on restart): unmined-tx
first-seen times and the async-operation registry (z_sendmany opids) - both detailed in
Stateless by design above - plus the Orchard proving key (ProvingKeyCache), built once at
startup and shared across wallets (a performance cache, not state). None of these are
transparent-specific, and none survive a restart.
So a transparent-enabled wallet is as stateless as a shielded-only one in the persistence sense -
the only practical difference is recovery breadth: shielded funds are unconditionally
recoverable from the seed (trial-decryption needs no scan range), while transparent funds are
recoverable only within the configured gap window. Size transparent_gap_limit /
transparent_initial_scan to your issuance + outstanding-change high-water mark.
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 - it is cached only by the instance that authored the send, and a restore-from-seed
recovers only the single receiver actually paid. To keep history deterministic across a
restore, zecd's transaction-history RPCs (listtransactions, gettransaction.details,
listsinceblock, z_listtransactions) report outgoing recipients as that single paid
receiver - a bare t/zs address, or a single-receiver UA for Orchard - rather than the
multi-receiver UA. The output's pool is also available directly in z_listtransactions's
pool field. Received and self-transfer entries are unaffected (they show your own address).
To match a payment to a multi-receiver UA you issued, deconstruct that UA into its individual
receivers and compare against the displayed receiver (zecd is stateless and keeps no
recipient-side mapping itself).
A zecd wallet can run watch-only: initialized from a ZIP-316 Unified Full Viewing Key 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 hands out diversified receive addresses of the same account, but holds no spending material anywhere on disk or in memory. Typical split: an internet-facing payment server runs watch-only (issues invoices, detects payments), while the spending wallet lives elsewhere.
# On the spending wallet's host: print the wallet's Unified Full Viewing Key (offline; reads
# only the wallet DB, works for locked/encrypted wallets too).
zecd --datadir ./data --testnet export-ufvk --wallet default
# On the watch-only host: initialize from that key. Like a restore, pass --birthday (a height
# at or before the wallet's first transaction) to avoid the safe-but-slow default scan from
# Sapling activation.
zecd --datadir ./watch --testnet \
init --ufvk "uviewtest1..." --birthday 2500000Semantics, following Bitcoin Core's wallets without private keys (the modern
createwallet disable_private_keys=true descriptor model - watch-only is a property of the
whole wallet, never of individual addresses):
getwalletinfo.private_keys_enabled: falseis the watch-only signal, as in Core.getaddressinfois unchanged:iswatchonlystaysfalse(deprecated in Core master, always false there too) and own addresses staysolvable(Core defines it "ignoring the possible lack of private keys").getnewaddressworks (diversified addresses derive from the viewing key), and every invoice the watch-only instance issues is a diversified address of the shared account - always detected and spendable by the paired spending wallet, whose note detection is viewing-key-based and doesn't depend on which instance issued the address. (The two instances do not hand out literally identical address sequences: librustzcash picks shielded diversifier indexes from the clock - the same is true of two same-seed zecd instances.)sendtoaddress/sendmanyfail with-4Error: Private keys are disabled for this wallet; the passphrase RPCs are-15, as for any unencrypted wallet - both byte-identical to Core.- The UFVK grants full view access (all amounts, addresses, and history - ZIP-316 keys carry no per-pool trimming here): share it only with hosts that may see your transaction graph, and remember a watch-only datadir still deserves protection for privacy.
One daemon may load at most one spending wallet, plus any number of watch-only wallets
alongside it (each its own [wallets.<name>], addressed at /wallet/<name>). This keeps spend
authority unambiguous - there is never a question of which key signs. zecd init refuses to
create a second spending wallet when one already exists (use --ufvk for a watch-only wallet
instead), and the daemon re-checks at startup and refuses to run with two spenders - naming
both - as a backstop. Convert one to watch-only (export-ufvk + init --ufvk) or remove it.
zecd targets generic Bitcoin-RPC compatibility: any integration that drives a coin purely through
Bitcoin-Core RPC (request an address with getnewaddress, poll
listtransactions/gettransaction/getbalance for payment and confirmations) works.
Out of scope by design:
- BTCPayServer via NBXplorer. NBXplorer indexes the chain over Bitcoin P2P / full blocks and tracks arbitrary xpub derivation schemes over transparent UTXOs. The zebra/zecd stack exposes no P2P surface, and zecd is a single-seed, single-account wallet - not an xpub-tracking indexer - so it is not a drop-in for that integration (its own transparent support is opt-in and single-account).
Edges to be aware of (consequences of being a shielded-first light wallet):
- Spending needs confirmations: an incoming mempool payment is visible immediately
(
getunconfirmedbalance/listtransactionsat 0 conf, via zebra'sgetrawmempoolpoller), but received notes must mine and reach the confirmation minimum before they are spendable. The default minimum is ZIP 315's: 3 confirmations for the wallet's own change, 10 for third-party payments (~12.5 minutes at 75-second blocks);[spend] trusted_confirmations/untrusted_confirmationstune it wallet-wide. A parameterlessgetbalancereports what is spendable under that policy - funds with fewer confirmations show ingetunconfirmedbalance/getbalances.mine.untrusted_pendingmeanwhile. Passing an explicitminconf(getbalance "*" 1) overrides the policy and counts everything at that depth, like Bitcoin Core;minconf0 is served as 1 because a shielded note is never spendable unmined. - Fees are never client-settable. Fees follow ZIP-317, a deterministic formula (5,000 zatoshis ×
max(2, logical actions); a typical send is 0.0001 ZEC) computed at transaction-build time, with
no fee market to outbid. Explicit fee instructions are rejected with
-8rather than silently ignored:subtractfeefromamount/subtractfeefromandfee_rateonsendtoaddress/sendmany, andsettxfee(conf_target/estimate_modeare estimation hints and are safely ignored).estimatesmartfee/estimatefeeremain as inert probe-compat stubs returning a stable conventional rate; the exact fee paid is reported after the fact ingettransaction.fee. - Addresses are shielded UAs (
u1.../utest1...): clients that parse the address string as a transparent Bitcoin address will not understand them; clients that treat addresses as opaque strings are fine. - A send that leaves a single shielded pool reveals information on-chain: a transparent
recipient reveals the amount and the recipient, and crossing the Sapling↔Orchard turnstile
(spending one pool, paying the other) reveals the crossed amount via
valueBalance. Both are allowed by default; set[spend] privacy_policy = "FullPrivacy"to reject them with-8- FullPrivacy permits only fully-shielded sends confined to one pool (matching zcashd/Zallet, zcash/zcash#6240). The transparent-recipient half is caught up front; the no-cross-pool half is enforced on the built transaction proposal (the input pool isn't known until then). - Shielded memos (ZIP 302) are supported as extensions beyond Bitcoin Core's surface:
sendtoaddresstakes a hex memo as an extra trailing parameter (afterverbose, zcashd'sz_sendmanyconventions: ≤512 bytes, rejected for transparent recipients), and history entries (listtransactions/gettransaction.details) carrymemo(hex) andmemoStr(decoded text) fields when an output has one. listunspentlists each unspent Orchard note as one entry. Itstxid/voutidentify the shielded action that created the note (there is no transparentscriptPubKey);addressis the diversified address the note was received on, or empty for change/internal notes. Theaddressesfilter andinclude_unsafeparameters work as in Bitcoin Core (an address filter never matches change notes).- During initial sync (or a post-restore rescan), read RPCs serve whatever has been scanned
so far:
getbalanceon a half-synced wallet is a partial number, not an error (bitcoind would block or warm-up here). Gate automation onGET /readyz, or ongetwalletinfo.scanning/getblockchaininfo.initialblockdownload, before trusting balances. sendmanyrecipients arrive as a JSON object, and JSON parsing collapses duplicate keys (last one wins) before zecd sees them - Bitcoin Core's "duplicated address" error cannot be reproduced. Don't list the same address twice; combine the amounts instead.- Reorgs invalidate
listsinceblockcursors. zecd keeps only the current chain's scanned block hashes (a light wallet has no stale-header index), so if the cursor block is reorged away (or is below the wallet birthday),listsinceblock <hash>returns-5 Block not foundinstead of bitcoind's walk back to the fork point. Treat-5as "cursor invalid": re-baseline with a parameterlesslistsinceblockand rely on txid-based dedupe (idempotent payment processing is required for reorg safety anyway).
zecd matches Bitcoin Core's method names, response field names/types, the JSON-RPC 1.0 envelope
({"result","error","id"}), HTTP 500-with-error-body / 401 semantics, decimal (8-dp) amounts, and
error codes. Intentional divergences are listed under Compatibility boundary above.
# 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, using the same client logic python-bitcoinrpc's
# AuthServiceProxy uses: Basic auth, the 1.0 envelope, amounts decoded as decimal.Decimal
# (no float drift), JSONRPCException codes, batching. The full suite (~140 checks) runs in CI
# on every PR against a live, funded regtest daemon (the Regtest E2E workflow's funded test);
# the original 49 checks were additionally validated against the public testnet.
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). Validates the
# walletlock/walletpassphrase gate, sendtoaddress, and sendmany by broadcasting real txs:
python3 scripts/rpc_send_smoke.py --send-timeout 180All wallet RPCs have been exercised end-to-end on regtest and the live testnet: balances,
addresses, history (listtransactions/gettransaction incl. hex), listunspent, the
walletlock/walletpassphrase gate, and real Orchard sendtoaddress/sendmany broadcasts.
docs/OPERATIONS.md is the production runbook: what to back up (mnemonic, keys.toml, age
identity, birthday height), restore procedures, monitoring/alerting, send semantics under failure,
upgrades, and the mainnet checklist.
Single instance per datadir. Like bitcoind/zcashd/zallet, zecd takes an exclusive advisory
lock on <datadir>/.lock while it owns the data directory, so only one daemon can run against a
given datadir at a time. A second zecd run (or a zecd init) on the same datadir fails fast
with Cannot lock data directory …. The lock is an OS advisory lock that the kernel releases
automatically when the daemon 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. The read-only
zecd export-ufvk is exempt (it only reads the wallet DB), so you can export a UFVK while the
daemon is running.
Three key-custody models - the first two mirror bitcoind's unencrypted/encrypted wallet states, the third is cloud-native key wrapping for ops teams:
- Unencrypted (default): the mnemonic in
<wallet>/keys.tomlis wrapped to the age identity file ([keys] age_identity, default<datadir>/identity.txt); with the defaultauto_unlock = truethe seed is decrypted into memory at startup (held as a zeroizing secret) so sends are unattended. The passphrase RPCs return-15, like bitcoind with an unencrypted wallet. Withidentity.txtco-located in the datadir, the at-rest encryption only protects against leakage ofkeys.tomlalone: anyone who can read the whole datadir has the seed. For unattended mainnet wallets, store the identity outside the datadir (secrets manager, separate mount, orZECD_AGE_IDENTITY). - Encrypted (
zecd init --encrypt): the mnemonic is wrapped with a passphrase (age scrypt) instead; no identity file can decrypt it. The wallet starts locked (sends return-13);walletpassphrase "<pass>" <timeout>unlocks (-14if wrong) and auto-relocks at the timeout;getwalletinfo.unlocked_untilreports the relock time. At-rest encryption is set once at init - there is no passphrase-mutating RPC, so the passphrase never crosses the network; to change it, re-init from the mnemonic.
In both models, anyone with RPC access to an unlocked wallet can spend: treat RPC credentials as spend authority.
Keeping secrets out of the config file (12-factor / Kubernetes). The RPC password, the
keys.toml location, and the age identity can each be sourced from the environment or a mounted
Secret file rather than the (ConfigMap-bound) TOML: ZECD_RPC_PASSWORD / [rpc] password_file
for the RPC password (spend-equivalent for clients), ZECD_KEYS_FILE / --keys-file / keys_file
for keys.toml, and ZECD_AGE_IDENTITY for the identity. zecd init --restore is non-interactive
via ZECD_MNEMONIC / --mnemonic-file (and ZECD_WALLET_PASSPHRASE for --encrypt). With
[keys] bootstrap_from_keys (default on), an empty data directory next to keys.toml is rebuilt
automatically on boot - the account is recreated from the seed (at the first walletpassphrase
for an encrypted wallet) and the wallet rescans - so the data directory is a disposable cache and
the deployment is "mount one Secret, start with an empty PVC".
Memory hardening (in-memory seed): once unlocked, the seed lives in process memory regardless of custody model. zecd hardens that against passive capture, best-effort at startup (each step is a no-op-with-warning if the platform/privileges disallow it, never a startup failure):
mlockpins the seed's pages into RAM so it is never written to swap. A deniedmlock(e.g. an unprivileged container withRLIMIT_MEMLOCK=0) logs a warning - raise the memlock limit to fix; for the residue (transient key copies made during proving), back swap with an encrypted device.- Core dumps disabled (
RLIMIT_CORE=0) so a crash can't spill the seed to a core file. SetZECD_ALLOW_CORE_DUMPS=1to keep core dumps for debugging. - Non-dumpable (
PR_SET_DUMPABLE=0on Linux) so other non-root processes can'tptracezecd or read/proc/<pid>/memto scrape the seed.
This defends passive disclosure (swap, core dumps, another process reading this one's memory), not an attacker with code execution inside zecd - for that isolation, run zecd watch-only and keep spend authority in a separate signer (see Watch-only).
RPC surface:
- Credentials follow bitcoind:
rpcuser/rpcpassword, bitcoind-stylerpcauthentries ([rpc] auth = ["<user>:<salt>$<hmac-sha256>"]or repeated--rpcauthflags, generated with the built-inzecd rpcauth <user> [password]- no externalrpcauth.pyneeded), and a generated cookie file (<datadir>/.cookie, mode 0600) when no user/password pair is set. - Optional RPC method safelist (
[rpc] allowed_methods): a coarse, server-wide allow-list that restricts the surface to a chosen subset of methods. When the list is non-empty, any method not on it is rejected with-32601(HTTP 404) - indistinguishable from a method that doesn't exist, so a locked-down server discloses nothing about what it disabled. Absent or empty means every method is served (the default); entries are validated at startup, so a typo fails fast. It is not per-user (RPC credentials are already spend authority), but it lets you shrink the blast radius of a leaked credential or buggy client - e.g. a receive-only invoicer can disablesendtoaddress/sendmany/stopand everything else it never calls. The example config lists every method, annotated and commented, ready to uncomment. - Do not expose the RPC port to untrusted networks. Bind to
127.0.0.1and/or front it with TLS or a reverse proxy. On mainnet, zecd refuses to start while the password is the example placeholder (CHANGE-ME). - The health port is unauthenticated by design and exposes sync status only; keep it off the public internet anyway.
Dual-licensed under Apache-2.0 or MIT.