Language-agnostic contract for the Satoshi wallet — the HD Bitcoin
wallet service used by the satoshi-mediator
to broadcast OP_RETURN anchor transactions. The canonical
implementation is
services/mediators/satoshi-wallet/.
This is the only Archon component that builds and signs Bitcoin
transactions. It does not hold its own mnemonic on disk: at signing time
it fetches the wallet’s BIP39 mnemonic from the local
Keymaster (via the admin-keyed
GET /api/v1/wallet/mnemonic) and discards it after the signing pass.
Related specs. Paired with the satoshi-mediator (which calls its HTTP routes to anchor batches and manage fees) and the Keymaster (source of the mnemonic). The UTXO / balance / history side of the wallet is delegated either to a locally-reachable Bitcoin Core wallet via RPC (
corebackend) or to a hosted Bitcoin JSON-RPC + UTXO provider (alchemybackend).
Two halves, both thin wrappers:
Watch-only wallet — derives xpubs from the Keymaster mnemonic,
imports wpkh descriptors into Bitcoin Core as a descriptor watch-
only wallet, and forwards balance/address/UTXO/history queries to
Core. The descriptor wallet name is ${ARCHON_WALLET_NAME} (default
archon-watch-<nodeID>).
Hosted UTXO wallet — when ARCHON_WALLET_BACKEND=alchemy,
derives BIP84 external/internal addresses locally, scans a gap window
through the configured UTXO API, persists scan state on disk, builds
funded PSBTs locally, signs from the Keymaster mnemonic, and broadcasts
through Bitcoin JSON-RPC sendrawtransaction.
Signing authority — for send, anchor, and bump-fee
operations, re-derives the private keys on demand from the mnemonic,
signs locally with bitcoinjs-lib + ECPair, and broadcasts via
sendrawtransaction. RBF bumping is currently supported only by the
core backend.
The mnemonic never lives in this service’s memory between requests. Every signing call fetches it afresh and scope-bounds its use to a single request.
Binds to ${ARCHON_WALLET_PORT} (default 4240). Routes under
/api/v1.
| Method | Path | Admin? | Notes |
|---|---|---|---|
GET |
/api/v1/wallet/version |
no | { version, commit } |
POST |
/api/v1/wallet/setup |
yes | Creates the watch-only wallet in Core and imports wpkh descriptors. Returns { ok: true, network, walletName, descriptors: [external, internal] }. Idempotent — safe to re-run. |
GET |
/api/v1/wallet/balance |
yes | { balance, unconfirmed_balance, network } in BTC. |
GET |
/api/v1/wallet/address |
yes | { address, network } — next unused external bech32 address. |
GET |
/api/v1/wallet/transactions?count=N&skip=N |
yes | { transactions: ListTransactionsEntry[], network }. Pagination: count (default 10), skip (default 0). |
GET |
/api/v1/wallet/utxos?minconf=N |
yes | { utxos: UnspentOutput[], network }. minconf default 1. |
GET |
/api/v1/wallet/fee-estimate?blocks=N |
yes | { feerate, blocks, network } — BTC/kB from Bitcoin JSON-RPC estimatesmartfee, with a conservative fallback in hosted mode. |
GET |
/api/v1/wallet/info |
yes | Wallet status block (balance, tx count, network, etc.). |
POST |
/api/v1/wallet/send |
yes | { to, amount, feeRate?, subtractFee? } → { txid, ... }. amount in BTC. feeRate in sat/vB. |
POST |
/api/v1/wallet/anchor |
yes | { data, feeRate? } → { txid, ... }. data is the UTF-8 string to put in OP_RETURN (≤ 80 bytes). Called by satoshi-mediator. |
GET |
/api/v1/wallet/transaction/:txid |
yes | { txid, confirmations, blockhash, fee, network } from Core’s gettransaction. HTTP 404 if the tx isn’t in this wallet. |
POST |
/api/v1/wallet/bump-fee |
yes | { txid, feeRate? } → { txid, ... }. RBF the given tx; txid MUST be a wallet tx currently in the mempool. Called by satoshi-mediator when a pending anchor stays unconfirmed. |
GET |
/metrics |
no | Prometheus. Binds to ${ARCHON_WALLET_METRICS_PORT} (default 4241), NOT the main port. |
Every admin route requires X-Archon-Admin-Key matching
ARCHON_ADMIN_API_KEY. With the key set, missing/wrong header → HTTP
ARCHON_ADMIN_API_KEY is empty, admin routes return HTTP 403
“Admin API key not configured” — the mediator doesn’t probe for this,
so you must configure it matching on both sides.Unlike the Keymaster, the satoshi-wallet returns raw-shaped JSON (no top-level key wrapping). Callers read fields directly:
// GET /api/v1/wallet/balance
{ "balance": 0.5, "unconfirmed_balance": 0.0, "network": "signet" }
// POST /api/v1/wallet/anchor
{ "txid": "abc...", "network": "signet" }
application/json { "error": "<message>" } with an appropriate
status:
400 for client-side validation (missing / invalid params, OP_RETURN
80 bytes, etc.).
404 for gettransaction misses.503 when Core reports an unloaded/missing wallet (RPC code -18), or
the address route refuses an unvalidated wallet.500 for other service/RPC failures.Express default (~100 KB). All bodies are tiny; no custom limit needed.
None by default. The wallet is meant to be reached only from the satoshi-mediator on the same private network.
The wallet uses BIP84 (native SegWit P2WPKH) derivation throughout:
m / 84' / <coin_type>' / 0' / <chain> / <index>
| Field | Value |
|---|---|
| purpose | 84' (BIP84) |
| coin_type | 0' for mainnet, 1' for signet/testnet4 |
| account | 0' (single account per wallet) |
| chain | 0 (external) / 1 (internal/change) |
| index | non-hardened, grows as needed |
Address type: bech32 wpkh(...). Master/account xpub uses
xprv/xpub version bytes for mainnet and tprv/tpub for
signet/testnet4.
On POST /wallet/setup, the service:
createwallet with disable_private_keys=true, blank=true,
descriptors=true — creates a pure-descriptor watch-only wallet in
Core. Loads it if it already exists.external: wpkh([<fingerprint>/84h/<coin>h/0h]<xpub>/0/*)
internal: wpkh([<fingerprint>/84h/<coin>h/0h]<xpub>/1/*)
importdescriptors with both descriptors, marking them active
and assigning ranges (default 1000 addresses each; the service
refreshes the range on setup if Core has consumed too many).Because Core holds the xpubs only, it can generate addresses and track UTXOs but cannot sign. Signing is done locally in this service.
For /send, /anchor, /bump-fee:
fetchMnemonic() — GET {keymasterURL}/api/v1/wallet/mnemonic with
the admin header.walletcreatefundedpsbt(inputs=[], outputs=[...], feeRate?) — asks
Core to build a funded PSBT from the watch-only wallet’s UTXOs.bip32Derivation fields).sendrawtransaction(<hex>) and return the txid.For bump-fee, the same flow plus psbtbumpfee from Core’s RPC which
returns a new PSBT; same signing pass follows.
OP_RETURN anchors add a single zero-value
scriptPubKey: OP_RETURN <data-push> output to the PSBT. data is
encoded as UTF-8 bytes. Size validation enforces bytes <= 80.
JSON-RPC over HTTP, basic auth:
http://<btcUser>:<btcPass>@<btcHost>:<btcPort>/wallet/<walletName>
Wallet name is a URL segment on the RPC path (Core’s multi-wallet
addressing convention). The mediator relies on Core being configured
with -txindex=0 (default) — gettransaction works against wallet
txs, which is all the mediator touches.
| bitcoind method | Used by |
|---|---|
createwallet |
/wallet/setup |
loadwallet |
/wallet/setup (fallback when wallet already exists) |
listdescriptors |
/wallet/setup (idempotency check) |
importdescriptors |
/wallet/setup |
getwalletinfo |
/wallet/info |
getbalances |
/wallet/balance |
getnewaddress (with bech32 type) |
/wallet/address |
getreceivedbyaddress |
/wallet/address (address-rotation check) |
listtransactions |
/wallet/transactions |
listunspent |
/wallet/utxos |
estimatesmartfee |
/wallet/fee-estimate and inside /send//anchor when no feeRate is supplied |
walletcreatefundedpsbt |
/send, /anchor |
gettransaction |
/wallet/transaction/:txid |
psbtbumpfee |
/wallet/bump-fee |
sendrawtransaction |
/send, /anchor, /bump-fee |
getblockcount |
metrics |
ARCHON_WALLET_NETWORK must match the Core node’s network. Accepted
values and their Core equivalents:
| WalletNetwork | Core chain | Default port |
|---|---|---|
mainnet |
main |
8332 |
signet |
signet |
38332 |
testnet4 |
testnet4 |
48332 |
Only these three are supported — regtest is intentionally omitted.
walletcreatefundedpsbt or
getblockchaininfo succeeds (the TS reference waits implicitly
during /wallet/setup).The service eagerly runs setupWatchOnlyWallet at startup with a
12-attempt retry loop before serving requests. Exhausting those attempts
does not settle the matter: setup is retried every 30 seconds in the
background until it succeeds, because the usual cause is a keymaster
still starting, and the satoshi-mediator waits on this service for an
address. A refusal is the exception — a descriptor mismatch, or a Core
without sqlite — since repeating it cannot change the answer.
Until setup succeeds, GET /api/v1/wallet/address answers 503. Balance
reads do not prove that descriptors have been validated, so balance alone
is not a readiness signal.
For the Core backend, a runtime RPC error -18 marks setup status as zero,
blocks address publication, and starts the same 30-second setup retry loop.
Detection comes from failed wallet requests (including /wallet/info, which
propagates this RPC error instead of returning a 200 status report) or the
existing 60-second metrics poll; no separate watchdog is used. Concurrent detections and manual setup
share a single in-flight setup attempt. Successful setup revalidates the
configured mnemonic against the wallet descriptors, restores readiness, and
stops retries. Descriptor mismatch and missing SQLite support stop automatic
retries; an operator can explicitly retry /wallet/setup after correcting the
cause. Other RPC/transport errors do not trigger wallet reinitialization.
Recovery repeats setup only, never the failed send, anchor, or fee-bump request. This behavior is specific to Core-loaded wallets; the Alchemy backend and other chain wallet services do not acquire an equivalent reload policy.
Regression checks should exercise successful startup followed by a runtime
unload, not just failed startup. Run the fast coordinator tests in
tests/wallet/runtime-recovery.test.ts. With the wallet service dependencies
installed, npm run test:recovery --prefix services/mediators/satoshi-wallet
builds and starts an isolated service against HTTP fixtures using the real
Bitcoin RPC client and real 30/60-second timers (about two minutes). It verifies
request/metrics recovery and descriptor refusal without touching a live node.
| Variable | Default | Meaning |
|---|---|---|
ARCHON_WALLET_PORT |
4240 |
Main HTTP port. |
ARCHON_WALLET_METRICS_PORT |
4241 |
Prometheus port (separate listener). |
ARCHON_KEYMASTER_URL |
http://localhost:4226 |
|
ARCHON_ADMIN_API_KEY |
empty | Shared admin key between Keymaster / wallet / mediator. |
ARCHON_WALLET_BACKEND |
core |
core uses Bitcoin Core descriptor wallet RPCs. alchemy uses local BIP84 signing plus hosted JSON-RPC / UTXO APIs. |
ARCHON_WALLET_BTC_RPC_URL |
unset | Full Bitcoin JSON-RPC URL. When set, takes precedence over host / port / user / password. Required for hosted alchemy mode. |
ARCHON_WALLET_UTXO_URL |
derived from ARCHON_WALLET_BTC_RPC_URL |
Base UTXO REST URL for alchemy mode. Defaults to <rpc-url>/api/v2. The wallet supports Alchemy /utxo/{address} and Esplora/mempool.space /address/{address}/utxo shapes. |
ARCHON_WALLET_STATE_PATH |
./data/satoshi-wallet-state.json |
Local scan-state cache used by alchemy mode. |
ARCHON_WALLET_REFRESH_TTL_MS |
30000 |
In-process hosted wallet scan cache TTL. Balance/address/UTXO calls inside this window reuse the same gap-window scan. |
ARCHON_WALLET_BTC_HOST |
localhost |
bitcoind RPC host. |
ARCHON_WALLET_BTC_PORT |
38332 (signet) |
bitcoind RPC port. |
ARCHON_WALLET_BTC_USER / ARCHON_WALLET_BTC_PASS |
empty | bitcoind RPC auth. |
ARCHON_WALLET_NAME |
archon-watch-<nodeID> |
Core wallet name. |
ARCHON_WALLET_NETWORK |
signet |
mainnet / signet / testnet4. |
ARCHON_WALLET_GAP_LIMIT |
20 |
Descriptor gap limit (address lookahead). |
ARCHON_WALLET_FEE_TARGET |
6 |
Confirmation target for estimatesmartfee. |
ARCHON_NODE_ID |
unset | Used only to form the default wallet name. |
GIT_COMMIT |
unknown |
No explicit handler; the HTTP server closes on SIGTERM/SIGINT by default. The watch-only wallet stays loaded in Core across restarts.
Binds to the metrics port (separate from the API port). Refreshed every 60 seconds in the background:
| Metric | Type | Labels |
|---|---|---|
wallet_setup_status |
gauge | (none) — 1 if the watch-only wallet is ready, 0 otherwise |
wallet_balance_confirmed_btc |
gauge | (none) — BTC |
wallet_balance_unconfirmed_btc |
gauge | (none) — BTC |
wallet_utxo_count |
gauge | (none) |
wallet_fee_estimate_sat_per_vb |
gauge | (none) — sat/vB |
wallet_bitcoind_block_height |
gauge | (none) |
wallet_sends_total |
counter | status (success / failed) |
wallet_http_requests_total |
counter | method, route, status |
Plus wallet_version_info{version,commit} and standard Prometheus
process metrics.
Route normalization on the route label:
/wallet/transaction/<txid> -> /wallet/transaction/:txid
Everything else is static.
pino (production) or morgan('dev') (development). Errors are
structured via pino with { err }. Nothing in the log output is
contractual for dashboards or log aggregators today.
ghcr.io/archetech/satoshi-walletVerified end-to-end against:
No isolated unit tests; conformance is observed via the mediator.
A conformant third implementation MUST:
walletName, with both external (/0/*) and internal (/1/*)
ranges.OP_RETURN payloads at 80 UTF-8 bytes.bump-fee (requires the pending tx to opt in by
setting nSequence appropriately; Core’s bumpfee handles this when
the original tx was created with default policy).