Language-agnostic contract for the Satoshi mediator — the service
that anchors Archon DID event batches onto a Bitcoin-family blockchain
(mainnet, signet, testnet4) via OP_RETURN transactions, and that
imports confirmed batches back off-chain.
The canonical implementation is services/mediators/satoshi/.
Related specs. The satoshi mediator reads from and writes to the Gatekeeper (DID queues, blocks,
getGenesis,importBatchByCids), creates signed batch assets through the Keymaster, and delegates all Bitcoin-signing and UTXO management to the satoshi-wallet service over HTTP.
Three background loops over a local Bitcoin Core node + the Archon stack.
Walks the configured chain from startBlock forward, decoding every
OP_RETURN in every transaction. When an OP_RETURN payload decodes as
a valid did:cid:..., that transaction is recorded as a
discovered item (height, index, time, txid, did). Handles reorgs by
withdrawing orphaned evidence and rescanning from a surviving checkpoint (§2.4).
For each discovered item, resolves the DID as an asset (expected shape:
{ batch: { version: 1, ops: [<CID>, ...] } }), then calls
gatekeeper.importBatchByCids(cids, metadata) with anchoring metadata
derived from the Bitcoin transaction (height, index, txid, batch
= the batch DID). Followed by gatekeeper.processEvents() to actually
merge the events into the local DB.
Retries failed items periodically — most failures are “DID not found” (the referenced operation hasn’t propagated through the hyperswarm yet) and resolve on their own.
Fires every exportInterval minutes. When the Gatekeeper’s registry
queue (gatekeeper.getQueue(<chain>)) has pending operations:
gatekeeper.addJSON,
collecting the CIDs.{ batch: { version: 1, ops: [<CID>, ...] } }. If the pin
registry is available, the batch asset is registered to pin;
otherwise it falls back to hyperswarm.satoshi-wallet /api/v1/wallet/anchor with the batch DID as
the OP_RETURN payload.Export mode also anchors the Gatekeeper’s block store: every scanned
block’s {height, hash, time} is posted to gatekeeper.addBlock(<chain>,
block) so the Gatekeeper can timestamp DIDs anchored in that block.
A mediator runs in read-only mode when ARCHON_SAT_EXPORT_INTERVAL=0
(the default). In that mode it only imports; it never signs or
broadcasts. Multiple nodes typically run satoshi mediators in
read-only mode for redundancy, with one or two privileged nodes in
export mode.
The mediator carries no private key material. All BTC signing happens in the satoshi-wallet service, which fetches the signing mnemonic from the Keymaster when needed.
OP_RETURN payload formatA single standard OP_RETURN output per anchor transaction. The data
push is the UTF-8 bytes of a did:cid:... string — the DID of the
batch asset. Total payload size MUST stay ≤ 80 bytes (Bitcoin consensus
standardness rule); in practice the Archon batch DID is ~60 bytes.
Anchor transactions are created by the satoshi-wallet (§satoshi-wallet spec), which builds a PSBT with:
OP_RETURN <did-bytes>, 0 sats.estimatesmartfee +
optional remote fee oracle).nSequence set for RBF (enabled globally when
ARCHON_SAT_RBF_ENABLED=true).The batch asset (created via Keymaster) has didDocumentData:
{
"batch": {
"version": 1,
"ops": [
"<CID>", // CID of an Archon Operation JSON
...
]
}
}
version MUST be 1 (the mediator skips any other version).ops MUST be a non-empty array of strings; each string is the CID of
an operation already pinned on IPFS via Gatekeeper addJSON.The asset is owned by ARCHON_NODE_ID. Exporters register it to the
pin registry when that registry is available, otherwise to
hyperswarm; the BTC anchor remains a timestamp proof.
When the scanner finds a transaction whose OP_RETURN is a valid DID,
the import path:
genesis = gatekeeper.getGenesis(did) — retrieve the genesis DID
document independently of publisher authorization or accepted DID history.genesis.didDocumentData.batch.ops[] in its original order. Missing
genesis content remains retryable; later asset state is never substituted.gatekeeper.importBatchByCids(ops, { registry: <chain>, time:
block.time, ordinal: [height, index], registration: { height, index,
txid, batch: did } }).gatekeeper.processEvents() to drain.The registration metadata is what later powers
didDocumentMetadata.timestamp.upperBound in DID resolution (see
Gatekeeper spec §6.3).
Every scan cycle, the mediator checks whether the last scanned block hash
still has confirmations > 0 via getblockheader. If the node reports the
block as unknown, the chain has moved: the mediator rewinds
ARCHON_SAT_REORG_DEPTH blocks (default 6), re-reads them, and subtracts
their transaction count from txnsScanned. Within that depth of the
configured start block it re-reads the window from its first block instead,
since there is no earlier block to resume above.
Any other RPC failure is not a reorg. The mediator leaves its position alone and skips the pass, so the stored hash is checked again next cycle rather than overwritten by a scan that never verified it.
Reorgs are counted via the satoshi_reorgs_total metric, once per rewind that is
committed.
The configured depth is the initial rewind range. Before committing it, the
mediator checks the preceding stored Gatekeeper block against the canonical
chain and searches backward to a surviving checkpoint (or the configured start).
It calls Gatekeeper rewindRegistry(registry, fromHeight) and waits for success,
then atomically removes discovered items in that suffix while saving the new
scan position. A failed rewind leaves the old scan position and discovered list
intact for retry. Rescanning discovers replacement anchors, including the case
where an orphaned batch has no replacement.
Gatekeeper withdraws chain receipts and block metadata in the suffix, retains the signed operations as unconfirmed Hyperswarm hints, and replays affected histories and controller dependents. Other registries and earlier anchors remain intact. The candidate journal takes precedence over stale accepted projections after an interrupted publication. A rewind is retryable and does not change batch DID resolution or key-rotation semantics.
The mediator connects directly to a bitcoind RPC endpoint via the
bitcoin-core npm client (JSON-RPC over HTTP, basic auth). A
conformant implementation can use any RPC client library; the methods it
needs are:
| bitcoind method | Used for |
|---|---|
getblockchaininfo |
Startup readiness probe. |
getblockcount |
Scan-loop ceiling. |
getblockhash(height) |
Deref height → hash. |
getblockheader(hash) |
Confirmation check during reorg walk. |
getblock(hash, 2) |
Fetch block with full tx+vout for OP_RETURN scanning. |
estimatesmartfee(N, ECONOMICAL) |
Local fee estimate. |
getmempoolentry(txid) |
Current fee rate of a pending anchor tx, for RBF. |
No other methods are required. Wallet operations (listunspent,
walletprocesspsbt, sendrawtransaction) live in the satoshi-wallet
service.
getblock is called with verbosity 2 (BlockVerbosity.JSON_TX_DATA)
so the response includes decoded tx[].vout[].scriptPubKey.asm —
parsing that field’s OP_RETURN <hex> is how the mediator discovers
DIDs.
The mediator uses a hybrid estimator (getHybridFeeRateSatPerVb):
estimatesmartfee(feeConf, "ECONOMICAL"). If it
returns a feerate, convert BTC/kB → sat/vB.{ fastestFee, halfHourFee, hourFee } JSON. Pick based on
feeConf:
feeConf <= 1: fastestFeefeeConf <= 3: halfHourFeehourFeemax(local, oracle) — conservative so an outdated local node
doesn’t starve the transaction.If both fail, falls back to feeFallback (default 10 sat/vB). Fee is
capped at feeMax BTC per tx; RBF bumps refuse to exceed this.
Mainnet typically runs with a mempool.space oracle
(ARCHON_SAT_FEE_ORACLE_URL=https://mempool.space/api/v1/fees/recommended);
signet/testnet run with oracle empty.
When rbfEnabled=true and the pending anchor tx hasn’t confirmed
within feeConf blocks, the mediator:
getmempoolentry(txid).entry.fees.modified >= feeMax, stops (at max fee already).targetSatPerVb = getHybridFeeRateSatPerVb().POST /wallet/bump-fee
with the new rate; records the new txid in pending.txids[].pending.txids is the full chain of RBF replacements; the mediator
considers any of them mined to confirm the batch.
MediatorDb)One JSON document per chain, stored in whichever backend
ARCHON_SAT_DB selects. Selector: json | sqlite | redis | mongodb
(default json).
{
"height": <int>, // last scanned height
"hash": "<block hash>", // last scanned hash (used for reorg detection)
"time": "<RFC 3339>", // last scanned block time
"blockCount": <int>, // chain tip at last scan
"blocksScanned": <int>, // total blocks scanned since startBlock
"blocksPending": <int>, // blockCount - height
"txnsScanned": <int>, // cumulative tx count processed
"registered": [ // batches this mediator anchored
{ "did": "<batch DID>", "txid": "<btc txid>" },
...
],
"discovered": [ // OP_RETURN DIDs seen on-chain
{
"height": <int>,
"index": <int>, // tx index within block
"time": "<RFC 3339>",
"txid": "<btc txid>",
"did": "<batch DID>",
"imported": ImportBatchResult | undefined, // last importBatchByCids result
"processed": ProcessEventsResult | undefined, // last processEvents result
"error": "<string>" | undefined // last failure
},
...
],
"lastExport": "<RFC 3339>" | undefined, // last export-loop run time
"pending": { // current anchor in flight
"txids": ["<txid>", ...], // original + RBF replacements
"blockCount": <int> // chain height at anchor time
} | undefined
}
data/<dbName>.json where dbName defaults to the
chain name with : → - (e.g. BTC-signet.json).data/<dbName>.db.satoshi-mediator:<dbName> → JSON string.satoshi-mediator, document _id = <dbName>.The backend is abstracted via a MediatorDbInterface:
interface MediatorDbInterface {
loadDb(): Promise<MediatorDb | null>;
saveDb(data: MediatorDb): Promise<boolean>;
updateDb(mutator: (db: MediatorDb) => void | Promise<void>): Promise<void>;
}
updateDb MUST be atomic (load → mutate → save). The TS reference
uses an async-promise lock inside each backend.
ARCHON_SAT_REIMPORTWhen set true (default), startup clears
imported/processed/error on every discovered item, forcing a full
reimport pass. This is a convenience for recovering from a damaged
Gatekeeper DB without rescanning the chain — the discovered list stays,
but the import state resets.
Small, metrics-only. Binds to ${ARCHON_SAT_METRICS_PORT} (default
4234).
| Method | Path | Body |
|---|---|---|
GET |
/version |
{ version, commit } |
GET |
/metrics |
Prometheus (gauges refreshed on scrape) |
No /ready, no CORS, no admin auth. No public client-facing routes.
ARCHON_NODE_ID.ARCHON_SAT_REIMPORT=true, clear per-item import state.getblockchaininfo./api/v1/wallet/balance and /api/v1/wallet/address, then log the
balance + funding address. The address route is the readiness signal:
the balance route reads from bitcoind and answers whether or not the
wallet’s own setup has succeeded.waitUntilReady=true).syncBlocks() — push every scanned block from startBlock up to
tip into Gatekeeper’s addBlock so resolution timestamps work.importLoop every importInterval minutes.exportLoop every exportInterval
minutes.| Variable | Default | Meaning |
|---|---|---|
ARCHON_NODE_ID |
unset (required for export) | Keymaster ID that owns anchor assets. |
ARCHON_ADMIN_API_KEY |
empty | Gatekeeper / Keymaster / satoshi-wallet admin key. |
ARCHON_GATEKEEPER_URL |
http://localhost:4224 |
|
ARCHON_KEYMASTER_URL |
unset | Required for export mode (asset creation). |
ARCHON_WALLET_URL |
unset | Required for export mode. Points at satoshi-wallet. |
ARCHON_SAT_CHAIN |
BTC:mainnet |
One of BTC:mainnet, BTC:testnet4, BTC:signet. Becomes the registry name. |
ARCHON_SAT_NETWORK |
bitcoin |
bitcoin / testnet / regtest. |
ARCHON_SAT_RPC_URL |
unset | Full Bitcoin JSON-RPC URL. When set, takes precedence over ARCHON_SAT_HOST / ARCHON_SAT_PORT / user / password. Useful for hosted RPC providers. |
ARCHON_SAT_FALLBACK_RPC_URL |
unset | Optional full Bitcoin JSON-RPC URL used for missing/pruned historical block reads from the primary RPC. |
ARCHON_SAT_HOST |
localhost |
bitcoind RPC host. |
ARCHON_SAT_PORT |
8332 |
bitcoind RPC port. |
ARCHON_SAT_USER / ARCHON_SAT_PASS |
empty | bitcoind RPC auth. |
ARCHON_SAT_IMPORT_INTERVAL |
0 |
Import loop period (minutes). 0 disables. |
ARCHON_SAT_EXPORT_INTERVAL |
0 |
Export loop period (minutes). 0 = read-only mode. |
ARCHON_SAT_FEE_BLOCK_TARGET |
1 |
Confirmation target for estimatesmartfee. |
ARCHON_SAT_FEE_FALLBACK_SAT_BYTE |
10 |
Fallback fee rate if estimator fails. |
ARCHON_SAT_FEE_MAX |
0.00002 |
Per-tx fee cap in BTC. RBF won’t exceed. |
ARCHON_SAT_FEE_ORACLE_URL |
empty | Optional remote fee oracle (e.g. mempool.space). |
ARCHON_SAT_RBF_ENABLED |
false |
Enable the replace-by-fee bump loop. |
ARCHON_SAT_START_BLOCK |
0 |
Scan from this height. Set per-chain to skip pre-launch history. |
ARCHON_SAT_REORG_DEPTH |
6 |
Initial rewind depth; extended backward if the preceding checkpoint did not survive. |
ARCHON_SAT_REIMPORT |
true |
Clear per-item import state on startup. |
ARCHON_SAT_DB |
json |
Storage backend. |
ARCHON_SAT_DB_NAME |
<chain> (: → -) |
Database key / filename base. |
ARCHON_SAT_METRICS_PORT |
4234 |
|
GIT_COMMIT |
unknown |
No explicit handlers. On SIGTERM the process exits; pending imports
(in memory) are lost, but the persisted DB remains. The next startup
resumes from the stored height/hash.
Gauges (refreshed on every /metrics scrape):
| Metric | Notes |
|---|---|
satoshi_block_height |
last scanned height |
satoshi_block_count |
chain tip |
satoshi_blocks_pending |
blockCount - height |
satoshi_blocks_scanned |
cumulative |
satoshi_txns_scanned |
cumulative |
satoshi_dids_discovered |
discovered.length |
satoshi_dids_registered |
registered.length |
satoshi_pending_txs |
pending.txids.length or 0 |
satoshi_import_loop_running |
0 / 1 |
satoshi_export_loop_running |
0 / 1 |
Counters:
| Metric | Notes |
|---|---|
satoshi_import_errors_total |
failed importBatchByCids calls |
satoshi_reorgs_total |
detected chain reorgs |
satoshi_batches_anchored_total |
successful OP_RETURN anchors |
satoshi_rbf_bumps_total |
RBF fee bump events |
Histograms:
| Metric | Buckets |
|---|---|
satoshi_import_batch_duration_seconds |
[0.1, 0.5, 1, 2, 5, 10, 30, 60] |
satoshi_anchor_batch_duration_seconds |
[0.5, 1, 2, 5, 10, 30, 60, 120] |
Plus service_version_info{version,commit} and standard Prometheus
process metrics.
The reference Grafana dashboards live at
observability/grafana/provisioning/dashboards/satoshi-mediator-mainnet.json
and satoshi-mediator-signet.json.
Plain console.log. Each scanned block logs
<height>/<tip> blocks (NN%) and each OP_RETURN hit logs a triple
<height> <index:04d> <txid>. Export-loop anchors log the
fee-rate decision and the resulting txid. RBF activity logs
RBF: Bumping fee from X sat/vB (estimate: Y sat/vB).
No structured logging in the TS reference; implementations MAY add structured output but SHOULD keep the human-readable lines stable.
ghcr.io/archetech/satoshi-mediatorsatoshi-mediator-mainnet.json, -signet.json.No dedicated conformance tests. Validation is manual: stand up a
bitcoind + satoshi-wallet + satoshi-mediator trio on signet/testnet4
or the hosted testnet4 profile,
create an ephemeral DID with registry=BTC:signet, watch the mediator
anchor it, wait for confirmation, and verify the resolution includes
didDocumentMetadata.timestamp.upperBound.blockid matching the
expected block.
A conformant third implementation MUST:
did:cid:... strings.MediatorDb shape in §4, including the atomic
updateDb contract.gatekeeper.importBatchByCids with the exact metadata shape
in §2.3 so resolution timestamps work.gatekeeper.addBlock(<chain>, { height, hash, time }) for every
scanned block.Gatekeeper’s processEvents().pending is a global queue count. When nonzero,
pendingBatches identifies the batch DIDs still represented in that queue.
After importing every CID, the mediator completes a batch only after a non-busy
processing result that has no pending events for that batch. Unrelated pending
events do not trigger reimports. This records processing completion, not final
authorization; retained history can still be replayed when missing evidence arrives.
Batch import logs report batchImport and batchComplete for this batch;
gatekeeperQueueAfterImport and gatekeeperProcessing describe the node-global
inbound queue. Persisted imported and processed keep the raw API responses.
Unavailable content, incomplete CID results, processing failures, and batches
with their own pending events remain retryable. Older Gatekeepers that omit
pendingBatches retain the conservative global-pending behavior. Persisted
“No progress” errors recover through the normal retry pass without database edits.