exfer-walletd (Python SDK)

A typed Python client for the exfer-walletd JSON-RPC API.

from exfer_walletd import Client

with Client("http://127.0.0.1:7448", token="...") as c:
    assert c.healthz()                # → bool
    addr = c.generate_address()       # → str
    bal  = c.get_balance(addr)        # → int (exfers)
    print(addr, bal)

What it is

  • A thin wrapper over walletd's JSON-RPC. One Python method per RPC method, no abstraction in between.
  • Both sync (Client) and async (AsyncClient) — same surface, shared wire layer.
  • Single-value endpoints (generate_address, get_balance, get_block_height, send_raw_transaction, …) return bare Python values (str, int). Multi-field endpoints return TypedDicts.
  • Stable error hierarchy mapped 1:1 to walletd's documented JSON-RPC error codes, all rooted at ExferError. Unknown codes fall through to bare WalletdError so future walletd releases don't break your code.

What it isn't

  • Not a chain client. This SDK talks to walletd; walletd talks to a node. The SDK never holds keys, never signs transactions, never derives addresses. If you need client-side signing, run walletd.
  • Not a high-level wallet abstraction. Methods map 1:1 to the wire grammar; build helpers on top as your application needs them.

Status

0.5.0 — alpha. Breaking changes from 0.4.x (return shapes unwrapped, get_block split, error code surfaced in str()); see the CHANGELOG. Tested against exfer-walletd >= 0.4.3.

MIT licensed. Source: github.com/exfer-stack/exfer-py.

Install

pip install exfer-walletd

That's it. httpx is the only runtime dependency.

Requirements

  • Python 3.9 – 3.13
  • A running exfer-walletd (>= 0.4.3). If you don't have one yet, see the walletd quick start.

Verify

import exfer_walletd
print(exfer_walletd.__version__)

Then probe a running walletd:

from exfer_walletd import Client

with Client.from_datadir() as c:        # reads ~/.exfer-walletd/token
    print(c.healthz())                  # → True

Optional: dev install

If you're hacking on the SDK itself:

git clone https://github.com/exfer-stack/exfer-py
cd exfer-py
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
pytest

Next: Quick start.

Quick start

This page assumes you have walletd running. If not, set it up first: walletd quick start.

Construct a client

Three ways, depending on how your deployment hands out the token.

from exfer_walletd import Client

# 1. Explicit (works everywhere)
c = Client("http://127.0.0.1:7448", "your-token")

# 2. From env vars (deployed backends)
#    Set WALLETD_URL + WALLETD_AUTH_TOKEN
c = Client.from_env()

# 3. From a local walletd datadir (dev / colocated services)
c = Client.from_datadir()       # reads ~/.exfer-walletd/token

Client is a context manager — use with so the underlying HTTP connection pool gets torn down cleanly:

with Client.from_datadir() as c:
    ...

TLS (production)

When walletd is run with --tls (walletd >= 0.5.0), point the SDK at the https:// URL and supply the SHA-256 fingerprint walletd printed on first run:

# Explicit
with Client(
    "https://<walletd-host>:7448",
    token="…",
    fingerprint="sha256:b66953c47263ac0da8192676e4770f0f799563322985c57246a6fab1bf24aa86",
) as c:
    c.ping()

# From env vars (set WALLETD_FINGERPRINT alongside URL + TOKEN)
with Client.from_env() as c:
    c.ping()

# Colocated with walletd — reads cert.fingerprint automatically
with Client.from_datadir(url="https://127.0.0.1:7448") as c:
    c.ping()

The SDK pins the cert by hash rather than verifying it against the CA chain — that's what makes the self-signed cert walletd generates actually trustable. If the server presents the wrong cert, the SDK raises FingerprintMismatchError.

Generate an address and watch its balance

with Client.from_datadir() as c:
    addr = c.generate_address()      # → str
    print("deposit address:", addr)

    bal = c.get_balance(addr)        # → int (exfers)
    print("balance (exfers):", bal)

walletd persists the key file in its datadir under wallets/<address>.key with mode 0600. The SDK never sees the private key.

Send a payment

with Client.from_datadir() as c:
    r = c.transfer(
        from_="<your-managed-address>",
        to="<recipient-address>",
        amount=30_000_000,         # exfers; 1 EXFER = 100_000_000 exfers
        # fee defaults to 100_000 (= 0.001 EXFER) if omitted
    )
    print("submitted tx:", r["tx_id"])

Note: from_ (trailing underscore) is the parameter name because from is a Python keyword. The wire field walletd sees is plain from.

Liveness probes

with Client.from_datadir() as c:
    if not c.healthz():
        # walletd's HTTP layer is down (or unreachable)
        ...
    c.ping()
    # ping() returns None on success; raises on any failure.
    # Use it to verify the token is valid AND the JSON-RPC layer is up.
    # (healthz is unauthenticated and says nothing about token validity.)

Handle errors

from exfer_walletd import (
    Client,
    ExferError,                       # catch-all SDK error
    InsufficientBalanceError,
    UpstreamError,
    WalletNotFoundError,
)

with Client.from_datadir() as c:
    try:
        c.transfer(from_=addr, to=other, amount=1_000_000_000)
    except InsufficientBalanceError as e:
        if e.in_flight_reserved:
            print("UTXOs reserved by pending transfers — retry shortly")
        else:
            print("wallet is empty; fund it first")
    except WalletNotFoundError:
        print("walletd doesn't hold the key for", addr)
    except UpstreamError as e:
        print("walletd's upstream node is unreachable:", e.message)
    except ExferError as e:
        # blanket catch — `str(e)` includes the error code
        log.error(f"transfer failed: {e}")    # "[-32xxx] some message"

Every documented walletd error code is a typed exception — see Errors.

Next

  • Async usage — if your backend is FastAPI / aiohttp / asyncio.
  • API reference — every method, every parameter, every return shape.

Async usage

AsyncClient mirrors Client method-for-method on top of httpx.AsyncClient. Use it from FastAPI, aiohttp, or any other asyncio-based backend.

Example

from exfer_walletd import AsyncClient

async def main() -> None:
    async with AsyncClient.from_datadir() as c:
        assert await c.healthz()
        addr = await c.generate_address()      # → str
        bal  = await c.get_balance(addr)       # → int
        print(addr, bal)

import asyncio
asyncio.run(main())

FastAPI

from contextlib import asynccontextmanager
from fastapi import FastAPI
from exfer_walletd import AsyncClient

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.walletd = AsyncClient.from_env()
    try:
        yield
    finally:
        await app.state.walletd.aclose()

app = FastAPI(lifespan=lifespan)

@app.get("/balance/{addr}")
async def balance(addr: str) -> dict[str, int]:
    return {"balance": await app.state.walletd.get_balance(addr)}

A single AsyncClient instance is reusable across all requests — it owns an underlying connection pool, and httpx is concurrency-safe.

Differences from Client

There are exactly two:

  • Every RPC method is async def and must be awaited.
  • Lifecycle is async with AsyncClient(...) / await c.aclose() instead of with Client(...) / c.close().

Everything else — parameter shapes, return types, the exception hierarchy — is identical. The two clients share _transport.py so they can't drift on the wire.

Concurrency

Calls multiplex over the underlying connection pool. Use the standard asyncio primitives:

import asyncio

async with AsyncClient.from_env() as c:
    balances = await asyncio.gather(
        c.get_balance(addr1),
        c.get_balance(addr2),
        c.get_balance(addr3),
    )

walletd handles concurrent reads cheaply. For concurrent transfers from the same wallet, walletd's in-flight UTXO tracker serializes them safely — see walletd's transfer docs for the details.

API reference

Every method exists on both Client (sync) and AsyncClient (async) with identical names, parameters, and return shapes. The async version is async def and must be awaited; otherwise they're interchangeable.

Amounts are integers in exfers, where 1 EXFER = 100_000_000 exfers. Hex strings (addresses, hashes, scripts, tx bytes) are lowercase, no 0x prefix.


Construction

Client(url: str, token: str, *, timeout: float = 30.0,
       transport: httpx.BaseTransport | None = None,
       fingerprint: str | None = None)

AsyncClient(url, token, *, timeout=30.0, transport=None, fingerprint=None)

fingerprint enables TLS pinning when walletd is run with --tls. Format is "sha256:<lowercase-hex-64>" (the exact string walletd writes to <datadir>/cert.fingerprint on first run). Requires an https:// URL. The pinning transport replaces CA-chain validation — walletd's leaf cert is trusted iff its SHA-256 matches.

transport= and fingerprint= are mutually exclusive — the latter installs a pinning transport itself, and accepting a custom one alongside would silently bypass verification.

Alternate constructors:

Client.from_env(*, url_env="WALLETD_URL",
                token_env="WALLETD_AUTH_TOKEN",
                fingerprint_env="WALLETD_FINGERPRINT")

Client.from_datadir(*, url="http://127.0.0.1:7448",
                    datadir="~/.exfer-walletd")

from_env reads WALLETD_FINGERPRINT if set (otherwise plaintext HTTP). from_datadir auto-reads <datadir>/cert.fingerprint when url is https://, raising FileNotFoundError if walletd hasn't been started with --tls yet.

Raise RuntimeError (from_env) or FileNotFoundError (from_datadir) if the required inputs are missing.


Liveness

healthz() -> bool

Probe GET /healthz — TCP+HTTP only. Returns True iff walletd answered 200 OK with body ok. Returns False on any failure rather than raising — drops cleanly into liveness loops.

No Authorization header is sent. A green healthz says nothing about whether your token is valid or whether walletd's upstream node is reachable.

ping() -> None

Authenticated JSON-RPC round-trip. Returns None on success and raises on any failure. Use this when you want to verify the token is valid and walletd's RPC layer is up — not just the TCP socket.


Read-scope methods

generate_address() -> str

Create a new managed address. Returns the address (lowercase 64-char hex). Walletd persists the keypair on disk under <datadir>/wallets/<address>.key.

list_addresses() -> list[str]

Every address walletd holds a key for, sorted ascending.

get_balance(address: str) -> int

Confirmed balance for address, in exfers. Mempool UTXOs are not counted; for the mempool-aware view, use get_address_utxos.

get_address_utxos(address: str) -> UtxosResult

Confirmed UTXOs locked to address plus tip metadata:

{
  "address":    "27e1c8...",
  "script_hex": None,
  "tip_height": 577429,
  "truncated":  False,
  "utxos": [
    {
      "tx_id":        "a02ab0...",
      "output_index": 1,
      "value":        69900000,
      "height":       577429,
      "is_coinbase":  False,
      "script_len":   None,
    },
  ],
}

truncated is True if the upstream hit a result limit.

get_script_utxos(script_hex: str) -> UtxosResult

Same shape as get_address_utxos, but matches by raw locking script. address is always None in the result.

get_block_height() -> int

Current chain tip height. For the (height, block_id) pair, use get_tip().

get_tip() -> Tip

Current chain tip as a NamedTuple:

from exfer_walletd import Tip

tip = c.get_tip()
print(tip.height, tip.block_id)
h, b = tip                    # unpack works too

get_block_by_height(height: int) -> Block

{
  "hash":              "17b95f...",
  "height":            577429,
  "prev_block_id":     "...",
  "state_root":        "...",
  "tx_root":           "...",
  "timestamp":         1700000000,
  "nonce":             42,
  "difficulty_target": "...",
  "tx_count":          1,
  "transactions":      ["a02ab0..."],
}

get_block_by_hash(block_hash: str) -> Block

Same shape; lookup by block hash instead of height.

get_transaction(tx_id: str) -> Transaction

Fetch a transaction. Covers mempool + confirmed; in_mempool distinguishes.

{
  "tx_id":        "a02ab0...",
  "tx_hex":       "01000200...",
  "in_mempool":   False,
  "block_hash":   "1bac70...",       # None if in mempool
  "block_height": 577429,            # None if in mempool
}

Spend-scope methods

transfer(*, from_, to, amount, fee=None) -> TransferResult

Build, sign, and broadcast a payment from a managed wallet.

  • from_ (trailing underscore) maps to wire field from.
  • amount, fee are integers in exfers.
  • Omitting fee lets walletd apply its default (100_000 = 0.001 EXFER).
{
  "tx_id":      "a02ab0...",
  "size":       227,
  "tip_height": 577427,
  "submitted":  True,
}

Common errors:

  • WalletNotFoundError — walletd doesn't hold the key for from_.
  • InsufficientBalanceError — check .in_flight_reserved to decide whether to retry.
  • UpstreamError — node rejected the broadcast or is unreachable.
  • TxAuthError — UTXO authentication failed; the upstream may be malicious or out of sync.

See Errors for the full list.

send_raw_transaction(tx_hex: str) -> str

Broadcast a pre-signed transaction. Returns the broadcast tx_id. Used by transfer internally; exposed for callers that build transactions externally.


Type definitions

Single-value methods return bare Python types (str, int). Multi-field methods return TypedDicts or NamedTuples — import them from exfer_walletd.types if you want to annotate variables.

from exfer_walletd.types import (
    Block,
    Transaction,
    TransferResult,
    Utxo,
    UtxosResult,
)
from exfer_walletd import Tip       # NamedTuple — also top-level

You don't need to import any of these for normal use — they're just return-type annotations.

Errors

The entire SDK error tree is rooted at ExferError — so a blanket except ExferError always works as the outermost catch.

Underneath, two operational classes:

  • WalletdError — walletd answered with a JSON-RPC error envelope. Every documented error code maps to a typed subclass.
  • TransportError — walletd itself is unreachable, the body isn't JSON, or HTTP framing went sideways. Separate branch from WalletdError.
from exfer_walletd import (
    ExferError,        # top-level catch-all
    WalletdError,      # walletd answered with an error
    TransportError,    # walletd unreachable / non-JSON / etc.
)

try:
    c.get_balance(addr)
except WalletdError as e:        # walletd answered with an error
    handle_rpc_error(e.code, e.message)
except TransportError as e:      # walletd unreachable, etc.
    handle_network_error(e)
except ExferError as e:          # belt-and-braces fallback
    log.exception("unexpected SDK error: %s", e)

str(e) always includes the code in the [-32xxx] message format, so plain log.error(f"{e}") is enough for production logs.

FingerprintMismatchError

A subclass of TransportError, raised when the TLS leaf cert presented by the server doesn't match the SHA-256 fingerprint you pinned. Means some walletd answered, but it isn't the walletd you configured — typical causes: an MitM, the cert was regenerated server-side and the operator forgot to update the client config, or the wrong fingerprint was wired in. Always a hard fail; never retry.

from exfer_walletd import Client, FingerprintMismatchError

try:
    with Client("https://<walletd-host>:7448", token,
                fingerprint="sha256:b669...") as c:
        c.ping()
except FingerprintMismatchError as e:
    print("expected:", e.expected)
    print("got:     ", e.actual)

Because it inherits TransportError (and therefore ExferError), existing except TransportError / except ExferError blocks catch it automatically — adding the specific class lets you distinguish "wrong walletd" from "walletd unreachable."

Code → exception table

Every code below is a subclass of WalletdError (and therefore of ExferError). Catch the narrow type when you have specific handling; otherwise catch WalletdError.

CodeExceptionWhen
-32001AuthenticationErrorToken missing, wrong, or scope insufficient for the method. Also raised on HTTP 401.
-32010WalletNotFoundErrorfrom address has no key file on walletd's disk.
-32011WalletExistsErrorAddress collision on generate_address (cosmically rare).
-32020UpstreamErrorWalletd's upstream node is unreachable, or returned an RPC error (e.g. mempool rejection). Walletd has already retried before surfacing this.
-32030TxAuthErrorUTXO authentication failed — the upstream returned tx data that didn't match the outpoint walletd asked about. Often means the node is malicious or out of sync.
-32031InsufficientBalanceErrorWallet can't cover amount + fee. See .in_flight_reserved.
-32601MethodNotFoundErrorMethod name unknown to walletd.
-32602InvalidParamsErrorBad hex, wrong address length, missing field, integer overflow.
-32603InternalErrorWalletd hit an unexpected internal error.
-32700ParseErrorWalletd couldn't parse the JSON-RPC envelope (HTTP 400).

Unknown codes (a future walletd may add new ones) fall through to bare WalletdError carrying the raw code and message, rather than being silently swallowed. Callers can always branch on e.code.

InsufficientBalanceError.in_flight_reserved

The one piece of value-add parsing the SDK does: when walletd's shortfall comes from UTXOs reserved by other pending transfers from the same daemon, in_flight_reserved is True. In that case retrying in a few seconds may succeed (once the pending transfers confirm and free their UTXOs).

from exfer_walletd import InsufficientBalanceError

try:
    c.transfer(from_=hot, to=user, amount=1_000_000)
except InsufficientBalanceError as e:
    if e.in_flight_reserved:
        time.sleep(5)         # let pending transfers confirm
        retry(...)
    else:
        alert_ops("hot wallet drained")

The flag is sourced from the error envelope's data field when walletd populates it; otherwise we fall back to string-matching walletd's canonical message. On parse miss, defaults to False — the safe choice so callers never blindly retry.

HTTP framing

Walletd returns:

  • HTTP 401 for any auth failure → AuthenticationError
  • HTTP 400 for unparseable JSON → ParseError
  • HTTP 200 for everything else (success or JSON-RPC error)

Other status codes (5xx, redirects) are wrapped as TransportError.

Common pitfalls

  • TransportError after UpstreamError: walletd is up, but its upstream node is down. Check walletd's logs.
  • AuthenticationError on transfer only: you constructed Client with a read-scope token. Use the spend-scope token, or split your service into a deposit-watcher (read) and a withdrawal-worker (spend) with separate clients.
  • InvalidParamsError with "invalid address": an upstream call got a non-hex or wrong-length string. Walletd validates inputs at the wrapper layer (added in walletd v0.4.1) so a typo'd address surfaces as -32602 rather than a misleading balance: 0.

FAQ

Does the SDK ever hold private keys?

No. Walletd is the only component that ever sees a private key. The SDK is a typed HTTP client and nothing more.

Why not pydantic?

The wire payload is a dict. Wrapping it in a pydantic model would add a runtime construction cost, a 5 MB transitive dependency, and a forward-compat liability (strict-by-default models break when walletd adds a new field). TypedDict gives full mypy / pyright coverage at zero cost and forward-compats trivially.

If you specifically want a pydantic model, build it on top — pass the SDK's dict into MyModel.model_validate(result).

Why is there no retry on -32020?

Walletd already retries upstream node failures with linear backoff (default 4 attempts, 500 ms base — see walletd's RetryPolicy). Layering another retry inside the SDK would multiply latency without adding reliability.

If walletd itself is unreachable (you get TransportError, not UpstreamError), retry at the caller — that's a separate failure mode the SDK can't sensibly handle for you.

Can I use one client for both read and spend?

Yes. The SDK doesn't model scopes — a single Client carries one bearer token, and walletd enforces what that token can do. If your token is read-only and you call transfer, you get AuthenticationError.

If you want hard separation between deposit-watcher and withdrawal-worker, construct two Client instances with two tokens and let the type system make crossing the line obvious.

How do I detect "wallet is empty" vs "wallet is busy"?

InsufficientBalanceError.in_flight_reserved:

except InsufficientBalanceError as e:
    if e.in_flight_reserved:
        # transient — pending transfers will free UTXOs
        retry_later()
    else:
        # actually empty — fund the wallet
        alert()

Why does transfer use from_ instead of from?

from is a Python keyword and can't be used as a parameter name. The SDK accepts from_ (trailing underscore, a common Python convention) and translates to the wire field from before sending.

How do I run the integration tests?

The integration suite spawns a real walletd binary. Build walletd first:

cd ../exfer-walletd
cargo build --release
cd ../exfer-py
pytest -m integration

Or point at a custom location:

WALLETD_BINARY=/usr/local/bin/exfer-walletd pytest -m integration

Tests skip cleanly if the binary isn't available.

Which walletd versions are supported?

The SDK tracks walletd's current minor release. v0.3.0 of the SDK is tested against walletd v0.4.3; the CI integration job pins to that exact tag. Any newer walletd should work — unknown error codes gracefully fall through to bare WalletdError.

Where do I report bugs?

github.com/exfer-stack/exfer-py/issues. For walletd-side issues (wire format, error semantics), report to exfer-walletd instead.