Skip to content

zecrocks/zecd

Repository files navigation

zecd

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.

Deployment model

flowchart LR
    app["your app /<br>Bitcoin RPC client"] -->|JSON-RPC| zecd
    zecd -->|JSON-RPC| zebra["zebra<br>(full node)"]
Loading

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.

Stateless by design

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, and listreceivedbylabel are not implemented - calling them is method-not-found (-32601), like any unknown method.
  • getnewaddress rejects a label argument (-8); the address itself derives from the seed and is unaffected.
  • The embedded label/labels fields 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).

Quick start

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 18232

Then 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.

Configuration

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 gap

Logging

zecd 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.

Concurrency & busy servers

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.

Health & readiness

With [health] enabled (default), zecd serves unauthenticated probes on a separate port (default 9233):

  • GET /healthz: liveness. 200 ok while 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_lag blocks 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 active server endpoint and conn_state (down | syncing | ready). getpeerinfo reflects 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.

Docker / self-hosted stack

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/zecd

ARM (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 service
cd 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 -d

Image 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.

Supported RPC methods

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 for help <method>; tooling that introspects via help gets nothing useful from zecd today.
  • estimatefee, settxfee, and getunconfirmedbalance were 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 /.

Addresses

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

Configurable shielded pools

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 out

Supported 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 (t-address) support

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 remain

With 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 a z_sendmany privacyPolicy of AllowFullyTransparent/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 default AllowRevealedRecipients, a transparent-only wallet's sendtoaddress/sendmany still 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 under FullPrivacy.

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

What librustzcash provides vs. what zecd implements

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

Caching and statelessness

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_limit of the last funded index, or pre-exposed by transparent_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.

Outgoing addresses in history are the 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 - 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).

Watch-only wallets (UFVK)

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 2500000

Semantics, 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: false is the watch-only signal, as in Core. getaddressinfo is unchanged: iswatchonly stays false (deprecated in Core master, always false there too) and own addresses stay solvable (Core defines it "ignoring the possible lack of private keys").
  • getnewaddress works (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/sendmany fail with -4 Error: 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.

Compatibility boundary

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 / listtransactions at 0 conf, via zebra's getrawmempool poller), 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_confirmations tune it wallet-wide. A parameterless getbalance reports what is spendable under that policy - funds with fewer confirmations show in getunconfirmedbalance / getbalances.mine.untrusted_pending meanwhile. Passing an explicit minconf (getbalance "*" 1) overrides the policy and counts everything at that depth, like Bitcoin Core; minconf 0 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 -8 rather than silently ignored: subtractfeefromamount/subtractfeefrom and fee_rate on sendtoaddress/sendmany, and settxfee (conf_target/estimate_mode are estimation hints and are safely ignored). estimatesmartfee/estimatefee remain as inert probe-compat stubs returning a stable conventional rate; the exact fee paid is reported after the fact in gettransaction.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: sendtoaddress takes a hex memo as an extra trailing parameter (after verbose, zcashd's z_sendmany conventions: ≤512 bytes, rejected for transparent recipients), and history entries (listtransactions/gettransaction.details) carry memo (hex) and memoStr (decoded text) fields when an output has one.
  • listunspent lists each unspent Orchard note as one entry. Its txid/vout identify the shielded action that created the note (there is no transparent scriptPubKey); address is the diversified address the note was received on, or empty for change/internal notes. The addresses filter and include_unsafe parameters 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: getbalance on a half-synced wallet is a partial number, not an error (bitcoind would block or warm-up here). Gate automation on GET /readyz, or on getwalletinfo.scanning / getblockchaininfo.initialblockdownload, before trusting balances.
  • sendmany recipients 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 listsinceblock cursors. 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 found instead of bitcoind's walk back to the fork point. Treat -5 as "cursor invalid": re-baseline with a parameterless listsinceblock and rely on txid-based dedupe (idempotent payment processing is required for reorg safety anyway).

Conformance & testing

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 180

All 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.

Operations

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.

Security

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.toml is wrapped to the age identity file ([keys] age_identity, default <datadir>/identity.txt); with the default auto_unlock = true the 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. With identity.txt co-located in the datadir, the at-rest encryption only protects against leakage of keys.toml alone: anyone who can read the whole datadir has the seed. For unattended mainnet wallets, store the identity outside the datadir (secrets manager, separate mount, or ZECD_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 (-14 if wrong) and auto-relocks at the timeout; getwalletinfo.unlocked_until reports 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):

  • mlock pins the seed's pages into RAM so it is never written to swap. A denied mlock (e.g. an unprivileged container with RLIMIT_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. Set ZECD_ALLOW_CORE_DUMPS=1 to keep core dumps for debugging.
  • Non-dumpable (PR_SET_DUMPABLE=0 on Linux) so other non-root processes can't ptrace zecd or read /proc/<pid>/mem to 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-style rpcauth entries ([rpc] auth = ["<user>:<salt>$<hmac-sha256>"] or repeated --rpcauth flags, generated with the built-in zecd rpcauth <user> [password] - no external rpcauth.py needed), 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 disable sendtoaddress/sendmany/stop and 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.1 and/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.

License

Dual-licensed under Apache-2.0 or MIT.

About

No description, website, or topics provided.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages