exfer-walletd
A JSON-RPC daemon that holds Ed25519 wallet keys and signs Exfer
transactions on behalf of a backend. Same pattern as
cardano-wallet
for Cardano — a separate signing service, decoupled from the chain
node.
your backend ──► exfer-walletd ──► exfer node(s)
(holds keys, (chain data, p2p,
signs locally) broadcast — no keys)
The Exfer node's JSON-RPC is read-only + broadcast; it can't sign, because nodes don't hold keys. Walletd closes that gap.
One binary, zero ceremony:
exfer-walletd
Token auto-generated on first run. Wallets persisted under
~/.exfer-walletd/. Optional in-process TLS via --tls (the SDK
pins by SHA-256 fingerprint, no CA required).
Read next
Install → Quick start → RPC reference. Everything else (picking a node, tokens, security, operations, FAQ) is for when something is unclear or you're going to production.
github.com/exfer-stack/exfer-walletd · Releases · MIT licensed.
Install
Pre-built binary
curl -L -o exfer-walletd \
https://github.com/exfer-stack/exfer-walletd/releases/latest/download/exfer-walletd-linux-x86_64
chmod +x exfer-walletd
sudo install -m 0755 exfer-walletd /usr/local/bin/
Other platforms on the Releases page: linux-x86_64, linux-arm64, macos-x86_64, macos-arm64, windows-x86_64.
From source
git clone https://github.com/exfer-stack/exfer-walletd
cd exfer-walletd && cargo build --release
# Binary at target/release/exfer-walletd
Rust 1.75+. The exfer crate (tx crypto) is pulled from GitHub at
build time.
Next: Quick start →
Quick start
Pick your shape
| Scenario | Command | Wire |
|---|---|---|
| Dev — node, walletd, caller on one host | exfer-walletd | plain HTTP on 127.0.0.1 |
| Prod — cross-host, recommended | exfer-walletd --tls --bind <private-ip>:7448 | HTTPS, self-signed cert, SDK pins by fingerprint |
| Prod — TLS terminator (nginx/Caddy/cloud LB) already in front | exfer-walletd --allow-public-bind --bind 0.0.0.0:7448 | plain HTTP behind your proxy |
If your walletd talks to a backend on a different host, you want
--tls. A bearer token over plaintext HTTP is fatal — walletd
fails-closed on public binds to make that hard to do by accident.
Full details: Production: enable --tls
below.
Dev (laptop / single VM)
Node, walletd, and your code all on one host:
export WALLETD_KEYSTORE_PASSPHRASE='correct horse battery staple'
exfer-walletd
Defaults: --bind 127.0.0.1:7448, --node-rpc http://127.0.0.1:9334,
--datadir ~/.exfer-walletd. WALLETD_KEYSTORE_PASSPHRASE is
required — walletd encrypts the HD seed at rest with argon2id +
ChaCha20-Poly1305 and refuses to start without an explicit passphrase.
On first run, walletd:
- Creates
~/.exfer-walletd/(mode0700). - Generates a 24-word BIP-39 mnemonic + a fresh HD seed; seals the seed and prints the mnemonic once on stderr inside an ASCII box. Write the words down — they are the only seed backup.
- Generates three bearer tokens (one per scope) at
<datadir>/token-{read,manage,spend}and prints each once in an ASCII box. - Starts serving.
Your datadir at a glance
Everything walletd manages lives in one directory. After first run:
$ ls -la ~/.exfer-walletd/
drwx------ walletd 4096 .
-rw------- walletd 65 token-read
-rw------- walletd 65 token-manage
-rw------- walletd 65 token-spend
drwx------ walletd 4096 wallets/
├── seed.enc ← sealed BIP-39 entropy
├── state.json ← next_index, labels, imported list
└── imported/ ← legacy .key migrations (sealed)
$ cat ~/.exfer-walletd/token-spend
a85da0752815bbf652a1b147649cde77c17f784f3e608d362c629c798a555e7b
If you also passed --tls (see below), three more files appear:
cert.pem, cert.key, cert.fingerprint.
Backup = the 24-word mnemonic (offline) plus
tar czf ... ~/.exfer-walletd/wallets/imported. The mnemonic
recovers every HD-derived address; the imported/ files cover
addresses that came in via migrate. Uninstall = rm -rf the
datadir.
Call it
TOKEN=$(cat ~/.exfer-walletd/token-spend)
curl -s http://127.0.0.1:7448/ \
-H 'content-type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","method":"ping","id":1}'
# → {"jsonrpc":"2.0","result":{"ok":true},"id":1}
Full method list: RPC reference.
Cross-host: bind a non-loopback interface
Default bind is loopback-only, so a backend on a different server can't reach it. Bind your host's private/internal IP:
exfer-walletd --bind 10.0.1.5:7448
Private/RFC1918 addresses are allowed with no extra flag. Public
IPs (0.0.0.0, any globally routable IP) need either --tls (next
section) or --allow-public-bind (acknowledging that an external TLS
terminator sits in front).
Production: enable --tls
Pass --tls and walletd terminates TLS itself with a self-signed
cert it generates on first run. No CA, no rotation ceremony, no
reverse proxy.
exfer-walletd --tls --bind 10.0.1.5:7448
The bound IP is auto-added to the cert's subjectAltName. If your
clients connect via a hostname (or you bind 0.0.0.0 and proxy in),
also pass --tls-san:
exfer-walletd --tls --bind 0.0.0.0:7448 --tls-san walletd.internal,10.0.1.5
On first start, walletd creates cert.pem, cert.key, and
cert.fingerprint in the datadir (all 0600) and prints the
SHA-256 fingerprint once in an ASCII box on stderr:
┌─ first run ───────────────────────────────────────────────────────────
│ generated self-signed TLS cert
│ cert: /var/lib/walletd/cert.pem
│ fingerprint: /var/lib/walletd/cert.fingerprint
│
│ sha256:b66953c47263ac0da8192676e4770f0f799563322985c57246a6fab1bf24aa86
│
│ pin this value on the client side (SDK: fingerprint=…).
└───────────────────────────────────────────────────────────────────────
Lost the line? cat ~/.exfer-walletd/cert.fingerprint.
--tls relaxes --allow-public-bind — TLS already protects the
token on the wire.
Changed your bind, hostname, or --tls-san later? The cert is only
generated on first run. Delete the trio (rm cert.{pem,key,fingerprint})
and restart to regenerate.
Two ways to verify the cert on the client
SDK — fingerprint pinning (recommended). No CA, no SAN dance.
from exfer_walletd import Client
with Client.from_datadir(url="https://<walletd-host>:7448") as c:
print(c.healthz())
Strict CA-style validation — curl --cacert, Java, anything that
checks the SAN. Drop cert.pem on the client and verify by the same
hostname/IP you put in the SAN:
curl --cacert /etc/walletd/cert.pem https://walletd.internal:7448/healthz
If the hostname/IP isn't in the SAN, you get
SSL: no alternative certificate subject name matches target host name.
Fix by regenerating the cert with --tls-san covering it.
Bootstrap from the backend host (no SSH to walletd)
Walletd with --tls exposes two unauthenticated GET endpoints on the
HTTPS port so you can grab the cert / fingerprint from the backend
side without shelling into the walletd host:
# from your backend host (or anywhere):
curl --insecure -o /etc/walletd/cert.pem \
https://<walletd-host>:7448/exfer-walletd/cert.pem
curl --insecure https://<walletd-host>:7448/exfer-walletd/cert.fingerprint
# → sha256:b66953c47263ac0da8192676e4770f0f799563322985c57246a6fab1bf24aa86
The --insecure flag is the bootstrap step — it skips cert
verification on this one request. After you've saved the cert
(or the fingerprint) and configured your client to pin it, every
subsequent connection is strict.
Security caveat — this is
TOFU: if an
attacker is on the network path the moment you run the bootstrap
curl, they can hand you a cert they control. For deployments
inside one VPC / private network this is essentially never an issue.
For deployments crossing untrusted networks (public internet, cafe
wifi, etc.), prefer copying cert.pem or cert.fingerprint over a
channel you already trust (scp, your secret manager, the same env
vars you push the token through).
The bootstrap endpoints are only mounted when --tls is on. Plain
HTTP walletd never serves these — handing out cert material over
plaintext would defeat the whole pinning model.
Other flags
exfer-walletd --node-rpc 'http://a:9334,http://b:9334' # round-robin + failover
exfer-walletd --datadir /var/lib/walletd # different storage location
exfer-walletd --auth-token-read "$(openssl rand -hex 32)" \ # supply any subset of the
--auth-token-manage "$(openssl rand -hex 32)" \ # three scoped tokens from
--auth-token-spend "$(openssl rand -hex 32)" # a secret manager
Every flag also reads from a matching env var (WALLETD_BIND,
WALLETD_TLS, EXFER_NODE_RPC, …). Full list: exfer-walletd --help.
Next
Picking a node
Walletd is decoupled from any specific Exfer node — anything that
speaks Exfer JSON-RPC works. Pass --node-rpc (or EXFER_NODE_RPC)
to point at it.
| Use case | Flag |
|---|---|
| Same host (default) | none — uses http://127.0.0.1:9334 |
| LAN / VPC | --node-rpc http://node:9334 |
| Public RPC | --node-rpc https://exfer-rpc.example.com |
| Multiple nodes | --node-rpc 'http://a:9334,http://b:9334' (round-robin + failover) |
The walletd → node hop carries signed bytes only — no private
keys. Walletd treats the upstream like any other HTTP service; no
upstream auth. Prefer HTTPS for upstreams outside your trust
boundary (rustls handles this transparently).
Multi-URL failover
Walletd rotates the starting node per call and fails over to the next
on transport / 5xx error. Application-level errors (Block not found,
etc.) are surfaced immediately — retrying on a different node could
even be wrong if nodes are out of sync.
| Triggers failover | Does NOT trigger failover |
|---|---|
| Connection refused / timeout | HTTP 4xx |
| HTTP 5xx | JSON-RPC body with error.code set |
| Non-JSON-RPC response body | — |
Latency math (why local nodes matter)
Walletd's transfer makes 3 sequential round-trips plus N parallel
parent-tx fetches (N = number of input UTXOs, parallelism capped at 8):
list_utxos 1 RTT
get_transaction × N (parallel, cap 8) ⌈N/8⌉ RTTs
send_raw_transaction 1 RTT
Submit time ≈ (2 + ⌈N/8⌉) × RTT.
- Local node (RTT ~5ms): single-UTXO transfer ~15ms
- LAN node (RTT ~50ms): ~150ms
- Public RPC (RTT ~750ms): ~2.3s
For an exchange hot wallet with many small UTXOs, run a local or VPC-internal node — public RPC will be visibly slow.
Next
Tokens and scopes
Walletd uses bearer-token authentication on every request except
GET /healthz. Comparison is constant-time
(subtle::ConstantTimeEq).
Three scoped tokens
v1.0 issues three tokens, one per scope. On first start walletd
auto-generates them at <datadir>/token-{read,manage,spend} (mode
0600).
| Scope | Methods |
|---|---|
read | ping, validate_address, get_* family, list_addresses, verify_message, get_status, get_wallet_balance |
manage | generate_address, abandon_transfer |
spend | transfer, send_raw_transaction, sign_message |
Containment: spend ⊇ manage ⊇ read. A token at a higher scope
satisfies every lower scope, so an exchange's withdrawal worker only
needs the spend token — it gets manage and read for free.
Configuring
The default behaviour (auto-generate on first run) suits most setups. Override any subset from a secret manager:
exfer-walletd \
--auth-token-read "$(vault read -field=token secret/walletd-read)" \
--auth-token-manage "$(vault read -field=token secret/walletd-manage)" \
--auth-token-spend "$(vault read -field=token secret/walletd-spend)"
Env equivalents: WALLETD_AUTH_TOKEN_READ, WALLETD_AUTH_TOKEN_MANAGE,
WALLETD_AUTH_TOKEN_SPEND. Setting any of them suppresses auto-file
creation for that scope.
Typical splits
| Component | Token to issue |
|---|---|
| Deposit watcher | token-read |
| Address provisioning | token-manage |
| Withdrawal worker | token-spend |
| Operator dashboard / SRE | token-read |
A leaked read token can survey balances and pubkeys but cannot mint addresses or spend. A leaked manage token can mint addresses but cannot spend or sign messages. A leaked spend token is "every wallet, all funds" — guard accordingly.
Bind safety
Walletd enforces at startup:
| Bind address | Policy |
|---|---|
Loopback (127.0.0.1, ::1) | Always allowed. |
| Private (RFC1918, ULA, link-local) | Allowed; warns if no token is set. |
Public (any global IP, 0.0.0.0, ::) | Refused unless --tls OR --allow-public-bind. |
The reason public binds need an opt-in: by default walletd doesn't
terminate TLS, and a plaintext bearer token on the public wire is
fatal. --tls (walletd terminates TLS itself, see
Quick start → Production)
solves it directly; --allow-public-bind is your assertion that an
external TLS terminator sits in front. Without one, walletd
fail-closes.
Next
Keystore (HD seed)
v1.0 keeps one BIP-39 seed per daemon. Every address you ever generate is derived from that seed by index — back up the seed once, back up every present and future address.
On-disk layout (<wallet_dir>/)
seed.enc ← BIP-39 entropy (32 B) sealed with argon2id + ChaCha20-Poly1305
state.json ← {"next_index": N, "derived": {addr→idx},
"labels": {addr→label}, "imported": [addr,…]}
imported/<addr>.key.enc ← sealed 32-byte secret for non-HD imports
<wallet_dir> defaults to <datadir>/wallets (mode 0700).
At-rest encryption
The seed (and any imported secrets) is wrapped in a small format:
magic "WDV1" (4)
version 1 (1)
salt 16 bytes (random per-seal; fed to argon2id)
nonce 12 bytes (random per-seal; fed to ChaCha20-Poly1305)
ct payload + 16-byte Poly1305 tag
Argon2id parameters: m=64 MiB / t=3 / p=1 (≈0.5–1s on a modern x86 core). Enough to defeat offline brute force against medium-strength passphrases; cheap enough that startup (one unseal per seed) and imported-key access stay snappy.
The passphrase
The encryption KEK comes from WALLETD_KEYSTORE_PASSPHRASE. walletd
refuses to start without it. Set it via:
- env var directly (development):
WALLETD_KEYSTORE_PASSPHRASE='correct horse battery staple' exfer-walletd - secret manager → env at process spawn (production):
- systemd:
Environment="WALLETD_KEYSTORE_PASSPHRASE=…"in a0600drop-in - Docker / k8s: pull from Secrets Manager / Vault / AWS-SM / GCP-SM into the container env at start
- systemd:
- never check in:
.envfiles in git are an immediate red flag
If the passphrase is wrong on a subsequent start, walletd surfaces
KeystoreLocked (-32012) and exits before binding any socket.
Derivation path
m / 44' / 9527' / 0' / 0' / index'
- SLIP-0010 Ed25519 (forces hardened derivation at every level).
- Coin type
9527'is a placeholder until Exfer registers an official SLIP-44 slot. Pinned in code; switching it would invalidate every derived address — only changes inside a major version bump. - BIP-39 passphrase is empty: the keystore passphrase encrypts entropy at rest; the wallet itself has no second factor.
derive_ed25519_private_key(seed, [44, 9527, 0, 0, index]) (from the
slip10_ed25519 crate) produces a 32-byte secret; we wrap that as an
ed25519_dalek::SigningKey and compute the address as
TxOutput::pubkey_hash_from_key(pubkey) — byte-identical to the
on-chain address scheme.
First-run mnemonic
The first time walletd starts with an empty <wallet_dir>, it
generates a 24-word BIP-39 mnemonic, seals the entropy to disk, and
prints the words to stderr inside a boxed section:
┌─ first run: HD seed ──────────────────────────────────────────────────
│ A new BIP-39 mnemonic has been generated and the seed has been
│ encrypted at rest with your WALLETD_KEYSTORE_PASSPHRASE.
│
│ ⚠ WRITE THESE 24 WORDS DOWN. They are the ONLY way to recover
│ every address this daemon will ever derive. They will NEVER be
│ shown again.
│
│ 1. abandon 2. ability 3. able 4. about 5. above 6. absent
│ 7. absorb 8. abstract 9. absurd 10. abuse 11. access 12. accident
│ 13. account 14. accuse 15. achieve 16. acid 17. acoustic 18. acquire
│ 19. across 20. act 21. action 22. actor 23. actress 24. actual
└───────────────────────────────────────────────────────────────────────
The mnemonic is never re-printed on later starts. Capture it out-of-band on first run (paper, password manager, hardware backup).
Recovery
Today: the only recovery is "restart with the same seed.enc + same
WALLETD_KEYSTORE_PASSPHRASE". A first-class "restore from mnemonic"
CLI shipping --from-mnemonic <words> is on the v1.1 roadmap.
For now, if you lose seed.enc but kept the mnemonic, re-importing
is a manual procedure: write a 32-byte entropy file derived from
your mnemonic, then arrange for it to be at <wallet_dir>/seed.enc
in walletd's sealed format. (Open an issue if you need this and we'll
prioritise the proper CLI.)
Imported (non-derived) addresses
exfer-walletd migrate --from <legacy-walletdir> imports legacy v0.x
.key files (raw 32-byte ed25519 secrets, or upstream-encrypted
EXFK) as non-derived addresses. Each imported secret is sealed
with the same KEK as the seed and stored at
<wallet_dir>/imported/<addr>.key.enc.
Imported addresses appear in list_addresses with imported: true
and index: null. They sign normally; the only loss is that they're
not recoverable from the mnemonic — back up imported secrets separately
or accept that they're abandoned-by-design after a re-keying.
Index pacing
Each generate_address bumps next_index and persists it atomically
(state.json.tmp → rename). With u32 indices you have ~4 billion
addresses before the keystore refuses to mint more — well beyond any
practical exchange / payment processor.
state.json is not required for derivation correctness — the
address at any index is purely a function of the seed. If you ever
lose state.json but kept the seed, re-creating an empty one is
safe; list_addresses will just be empty until you generate_address
or learn the indices through other means.
RPC reference (v1.0)
JSON-RPC 2.0 over POST /. GET /healthz is unauthenticated and
returns ok for liveness probes.
Single requests and batches (per JSON-RPC 2.0 § 6) are both accepted.
Notifications (requests with no id field) get no response per spec.
Every method below follows the same envelope:
Request
{
"jsonrpc": "2.0",
"method": "<method-name>",
"params": { ... },
"id": 1
}
jsonrpc must be exactly "2.0". id, when present, must be a
JSON string, number, or null; object, array, and boolean ids are
rejected with -32600 and response id: null. Omitting id makes
the request a JSON-RPC notification and walletd returns no response.
Response (success)
{ "jsonrpc": "2.0", "result": { ... }, "id": 1 }
Response (error)
{ "jsonrpc": "2.0",
"error": { "code": -32xxx, "message": "...", "data": { ... } },
"id": 1 }
data is omitted for most errors; populated for the structured ones
listed in Error codes.
Amounts and fees are integers in exfers, where
1 EXFER = 100_000_000 exfers. Consensus dust threshold is 200
exfers.
Examples below assume:
URL='http://127.0.0.1:7448'
SPEND=$(cat ~/.exfer-walletd/token-spend)
READ=$(cat ~/.exfer-walletd/token-read)
Scope mapping
| Scope | Methods |
|---|---|
read | ping, validate_address, get_balance, get_wallet_balance, get_block_height, get_block_by_id, get_block_by_height, get_block_id_at_height, get_transaction, get_address_utxos, get_script_utxos, get_status, list_addresses, verify_message |
manage | generate_address, abandon_transfer |
spend | transfer, send_raw_transaction, sign_message |
spend ⊇ manage ⊇ read. A token at a higher scope satisfies every
lower scope.
ping
| Scope | read |
| Params | {} |
| Returns | { ok: true } |
validate_address
Pure-function check that address is a syntactically well-formed
64-character hex string (32 bytes). No upstream call.
| Scope | read |
| Params | { address: string } |
| Returns | { valid: bool, normalized: hex64 | null } |
normalized is lowercased on success, null on failure.
generate_address
Derive the next HD address from the keystore seed and persist its
index. Optionally tag it with a label.
| Scope | manage |
| Params | { label?: string } |
| Returns | { address: hex64, pubkey: hex64, index: u32 } |
Sequential calls return index: 0, 1, 2, …. The address is fully
determined by (seed, index) — back up the 24-word mnemonic (shown
once at first start) and every present and future address is
recoverable.
list_addresses
Enumerate every known address (derived + imported).
| Scope | read |
| Params | {} |
| Returns | { addresses: AddressEntry[] } |
type AddressEntry = {
address: hex64,
index?: u32, // present for derived; absent for imported
label?: string,
imported: bool,
};
get_wallet_balance
Aggregate confirmed balance across every managed address.
For each known address, walletd calls upstream get_balance and
get_address_utxos so it can return both balance and utxo_count.
That is 2 upstream scan RPCs per address, executed concurrently
with cap 8. On public/community nodes with per-IP scan quotas, large
wallets can hit upstream rate limits; use list_addresses plus paced
per-address reads if you need quota-safe polling until upstream batch
balance lookup exists.
| Scope | read |
| Params | {} |
| Returns | { entries: WalletEntry[], total: u64 } |
type WalletEntry = {
address: hex64,
index?: u32,
label?: string,
imported: bool,
balance: u64,
utxo_count: u32,
truncated: bool, // upstream UTXO list was clipped at 1000
};
get_status
Operator dashboard in one call: daemon version, chain tip, wallet count, upstream URLs, in-flight counters.
| Scope | read |
| Params | {} |
| Returns | see below |
{
version: string,
tip: { block_id: hex64 | null, height: u64 | null },
upstream_ok: bool,
upstream_nodes: string[],
wallet_count: u32,
in_flight_utxos: u32,
in_flight_transfers: u32,
}
tip.* is null if the upstream RPC fails — the status call still
succeeds so a dashboard can show partial state.
get_balance
Confirmed balance for one address.
| Scope | read |
| Params | { address: hex64 } |
| Returns | { address: hex64, balance: u64 } |
Mempool entries are NOT counted (upstream design). For pending
balance, walk get_address_utxos and inspect mempool transactions
manually.
get_address_utxos
List confirmed UTXOs locked to an address.
| Scope | read |
| Params | { address: hex64 } |
| Returns | see below |
{
address: hex64 | null,
script_hex: hex | null,
tip_height: u64,
truncated: bool,
utxos: [
{ tx_id: hex64, output_index: u32, value: u64,
height: u64, is_coinbase: bool },
...
]
}
If truncated is true, the upstream node hit its 1000-entry result
limit and there is no pagination cursor (upstream limitation — see
README for the open RFC).
get_script_utxos
Same shape as get_address_utxos, keyed by raw script bytes (hex).
| Scope | read |
| Params | { script_hex: hex } |
get_block_height
Chain tip.
| Scope | read |
| Params | {} |
| Returns | { height: u64, block_id: hex64 } |
get_block_by_id
| Scope | read |
| Params | { block_id: hex64 } |
| Returns | BlockSummary (see below) |
get_block_by_height
| Scope | read |
| Params | { height: u64 } |
| Returns | BlockSummary |
type BlockSummary = {
block_id: hex64,
height: u64,
prev_block_id: hex64,
state_root: hex64,
tx_root: hex64,
timestamp: u64,
nonce: u64,
difficulty_target: hex64,
tx_count: u64,
transactions: hex64[], // tx_ids
};
get_block_id_at_height
Explicit height → block_id lookup. Same shape as get_block_height.
| Scope | read |
| Params | { height: u64 } |
| Returns | { height: u64, block_id: hex64 } |
Performance: the upstream node has no native height→id index, so
walletd fetches the full block and discards everything else. Same
network cost as get_block_by_height. If your next step is to read
the block body, call get_block_by_height directly — one round trip
instead of two.
get_transaction
Fetch a single transaction by id. Returns confirmed-chain or mempool
entries; in_mempool distinguishes.
| Scope | read |
| Params | { tx_id: hex64 } |
| Returns | see below |
{
tx_id: hex64,
tx_hex: hex,
in_mempool: bool,
block_id: hex64 | null,
block_height: u64 | null,
// Decoded view (added for accounting / explorers — no upstream
// calls beyond parent-tx fetches for value resolution).
inputs: [
{
prev_tx_id: hex64,
output_index: u32,
address?: hex64,
script_hex?: hex,
value?: u64,
witness?: {
pubkey?: hex64,
signature?: hex128,
witness_hex?: hex,
redeemer_hex?: hex,
},
}, ...
],
outputs: [
{ address?: hex64, script_hex?: hex, value: u64 },
...
],
total_out: u64,
total_in?: u64, // omitted if any input failed to resolve
fee?: u64, // omitted with total_in
size: u64,
}
transfer
Build, sign, and broadcast a multi-output payment.
| Scope | spend |
Params
| Field | Type | Required | Description |
|---|---|---|---|
from | hex64 | yes | Sender address (HD-derived or imported). |
outputs | [{ to: hex64, amount: u64 }] (1..=16) | yes | Recipient list. Each amount ≥ DUST_THRESHOLD (200). |
fee_rate | u64 | no | exfers per cost-unit. Mutually exclusive with fee. |
fee | u64 | no | Absolute fee in exfers. Mutually exclusive with fee_rate. |
max_fee | u64 | no | Cap; default 2_000_000 (0.02 EXFER). |
client_token | string (8..=128 ASCII) | no | Idempotency key. |
Defaults: if neither fee nor fee_rate is set, fee_rate=1
(consensus minimum). Fee is always floored at consensus::cost::min_fee
and refused if it would exceed max_fee.
Returns
{
tx_id: hex64,
size: u64,
fee: u64, // effective fee (incl. folded sub-dust change)
fee_rate: u64, // effective fee × MIN_FEE_DIVISOR / tx_cost
inputs: [{ tx_id: hex64, output_index: u32, value: u64 }],
outputs: [{ to: hex64, amount: u64, is_change: bool }],
built_at_height: u64, // tip at UTXO-listing time (not inclusion)
}
Idempotency: when client_token is supplied, the receipt is
cached for 1 hour. A repeat call with the same token + same params
returns the cached receipt without re-running. Same token + different
params → -32035 IdempotencyConflict.
Common errors
| Code | When |
|---|---|
-32001 | Wrong scope token. |
-32602 | Param shape error (from not 64-hex, outputs[] missing, fee+fee_rate both set, …). |
-32010 | Wallet not found — from is not a known address. |
-32020 | Upstream node unreachable / RPC error. |
-32030 | UTXO authentication failed. |
-32031 | Insufficient balance (with in_flight_reserved hint). |
-32032 | Fee exceeds max_fee. |
-32033 | An outputs[].amount is below dust. |
-32034 | outputs[] longer than 16. |
-32035 | Same client_token used with different params. |
send_raw_transaction
Broadcast a pre-signed transaction. Passes through to the upstream.
| Scope | spend |
| Params | { tx_hex: hex } |
| Returns | { tx_id: hex64 } |
abandon_transfer
Release outpoints from walletd's in-flight set (the local "soft
reserve" that prevents two concurrent transfers from picking the
same UTXO). Use after a transfer's broadcast appears to have failed
and you've confirmed via get_transaction(tx_id) that the network
never accepted it.
| Scope | manage |
| Params | { outpoints: [{ tx_id: hex64, output_index: u32 }] } |
| Returns | { released_count: u32, remaining_in_flight: u32 } |
In-flight outpoints also auto-expire on TTL (10 minutes); this call is for explicit / faster release.
sign_message
Sign an arbitrary UTF-8 message with the Ed25519 key of a managed
wallet. Domain-separated under EXFER-MSG, so a message signature
can never be mistaken for a transaction signature (transactions sign
under EXFER-SIG).
| Scope | spend |
| Params | { address: hex64, message: string } |
| Returns | { signature: hex128, pubkey: hex64, address: hex64 } |
sign_message is gated behind spend even though it doesn't move
funds, because the artifact is a verifiable proof of key ownership —
value-bearing in exchange / KYC contexts.
verify_message
Verify an Ed25519 message signature. Pure crypto, no wallet access.
| Scope | read |
| Params | { pubkey: hex64, signature: hex128, message: string, address?: hex64 } |
| Returns | { valid: bool, address: hex64 } |
address (in the response) is always the address derived from
pubkey, so a verifier sees what the key actually hashes to even on
valid: false. If the optional request address is supplied, valid
is true iff signature verifies AND H(DS_ADDR || pubkey) == address.
Batch requests
Send a JSON array of envelopes; receive a JSON array of responses,
with notifications (no id) omitted. JSON-RPC 2.0 permits batch
responses in any order, so clients should correlate by id. Walletd
currently preserves request order in the response array, but callers
should not rely on order when using generic JSON-RPC tooling.
curl -s $URL \
-H "Authorization: Bearer $READ" \
-H 'content-type: application/json' \
-d '[
{"jsonrpc":"2.0","method":"ping","id":1},
{"jsonrpc":"2.0","method":"get_block_height","id":2}
]'
Empty batches return a single top-level -32600 response. Batches
consisting entirely of notifications return 204 No Content. Mixed
batches return HTTP 200 with per-item result / error objects in
the array.
Error codes
JSON-RPC convention: errors usually return HTTP 200 with the error in the body. Walletd emits non-200 only for transport-layer problems (401, 400 for malformed JSON or invalid request envelopes).
For non-empty batch requests, walletd returns HTTP 200 with a response
array; item-level errors keep their JSON-RPC error.code in the body.
Top-level malformed JSON and empty batches still use the HTTP status
shown below.
v1.0 partitions the JSON-RPC implementation-defined server-error range
(-32000..-32099) into per-area slots so clients can branch on the
high digit:
-32000..-32009: auth-32010..-32019: wallet / keystore-32020..-32029: upstream-32030..-32039: transaction / fee-32040..: reserved
| Code | HTTP (single/top-level) | Name | Meaning |
|---|---|---|---|
-32700 | 400 | Parse error | Body is not valid JSON. |
-32600 | 400 | Invalid Request | Envelope shape wrong (bad jsonrpc, missing method, empty batch, …). |
-32601 | 200 | Method not found | Unknown method name. |
-32602 | 200 | Invalid params | Per-method param shape error: bad hex, wrong address length, mutually-exclusive fields supplied together, … |
-32603 | 200 | Internal error | Unexpected; the message has details. |
-32001 | 401 | Unauthorized | Missing token, wrong token, or insufficient scope. |
-32010 | 200 | Wallet not found | Address is not derived or imported in this keystore. |
-32011 | 200 | Wallet exists | Address collision on import (cosmically rare for derived). |
-32012 | 200 | Keystore locked | Wrong passphrase / corrupted seed file. |
-32020 | 200 | Upstream | Upstream node unreachable, or returned an RPC error; message intact. |
-32030 | 200 | Tx build / auth | UTXO authentication failed, or transaction construction failed. |
-32031 | 200 | Insufficient balance | Walletd can't cover amount + fee from spendable UTXOs. |
-32032 | 200 | Fee too high | Computed fee exceeds the max_fee cap on transfer. |
-32033 | 200 | Dust output | An outputs[].amount < DUST_THRESHOLD (200 exfers). |
-32034 | 200 | Too many outputs | transfer.outputs[] longer than the hard cap (16). |
-32035 | 200 | Idempotency conflict | transfer.client_token reused with different params. |
-32031 insufficient balance
The most common spend-path error. Walletd's error body carries a
machine-readable data payload so clients don't have to grep the
message string:
{
"code": -32031,
"message": "insufficient balance: need 5100000 exfers (amount + fee), wallet has 4000000 spendable across 1 UTXO(s)",
"data": {
"in_flight_reserved": false,
"needed": 5100000,
"available": 4000000,
"utxo_count": 1,
"in_flight_value": 0,
"in_flight_count": 0
}
}
If some UTXOs were filtered out by the
in-flight tracker
(another transfer from this wallet hasn't confirmed yet),
in_flight_reserved is true and the in-flight totals are
populated; the message also spells it out for human log readers:
insufficient balance: need 1100000 exfers (amount + fee), wallet has
0 spendable across 0 UTXO(s) (1 more UTXO(s) worth 64800000 exfers
reserved by pending transfers from this daemon; retry once they
confirm or use a different sending wallet)
For an integrator: branch on data.in_flight_reserved —
true → retry after the pending tx confirms; false → the wallet is
genuinely under-funded. Older clients can still grep the message
string for reserved by pending transfers; the wording is stable
but the data payload is the contract.
-32020 upstream errors
Walletd preserves the upstream node's error code and message in the
text of -32020. Examples seen in practice:
upstream node returned error code -32602: Mempool pre-check failed: double-spend of OutPoint {...}— another transfer of yours is already spending the same UTXO. The in-flight tracker protects against this in normal use, but it can still surface across walletd restarts.upstream node returned error code -32004: tx not found— querying a tx_id that's neither on chain nor in mempool.upstream node unreachable: ...: error sending request— transient transport failure. Walletd already retried up to--upstream-attemptstimes (default4) with linear backoff (--upstream-retry-backoff-ms, default500ms→ waits of 500/1000/1500ms between sweeps); each attempt rotates through every configured--node-rpcURL before counting as failed. The message reports the last URL it tried.
-32001 unauthorized
Four cases produce this:
- No
Authorization: Bearerheader. - The header is set but the token doesn't match.
- Two-token mode, the request used the read token, but the method needs spend scope.
- The token compares against
subtle::ConstantTimeEq— even a single-character difference returns 401 in identical time.
The body message is the same across all four (authentication required)
to avoid leaking which case it was.
Mapping table for clients
| If you see… | Action |
|---|---|
-32001 | Check token + scope. Don't retry blindly. |
-32020 "unreachable" | Wait a moment, retry. Consider multi-URL. |
-32020 "double-spend" | Retry after confirmation or use new wallet. |
-32031 data.in_flight_reserved=true | Wait for the pending tx to confirm. |
-32031 data.in_flight_reserved=false | Fund the wallet. |
-32602 | Programming error — check param formatting. |
Next
Security model
What you get out of the box
- Bearer token at rest is
<datadir>/tokenmode0600; datadir itself is0700. Constant-time comparison on every request. - Public binds fail-close unless TLS is on (
--tls) or you opt in with--allow-public-bind. See Tokens and scopes → Bind safety. - Wallet
.keyfiles mode0600in<datadir>/wallets/. Filenames are validated 64-hex addresses — no path traversal. - Signing happens in-process. Only the signed transaction bytes go to the upstream node; private keys never leave the daemon.
- In-flight UTXO tracker prevents back-to-back transfers from the same wallet racing onto the same outpoint (see internals below).
- Every spend-scope request emits a structured audit log line
(
spend audit) withmethod,client_ip,request_id,outcome— atINFOon success,WARNon error (the warn line also carries the error message). HonorsX-Forwarded-Forforclient_ipwhen present.
What's not protected (by design)
These are deliberate trade-offs, not bugs. Know what model you're running.
- Wallet keys are plaintext on disk. A daemon has no human to type a passphrase — keys live unencrypted, protected by FS permissions plus volume-level encryption (LUKS, dm-crypt, cloud volume encryption). Anyone who can read the wallet directory can spend every wallet.
- One spend token = total spend authority. No per-key
authorization, no quorum, no MPC. If you need finer-grained
authority, implement the
WalletStoretrait against an HSM or KMS and slot it in. - TLS is opt-in. Walletd defaults to plaintext HTTP on loopback.
For cross-host traffic, either pass
--tls(in-process, fingerprint-pinned by the SDK) or terminate TLS externally and pair with--allow-public-bind. - No rate limit, no IP allowlist. A 32-byte token is infeasible to brute-force online, but for public exposure you still want a WAF / firewall / Cloudflare in front for DoS protection.
- Upstream node RPC is unauthenticated. Walletd → node assumes a trusted hop (loopback, VPC, or HTTPS to a public provider).
In-flight UTXO tracker
When transfer picks an outpoint as an input, walletd records it in
an in-memory set with a 10-minute TTL. Subsequent transfers from the
same wallet skip outpoints in this set, so two back-to-back transfers
can't race onto the same UTXO and trigger the upstream's mempool
double-spend rejection.
- Pre-broadcast errors release the claim (RAII guard). Build / sign / authentication failures leave the outpoints re-selectable.
- Successful broadcast holds the claim until TTL. Even if the consuming tx confirms in 30s, the slot stays held for 10 minutes — small wasted availability, always safe.
- Transport-error broadcast also releases. Walletd can't be sure the broadcast landed; releasing is the safe call (a retry will get the upstream's own double-spend rejection if it did).
- In-memory only — no persistence across restarts. A pending tx from a pre-restart process is invisible to a fresh one. Run a single daemon instance per datadir.
Common misconfigurations to avoid
- Don't pass
--auth-token=…on the command line. It shows up inps aux. Use the auto-generated<datadir>/tokenor an env var. - Don't put the wallet directory on shared storage (NFS, S3-FUSE). Mode bits don't translate; concurrent writers will corrupt keys.
- Don't deploy two walletd processes against the same datadir. They don't coordinate on the in-flight tracker.
- Don't expose port 80 plaintext to the public internet. Some cloud proxies' "force HTTPS" toggle only redirects GET/HEAD; a stray POST will leak the token before redirect happens.
Next
Backup, upgrade, rotate
Backup
Back up ~/.exfer-walletd/wallets/. Losing it = losing every key =
losing every penny those addresses hold.
# Stop walletd briefly for a consistent snapshot, then:
tar -C ~ -czf wallets-$(date +%F).tar.gz .exfer-walletd
# Encrypt before shipping off-box
gpg --symmetric --cipher-algo AES256 wallets-$(date +%F).tar.gz
If you can't stop the daemon, snapshot the underlying volume (LVM,
ZFS, your cloud snapshot). Walletd writes .key files with
O_CREAT | O_EXCL and never modifies in place, so atomic per-file
copies are fine too.
Upgrade
Semver. Patches and minors are drop-in; majors will be called out in release notes.
curl -L -o /tmp/exfer-walletd \
https://github.com/exfer-stack/exfer-walletd/releases/latest/download/exfer-walletd-linux-x86_64
sudo install -m 0755 /tmp/exfer-walletd /usr/local/bin/
# restart walletd via whatever supervisor you use
The <datadir> format is stable — any version can load any wallet
file written by any other version.
Rotate tokens
Single-token (auto-generated) mode — delete the file and restart:
rm ~/.exfer-walletd/token
exfer-walletd # generates + prints a fresh one
Two-token mode (--auth-token-read / --auth-token-spend set) —
walletd ignores <datadir>/token, so deleting it does nothing.
Change the env values / CLI flags and restart instead.
Either way, any in-flight request still using the old token will fail after the restart. Plan the window.
Rotate the TLS cert
Same idea — delete the trio and restart:
rm ~/.exfer-walletd/cert.{pem,key,fingerprint}
exfer-walletd --tls # generates a fresh cert + prints the new fingerprint
Clients pinning the old fingerprint will start raising
FingerprintMismatchError on the next request. Push the new
fingerprint to them before restarting if you can't tolerate the
window.
Running under systemd
Minimal hardened unit. Adjust User, WALLETD_DATADIR, and flags
for your environment.
# /etc/systemd/system/exfer-walletd.service
[Unit]
Description=Exfer Wallet Daemon
After=network-online.target
Wants=network-online.target
[Service]
User=walletd
Group=walletd
Environment=WALLETD_DATADIR=/var/lib/walletd
Environment=EXFER_NODE_RPC=http://127.0.0.1:9334
ExecStart=/usr/local/bin/exfer-walletd --tls --bind 10.0.1.5:7448
Restart=on-failure
RestartSec=2s
# Hardening — walletd never needs anything outside its datadir.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/var/lib/walletd
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
sudo useradd --system --home /var/lib/walletd --shell /usr/sbin/nologin walletd
sudo install -d -o walletd -g walletd -m 0700 /var/lib/walletd
sudo systemctl daemon-reload
sudo systemctl enable --now exfer-walletd
journalctl -u exfer-walletd -f # first-run token + fingerprint print here
For Kubernetes: same idea, but mount the datadir as a
PersistentVolume (token + wallet keys must persist across pod
restarts), set runAsUser to a non-root UID, and add a readinessProbe
hitting GET /healthz.
Uninstall
No ceremony — walletd doesn't touch anything outside its --datadir.
# Stop the daemon, then:
rm -rf ~/.exfer-walletd # tokens + wallets + certs — IRREVERSIBLE
sudo rm /usr/local/bin/exfer-walletd
If you back up ~/.exfer-walletd/wallets/ first, you can restore the
same address set later by dropping the directory back into place.
Next
Migrating from v0.x → v1.0
v1.0 is a deliberately breaking release. The wire schema, scope model,
and on-disk keystore all change. Treat the upgrade as: stand up a fresh
v1.0 keystore alongside your v0.x deployment, migrate the old .key
files in, repoint clients to the new schema, then turn off v0.x.
What changed
Field naming
| v0.x | v1.0 |
|---|---|
get_transaction param {hash} | {tx_id} |
get_block (untagged hash xor height) | get_block_by_id {block_id} + get_block_by_height {height} |
get_block_hash {height} | get_block_id_at_height {height} |
BlockSummary.hash | BlockSummary.block_id |
TxStatus.block_hash | TxStatus.block_id |
UTXO entry script_len (always null) | removed |
transfer {from, to, amount, fee} | {from, outputs: [{to, amount}], fee_rate? / fee?, max_fee?, client_token?} |
transfer receipt {tx_id, size, tip_height, submitted} | {tx_id, size, fee, fee_rate, inputs, outputs, built_at_height} |
Error codes
BadEnvelope split into:
-32700JSON parse error (body not parseable)-32600envelope-shape error (jsonrpc != "2.0", missing method, …)-32602per-method param shape error (most call sites; matches spec's "Invalid params")
New error codes for v1.0 transfer semantics:
| Code | Variant |
|---|---|
-32012 | KeystoreLocked (wrong passphrase, corrupted seed) |
-32032 | FeeTooHigh (computed fee exceeds max_fee) |
-32033 | DustOutput |
-32034 | TooManyOutputs (cap = 16) |
-32035 | IdempotencyConflict (token reused with different params) |
Auth scopes
Two scopes (read, spend) → three scopes (read, manage, spend).
generate_address moves to manage (previously was read — a bug,
since it writes state).
Config flags:
| v0.x | v1.0 |
|---|---|
--auth-token (legacy single token) | removed |
--auth-token-read | same |
| (none) | --auth-token-manage |
--auth-token-spend | same |
On first run, walletd auto-generates three files in <datadir>/:
token-read, token-manage, token-spend (each mode 0600).
Keystore
- v0.x: one plaintext
<addr>.keyfile per address (32 raw bytes). - v1.0: one encrypted
seed.encfor the entire HD chain, plusstate.jsonindexing derived addresses.
The encryption KEK is derived from WALLETD_KEYSTORE_PASSPHRASE via
Argon2id; walletd refuses to start without that env var set.
JSON-RPC compliance
jsonrpcfield is now strictly checked; anything other than"2.0"returns-32600.- Batch requests (JSON array of envelopes) are supported per § 6.
- Notifications (envelopes with no
idfield) get no response per § 4.1.
Migration procedure
-
Stop v0.x (or leave it running on a separate port; nothing prevents the two from coexisting).
-
Set the keystore passphrase:
export WALLETD_KEYSTORE_PASSPHRASE='whatever-your-secret-manager-provides' -
Start v1.0 once to initialise the HD seed and three scoped tokens:
exfer-walletd --datadir /var/lib/walletd-v1Capture the 24-word mnemonic printed to stderr (paper / password manager / hardware backup). The mnemonic will never be shown again.
-
Stop v1.0, then import the legacy
.keyfiles:exfer-walletd migrate \ --datadir /var/lib/walletd-v1 \ --from /var/lib/walletd-v0/walletsFor each legacy
<addr>.key, walletd imports the secret under<datadir>/wallets/imported/<addr>.key.encand records the address instate.json.imported[]. -
Restart v1.0 in serving mode and switch clients over.
-
Repoint clients:
- Use the new field names (
tx_id,block_id, multi-output transfer, etc.). The dry-run table at the top of this doc maps every renamed surface. - Use the new scoped tokens (
token-spendfor withdrawal workers,token-readfor deposit watchers, etc.). - Use the
client_tokenfield ontransferfor retry safety.
- Use the new field names (
-
Decommission v0.x once you've verified all dependent clients speak v1.
Notes
- Imported addresses are NOT recoverable from the mnemonic. They
remain spendable as long as
<datadir>/wallets/imported/*.key.encexists. Back them up alongside the seed. migraterefuses to run against a brand-new keystore (no captured mnemonic) — the assumption is you want all imports plus future derivations to be backed by a recorded seed.- If a legacy
.keyis in the upstream encrypted (EXFK) format, pass--legacy-passphrase(orWALLETD_LEGACY_PASSPHRASE).
FAQ & troubleshooting
For "where's my token / how do I rotate / how do I bind a public IP" see Quick start, Tokens and scopes, and Operations first.
transfer returns -32031 but get_balance says I have money
The in-flight UTXO tracker. A previous transfer from the same
wallet hasn't confirmed yet, and walletd reserved its UTXOs in
memory so the new transfer can't race onto the same outpoints.
The error message has the detail:
insufficient balance: need 1100000 exfers (amount + fee), wallet
has 0 spendable across 0 UTXO(s) (1 more UTXO(s) worth 64800000
exfers reserved by pending transfers from this daemon; retry once
they confirm or use a different sending wallet)
Wait for the pending tx to confirm, or use a different sender wallet. TTL on the in-flight claim is 10 minutes; after that, walletd re-tries the outpoint (and would lose to a mempool double-spend rejection if the original is still pending).
I restarted walletd and now transfer fails with mempool double-spend
The in-flight tracker is in-memory only. A pre-restart pending tx is
invisible to the fresh process. If get_address_utxos on the
upstream still returns the (mempool-spent) UTXO as confirmed, the
fresh walletd will pick it and get rejected.
Wait for the pending tx to confirm — once it's in a block, the spent UTXO stops appearing. Or: don't restart walletd while transfers are unconfirmed.
Why doesn't get_address_utxos see my mempool spends?
Common policy across UTXO-chain nodes: get_address_utxos returns
the confirmed UTXO set, not the mempool view. A mempool-spent
UTXO keeps appearing here until its consuming tx confirms.
This is why walletd has the in-flight tracker — to bridge that gap locally without depending on a mempool-aware UTXO endpoint upstream.
Balances look wrong / get_block_height is way behind
Almost always: your upstream node isn't fully synced. Walletd returns whatever the node has — node at height 50k while the chain is at 580k means balances reflect the 50k view.
The walletd-local methods (generate_address, list_addresses,
healthz, ping) are safe to call regardless of node sync state.
How do I see what walletd is doing?
Daemon logs go to stdout/stderr. Bump verbosity with RUST_LOG:
RUST_LOG=debug,exfer_walletd=trace exfer-walletd
Spend-scope requests always emit an audit line at INFO with
method, client IP, request id, and outcome.
Something else?
Open an issue with the error message, surrounding log lines (with token values redacted), and what you were trying to do.