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 returnTypedDicts. - Stable error hierarchy mapped 1:1 to walletd's documented JSON-RPC
error codes, all rooted at
ExferError. Unknown codes fall through to bareWalletdErrorso 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 becausefromis a Python keyword. The wire field walletd sees is plainfrom.
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 defand must beawaited. - Lifecycle is
async with AsyncClient(...)/await c.aclose()instead ofwith 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 fieldfrom.amount,feeare integers in exfers.- Omitting
feelets 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 forfrom_.InsufficientBalanceError— check.in_flight_reservedto 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 fromWalletdError.
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.
| Code | Exception | When |
|---|---|---|
-32001 | AuthenticationError | Token missing, wrong, or scope insufficient for the method. Also raised on HTTP 401. |
-32010 | WalletNotFoundError | from address has no key file on walletd's disk. |
-32011 | WalletExistsError | Address collision on generate_address (cosmically rare). |
-32020 | UpstreamError | Walletd's upstream node is unreachable, or returned an RPC error (e.g. mempool rejection). Walletd has already retried before surfacing this. |
-32030 | TxAuthError | UTXO 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. |
-32031 | InsufficientBalanceError | Wallet can't cover amount + fee. See .in_flight_reserved. |
-32601 | MethodNotFoundError | Method name unknown to walletd. |
-32602 | InvalidParamsError | Bad hex, wrong address length, missing field, integer overflow. |
-32603 | InternalError | Walletd hit an unexpected internal error. |
-32700 | ParseError | Walletd 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
TransportErrorafterUpstreamError: walletd is up, but its upstream node is down. Check walletd's logs.AuthenticationErrorontransferonly: you constructedClientwith 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.InvalidParamsErrorwith "invalid address": an upstream call got a non-hex or wrong-length string. Walletd validates inputs at the wrapper layer (added in walletdv0.4.1) so a typo'd address surfaces as-32602rather than a misleadingbalance: 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.