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.500 for anything else.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, so the watch-only wallet
is ready by the time the satoshi-mediator hits it.
| 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).