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

InstallQuick startRPC 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

ScenarioCommandWire
Dev — node, walletd, caller on one hostexfer-walletdplain HTTP on 127.0.0.1
Prod — cross-host, recommendedexfer-walletd --tls --bind <private-ip>:7448HTTPS, self-signed cert, SDK pins by fingerprint
Prod — TLS terminator (nginx/Caddy/cloud LB) already in frontexfer-walletd --allow-public-bind --bind 0.0.0.0:7448plain 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:

  1. Creates ~/.exfer-walletd/ (mode 0700).
  2. 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 with export_vault (see Keystore).
  3. Generates three bearer tokens (one per scope) at <datadir>/token-{read,manage,spend} and prints each once in an ASCII box.
  4. 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 validationcurl --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 caseFlag
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 failoverDoes NOT trigger failover
Connection refused / timeoutHTTP 4xx
HTTP 5xxJSON-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.

ScopeMethods
readping, 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
managegenerate_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
spendtransfer, 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

ComponentToken to issue
Deposit watchertoken-read
Address provisioningtoken-manage
Withdrawal workertoken-spend
Operator dashboard / SREtoken-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 addressPolicy
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:

OriginCreated byRecovery
Standard (default)generate_standard_address, import_standard_mnemonicits own 24-word BIP-39 phrase, cross-wallet compatible
Independentgenerate_independent_addressits own 24-word phrase (the raw ed25519 secret encoded as BIP-39)
Importedimport_private_key, import_mnemonicthe supplied secret / phrase; back it up yourself
HD-derived (legacy)generate_address on a seeded keyringthe 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 across exfer.dev web and the apps). Its phrase is sealed alongside the key so reveal_address_mnemonic can 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 a 0600 drop-in; Docker/k8s from a Secrets Manager.
  • never check .env files 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:

  1. The vault (recommended). export_vault seals the entire keyring — every key, with standard mnemonics — into one passphrase-protected blob; import_vault restores it into another walletd. This is the single-file backup that survives adding new addresses without re-backing-up. export_address exports just one key as a vault blob.
  2. Per-address recovery phrase. reveal_address_mnemonic returns 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.enc is present and generate_address derives addresses by index along m/44'/9527'/0'/0'/i' (SLIP-0010 Ed25519, all-hardened). The single seed mnemonic backs up every derived address. Coin type 9527' 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

spendmanageread — 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

MethodPurpose
pingliveness + version
get_status / get_follower_statusdaemon + indexer-follower health
get_block_heighttip height + genesis_block_id
get_block_by_id / get_block_by_height / get_block_id_at_heightblock lookups
get_transactiontransaction by id
get_balance / get_wallet_balanceone address / whole-keyring balance
get_address_utxos / get_script_utxosspendable outputs
get_address_mempoolunconfirmed entries touching an address
get_output_spent_bythe input that spent an outpoint (indexer)
validate_addressaddress well-formedness check
wait_for_tx / wait_for_paymentlong-poll for confirmation / incoming funds

Indexer reads (indexer) — read

MethodPurpose
get_address_historyconfirmed tx history for an address
list_settlementssettlement records the daemon tracks
htlc_status / htlc_list / htlc_lookup_by_hashlockHTLC lifecycle observability
contract_statsaggregate contract counters
get_attestation_edgesattestation graph edges
detect_in_chain_swapsscan for on-chain swap legs

Keyring — manage to create, spend to reveal/export/delete

MethodScopePurpose
generate_standard_addressmanagedefault: 1:1 address from a standard BIP-39 phrase (exfer.dev-compatible)
generate_independent_addressmanage1:1 address, raw secret as its own phrase
generate_addressmanageHD-derive next index (seeded keyrings only)
import_private_keymanageimport a raw 32-byte secret
import_mnemonicmanageimport an independent 24-word phrase
import_standard_mnemonicmanageimport a standard 24-word phrase
list_addressesreadlist keyring addresses + labels
reveal_address_mnemonicspenda single address's 24-word phrase
reveal_mnemonic / reveal_private_keyspendlegacy seed mnemonic / raw key
export_address / export_vaultspendseal one key / the whole keyring to a vault blob
import_vaultspendrestore a vault blob
delete_addressspenderase a key (refuses on non-zero balance unless force)

Transactions & messages

MethodScopePurpose
transferspendbuild, sign, broadcast a payment
send_raw_transactionspendbroadcast a pre-built tx
abandon_transfermanagedrop a stuck in-flight transfer
sign_message / verify_messagespend / readproof-of-ownership signatures
simulate_transfer / simulate_htlc_lockreadcost/feasibility dry-run

HTLC

MethodScopePurpose
htlc_lock / htlc_claim / htlc_reclaimspendopen / claim / reclaim an HTLC
htlc_forgetmanagedrop local HTLC tracking state

Payment URI — read

payment_uri_encode / payment_uri_decodeexfer: URI codec.

Cross-chain swap & LP (swap engine / pool)

MethodScopePurpose
swap_get_quotespendreserve a preimage + seal a swap quote
swap_execute / swap_refundspendlock/claim / reclaim both legs
swap_status / swap_listreadswap journal
swap_pool_info / swap_price_klinesreadpool reserves, fees / price chart
lp_pool_info / lp_position / lp_deposit_statusreadLP pool + position views
lp_deposit_startreadbegin an LP deposit (returns funding instructions)
lp_withdraw_selfspendcash out LP shares to the user

BSC / EVM side (native BNB counter-asset)

MethodScopePurpose
bsc_get_address / bsc_get_balancesreadEVM address / BNB+token balances
bsc_tx_historyreadnative-BNB transfer history
bsc_create_address / bsc_import_mnemonic / bsc_import_keymanageprovision the independent EVM key
bsc_reveal_mnemonicspendreveal the EVM recovery phrase
reveal_evm_private_keyspendexport the EVM key (MetaMask import)
bsc_delete_keyspenddelete the EVM key (can strand BNB)
bsc_send_bnbspendwithdraw native BNB

ping

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

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

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

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

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

Scoperead
Params{}
Returnssee 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.

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

Scoperead
Params{ address: hex64 }
Returnssee 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).

Scoperead
Params{ script_hex: hex }

get_block_height

Chain tip.

Scoperead
Params{}
Returns{ height: u64, block_id: hex64 }

get_block_by_id

Scoperead
Params{ block_id: hex64 }
ReturnsBlockSummary (see below)

get_block_by_height

Scoperead
Params{ height: u64 }
ReturnsBlockSummary
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.

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

Scoperead
Params{ tx_id: hex64 }
Returnssee 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.

Scopespend

Params

FieldTypeRequiredDescription
fromhex64yesSender address (HD-derived or imported).
outputs[{ to: hex64, amount: u64 }] (1..=16)yesRecipient list. Each amount ≥ DUST_THRESHOLD (200).
fee_rateu64noexfers per cost-unit. Mutually exclusive with fee.
feeu64noAbsolute fee in exfers. Mutually exclusive with fee_rate.
max_feeu64noCap; default 2_000_000 (0.02 EXFER).
client_tokenstring (8..=128 ASCII)noIdempotency key.
datumhex (≤ 4096 bytes)noGeneric 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

CodeWhen
-32001Wrong scope token.
-32602Param shape error (from not 64-hex, outputs[] missing, fee+fee_rate both set, …).
-32010Wallet not found — from is not a known address.
-32020Upstream node unreachable / RPC error.
-32030UTXO authentication failed.
-32031Insufficient balance (with in_flight_reserved hint).
-32032Fee exceeds max_fee.
-32033An outputs[].amount is below dust.
-32034outputs[] longer than 16.
-32035Same 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 p with sha256(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):

  1. B picks a secret, shares hash_lock = sha256(secret) with A.
  2. 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 height H+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_reclaim spend 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 plain transfer. walletd computes it automatically when fee is omitted; pass fee only 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).

Scopespend

Params

FieldTypeRequiredDescription
fromhex64yesSender wallet address (funds + signs).
receiverhex64yesReceiver's 32-byte pubkey (the key that can claim).
hash_lockhex64yessha256(preimage).
timeoutu64yesAbsolute block height after which from may reclaim.
amountu64yesAmount to lock (exfers), ≥ DUST_THRESHOLD (200).
fee_rateu64noexfers per cost-unit. Mutually exclusive with fee.
feeu64noAbsolute fee. Mutually exclusive with fee_rate.
max_feeu64noCap; 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).

Scopespend

Params

FieldTypeRequiredDescription
fromhex64yesReceiver wallet address (claims + receives).
lock_tx_idhex64yesThe htlc_lock transaction id.
output_indexu32noHTLC output index in the lock tx (default 0).
preimagehex (1..=1024 bytes)yesSecret whose sha256 equals the lock's hash_lock.
senderhex64yesSender's pubkey — reconstructs the script.
timeoutu64yesThe lock's timeout — reconstructs the script.
feeu64noAbsolute 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.

Scopespend

Params

FieldTypeRequiredDescription
fromhex64yesSender wallet address (reclaims).
lock_tx_idhex64yesThe htlc_lock transaction id.
output_indexu32noHTLC output index (default 0).
receiverhex64yesReceiver's pubkey — reconstructs the script.
hash_lockhex64yesThe lock's hash_lock — reconstructs the script.
timeoutu64yesThe lock's timeout height.
feeu64noAbsolute 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.

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

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

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

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

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

Scopespend
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

Scoperead

Params — identical to transfer except client_token is not accepted (there's nothing to deduplicate when no tx is broadcast):

FieldTypeRequiredDescription
fromhex64yesSender address.
outputsarrayyes1..=16 {to: hex64, amount: u64}.
fee_rateu64noSame as transfer.
feeu64noSame as transfer.
max_feeu64noSame 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

Scoperead

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

Scoperead

Params

FieldTypeRequiredDescription
lock_tx_idhex64yesThe lock transaction's id.
output_indexu32noDefault 0.

Returns — full HtlcRecord (see htlc_list for the shape).

Errors-32010 WalletNotFound if the index has no record for that outpoint.

htlc_list

Scoperead

Params — every field optional:

FieldTypeDescription
roleenumsender / receiver / both / any (default).
stateenum / array of enumFilter to one or more of locked / locked_expired / claimed / reclaimed / unknown.
since_heightu64Only entries with lock_block_height ≥ this.
limitu32Default 100, capped at 1000.
cursorstrOpaque cursor from a previous response.
addresshex64Reserved 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

Scopemanage

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

FieldTypeRequiredDescription
lock_tx_idhex64yes
output_indexu32noDefault 0.

Returns

{ removed: bool }

removed = false when no such record existed.

Errors-32602 BadParams for a non-settled record.

get_follower_status

Scoperead

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

Scoperead

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

FieldTypeRequiredDefaultDescription
tx_idhex64yesThe transaction id.
min_confirmationsu32no1Block depth required.
timeout_secsu64no60Max 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

Scoperead

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

FieldTypeRequiredDefaultDescription
addresshex64yesThe address (script) to watch for incoming credit.
min_amountu64no1Only report a credit of at least this many exfers.
timeout_secsu64no60Max 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

Scoperead

Params

FieldTypeRequiredDescription
addresshex64yesRecipient address.
amountu64noBase units (exfers).
memostrnoFree-form, percent-encoded.
hash_lockhex64noFor HTLC requests.
timeoutu64noPair with hash_lock.
labelstrnoShort payee label.

Returns{ uri: str }.

payment_uri_decode

Scoperead

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_address params: { "address": "<64 hex>", "passphrase": "<vault passphrase>" }{ "address": "<64 hex>", "vault_hex": "<hex blob>" }.
  • export_vault params: { "passphrase": "<vault passphrase>" } → the sealed vault_hex blob.

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
CodeHTTP (single/top-level)NameMeaning
-32700400Parse errorBody is not valid JSON.
-32600400Invalid RequestEnvelope shape wrong (bad jsonrpc, missing method, empty batch, …).
-32601200Method not foundUnknown method name.
-32602200Invalid paramsPer-method param shape error: bad hex, wrong address length, mutually-exclusive fields supplied together, …
-32603200Internal errorUnexpected; the message has details.
-32001401UnauthorizedMissing token, wrong token, or insufficient scope.
-32010200Wallet not foundAddress is not derived or imported in this keystore.
-32011200Wallet existsAddress collision on import (cosmically rare for derived).
-32012200Keystore lockedWrong passphrase / corrupted seed file.
-32020200UpstreamUpstream node unreachable, or returned an RPC error; message intact.
-32030200Tx buildTransaction construction failed (param overflow, encoding error, etc.).
-32031200Insufficient balanceWalletd can't cover amount + fee from spendable UTXOs.
-32032200Fee too highComputed fee exceeds the max_fee cap on transfer.
-32033200Dust outputAn outputs[].amount < DUST_THRESHOLD (200 exfers).
-32034200Too many outputstransfer.outputs[] longer than the hard cap (16).
-32035200Idempotency conflicttransfer.client_token reused with different params.
-32036200HTLC output authOn 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.
-32037200Timeout not reachedhtlc_reclaim attempted before the refund timeout (current_height ≤ timeout).
-32040200Wait timeoutwait_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_reservedtrue → 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-attempts times (default 4) with linear backoff (--upstream-retry-backoff-ms, default 500ms → waits of 500/1000/1500ms between sweeps); each attempt rotates through every configured --node-rpc URL before counting as failed. The message reports the last URL it tried.

-32001 unauthorized

Three cases produce this:

  1. No Authorization: Bearer header.
  2. The header is set but the token doesn't match any of the three scoped tokens (read / manage / spend).
  3. The token matched, but the method requires a higher scope than the one presented (a read token calling transfer, for example). Containment is spend ⊇ 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
-32001Check 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=trueWait for the pending tx to confirm.
-32031 data.in_flight_reserved=falseFund the wallet.
-32602Programming 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_pubkey is 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_id in its output datum.
  • Gate — a durable store consulted before honoring: SEEN-SET (one quote_id once) 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 address domain_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:

  1. The image strict-decodes (Section 3).
  2. The version is one the verifier implements; an unknown version is rejected with no best-effort decode.
  3. signer_pubkey, payee_pubkey, and (if present) payer_pubkey are not weak/small-order keys — is_weak_ed25519_key, mandatory in all verify paths (node src/types/mod.rs:353, enforced in consensus at src/consensus/validation.rs:598). NOTE: walletd's verify_message omits this check, so it is not a complete pattern to copy.
  4. The signature verifies under signer_pubkey over the image (Section 4).
  5. The image's genesis_block_id equals the live value at get_block_height (node src/rpc.rs:456); reject cross-genesis quotes.
  6. Local time < expires_at (no grace inside the quote).
  7. issued_at < expires_at, expires_at - issued_at <= MAX_TTL, issued_at <= now + MAX_SKEW; currency resolves to a known code (Section 7).
  8. signer_pubkey is one the acceptor accepts (out of band) — the quote proves authorship, not authority.
  9. payee_pubkey is 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_id in its datum, and the acceptor MUST match it. Datum is covered by the payer's funding signature (node src/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

BoundValueKind
MAX_TTL3600 s cap; 300 s RECOMMENDEDprotocol cap / policy
issued_at < expires_atstrictprotocol
MAX_SKEW60 s on issued_at only (expiry-side skew folded into MAX_RECHECK_WINDOW)policy
MAX_RECHECK_WINDOW120 s RECOMMENDEDacceptor policy
memo≤ 256 bytes UTF-8protocol
currency3–12 chars [A-Z0-9]protocol
CLAIM_MARGIN> max-tolerated reorg depth + confirmation depthacceptor 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:

BoundMechanismRemoves
Single instancegenesis_block_id binding (Sections 3–4)cross-instance replay (devnet quote on mainnet)
Single domainEXFER-QUOTE tag + mutual-non-prefix (Section 3)reinterpretation as a tx or message
Single versionversion-byte reject-if-unknown (Sections 5, 9)re-decode under another layout
Bounded lifetimeexpires_at + strict late-honor (Sections 5, 6)post-expiry honor and first-presentation
Single settlementquote_id-in-datum + two gates (Section 6)multi-quote/one-settlement and double-honor
Honest key onlyweak-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}, mode 0600; datadir itself is 0700. 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) with method, client_ip, request_id, outcome — at INFO on success, WARN on error (the warn line also carries the error message). Honors X-Forwarded-For for client_ip when 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_PASSPHRASE via 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 WalletStore trait 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 in ps 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.