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 encrypts the HD seed 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. Generates a 24-word BIP-39 mnemonic + a fresh HD seed; seals the seed and prints the mnemonic once on stderr inside an ASCII box. Write the words down — they are the only seed backup.
  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/
    ├── seed.enc        ← sealed BIP-39 entropy
    ├── state.json      ← next_index, labels, imported list
    └── imported/       ← legacy .key migrations (sealed)

$ cat ~/.exfer-walletd/token-spend
a85da0752815bbf652a1b147649cde77c17f784f3e608d362c629c798a555e7b

If you also passed --tls (see below), three more files appear: cert.pem, cert.key, cert.fingerprint.

Backup = the 24-word mnemonic (offline) plus tar czf ... ~/.exfer-walletd/wallets/imported. The mnemonic recovers every HD-derived address; the imported/ files cover addresses that came in via migrate. Uninstall = rm -rf the datadir.

Call it

TOKEN=$(cat ~/.exfer-walletd/token-spend)
curl -s http://127.0.0.1:7448/ \
     -H 'content-type: application/json' \
     -H "Authorization: Bearer $TOKEN" \
     -d '{"jsonrpc":"2.0","method":"ping","id":1}'
# → {"jsonrpc":"2.0","result":{"ok":true},"id":1}

Full method list: RPC reference.

Cross-host: bind a non-loopback interface

Default bind is loopback-only, so a backend on a different server can't reach it. Bind your host's private/internal IP:

exfer-walletd --bind 10.0.1.5:7448

Private/RFC1918 addresses are allowed with no extra flag. Public IPs (0.0.0.0, any globally routable IP) need either --tls (next section) or --allow-public-bind (acknowledging that an external TLS terminator sits in front).

Production: enable --tls

Pass --tls and walletd terminates TLS itself with a self-signed cert it generates on first run. No CA, no rotation ceremony, no reverse proxy.

exfer-walletd --tls --bind 10.0.1.5:7448

The bound IP is auto-added to the cert's subjectAltName. If your clients connect via a hostname (or you bind 0.0.0.0 and proxy in), also pass --tls-san:

exfer-walletd --tls --bind 0.0.0.0:7448 --tls-san walletd.internal,10.0.1.5

On first start, walletd creates cert.pem, cert.key, and cert.fingerprint in the datadir (all 0600) and prints the SHA-256 fingerprint once in an ASCII box on stderr:

  ┌─ first run ───────────────────────────────────────────────────────────
  │ generated self-signed TLS cert
  │   cert:        /var/lib/walletd/cert.pem
  │   fingerprint: /var/lib/walletd/cert.fingerprint
  │
  │     sha256:b66953c47263ac0da8192676e4770f0f799563322985c57246a6fab1bf24aa86
  │
  │ pin this value on the client side (SDK: fingerprint=…).
  └───────────────────────────────────────────────────────────────────────

Lost the line? cat ~/.exfer-walletd/cert.fingerprint.

--tls relaxes --allow-public-bind — TLS already protects the token on the wire.

Changed your bind, hostname, or --tls-san later? The cert is only generated on first run. Delete the trio (rm cert.{pem,key,fingerprint}) and restart to regenerate.

Two ways to verify the cert on the client

SDK — fingerprint pinning (recommended). No CA, no SAN dance.

from exfer_walletd import Client
with Client.from_datadir(url="https://<walletd-host>:7448") as c:
    print(c.healthz())

Strict CA-style 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 3 sequential round-trips plus N parallel parent-tx fetches (N = number of input UTXOs, parallelism capped at 8):

list_utxos                                    1 RTT
get_transaction × N (parallel, cap 8)         ⌈N/8⌉ RTTs
send_raw_transaction                          1 RTT

Submit time ≈ (2 + ⌈N/8⌉) × RTT.

  • Local node (RTT ~5ms): single-UTXO transfer ~15ms
  • LAN node (RTT ~50ms): ~150ms
  • Public RPC (RTT ~750ms): ~2.3s

For an exchange hot wallet with many small UTXOs, run a local or VPC-internal node — public RPC will be visibly slow.

Next

Tokens and scopes

Walletd uses bearer-token authentication on every request except GET /healthz. Comparison is constant-time (subtle::ConstantTimeEq).

Three scoped tokens

v1.0 issues three tokens, one per scope. On first start walletd auto-generates them at <datadir>/token-{read,manage,spend} (mode 0600).

ScopeMethods
readping, validate_address, get_* family, list_addresses, verify_message, get_status, get_wallet_balance
managegenerate_address, abandon_transfer
spendtransfer, send_raw_transaction, sign_message

Containment: spend ⊇ manage ⊇ read. A token at a higher scope satisfies every lower scope, so an exchange's withdrawal worker only needs the spend token — it gets manage and read for free.

Configuring

The default behaviour (auto-generate on first run) suits most setups. Override any subset from a secret manager:

exfer-walletd \
    --auth-token-read   "$(vault read -field=token secret/walletd-read)" \
    --auth-token-manage "$(vault read -field=token secret/walletd-manage)" \
    --auth-token-spend  "$(vault read -field=token secret/walletd-spend)"

Env equivalents: WALLETD_AUTH_TOKEN_READ, WALLETD_AUTH_TOKEN_MANAGE, WALLETD_AUTH_TOKEN_SPEND. Setting any of them suppresses auto-file creation for that scope.

Typical splits

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 (HD seed)

v1.0 keeps one BIP-39 seed per daemon. Every address you ever generate is derived from that seed by index — back up the seed once, back up every present and future address.

On-disk layout (<wallet_dir>/)

seed.enc                  ← BIP-39 entropy (32 B) sealed with argon2id + ChaCha20-Poly1305
state.json                ← {"next_index": N, "derived": {addr→idx},
                              "labels": {addr→label}, "imported": [addr,…]}
imported/<addr>.key.enc   ← sealed 32-byte secret for non-HD imports

<wallet_dir> defaults to <datadir>/wallets (mode 0700).

At-rest encryption

The seed (and any imported secrets) is wrapped in a small format:

magic   "WDV1" (4)
version 1 (1)
salt    16 bytes  (random per-seal; fed to argon2id)
nonce   12 bytes  (random per-seal; fed to ChaCha20-Poly1305)
ct      payload + 16-byte Poly1305 tag

Argon2id parameters: m=64 MiB / t=3 / p=1 (≈0.5–1s on a modern x86 core). Enough to defeat offline brute force against medium-strength passphrases; cheap enough that startup (one unseal per seed) and imported-key access stay snappy.

The passphrase

The encryption KEK comes from WALLETD_KEYSTORE_PASSPHRASE. walletd refuses to start without it. Set it via:

  • env var directly (development):
    WALLETD_KEYSTORE_PASSPHRASE='correct horse battery staple' exfer-walletd
    
  • secret manager → env at process spawn (production):
    • systemd: Environment="WALLETD_KEYSTORE_PASSPHRASE=…" in a 0600 drop-in
    • Docker / k8s: pull from Secrets Manager / Vault / AWS-SM / GCP-SM into the container env at start
  • never check in: .env files in git are an immediate red flag

If the passphrase is wrong on a subsequent start, walletd surfaces KeystoreLocked (-32012) and exits before binding any socket.

Derivation path

m / 44' / 9527' / 0' / 0' / index'
  • SLIP-0010 Ed25519 (forces hardened derivation at every level).
  • Coin type 9527' is a placeholder until Exfer registers an official SLIP-44 slot. Pinned in code; switching it would invalidate every derived address — only changes inside a major version bump.
  • BIP-39 passphrase is empty: the keystore passphrase encrypts entropy at rest; the wallet itself has no second factor.

derive_ed25519_private_key(seed, [44, 9527, 0, 0, index]) (from the slip10_ed25519 crate) produces a 32-byte secret; we wrap that as an ed25519_dalek::SigningKey and compute the address as TxOutput::pubkey_hash_from_key(pubkey) — byte-identical to the on-chain address scheme.

First-run mnemonic

The first time walletd starts with an empty <wallet_dir>, it generates a 24-word BIP-39 mnemonic, seals the entropy to disk, and prints the words to stderr inside a boxed section:

┌─ first run: HD seed ──────────────────────────────────────────────────
│ A new BIP-39 mnemonic has been generated and the seed has been
│ encrypted at rest with your WALLETD_KEYSTORE_PASSPHRASE.
│
│ ⚠  WRITE THESE 24 WORDS DOWN. They are the ONLY way to recover
│    every address this daemon will ever derive. They will NEVER be
│    shown again.
│
│     1. abandon    2. ability   3. able      4. about     5. above     6. absent
│     7. absorb     8. abstract  9. absurd   10. abuse    11. access   12. accident
│    13. account   14. accuse   15. achieve  16. acid     17. acoustic 18. acquire
│    19. across    20. act      21. action   22. actor    23. actress  24. actual
└───────────────────────────────────────────────────────────────────────

The mnemonic is never re-printed on later starts. Capture it out-of-band on first run (paper, password manager, hardware backup).

Recovery

Today: the only recovery is "restart with the same seed.enc + same WALLETD_KEYSTORE_PASSPHRASE". A first-class "restore from mnemonic" CLI shipping --from-mnemonic <words> is on the v1.1 roadmap.

For now, if you lose seed.enc but kept the mnemonic, re-importing is a manual procedure: write a 32-byte entropy file derived from your mnemonic, then arrange for it to be at <wallet_dir>/seed.enc in walletd's sealed format. (Open an issue if you need this and we'll prioritise the proper CLI.)

Imported (non-derived) addresses

exfer-walletd migrate --from <legacy-walletdir> imports legacy v0.x .key files (raw 32-byte ed25519 secrets, or upstream-encrypted EXFK) as non-derived addresses. Each imported secret is sealed with the same KEK as the seed and stored at <wallet_dir>/imported/<addr>.key.enc.

Imported addresses appear in list_addresses with imported: true and index: null. They sign normally; the only loss is that they're not recoverable from the mnemonic — back up imported secrets separately or accept that they're abandoned-by-design after a re-keying.

Index pacing

Each generate_address bumps next_index and persists it atomically (state.json.tmp → rename). With u32 indices you have ~4 billion addresses before the keystore refuses to mint more — well beyond any practical exchange / payment processor.

state.json is not required for derivation correctness — the address at any index is purely a function of the seed. If you ever lose state.json but kept the seed, re-creating an empty one is safe; list_addresses will just be empty until you generate_address or learn the indices through other means.

RPC reference (v1.0)

JSON-RPC 2.0 over POST /. GET /healthz is unauthenticated and returns ok for liveness probes.

Single requests and batches (per JSON-RPC 2.0 § 6) are both accepted. Notifications (requests with no id field) get no response per spec.

Every method below follows the same envelope:

Request

{
  "jsonrpc": "2.0",
  "method":  "<method-name>",
  "params":  { ... },
  "id":      1
}

jsonrpc must be exactly "2.0". id, when present, must be a JSON string, number, or null; object, array, and boolean ids are rejected with -32600 and response id: null. Omitting id makes the request a JSON-RPC notification and walletd returns no response.

Response (success)

{ "jsonrpc": "2.0", "result": { ... }, "id": 1 }

Response (error)

{ "jsonrpc": "2.0",
  "error":   { "code": -32xxx, "message": "...", "data": { ... } },
  "id":      1 }

data is omitted for most errors; populated for the structured ones listed in Error codes.

Amounts and fees are integers in exfers, where 1 EXFER = 100_000_000 exfers. Consensus dust threshold is 200 exfers.

Examples below assume:

URL='http://127.0.0.1:7448'
SPEND=$(cat ~/.exfer-walletd/token-spend)
READ=$(cat ~/.exfer-walletd/token-read)

Scope mapping

ScopeMethods
readping, validate_address, get_balance, get_wallet_balance, get_block_height, get_block_by_id, get_block_by_height, get_block_id_at_height, get_transaction, get_address_utxos, get_script_utxos, get_status, list_addresses, verify_message
managegenerate_address, abandon_transfer
spendtransfer, send_raw_transaction, sign_message

spendmanageread. A token at a higher scope satisfies every lower scope.


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 get_address_utxos so it can return both balance and utxo_count. That is 2 upstream scan RPCs per address, executed concurrently with cap 8. On public/community nodes with per-IP scan quotas, large wallets can hit upstream rate limits; use list_addresses plus paced per-address reads if you need quota-safe polling until upstream batch balance lookup exists.

Scoperead
Params{}
Returns{ entries: WalletEntry[], total: u64 }
type WalletEntry = {
  address: hex64,
  index?: u32,
  label?: string,
  imported: bool,
  balance: u64,
  utxo_count: u32,
  truncated: bool,  // upstream UTXO list was clipped at 1000
};

get_status

Operator dashboard in one call: daemon version, chain tip, wallet count, upstream URLs, in-flight counters.

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.

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.

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.


Batch requests

Send a JSON array of envelopes; receive a JSON array of responses, with notifications (no id) omitted. JSON-RPC 2.0 permits batch responses in any order, so clients should correlate by id. Walletd currently preserves request order in the response array, but callers should not rely on order when using generic JSON-RPC tooling.

curl -s $URL \
  -H "Authorization: Bearer $READ" \
  -H 'content-type: application/json' \
  -d '[
        {"jsonrpc":"2.0","method":"ping","id":1},
        {"jsonrpc":"2.0","method":"get_block_height","id":2}
      ]'

Empty batches return a single top-level -32600 response. Batches consisting entirely of notifications return 204 No Content. Mixed batches return HTTP 200 with per-item result / error objects in the array.

Error codes

JSON-RPC convention: errors usually return HTTP 200 with the error in the body. Walletd emits non-200 only for transport-layer problems (401, 400 for malformed JSON or invalid request envelopes).

For non-empty batch requests, walletd returns HTTP 200 with a response array; item-level errors keep their JSON-RPC error.code in the body. Top-level malformed JSON and empty batches still use the HTTP status shown below.

v1.0 partitions the JSON-RPC implementation-defined server-error range (-32000..-32099) into per-area slots so clients can branch on the high digit:

  • -32000..-32009: auth
  • -32010..-32019: wallet / keystore
  • -32020..-32029: upstream
  • -32030..-32039: transaction / fee
  • -32040..: reserved
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 build / authUTXO authentication failed, or transaction construction failed.
-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.

-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

Four cases produce this:

  1. No Authorization: Bearer header.
  2. The header is set but the token doesn't match.
  3. Two-token mode, the request used the read token, but the method needs spend scope.
  4. The token compares against subtle::ConstantTimeEq — even a single-character difference returns 401 in identical time.

The body message is the same across all four (authentication required) to avoid leaking which case it was.

Mapping table for clients

If you see…Action
-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

Security model

What you get out of the box

  • Bearer token at rest is <datadir>/token mode 0600; datadir itself is 0700. Constant-time comparison on every request.
  • Public binds fail-close unless TLS is on (--tls) or you opt in with --allow-public-bind. See Tokens and scopes → Bind safety.
  • Wallet .key files mode 0600 in <datadir>/wallets/. Filenames are validated 64-hex addresses — no path traversal.
  • Signing happens in-process. Only the signed transaction bytes go to the upstream node; private keys never leave the daemon.
  • In-flight UTXO tracker prevents back-to-back transfers from the same wallet racing onto the same outpoint (see internals below).
  • Every spend-scope request emits a structured audit log line (spend audit) 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.

  • Wallet keys are plaintext on disk. A daemon has no human to type a passphrase — keys live unencrypted, protected by FS permissions plus volume-level encryption (LUKS, dm-crypt, cloud volume encryption). Anyone who can read the wallet directory can spend every wallet.
  • One spend token = total spend authority. No per-key authorization, no quorum, no MPC. If you need finer-grained authority, implement the 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=… on the command line. It shows up in ps aux. Use the auto-generated <datadir>/token or an env var.
  • Don't put the wallet directory on shared storage (NFS, S3-FUSE). Mode bits don't translate; concurrent writers will corrupt keys.
  • Don't deploy two walletd processes against the same datadir. They don't coordinate on the in-flight tracker.
  • Don't expose port 80 plaintext to the public internet. Some cloud proxies' "force HTTPS" toggle only redirects GET/HEAD; a stray POST will leak the token before redirect happens.

Next

Backup, upgrade, rotate

Backup

Back up ~/.exfer-walletd/wallets/. Losing it = losing every key = losing every penny those addresses hold.

# Stop walletd briefly for a consistent snapshot, then:
tar -C ~ -czf wallets-$(date +%F).tar.gz .exfer-walletd

# Encrypt before shipping off-box
gpg --symmetric --cipher-algo AES256 wallets-$(date +%F).tar.gz

If you can't stop the daemon, snapshot the underlying volume (LVM, ZFS, your cloud snapshot). Walletd writes .key files with O_CREAT | O_EXCL and never modifies in place, so atomic per-file copies are fine too.

Upgrade

Semver. Patches and minors are drop-in; majors will be called out in release notes.

curl -L -o /tmp/exfer-walletd \
     https://github.com/exfer-stack/exfer-walletd/releases/latest/download/exfer-walletd-linux-x86_64
sudo install -m 0755 /tmp/exfer-walletd /usr/local/bin/
# restart walletd via whatever supervisor you use

The <datadir> format is stable — any version can load any wallet file written by any other version.

Rotate tokens

Single-token (auto-generated) mode — delete the file and restart:

rm ~/.exfer-walletd/token
exfer-walletd      # generates + prints a fresh one

Two-token mode (--auth-token-read / --auth-token-spend set) — walletd ignores <datadir>/token, so deleting it does nothing. Change the env values / CLI flags and restart instead.

Either way, any in-flight request still using the old token will fail after the restart. Plan the window.

Rotate the TLS cert

Same idea — delete the trio and restart:

rm ~/.exfer-walletd/cert.{pem,key,fingerprint}
exfer-walletd --tls    # generates a fresh cert + prints the new fingerprint

Clients pinning the old fingerprint will start raising FingerprintMismatchError on the next request. Push the new fingerprint to them before restarting if you can't tolerate the window.

Running under systemd

Minimal hardened unit. Adjust User, WALLETD_DATADIR, and flags for your environment.

# /etc/systemd/system/exfer-walletd.service
[Unit]
Description=Exfer Wallet Daemon
After=network-online.target
Wants=network-online.target

[Service]
User=walletd
Group=walletd
Environment=WALLETD_DATADIR=/var/lib/walletd
Environment=EXFER_NODE_RPC=http://127.0.0.1:9334
ExecStart=/usr/local/bin/exfer-walletd --tls --bind 10.0.1.5:7448
Restart=on-failure
RestartSec=2s

# Hardening — walletd never needs anything outside its datadir.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/var/lib/walletd
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
SystemCallArchitectures=native

[Install]
WantedBy=multi-user.target
sudo useradd --system --home /var/lib/walletd --shell /usr/sbin/nologin walletd
sudo install -d -o walletd -g walletd -m 0700 /var/lib/walletd
sudo systemctl daemon-reload
sudo systemctl enable --now exfer-walletd
journalctl -u exfer-walletd -f      # first-run token + fingerprint print here

For Kubernetes: same idea, but mount the datadir as a PersistentVolume (token + wallet keys must persist across pod restarts), set runAsUser to a non-root UID, and add a readinessProbe hitting GET /healthz.

Uninstall

No ceremony — walletd doesn't touch anything outside its --datadir.

# Stop the daemon, then:
rm -rf ~/.exfer-walletd     # tokens + wallets + certs — IRREVERSIBLE
sudo rm /usr/local/bin/exfer-walletd

If you back up ~/.exfer-walletd/wallets/ first, you can restore the same address set later by dropping the directory back into place.

Next

Migrating from v0.x → v1.0

v1.0 is a deliberately breaking release. The wire schema, scope model, and on-disk keystore all change. Treat the upgrade as: stand up a fresh v1.0 keystore alongside your v0.x deployment, migrate the old .key files in, repoint clients to the new schema, then turn off v0.x.

What changed

Field naming

v0.xv1.0
get_transaction param {hash}{tx_id}
get_block (untagged hash xor height)get_block_by_id {block_id} + get_block_by_height {height}
get_block_hash {height}get_block_id_at_height {height}
BlockSummary.hashBlockSummary.block_id
TxStatus.block_hashTxStatus.block_id
UTXO entry script_len (always null)removed
transfer {from, to, amount, fee}{from, outputs: [{to, amount}], fee_rate? / fee?, max_fee?, client_token?}
transfer receipt {tx_id, size, tip_height, submitted}{tx_id, size, fee, fee_rate, inputs, outputs, built_at_height}

Error codes

BadEnvelope split into:

  • -32700 JSON parse error (body not parseable)
  • -32600 envelope-shape error (jsonrpc != "2.0", missing method, …)
  • -32602 per-method param shape error (most call sites; matches spec's "Invalid params")

New error codes for v1.0 transfer semantics:

CodeVariant
-32012KeystoreLocked (wrong passphrase, corrupted seed)
-32032FeeTooHigh (computed fee exceeds max_fee)
-32033DustOutput
-32034TooManyOutputs (cap = 16)
-32035IdempotencyConflict (token reused with different params)

Auth scopes

Two scopes (read, spend) → three scopes (read, manage, spend). generate_address moves to manage (previously was read — a bug, since it writes state).

Config flags:

v0.xv1.0
--auth-token (legacy single token)removed
--auth-token-readsame
(none)--auth-token-manage
--auth-token-spendsame

On first run, walletd auto-generates three files in <datadir>/: token-read, token-manage, token-spend (each mode 0600).

Keystore

  • v0.x: one plaintext <addr>.key file per address (32 raw bytes).
  • v1.0: one encrypted seed.enc for the entire HD chain, plus state.json indexing derived addresses.

The encryption KEK is derived from WALLETD_KEYSTORE_PASSPHRASE via Argon2id; walletd refuses to start without that env var set.

JSON-RPC compliance

  • jsonrpc field is now strictly checked; anything other than "2.0" returns -32600.
  • Batch requests (JSON array of envelopes) are supported per § 6.
  • Notifications (envelopes with no id field) get no response per § 4.1.

Migration procedure

  1. Stop v0.x (or leave it running on a separate port; nothing prevents the two from coexisting).

  2. Set the keystore passphrase:

    export WALLETD_KEYSTORE_PASSPHRASE='whatever-your-secret-manager-provides'
    
  3. Start v1.0 once to initialise the HD seed and three scoped tokens:

    exfer-walletd --datadir /var/lib/walletd-v1
    

    Capture the 24-word mnemonic printed to stderr (paper / password manager / hardware backup). The mnemonic will never be shown again.

  4. Stop v1.0, then import the legacy .key files:

    exfer-walletd migrate \
        --datadir /var/lib/walletd-v1 \
        --from    /var/lib/walletd-v0/wallets
    

    For each legacy <addr>.key, walletd imports the secret under <datadir>/wallets/imported/<addr>.key.enc and records the address in state.json.imported[].

  5. Restart v1.0 in serving mode and switch clients over.

  6. Repoint clients:

    • Use the new field names (tx_id, block_id, multi-output transfer, etc.). The dry-run table at the top of this doc maps every renamed surface.
    • Use the new scoped tokens (token-spend for withdrawal workers, token-read for deposit watchers, etc.).
    • Use the client_token field on transfer for retry safety.
  7. Decommission v0.x once you've verified all dependent clients speak v1.

Notes

  • Imported addresses are NOT recoverable from the mnemonic. They remain spendable as long as <datadir>/wallets/imported/*.key.enc exists. Back them up alongside the seed.
  • migrate refuses to run against a brand-new keystore (no captured mnemonic) — the assumption is you want all imports plus future derivations to be backed by a recorded seed.
  • If a legacy .key is in the upstream encrypted (EXFK) format, pass --legacy-passphrase (or WALLETD_LEGACY_PASSPHRASE).

FAQ & troubleshooting

For "where's my token / how do I rotate / how do I bind a public IP" see Quick start, Tokens and scopes, and Operations first.

transfer returns -32031 but get_balance says I have money

The in-flight UTXO tracker. A previous transfer from the same wallet hasn't confirmed yet, and walletd reserved its UTXOs in memory so the new transfer can't race onto the same outpoints.

The error message has the detail:

insufficient balance: need 1100000 exfers (amount + fee), wallet
has 0 spendable across 0 UTXO(s) (1 more UTXO(s) worth 64800000
exfers reserved by pending transfers from this daemon; retry once
they confirm or use a different sending wallet)

Wait for the pending tx to confirm, or use a different sender wallet. TTL on the in-flight claim is 10 minutes; after that, walletd re-tries the outpoint (and would lose to a mempool double-spend rejection if the original is still pending).

I restarted walletd and now transfer fails with mempool double-spend

The in-flight tracker is in-memory only. A pre-restart pending tx is invisible to the fresh process. If get_address_utxos on the upstream still returns the (mempool-spent) UTXO as confirmed, the fresh walletd will pick it and get rejected.

Wait for the pending tx to confirm — once it's in a block, the spent UTXO stops appearing. Or: don't restart walletd while transfers are unconfirmed.

Why doesn't get_address_utxos see my mempool spends?

Common policy across UTXO-chain nodes: get_address_utxos returns the confirmed UTXO set, not the mempool view. A mempool-spent UTXO keeps appearing here until its consuming tx confirms.

This is why walletd has the in-flight tracker — to bridge that gap locally without depending on a mempool-aware UTXO endpoint upstream.

Balances look wrong / get_block_height is way behind

Almost always: your upstream node isn't fully synced. Walletd returns whatever the node has — node at height 50k while the chain is at 580k means balances reflect the 50k view.

The walletd-local methods (generate_address, list_addresses, healthz, ping) are safe to call regardless of node sync state.

How do I see what walletd is doing?

Daemon logs go to stdout/stderr. Bump verbosity with RUST_LOG:

RUST_LOG=debug,exfer_walletd=trace exfer-walletd

Spend-scope requests always emit an audit line at INFO with method, client IP, request id, and outcome.

Something else?

Open an issue with the error message, surrounding log lines (with token values redacted), and what you were trying to do.