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 seals every key at rest with argon2id +
ChaCha20-Poly1305 and refuses to start without an explicit passphrase.
On first run, walletd:
- Creates
~/.exfer-walletd/(mode0700). - Initializes an empty keyring. Addresses are minted on demand
(
generate_standard_address), each with its own recovery phrase — there is no single seed to write down. Back up the keyring withexport_vault(see Keystore). - 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/
├── state.json ← next_index, labels, imported list
├── imported/ ← one sealed key per 1:1 address
└── seed.enc ← OPTIONAL: only on a legacy seeded keyring
$ cat ~/.exfer-walletd/token-spend
a85da0752815bbf652a1b147649cde77c17f784f3e608d362c629c798a555e7b
If you also passed --tls (see below), three more files appear:
cert.pem, cert.key, cert.fingerprint.
Backup = a vault blob from export_vault (one passphrase-sealed
file covering every key), kept offline. Per-address phrases
(reveal_address_mnemonic) are the finer-grained alternative. See
Keystore. 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 2 sequential round-trips, regardless of how
many input UTXOs the tx consumes:
list_utxos 1 RTT
send_raw_transaction 1 RTT
Submit time ≈ 2 × RTT.
- Local node (RTT ~5ms): ~10ms
- LAN node (RTT ~50ms): ~100ms
- Public RPC (RTT ~750ms): ~1.5s
Input count doesn't enter the latency budget: walletd trusts the
value reported by get_address_utxos and does not pre-fetch each
funding tx. A node that returns wrong values can only cause the
broadcast to fail consensus — funds stay safe.
Next
Tokens and scopes
Walletd uses bearer-token authentication on every request except
GET /healthz. Comparison is constant-time
(subtle::ConstantTimeEq).
Three scoped tokens
Walletd issues three tokens, one per scope. On first start it
auto-generates them at <datadir>/token-{read,manage,spend} (mode
0600).
The authoritative scope of every method is Scope::for_method in
src/auth.rs; anything not listed below as manage or spend is
read. The RPC reference tags each method with
its scope.
| Scope | Methods |
|---|---|
read | ping, validate_address, the get_* family, list_addresses, list_settlements, verify_message, get_status, get_wallet_balance, htlc_status/htlc_list/htlc_lookup_by_hashlock, simulate_*, wait_for_tx/wait_for_payment, payment_uri_*, swap_status/swap_list/swap_pool_info/swap_price_klines, lp_pool_info/lp_position/lp_deposit_status, bsc_get_address/bsc_get_balances/bsc_tx_history, contract_stats, get_attestation_edges |
manage | generate_address, generate_independent_address, generate_standard_address, import_private_key, import_mnemonic, import_standard_mnemonic, bsc_create_address, bsc_import_mnemonic, bsc_import_key, abandon_transfer, htlc_forget |
spend | transfer, send_raw_transaction, sign_message, htlc_lock/htlc_claim/htlc_reclaim, reveal_mnemonic/reveal_private_key/reveal_address_mnemonic/reveal_evm_private_key, export_vault/export_address/import_vault, delete_address, swap_get_quote/swap_execute/swap_refund, bsc_send_bnb/bsc_reveal_mnemonic/bsc_delete_key, lp_withdraw_self |
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 (keyring)
Walletd's keystore is a flat keyring: a collection of independent keys, each individually exportable, importable, and deletable. No single secret governs the others — every address has its OWN 24-word recovery phrase, and a single vault file backs up the whole keyring under one passphrase.
This replaced the older "one HD seed derives every address" model. A legacy HD seed still works as a backward-compatible origin (see Seeded vs seedless), but it is no longer the spine of the wallet.
Key origins
A key enters the keyring one of three ways:
| Origin | Created by | Recovery |
|---|---|---|
| Standard (default) | generate_standard_address, import_standard_mnemonic | its own 24-word BIP-39 phrase, cross-wallet compatible |
| Independent | generate_independent_address | its own 24-word phrase (the raw ed25519 secret encoded as BIP-39) |
| Imported | import_private_key, import_mnemonic | the supplied secret / phrase; back it up yourself |
| HD-derived (legacy) | generate_address on a seeded keyring | the keyring's single seed mnemonic |
generate_standard_address is the default for new addresses. It
mints a fresh 1:1 address from a random standard BIP-39 phrase whose
derivation matches exfer.dev and the apps — the same phrase re-imported
into any Exfer wallet yields the same address. Each method returns
{address, pubkey, imported: true}.
Standard vs independent
Both are 1:1 keys with their own recovery phrase; they differ only in how the phrase maps to the secret:
- Standard mixes the BIP-39 seed with the domain tag
EXFER-MNEMONIC-ED25519-V1(pinned, byte-identical acrossexfer.devweb and the apps). Its phrase is sealed alongside the key soreveal_address_mnemoniccan return it, and the phrase restores the same address in any Exfer wallet. - Independent encodes the raw 32-byte ed25519 secret directly as a BIP-39 phrase. Self-contained, but not derived through the standard domain — restore it into walletd, not into a different wallet's "import mnemonic".
On-disk layout (<wallet_dir>/)
seed.enc ← OPTIONAL legacy HD entropy (32 B), sealed.
Absent in a seedless keyring.
state.json ← {"next_index": N, "derived": {addr→idx},
"labels": {addr→label}, "imported": [addr,…]}
imported/<addr>.key.enc ← sealed 32-byte secret per 1:1 key
imported/<addr>.mnemonic.enc ← sealed BIP-39 phrase for STANDARD keys only
(lets reveal_address_mnemonic return it)
<wallet_dir> defaults to <datadir>/wallets (mode 0700).
At-rest encryption
Every sealed file (keys, mnemonics, the optional seed, the vault) uses one 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 unseal stays snappy. Each sealed class is
bound to its own AAD (exfer-walletd/v1/{seed,imported,vault,mnemonic})
so a blob can't be replayed across roles.
The keystore passphrase
The at-rest 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=in a0600drop-in; Docker/k8s from a Secrets Manager. - never check
.envfiles into git.
A wrong passphrase on a later start surfaces KeystoreLocked
(-32012) and walletd exits before binding any socket.
Backup and recovery
Two complementary backups:
- The vault (recommended).
export_vaultseals the entire keyring — every key, with standard mnemonics — into one passphrase-protected blob;import_vaultrestores it into another walletd. This is the single-file backup that survives adding new addresses without re-backing-up.export_addressexports just one key as a vault blob. - Per-address recovery phrase.
reveal_address_mnemonicreturns one address's 24 words. A standard phrase restores that address into any Exfer wallet; an independent phrase restores into walletd.
Deleting a key (delete_address) is destructive: it refuses while the
address holds a balance unless you pass force: true, and once erased
the key is gone unless it was backed up (vault or phrase). Sweep first.
Seeded vs seedless
- Seedless (default for fresh keyrings): no
seed.enc. Every address is its own 1:1 key; backup is the vault or per-address phrases. - Seeded (legacy / operator): a
seed.encis present andgenerate_addressderives addresses by index alongm/44'/9527'/0'/0'/i'(SLIP-0010 Ed25519, all-hardened). The single seed mnemonic backs up every derived address. Coin type9527'is a private-use SLIP-44 placeholder until Exfer registers an official slot; changing it would invalidate every derived address.
Existing seeded wallets keep working unchanged — they derive as before,
and new addresses default to standard 1:1 keys. state.json is not
required for derivation correctness on a seeded keyring (the address at
any index is a pure function of the seed), but it is the only record of
imported/independent keys, so it is included in the vault.
RPC reference (v1.13)
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
spend ⊇ manage ⊇ read — a token at a higher scope satisfies every
lower scope. The authoritative source is Scope::for_method in
src/auth.rs; the per-method scope is shown in the catalog below.
Method catalog
Every dispatched method, grouped by family. Methods marked (node) or (indexer) proxy the upstream node / the embedded indexer; the rest act on the local keyring or the swap engine. Detailed request/response sections follow for the core wallet, HTLC, simulation, observability, and payment-URI methods; the keyring, swap, LP, and BSC families are documented at the end of this page.
Chain reads (node) — read
| Method | Purpose |
|---|---|
ping | liveness + version |
get_status / get_follower_status | daemon + indexer-follower health |
get_block_height | tip height + genesis_block_id |
get_block_by_id / get_block_by_height / get_block_id_at_height | block lookups |
get_transaction | transaction by id |
get_balance / get_wallet_balance | one address / whole-keyring balance |
get_address_utxos / get_script_utxos | spendable outputs |
get_address_mempool | unconfirmed entries touching an address |
get_output_spent_by | the input that spent an outpoint (indexer) |
validate_address | address well-formedness check |
wait_for_tx / wait_for_payment | long-poll for confirmation / incoming funds |
Indexer reads (indexer) — read
| Method | Purpose |
|---|---|
get_address_history | confirmed tx history for an address |
list_settlements | settlement records the daemon tracks |
htlc_status / htlc_list / htlc_lookup_by_hashlock | HTLC lifecycle observability |
contract_stats | aggregate contract counters |
get_attestation_edges | attestation graph edges |
detect_in_chain_swaps | scan for on-chain swap legs |
Keyring — manage to create, spend to reveal/export/delete
| Method | Scope | Purpose |
|---|---|---|
generate_standard_address | manage | default: 1:1 address from a standard BIP-39 phrase (exfer.dev-compatible) |
generate_independent_address | manage | 1:1 address, raw secret as its own phrase |
generate_address | manage | HD-derive next index (seeded keyrings only) |
import_private_key | manage | import a raw 32-byte secret |
import_mnemonic | manage | import an independent 24-word phrase |
import_standard_mnemonic | manage | import a standard 24-word phrase |
list_addresses | read | list keyring addresses + labels |
reveal_address_mnemonic | spend | a single address's 24-word phrase |
reveal_mnemonic / reveal_private_key | spend | legacy seed mnemonic / raw key |
export_address / export_vault | spend | seal one key / the whole keyring to a vault blob |
import_vault | spend | restore a vault blob |
delete_address | spend | erase a key (refuses on non-zero balance unless force) |
Transactions & messages
| Method | Scope | Purpose |
|---|---|---|
transfer | spend | build, sign, broadcast a payment |
send_raw_transaction | spend | broadcast a pre-built tx |
abandon_transfer | manage | drop a stuck in-flight transfer |
sign_message / verify_message | spend / read | proof-of-ownership signatures |
simulate_transfer / simulate_htlc_lock | read | cost/feasibility dry-run |
HTLC
| Method | Scope | Purpose |
|---|---|---|
htlc_lock / htlc_claim / htlc_reclaim | spend | open / claim / reclaim an HTLC |
htlc_forget | manage | drop local HTLC tracking state |
Payment URI — read
payment_uri_encode / payment_uri_decode — exfer: URI codec.
Cross-chain swap & LP (swap engine / pool)
| Method | Scope | Purpose |
|---|---|---|
swap_get_quote | spend | reserve a preimage + seal a swap quote |
swap_execute / swap_refund | spend | lock/claim / reclaim both legs |
swap_status / swap_list | read | swap journal |
swap_pool_info / swap_price_klines | read | pool reserves, fees / price chart |
lp_pool_info / lp_position / lp_deposit_status | read | LP pool + position views |
lp_deposit_start | read | begin an LP deposit (returns funding instructions) |
lp_withdraw_self | spend | cash out LP shares to the user |
BSC / EVM side (native BNB counter-asset)
| Method | Scope | Purpose |
|---|---|---|
bsc_get_address / bsc_get_balances | read | EVM address / BNB+token balances |
bsc_tx_history | read | native-BNB transfer history |
bsc_create_address / bsc_import_mnemonic / bsc_import_key | manage | provision the independent EVM key |
bsc_reveal_mnemonic | spend | reveal the EVM recovery phrase |
reveal_evm_private_key | spend | export the EVM key (MetaMask import) |
bsc_delete_key | spend | delete the EVM key (can strand BNB) |
bsc_send_bnb | spend | withdraw native BNB |
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 (by
default) 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.
Pass { "utxos": false } to skip the per-address get_address_utxos
call: this returns balances only (1 scan RPC per address) and omits
utxo_count / truncated. Use it for frequent balance polling (e.g. a
live deposit watcher) and fetch UTXO counts on demand when you actually
need them.
Pass { "addresses": [hex64, …] } to scan only a subset of managed
addresses (unknown addresses are ignored). The scan count then tracks
how many addresses you actually poll, so a client can skip hidden
addresses and poll a single visible address far more often without
tripping the node's rate limit. Absent ⇒ every managed address.
| Scope | read |
| Params | { utxos?: bool, addresses?: hex64[] } — utxos defaults to true; addresses defaults to all |
| Returns | { entries: WalletEntry[], total: u64 } (only the scanned addresses; total sums them) |
type WalletEntry = {
address: hex64,
index?: u32,
label?: string,
imported: bool,
balance: u64,
utxo_count?: u32, // omitted when called with { utxos: false }
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. |
datum | hex (≤ 4096 bytes) | no | Generic app-defined on-chain blob attached to the primary (first) recipient output. The chain validates only size; meaning is the application's. Read it back from get_transaction (outputs[].datum). |
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. |
HTLC methods
Hash time-locked contracts over JSON-RPC, so an agent can run HTLC
payments (atomic swaps, escrow, conditional settlement) without
re-implementing Exfer Script or the signing transcript in its own
language. walletd builds and signs in-process and broadcasts via the
node — identical wire output to the exfer script htlc-* CLI.
The HTLC script has two spend arms:
- hashlock — the receiver claims by revealing a preimage
pwithsha256(p) == hash_lock, plus the receiver's signature. - refund — after
timeout(an absolute block height), the sender reclaims with their signature.
Lifecycle (receiver B claims; otherwise sender A reclaims):
- B picks a secret, shares
hash_lock = sha256(secret)with A. - A:
htlc_lock { from: A_addr, receiver: B_pubkey, hash_lock, timeout: H+N, amount }→tx_id. 3a. B:htlc_claim { from: B_addr, lock_tx_id: tx_id, preimage: secret, sender: A_pubkey, timeout: H+N }. 3b. or, if B never claims, after heightH+N— A:htlc_reclaim { from: A_addr, lock_tx_id: tx_id, receiver: B_pubkey, hash_lock, timeout: H+N }.
For a cross-chain atomic swap, the same preimage unlocks the mirror HTLC
on the other chain — htlc_claim reveals it on-chain in plaintext.
Fee note.
htlc_claim/htlc_reclaimspend a script input, which the node prices with the spent script's evaluation cost (min_fee_with_script_cost), so their minimum fee is higher than a plaintransfer. walletd computes it automatically whenfeeis omitted; passfeeonly to override (it must still clear the minimum).
htlc_lock
Fund an HTLC output payable to receiver against hash_lock,
refundable to from after timeout. Funds from from's UTXOs exactly
like transfer (auto-change, same fee handling).
| Scope | spend |
Params
| Field | Type | Required | Description |
|---|---|---|---|
from | hex64 | yes | Sender wallet address (funds + signs). |
receiver | hex64 | yes | Receiver's 32-byte pubkey (the key that can claim). |
hash_lock | hex64 | yes | sha256(preimage). |
timeout | u64 | yes | Absolute block height after which from may reclaim. |
amount | u64 | yes | Amount to lock (exfers), ≥ DUST_THRESHOLD (200). |
fee_rate | u64 | no | exfers per cost-unit. Mutually exclusive with fee. |
fee | u64 | no | Absolute fee. Mutually exclusive with fee_rate. |
max_fee | u64 | no | Cap; default 2_000_000. |
Returns
{
tx_id: hex64,
htlc_output_index: u32, // always 0 (change, if any, is output 1)
amount: u64,
hash_lock: hex64,
timeout: u64,
receiver: hex64,
size: u64,
fee: u64,
fee_rate: u64,
built_at_height: u64,
change?: u64, // present iff change was returned to `from`
}
Common errors: -32001, -32602, -32010 (from unknown),
-32020, -32031 (insufficient balance), -32032 (fee > max_fee),
-32033 (amount < dust).
htlc_claim
Claim an HTLC's hashlock arm by revealing the preimage. from is the
receiver wallet (also where the funds land).
| Scope | spend |
Params
| Field | Type | Required | Description |
|---|---|---|---|
from | hex64 | yes | Receiver wallet address (claims + receives). |
lock_tx_id | hex64 | yes | The htlc_lock transaction id. |
output_index | u32 | no | HTLC output index in the lock tx (default 0). |
preimage | hex (1..=1024 bytes) | yes | Secret whose sha256 equals the lock's hash_lock. |
sender | hex64 | yes | Sender's pubkey — reconstructs the script. |
timeout | u64 | yes | The lock's timeout — reconstructs the script. |
fee | u64 | no | Absolute fee. Default = script-aware consensus minimum. |
Returns
{ tx_id: hex64, kind: "claim", value: u64, fee: u64,
lock_tx_id: hex64, output_index: u32, size: u64 }
value is paid to from (htlc_value − fee).
walletd reconstructs the HTLC script from (sender, from's pubkey,
sha256(preimage), timeout) and authenticates the on-chain output
against it before spending — a wrong preimage/sender/timeout (or
a lying node) yields -32036 and nothing is broadcast.
Common errors: -32001, -32602, -32010, -32020,
-32036 (output auth / script mismatch), -32030, -32603.
htlc_reclaim
Reclaim an HTLC's refund arm after timeout. from is the original
sender wallet.
| Scope | spend |
Params
| Field | Type | Required | Description |
|---|---|---|---|
from | hex64 | yes | Sender wallet address (reclaims). |
lock_tx_id | hex64 | yes | The htlc_lock transaction id. |
output_index | u32 | no | HTLC output index (default 0). |
receiver | hex64 | yes | Receiver's pubkey — reconstructs the script. |
hash_lock | hex64 | yes | The lock's hash_lock — reconstructs the script. |
timeout | u64 | yes | The lock's timeout height. |
fee | u64 | no | Absolute fee. Default = script-aware consensus minimum. |
Returns: same shape as htlc_claim, with kind: "reclaim".
walletd checks get_block_height first and rejects with -32037
(timeout not reached) when current_height ≤ timeout, before building
anything. Output authentication (-32036) applies as in htlc_claim.
Common errors: -32001, -32602, -32010, -32020,
-32037 (timeout not reached), -32036 (output auth), -32030.
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.
reveal_mnemonic
Re-supply the keystore passphrase and receive the 24-word BIP-39 mnemonic that produced this keystore. Sensitive — only call after a deliberate user action.
| Scope | spend |
| Params | { passphrase: string } |
| Returns | { mnemonic: string[] } (24 lowercase BIP-39 words) |
The passphrase is verified by re-unsealing seed.enc with it. Wrong
passphrase surfaces as -32012 Keystore locked. The
in-memory passphrase from daemon start is not reused — clients
must pass it freshly, which mirrors standard wallet
"type your password to reveal" gating.
Walletd's HD path is m/44'/9527'/0'/0'/i', so the returned mnemonic
is not directly portable to most third-party wallets (their default
coin-type-44 derivation uses a different coin_type slot). It is the
canonical recovery secret for re-running walletd against the same
keystore.
reveal_private_key
Re-supply the keystore passphrase and receive the raw 32-byte ed25519 secret for a single managed address. Sensitive — only call after a deliberate user action.
| Scope | spend |
| Params | { address: hex64, passphrase: string } |
| Returns | { address: hex64, secret_hex: hex64 } |
Works for HD-derived addresses (re-derives from the just-unsealed
seed) and for imported addresses (re-unseals the per-key file with the
same passphrase). Wrong passphrase → -32012 Keystore locked. Address
not in this keystore → -32010 Wallet not found.
The returned secret_hex is a 32-byte ed25519 private key in
lowercase hex, the same secret format consumed by
exfer-walletd migrate --from <dir>.
Cost simulation (v1.9)
Read-scope dry-runs of the corresponding spend methods. Same fee estimation, same UTXO selection, same builder — but never broadcasts and never moves funds. Use them to prove a cost ceiling holds before committing to spend.
simulate_transfer
| Scope | read |
Params — identical to transfer except client_token is not
accepted (there's nothing to deduplicate when no tx is broadcast):
| Field | Type | Required | Description |
|---|---|---|---|
from | hex64 | yes | Sender address. |
outputs | array | yes | 1..=16 {to: hex64, amount: u64}. |
fee_rate | u64 | no | Same as transfer. |
fee | u64 | no | Same as transfer. |
max_fee | u64 | no | Same as transfer. |
Returns
{
size: u64,
fee: u64,
fee_rate: u64,
inputs: [{tx_id, output_index, value}],
outputs: [{to, amount, is_change}],
total_in: u64, // sum of inputs.value
total_out: u64, // sum of outputs.amount
change: u64, // 0 if no change output
built_at_height: u64,
}
tx_id is intentionally omitted — nothing was broadcast.
total_in = total_out + fee is invariant for a well-formed result.
simulate_htlc_lock
| Scope | read |
Same params as htlc_lock. Returns the same fee /
size / change shape as simulate_transfer, plus the HTLC-specific
fields:
{
size, fee, fee_rate,
htlc_output_index: u32,
amount: u64,
hash_lock: hex64,
timeout: u64,
receiver: hex64,
total_in: u64,
change: u64,
built_at_height: u64,
}
HTLC observability (v1.9)
The block follower watches every accepted block, identifies HTLC outputs paying any owned key, and tracks their lifecycle in a local index. These methods read that index.
htlc_status
| Scope | read |
Params
| Field | Type | Required | Description |
|---|---|---|---|
lock_tx_id | hex64 | yes | The lock transaction's id. |
output_index | u32 | no | Default 0. |
Returns — full HtlcRecord (see htlc_list for the
shape).
Errors — -32010 WalletNotFound if the index has no record for
that outpoint.
htlc_list
| Scope | read |
Params — every field optional:
| Field | Type | Description |
|---|---|---|
role | enum | sender / receiver / both / any (default). |
state | enum / array of enum | Filter to one or more of locked / locked_expired / claimed / reclaimed / unknown. |
since_height | u64 | Only entries with lock_block_height ≥ this. |
limit | u32 | Default 100, capped at 1000. |
cursor | str | Opaque cursor from a previous response. |
address | hex64 | Reserved for the indexer integration (v1.9.1+). |
Returns
{
htlcs: [
{
lock_tx_id: hex64,
output_index: u32,
params: {
sender: hex64, // pubkey
receiver: hex64, // pubkey
hash_lock: hex64,
timeout_height: u64,
},
amount: u64,
lock_block_height: u64 | null,
state: "locked"|"locked_expired"|"claimed"|"reclaimed"|"unknown",
claim: { tx_id, preimage, block_height, input_index } | null,
reclaim: { tx_id, block_height, input_index } | null,
role: "sender"|"receiver"|"both"|"observer",
last_indexed_height: u64,
},
...
],
next_cursor?: str,
}
Records are returned in ascending (lock_block_height, lock_tx_id, output_index) order. If next_cursor is present, pass it back as
cursor to fetch the next page.
htlc_forget
| Scope | manage |
Remove a settled (Claimed / Reclaimed) HTLC from the local index. Refuses to forget still-Locked entries — an active wallet must not silently lose track of an open obligation.
Params
| Field | Type | Required | Description |
|---|---|---|---|
lock_tx_id | hex64 | yes | |
output_index | u32 | no | Default 0. |
Returns
{ removed: bool }
removed = false when no such record existed.
Errors — -32602 BadParams for a non-settled record.
get_follower_status
| Scope | read |
Operator / agent dashboard for "how caught up is the follower."
Params — {}.
Returns
{
last_indexed_height: u64,
last_indexed_block_id: hex64,
tip_height: u64,
lag: i64, // = tip_height - last_indexed_height
indexed_htlc_count: u64,
follower_started_at: u64, // unix seconds
full_scan_complete: bool,
}
wait_for_tx
| Scope | read |
Subscribes to the follower's tip channel and returns as soon as the
named transaction has at least min_confirmations blocks behind it.
No client-side polling required.
Params
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
tx_id | hex64 | yes | The transaction id. | |
min_confirmations | u32 | no | 1 | Block depth required. |
timeout_secs | u64 | no | 60 | Max wait, capped at 600. |
Returns
{
tx_id: hex64,
block_id: hex64,
block_height: u64,
confirmations: u64, // ≥ min_confirmations on success
}
Errors — -32040 WaitTimeout if the budget expires before the
transaction reaches min_confirmations. The error's data payload
includes {tx_id, min_confirmations, elapsed_secs} so a client can
retry or escalate programmatically.
Unknown-tx and in_mempool=true responses from the node are treated
as "not yet visible" — wait_for_tx keeps waiting up to the timeout.
wait_for_payment
| Scope | read |
Blocks until a new credit to address is observed, then returns
immediately. The fast path wakes on the in-process push bus fed by the
node's /sse endpoint — a script_changed nudge arrives within a
network RTT of the paying transaction hitting the node's mempool, so an
agent learns "I was paid" sub-second, with no polling. When the node has
no /sse (pre-1.12) the method falls back to waking on each follower tip
advance.
Returns as soon as the payment is seen in the mempool (0
confirmations) — this is a liveness/receipt signal, not settlement
finality. Pair it with wait_for_tx when you need the
credit buried to a confirmation depth.
Params
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
address | hex64 | yes | The address (script) to watch for incoming credit. | |
min_amount | u64 | no | 1 | Only report a credit of at least this many exfers. |
timeout_secs | u64 | no | 60 | Max wait, capped at 600. |
Returns — on a credit:
{
address: hex64,
received: true,
timed_out: false,
tx_id: hex64 | null, // null when detected via a confirmed-balance delta
amount: u64, // value of the new credit
confirmations: u64, // 0 = mempool-seen, ≥1 = already confirmed
tip_height: u64,
}
On timeout (a quiet window — a normal outcome for a watcher, not an error):
{ address: hex64, received: false, timed_out: true, waited_secs: u64 }
Payment URI codec (v1.9)
Pure functions — no upstream calls, no key access. Round-trip payment requests through a canonical BIP21-style string:
exfer:<address>[?amount=N&memo=...&hash_lock=...&timeout=N&label=...]
payment_uri_encode
| Scope | read |
Params
| Field | Type | Required | Description |
|---|---|---|---|
address | hex64 | yes | Recipient address. |
amount | u64 | no | Base units (exfers). |
memo | str | no | Free-form, percent-encoded. |
hash_lock | hex64 | no | For HTLC requests. |
timeout | u64 | no | Pair with hash_lock. |
label | str | no | Short payee label. |
Returns — { uri: str }.
payment_uri_decode
| Scope | read |
Params — { uri: str }.
Returns — the same shape as payment_uri_encode's input.
Unknown query keys are silently dropped (forward-compatible).
Address / hash_lock are normalised to lowercase hex.
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.
Keyring management
The keyring is a flat set of 1:1 keys; see Keystore for the model. All addresses are 64-hex pubkey hashes (lowercase).
generate_standard_address
manage. The default way to mint an address. Derives a fresh 1:1
key from a random standard BIP-39 phrase (the exfer.dev-compatible
derivation), so its recovery phrase restores the same address in any
Exfer wallet.
- Params:
{ "label": "<string, optional>" }(or none). - Result:
{ "address": "<64 hex>", "pubkey": "<64 hex>", "imported": true }.
generate_independent_address
manage. Same shape, but the key's recovery phrase is its raw 32-byte
secret encoded as BIP-39 — self-contained, walletd-restore only.
generate_address
manage. Legacy HD derivation: bumps next_index and derives the next
address from the keyring's seed. Only meaningful on a seeded keyring
(see Keystore → Seeded vs seedless).
Params { "label": "<optional>" }.
import_private_key
manage. Register a raw 32-byte ed25519 secret.
Params { "private_key": "<64 hex>", "label": "<optional>" }.
import_mnemonic / import_standard_mnemonic
manage. Register a 24-word BIP-39 phrase as a key — import_mnemonic
treats it as an independent phrase, import_standard_mnemonic derives it
through the standard exfer.dev domain.
- Params:
{ "mnemonic": "<24 words>", "label": "<optional>" }. - Result: the imported
address.
reveal_address_mnemonic
spend. Return one address's own 24-word recovery phrase. Sensitive.
- Params:
{ "address": "<64 hex>", "passphrase": "<keystore passphrase>" }. - Result:
{ "address": "<64 hex>", "mnemonic": ["word", …] }.
export_address / export_vault
spend. Seal key material to a WDV1 vault blob.
export_address covers a single address; export_vault covers every
key in the keyring (single-file backup that survives adding addresses).
export_addressparams:{ "address": "<64 hex>", "passphrase": "<vault passphrase>" }→{ "address": "<64 hex>", "vault_hex": "<hex blob>" }.export_vaultparams:{ "passphrase": "<vault passphrase>" }→ the sealedvault_hexblob.
The vault passphrase is independent of the keystore passphrase: it protects the portable blob.
import_vault
spend. Restore keys from an export_vault / export_address blob;
each lands as an independent key, and addresses already present are
skipped.
- Params:
{ "vault_hex": "<hex blob>", "passphrase": "<vault passphrase>" }. - Result: the list of restored addresses.
delete_address
spend. Erase a key from the keyring. Destructive — refuses while the
address holds a confirmed balance (or when the upstream balance can't be
checked) unless force is set. Back the key up first
(export_address or
reveal_address_mnemonic).
- Params:
{ "address": "<64 hex>", "passphrase": "<keystore passphrase>", "force": false }.
Swap, LP, and BSC
The cross-chain swap engine settles EXFER against native BNB over a
pool, using HTLCs on the Exfer leg and an EVM key on the BSC leg. These
methods are summarized in the catalog
above; their request/response shapes track the pool protocol and the
EVM side, and the fund-moving ones (swap_get_quote, swap_execute,
swap_refund, bsc_send_bnb, lp_withdraw_self, and the EVM key
reveals/deletes) require spend. LP read views (lp_pool_info,
lp_position, lp_deposit_status) and swap views (swap_status,
swap_list, swap_pool_info, swap_price_klines) are read. The
LP deposit/withdraw calls key off the caller's EXFER and BSC addresses
(lp_deposit_start(exfer_address, bsc_address),
lp_withdraw_self(exfer_address, shares)).
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..-32049: async waits (wait_for_tx)-32050..: 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 | Transaction construction failed (param overflow, encoding error, etc.). |
-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. |
-32036 | 200 | HTLC output auth | On htlc_claim/htlc_reclaim, the on-chain output doesn't match the locally reconstructed HTLC script (wrong preimage/sender/receiver/hash/timeout, or a lying node). Nothing is broadcast. |
-32037 | 200 | Timeout not reached | htlc_reclaim attempted before the refund timeout (current_height ≤ timeout). |
-32040 | 200 | Wait timeout | wait_for_tx budget expired before the tx reached min_confirmations. data carries {tx_id, min_confirmations, elapsed_secs}. Retry is safe. |
-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
Three cases produce this:
- No
Authorization: Bearerheader. - The header is set but the token doesn't match any of the three
scoped tokens (
read/manage/spend). - The token matched, but the method requires a higher scope than the
one presented (a
readtoken callingtransfer, for example). Containment isspend ⊇ manage ⊇ read— see Tokens and scopes.
Comparison is subtle::ConstantTimeEq, so even a one-character
difference returns 401 in the same time as a totally-wrong token.
The body message is the same across all three (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
EXFER-QUOTE: A Signed Price-Credential Standard
Status: Draft · Wire version byte: 1 · Layer: application / wallet.
A signed EXFER-QUOTE is a price credential: a canonical, signed statement of what one party will be owed, in what unit, on what chain, until when, and who said so. It adds nothing to EXFER consensus — no transaction field, no jet, no consensus tag, no covenant change. Settlement happens only through ordinary EXFER transactions, linked to a quote by quote_id in a settlement datum (Section 6). The chain is reached only through JSON-RPC.
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY, and OPTIONAL are to be interpreted as in RFC 2119 / RFC 8174 when in all capitals.
This standard derives from and resolves ahuman-exfer/exfer#28. The walletd methods (Section 11) are gated on #33 (node-derived genesis).
A quote is a price credential, not a money credential: holding one moves no value. A leaked or replayed quote can at worst be presented for one settlement at that price before it expires; every rule below exists to bound that blast radius to one settlement, and every check is acceptor-side.
1. Terminology
- Image — the byte-exact serialization that is signed (Section 3). The signature is not part of the image.
- Signer / Issuer — holder of the key that signs;
signer_pubkeyis its public key. - Payee — the party to be paid (
payee_pubkey). - Payer — the party funding settlement; optionally bound by
payer_pubkey. - Acceptor — the party that delivers priced goods against an observed settlement. It verifies (Section 5) and enforces honor (Section 6). Usually the payee.
- Accept — pure, stateless check that a quote is a valid credential (Section 5).
- Honor — stateful delivery of goods after a settlement is observed and gated (Section 6).
- Settlement — the on-chain EXFER transaction paying the payee, linked to the quote by
quote_idin its output datum. - Gate — a durable store consulted before honoring: SEEN-SET (one
quote_idonce) and CONSUMED-OUTPOINT (one outpoint once).
Conventions: JSON is snake_case, binary is lowercase hex. Image integers are big-endian. EXFER amounts are u64 base units (1 EXFER = 100,000,000 exfers); they MUST NOT be encoded as floats or decimal strings.
2. The Object
{
"version": 1,
"quote_id": "9f2c4a1e0b7d3c5a8e6f1029384b5c6d",
"currency": "USD",
"amount_minor": 1250,
"rate_exfers_per_unit": 4000000000,
"exfer_amount": 50000000000,
"payee_pubkey": "<64 hex>",
"payer_pubkey": "<64 hex, optional>",
"issued_at": 1781234567,
"expires_at": 1781234867,
"memo": "api-credits/2026-06",
"signer_pubkey": "<64 hex>",
"signature": "<128 hex>"
}
quote_id— 16 random bytes (hex32). Also the settlement-to-quote link: a honored settlement MUST carry it in its output datum (Section 6).currency— pricing-unit code, 3–12 chars[A-Z0-9]. The pricing unit is never the settlement unit; settlement is always EXFER.amount_minor,rate_exfers_per_unit— signed-but-informative display/audit data. No verifier recomputes the conversion.exfer_amount(u64 base units) — the only binding amount.payee_pubkey— by pubkey, not address: covers both the HTLC hash-arm receiver and the plain-output addressdomain_hash(EXFER-ADDR, payee_pubkey).payer_pubkey(optional) — when present, makes the quote non-transferable (Section 6).issued_at,expires_at— absolute Unix seconds (wall-clock), deliberately not block height.memo— signed UTF-8 note, ≤ 256 bytes.signer_pubkey,signature— the issuer key and its raw Ed25519 signature over the image.
3. The Signing Image
The signature is raw Ed25519 over a fixed binary image, not over the JSON — byte-identical across implementations, no canonicalization. All integers big-endian (this diverges from the little-endian tx body; state it, do not "fix" it).
"EXFER-QUOTE" (11 bytes, literal ASCII; NOT length-prefixed)
genesis_block_id (32 bytes)
version (u8)
quote_id (16 bytes)
currency_len (u8)
currency (currency_len bytes, ASCII)
amount_minor (u64 BE)
rate_exfers_per_unit (u64 BE)
exfer_amount (u64 BE)
payee_pubkey (32 bytes)
payer_flag (u8; MUST be exactly 0 or 1)
[payer_pubkey (32 bytes) iff payer_flag == 1]
issued_at (u64 BE)
expires_at (u64 BE)
memo_len (u16 BE)
memo (memo_len bytes, UTF-8)
The v1 image has no reserved region; it strict-ends at memo.
Strict decode. A verifier MUST, before any signature check: reject trailing bytes after memo (length is fully determined by currency_len, payer_flag, memo_len); reject payer_flag not in {0,1}; derive payer_flag from JSON-field presence alone and never honor a quote whose payer_pubkey it silently ignores; enforce field widths and the currency/memo bounds (Section 7). With a fixed prefix, fixed-width keys/amounts, length-prefixed currency/memo, and a presence-determined payer_pubkey, the field-tuple-to-bytes map is injective.
Domain separation. The literal tag EXFER-QUOTE and its raw-Ed25519 siblings EXFER-SIG (node src/types/mod.rs:364, transaction signing) and EXFER-MSG (walletd src/api/signmsg.rs:34, message signing) MUST be mutually non-prefix, so no image of one domain can be reinterpreted as another. A conforming implementation MUST ship a test asserting pairwise mutual-non-prefix across all signable EXFER-* tags. Format evolution extends through the version byte, never a new tag (Section 9).
4. Signing and Address Derivation
The signature is raw Ed25519 (RFC 8032) over the image. The issuer MUST bind the live, node-derived genesis_block_id, read at get_block_height (node src/rpc.rs:456, node.genesis_id, never tip-derived), NOT a compiled-in constant — a compiled constant separates builds, not networks (the PR #30 mismatch), and is forbidden. Unlike EXFER-MSG, EXFER-QUOTE IS genesis-bound, like EXFER-SIG.
The issuer address is domain_hash(EXFER-ADDR, signer_pubkey), where domain_hash(sep, data) = SHA-256(len(sep) || sep || data) — byte-identical to TxOutput::pubkey_hash_from_key (node src/types/transaction.rs:95, DS_ADDR at src/types/mod.rs:368; walletd src/api/signmsg.rs:39). The payee's plain-output address derives identically. Hex fields are bare lowercase hex; an issuer MUST NOT emit the swap path's 0x-prefixed hex or decimal-string amounts.
5. Verification: Accept
Accept is the pure, stateless check that a quote is a valid credential. A verifier MUST NOT touch a key store or release value. An acceptor accepts iff all hold:
- The image strict-decodes (Section 3).
- The
versionis one the verifier implements; an unknown version is rejected with no best-effort decode. signer_pubkey,payee_pubkey, and (if present)payer_pubkeyare not weak/small-order keys —is_weak_ed25519_key, mandatory in all verify paths (nodesrc/types/mod.rs:353, enforced in consensus atsrc/consensus/validation.rs:598). NOTE: walletd'sverify_messageomits this check, so it is not a complete pattern to copy.- The signature verifies under
signer_pubkeyover the image (Section 4). - The image's
genesis_block_idequals the live value atget_block_height(nodesrc/rpc.rs:456); reject cross-genesis quotes. - Local time <
expires_at(no grace inside the quote). issued_at < expires_at,expires_at - issued_at <= MAX_TTL,issued_at <= now + MAX_SKEW;currencyresolves to a known code (Section 7).signer_pubkeyis one the acceptor accepts (out of band) — the quote proves authorship, not authority.payee_pubkeyis a key the acceptor itself controls — a quote naming a third-party payee MUST be rejected.
A verifier SHOULD return the derived issuer address and parsed fields even when valid:false, and map malformed input to JSON-RPC -32602 while returning a verification failure as valid:false.
6. Settlement Binding and Honor
Accept checks the credential; honor delivers goods against an observed settlement. Honor itself (the business logic of release) is the integrating application's job; this standard fixes the binding, the gates, and the timing.
One settlement, one quote. Output-existence alone is insufficient: every payment to a payee lands at the same reused address, so two quotes for the same payee at the same price would both be satisfied by one payment. The binding is 1:1:
- The honored settlement output MUST carry
quote_idin its datum, and the acceptor MUST match it. Datum is covered by the payer's funding signature (nodesrc/types/transaction.rs:115, into the EXFER-SIG image at:340), so the link is signature-bound with no consensus change.quote_id-in-datum is therefore REQUIRED. - The acceptor maintains a durable CONSUMED-OUTPOINT gate; the settlement outpoint MUST be unconsumed; on honor it records both
(signer_pubkey, quote_id)and the outpoint. One outpoint honors at most one quote.
Plain-output honor. Honor when a settlement output, confirmed to the acceptor's depth policy, pays exactly exfer_amount to domain_hash(EXFER-ADDR, payee_pubkey), carries the matching quote_id in datum, and has an unconsumed outpoint. An output below exfer_amount MUST NOT be honored.
HTLC honor. An HTLC output is not receipt: the timeout path is height_gt(timeout_height) AND sig_check(sender) (node src/covenants/htlc.rs:32-34), strictly greater and reading the confirming block height, so a sender reclaim is first confirmable at timeout_height + 1. Therefore honor only AFTER the acceptor's OWN claim transaction confirms to depth — at which point it reduces to plain-output honor (same datum/outpoint binding, keyed on the HTLC outpoint). To attempt the claim: the HTLC output is confirmed with receiver_key = payee_pubkey, value exactly exfer_amount, quote_id in datum, outpoint unconsumed; the payee holds the preimage locally; and tip_height + CLAIM_MARGIN <= timeout_height, with CLAIM_MARGIN exceeding max-tolerated reorg depth plus confirmation depth. Because preimage-in-hand is a precondition, the payee is the hash_lock originator in practice, so issuer tooling MUST generate the secret payee-side. HTLC scripts are parsed client-side via try_parse_htlc (node src/covenants/htlc.rs:187); an acceptor MUST confirm the actual on-chain script, not the quote's claimed parameters.
Payer binding. When payer_pubkey is present: for the HTLC form, the covenant's sender_key MUST equal payer_pubkey; for the plain-output form, at least one settlement input MUST spend an output locked to domain_hash(EXFER-ADDR, payer_pubkey) (consensus already enforced a signature under that key — validate_phase1_input, node src/consensus/validation.rs:566, check at :598). The plain-output rule proves the payer authorized the transaction, not that the payer funded exfer_amount; if "payer-funded" is intended, additionally require the payer-keyed inputs to sum to >= exfer_amount. Funding purely from covenant/script inputs cannot satisfy payer binding — the acceptor MUST NOT honor, and issuers MUST omit payer_pubkey for such payers. There is no acceptance-signature side channel.
Gates. Both gates (SEEN-SET keyed (signer_pubkey, quote_id), CONSUMED-OUTPOINT keyed by outpoint) MUST be durably committed before honor (write-ahead), retained until expires_at + MAX_RECHECK_WINDOW, and shared per accepting identity (multiple instances / co-payees MUST share or partition). Late honor in the retention window applies ONLY to a quote accepted-and-observed strictly before expires_at; a quote first presented at or after expires_at MUST be declined.
Two clocks. expires_at is wall-clock and bounds price risk; HTLC timeout_height, confirmation depth, and CLAIM_MARGIN are on the block-height clock. A reorg moves the block-height clock backward while expires_at does not move. An acceptor MUST drive honor off the block-height clock plus CLAIM_MARGIN and use the wall-clock only for observation and gate retention; treating a once-seen confirmation as permanent across a reorg is the error this forbids.
7. Parameters and Bounds
| Bound | Value | Kind |
|---|---|---|
MAX_TTL | 3600 s cap; 300 s RECOMMENDED | protocol cap / policy |
issued_at < expires_at | strict | protocol |
MAX_SKEW | 60 s on issued_at only (expiry-side skew folded into MAX_RECHECK_WINDOW) | policy |
MAX_RECHECK_WINDOW | 120 s RECOMMENDED | acceptor policy |
memo | ≤ 256 bytes UTF-8 | protocol |
currency | 3–12 chars [A-Z0-9] | protocol |
CLAIM_MARGIN | > max-tolerated reorg depth + confirmation depth | acceptor policy |
This standard fixes the relationships among CLAIM_MARGIN, reorg depth, and confirmation depth, not their concrete values; mis-sizing them defeats the reorg mitigation despite conformance to the ordering.
8. Security Notes
A signed quote is a bearer credential: verification is a pure, key-free function of the quote plus the live genesis_block_id. The design does not prevent copying; it bounds what a copy can do:
| Bound | Mechanism | Removes |
|---|---|---|
| Single instance | genesis_block_id binding (Sections 3–4) | cross-instance replay (devnet quote on mainnet) |
| Single domain | EXFER-QUOTE tag + mutual-non-prefix (Section 3) | reinterpretation as a tx or message |
| Single version | version-byte reject-if-unknown (Sections 5, 9) | re-decode under another layout |
| Bounded lifetime | expires_at + strict late-honor (Sections 5, 6) | post-expiry honor and first-presentation |
| Single settlement | quote_id-in-datum + two gates (Section 6) | multi-quote/one-settlement and double-honor |
| Honest key only | weak-key rejection (Section 5) | one signature validating across quotes |
An acceptor MUST treat the conjunction as the perimeter and MUST NOT relax any bound for convenience. Residual risks (accept or mitigate at the app layer; not fixable without a consensus change): independent acceptors that do not share gate state can each honor one settlement (no global registry); prev-output script lookup is gated by TX_INDEX_TABLE (node src/chain/storage.rs:79), SPENT_BY_TABLE carries no script (:105), and there is no outpoint-to-output RPC — the caveat bites only historical funding, since a just-submitted settlement's inputs are recent and indexed; parameter mis-sizing and a non-payee-originated preimage both break the reasoning above and must be re-derived for such deployments.
9. Versioning and Conformance
Versioning. The version byte selects a complete image layout; a signature under one version MUST NOT verify under another, and a verifier MUST reject an unknown version with no best-effort decode. Any change to field layout/width/encoding, endianness, the domain tag, the hash construction, the signature scheme, or the set of required fields REQUIRES a new version byte. A new pricing unit is added by registering a currency code (additive, no version bump). A new EXFER-* domain tag is reserved for a genuinely new signing use-case — a format change MUST NOT mint a new tag.
Conformance. Three roles: a VERIFIER implements Section 5 (pure, key-free; maps to quote_verify); an ISSUER additionally constructs valid images and generates the HTLC secret payee-side (maps to quote_issue); an ACCEPTOR additionally enforces the honor rules and gates of Section 6. A conformance claim MUST be backed by the Section 10 vectors plus the two required tests (tag-non-prefix, strict-decode).
10. Test Vectors
Byte-exact and reproducible. An issuer MUST reproduce image_hex; a verifier re-signing with the stated seed MUST reproduce the signature; a strict decoder MUST reject the two negatives at decode, before any signature check. (Generated and independently signature-verified with an RFC 8032 Ed25519 implementation; the generator is under docs/quote-test-vectors/.)
Parameters. Signature: raw Ed25519 over the image. genesis_block_id = 32 zero bytes (TEST DOMAIN only; a real quote binds the node-derived genesis). Seeds: signer 00010203…1f, payee 2021…3f, payer 4041…5f. Derived signer_pubkey = 03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8, payee_pubkey = 29acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7, payer_pubkey = 2543b92ff1095511476adc8369db6ddc933665a11978dda1404ee1066ca9559d. payee_address = domain_hash(EXFER-ADDR, payee_pubkey) = 7f90e5febf8366d1240e1a2bdec7d1a2f5361a486ef34afa96a73b3990651fd9.
vec_a — minimal, no payer, empty memo (quote_id=000102030405060708090a0b0c0d0e0f, currency=USD, amount_minor=1250, rate=4000000000, exfer_amount=50000000000, issued_at=1781234567, expires_at=1781234867). 139 bytes.
image: 45584645522d51554f5445000000000000000000000000000000000000000000000000000000000000000001000102030405060708090a0b0c0d0e0f0355534400000000000004e200000000ee6b28000000000ba43b740029acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd700000000006a2b7b87000000006a2b7cb30000
signature: bcfc1e3b8e9f4a42ebd1daf1e3f093b8f164028094e75d6bb1ea750c6fe40e4c8d9401c7a350520024498a134888ec7ccff16c9297457817c3a1fd3a46928e05
vec_b — with payer (payer_flag=1, non-transferable; quote_id=9f2c4a1e0b7d3c5a8e6f1029384b5c6d, memo="api-credits/2026-06"). 190 bytes.
image: 45584645522d51554f54450000000000000000000000000000000000000000000000000000000000000000019f2c4a1e0b7d3c5a8e6f1029384b5c6d0355534400000000000004e200000000ee6b28000000000ba43b740029acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd7012543b92ff1095511476adc8369db6ddc933665a11978dda1404ee1066ca9559d000000006a2b7b87000000006a2b7cb300136170692d637265646974732f323032362d3036
signature: 11ec5160924ec6d91efd3900fbeec6798a08ca02aef8db2e2a07afba1b24a0968927fcb20a7c81601bf6a2640ab6e223f46d1f8ee131add0e17b37dd959fe906
vec_c — multi-byte UTF-8 memo, non-ISO ticker (quote_id=ffeeddccbbaa99887766554433221100, currency=SATS, amount_minor=123456, rate=1, exfer_amount=123456, memo="货到付款 — café 🚀"). 167 bytes.
image: 45584645522d51554f5445000000000000000000000000000000000000000000000000000000000000000001ffeeddccbbaa998877665544332211000453415453000000000001e2400000000000000001000000000001e24029acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd700000000006a2b7b87000000006a2b7cb3001be8b4a7e588b0e4bb98e6acbe20e2809420636166c3a920f09f9a80
signature: 07f427d44873b88f6313c7d11ba394216f12728c7abd18c546add914c66f2eb8e7a683c5fb0adfe056d4ade389b8d01ecbd904fdd51864759e7dbbdedbc5390d
vec_d (negative) — vec_a plus one trailing 0x00 after memo (140 bytes). Strict-decode MUST reject (trailing bytes).
image: 45584645522d51554f5445000000000000000000000000000000000000000000000000000000000000000001000102030405060708090a0b0c0d0e0f0355534400000000000004e200000000ee6b28000000000ba43b740029acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd700000000006a2b7b87000000006a2b7cb3000000
vec_e (negative) — vec_a with the payer_flag byte (offset 120) = 0x02 (139 bytes). Strict-decode MUST reject (payer_flag not in {0,1}).
image: 45584645522d51554f5445000000000000000000000000000000000000000000000000000000000000000001000102030405060708090a0b0c0d0e0f0355534400000000000004e200000000ee6b28000000000ba43b740029acbae141bccaf0b22e1a94d34d0bc7361e526d0bfe12c89794bc9322966dd702000000006a2b7b87000000006a2b7cb30000
11. walletd Integration
The reference implementation is walletd quote_issue (Scope::Spend, same posture as sign_message) and quote_verify (Scope::Read, pure), following the signmsg.rs register. Both ship gated on #33: they bind genesis_block_id from the node's get_block_height (node src/rpc.rs:456) or do not ship at all. quote_verify implements accept (Section 5); honor (Section 6) is the integrating application's job. exfer-py and the MCP server then expose both, carrying the scope through (a Read token cannot mint quotes). Nothing in the consensus crate, no jet, no tag.
Two RPC-surface facts an acceptor must budget for: there is no outpoint-to-output RPC, so plain-output payer-binding verification is a two-call derivation off get_transaction (contingent on TX_INDEX_TABLE coverage); and HTLC script parsing is client-side via try_parse_htlc. A signed quote MAY also be carried as a quote= parameter on an exfer: URI (walletd src/payment_uri.rs) with no format break. The existing unsigned swap quote (swap_get_quote, channel-authenticated only) MAY later additionally carry an EXFER-QUOTE image for object-side verification; until then the two coexist and this standard changes no swap behavior.
12. Changelog
- v1 (Draft), 2026-06-07. Initial standard, derived from and resolving issue #28; wire image frozen per the approval. Implementation gated on #33. Wire version byte = 1.
Security model
What you get out of the box
- Three scoped bearer tokens at rest:
<datadir>/token-{read,manage,spend}, mode0600; datadir itself is0700. Constant-time comparison on every request. See Tokens and scopes. - Public binds fail-close unless TLS is on (
--tls) or you opt in with--allow-public-bind. See Tokens and scopes → Bind safety. - Every key is sealed at rest with argon2id + ChaCha20-Poly1305 (KEK
from
WALLETD_KEYSTORE_PASSPHRASE): one 1:1 key per file at<wallet_dir>/imported/<addr>.key.enc, standard mnemonics alongside at<addr>.mnemonic.enc, and — only on a legacy seeded keyring — the HD seed at<wallet_dir>/seed.enc. Each sealed class has its own AAD so a blob can't be replayed across roles. 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.
- One passphrase unlocks every key. All sealed keys share the same
KEK (derived from
WALLETD_KEYSTORE_PASSPHRASEvia argon2id). Anyone who has the passphrase plus read access to<wallet_dir>/can spend every wallet. At-rest encryption defeats offline attackers against the disk; live attackers with both inputs get full authority. (The vault export passphrase is separate and protects only the portable backup blob.) - 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-{read,manage,spend}=…on the command line. They show up inps aux. Use the auto-generated<datadir>/token-{read,manage,spend}files, or env vars from a secret manager. - 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 state.json,
imported/*.key.enc (and, on a legacy seeded keyring, seed.enc)
atomically (write-to-.tmp + rename), so per-file copies during a
quiet moment are fine too.
The canonical backup is a vault blob from export_vault — one
passphrase-sealed file covering every key in the keyring, including
standard recovery phrases. Keep it offline, and re-export after minting
new addresses. Per-address phrases (reveal_address_mnemonic) are the
finer-grained alternative. See Keystore.
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
Auto-generated mode — delete the file(s) for the scope(s) you want to rotate and restart:
rm ~/.exfer-walletd/token-spend # one scope
# or:
rm ~/.exfer-walletd/token-{read,manage,spend} # all three
exfer-walletd # regenerates + prints whatever's missing
Externally-supplied mode (any of --auth-token-{read,manage,spend}
or WALLETD_AUTH_TOKEN_{READ,MANAGE,SPEND} set) — walletd ignores
the on-disk file for that scope, so deleting it does nothing. Change
the env / CLI value and restart instead.
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
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.