satd Operator Manual
satd is a Bitcoin Core-compatible full node written in Rust, built from the
ground up to make running a node easier, safer, and more transparent for the
people who actually operate infrastructure — home-server and Raspberry Pi
self-custodians, downstream packagers (Umbrel, Start9, RaspiBlitz, MyNode), and
integrators building wallets, Lightning nodes, and explorers on top of a node
they control.
This manual is the operator-, integrator-, and packager-facing reference. It catalogs every shipped operator surface: observability and metrics, configuration and tuning, live reload, integrator APIs, the terminal UI, the native protocol surfaces (Esplora / Electrum / BIP 157-158), and the packaging contract.
One process, one store. The defining architectural choice is that every API
service — JSON-RPC, Esplora, Electrum, BIP 157/158 filters, the streaming APIs,
MCP — is a query layer over the same RocksDB and chainstate the node itself
uses, updated atomically inside block connection. There is no second process
and no second copy of the data: running satd is not bitcoind + electrs + an
Esplora indexer + exporters glued together, but a single daemon where all surfaces
share the node's storage. This eliminates the parallel block re-scan and the
reorg-window race where an external indexer's view lags the node, so every surface
reads a single, tip-consistent store. Serving Electrum, Esplora,
getrawtransaction, and BIP 158 from one node means satd's aggregate on-disk
index is larger than a standalone external index — you trade disk for
consistency and single-process operation. See Disk Footprint &
Indices for the byte-level accounting, and API Scaling &
Runtimes for the scale-out trade-off.
How this manual is organized
- Operating — the day-to-day surfaces: observability and
metrics, configuration, tuning, and live
reload, initial block download and AssumeUTXO fast
sync, API scaling and the two-runtime model,
authentication and authorization
(Core-compatible credentials plus the unified bearer-token layer), the
integrator APIs, and the
sat-tuiterminal dashboard. - Protocol Surfaces — the Esplora REST API and Electrum
protocol references, the streaming consumption API,
the MCP server, and the
architecture behind satd's native, shared-chainstate
protocol servers (the headline differentiator over the
bitcoind+electrsstatus quo). - Packaging & Deployment — the authoritative packaging contract for downstream distributions: file layout, signals, ports, the release/signing pipeline, and reproducible builds.
- Reference — the Configuration Flag Reference: every recognized config key, its default, reload disposition, and whether it is Bitcoin Core-compatible or a satd extension.
Related documents (in the repository)
These live at the repository root rather than in this manual:
CORE_DIFFERENCES.md— the catalog of intentional deviations from Bitcoin Core.STABILITY_POLICY.md— the tiered stability contract and deprecation policy.SECURITY.md— signing keys, verification commands, and vulnerability reporting.MANIFESTO.md— node sovereignty, the monoculture risk, and the conservative BIP policy.ROADMAP.md— upcoming operator features and research areas not yet shipped.- The streaming-consumption API is specified in
docs/api/streaming.md(a forward-looking protocol spec, distinct from this shipped-surface manual).
Observability & Metrics
satd is built to make running a full node transparent. Rather than running
blind or parsing a chatty log file, operators get a native terminal dashboard,
a Prometheus endpoint, and structured logs out of the box.
Native TUI (sat-tui)
satd ships with a native Ratatui-based terminal interface. Rather than running
blind or relying on chatty log files, operators can visualize node progress in
real-time.
- IBD Bitmap: Visualizes block download and verification progress.
- Peer Stats: Shows connected peers, their latency, and block delivery rates.
- Mempool Status: Live view of mempool depth and fee percentiles.
The full sat-tui reference — every view, panel, field, and keybinding — is in
the Terminal UI chapter.
Prometheus Metrics Endpoint
- Enable flag:
--metricsport=<port>— the metrics/health server starts only when a port is set.--metricsbind=<addr>sets the bind address alone (default127.0.0.1); it does not enable the server on its own. The listener binds<metricsbind>:<metricsport>. - Exposes a native Prometheus HTTP server at
GET /metricsproviding deep insights into P2P traffic, block validation times, mempool depth, and RocksDB performance. P2P wire volume is exported as thesatd_net_bytes_sent_total/satd_net_bytes_recv_totalcounters (peer count viasatd_peer_connections). - Includes
GET /healthzandGET /readyzendpoints for load balancer and orchestrator integration.
See the Packaging chapter for how to wire
/healthz and /readyz to Docker HEALTHCHECK, Kubernetes probes, or a
systemd ExecStartPost= poll.
Prefer
/metricsover RPC polling for monitoring. The Bitcoin Core RPCsgetnettotals(byte totals) andgetpeerinfo(bytessent/bytesrecv/lastsend/lastrecv) are populated and accurate for steady-state traffic, but they exist for Core compatibility. For dashboards and alerting, scrape the native Prometheus endpoint instead: it is a counter model purpose-built for time-series tooling (rates, retention, labels) and does not consume an RPC worker on every scrape. The RPC byte counters cover post-handshake traffic only (the one-time handshake bytes are not included), so absolute socket totals will read marginally lower than the kernel's.
Structured JSON Logging
- Flag:
--log-format=json|text - Replaces the traditional
debug.logtext firehose with structured, machine-parseable JSON logs. Perfect for Datadog, ELK, or custom log-alerting pipelines. Trace IDs allow operators to follow a single block through prefetch, connect, and flush.
Configuration, Tuning & Reload
satd reads Bitcoin Core's bitcoin.conf syntax and CLI-flag names directly, so
an existing Core config drops in and starts the node — commonly-used options are
honored (semantics pinned to Core v30), and a recognized option satd doesn't
implement is skipped with a startup warning rather than aborting (see the
Configuration Flag Reference
for the exact disposition). On top of that compatibility it adds hardware-profile
presets, a set of operator-sovereignty policy knobs, and live reload of both
configuration (SIGHUP) and TLS certificates (SIGUSR1).
For the observability surfaces (TUI, Prometheus, structured logs) see
Observability & Metrics; for the satd-specific developer
APIs see Integrator APIs; for the two-runtime model and the
--api-threads / admission-control knobs that tune API throughput see
API Scaling & Runtimes. For the complete per-key index —
every flag, its default, reload disposition, and whether it is Core-compatible or
a satd extension — see the Configuration Flag Reference.
Configuration & Tuning
satd reads Bitcoin Core's bitcoin.conf syntax and CLI-flag names (a Core config drops in directly — see the Configuration Flag Reference). To simplify deployment on different hardware profiles, satd also introduces configuration presets.
--profile Presets
Instead of manually tuning -dbcache, -maxmempool, and connection limits, operators can use --profile=<preset>:
archival: Maximizes indexing and P2P serving. Disables pruning.pruned-home: Optimizes for Raspberry Pi or home servers. Enables pruning, bounds memory.mining: Optimizes block template generation latency.regtest-dev: Fast, isolated environment for local development.
Indexing & Protocol Flags
| Flag | Default | Notes |
|---|---|---|
--addressindex=<0|1> | 1 | Builds the scripthash history index over RocksDB. Required for Esplora/Electrum. |
--esplora=<0|1> | 1 | Enables the native Esplora REST API (loopback unauthenticated by default). |
--electrum=<0|1> | 0 | Enables the native Electrum protocol server. |
--blockfilterindex=<0|1|basic> | 0 | Builds the BIP 158 compact block filter index. |
--peerblockfilters=<0|1> | 0 | Advertises NODE_COMPACT_FILTERS (bit 6) and serves BIP 157 P2P queries. |
--rpctlsbind=<addr:port> | None | Enables native TLS for JSON-RPC, eliminating the need for a TLS-terminating sidecar. Requires --rpctlscert and --rpctlskey. |
--electrumtlsbind=<addr:port> | None | Enables native TLS for the Electrum server. Requires --electrumtlscert and --electrumtlskey. |
--esploratlsbind=<addr:port> | None | Enables native TLS for the Esplora REST API. Requires --esploratlscert and --esploratlskey. |
--v2transport=<0|1> | 1 | Enables BIP 324 v2 encrypted P2P transport. Offers/accepts the ElligatorSwift + ChaCha20-Poly1305 v2 handshake, transparently falling back to v1. |
--v2only=<0|1> | 0 | satd-specific privacy/anti-surveillance flag. If 1, strictly refuses or immediately disconnects any peer not using the v2 encrypted P2P transport. |
--dbcache=auto | None | Spawns the adaptive dbcache resizing task to automatically scale RocksDB block cache and CoinCache clean-LRU in response to system memory pressure. |
Mempool Policy Sovereignty
satd believes that operators should have strict, ultimate control over what their hardware validates and relays. Instead of requiring a patched C++ fork (like Bitcoin Knots) to filter spam or unwanted data, satd exposes these policies as first-class configuration knobs:
| Flag | Default | Notes |
|---|---|---|
--datacarrier=<0|1> | 1 | If set to 0, strictly rejects all transactions containing OP_RETURN outputs from entering the mempool or being relayed. |
--datacarriersize=<bytes> | 83 | The maximum permitted size of an OP_RETURN script. Anything larger is rejected as non-standard. |
--dustrelayfee=<sat/kvB> | 3000 | The threshold used to calculate dust. Raising this forces spam transactions creating tiny, unspendable UTXOs to pay significantly higher fees. |
--permitbaremultisig=<0|1> | 1 | If 0, rejects complex, non-standard bare multisig setups often used for data-storage hacks. |
--limitancestorcount=<N> | 25 | Maximum unconfirmed ancestor count. |
Live Config Reload (SIGHUP)
Edit bitcoin.conf and send SIGHUP — kill -HUP <pid>, or systemctl reload satd — to re-read the file and apply the hot-reloadable settings without restarting. The P2P swarm and chainstate are untouched. CLI flags remain authoritative across reloads: only the config file is re-read, so a flag passed on the command line always wins over the same key in the file.
Difference from Bitcoin Core. Core uses
SIGHUPto reopendebug.logfor logrotate. satd has nodebug.log— it logs to stdout, delegating rotation/retention to systemd-journald or the container runtime — soSIGHUPis repurposed for config reload. SeeCORE_DIFFERENCES.md.
Safety: a reload that fails to parse (a typo or invalid value — rejected at load; a recognized-but-unsupported Core option is skipped with a warning, not an error) is logged and the running config is kept; the daemon never crashes on a bad reload. Every change is either applied live or logged as restart required — nothing is silently ignored. Secret-bearing keys (rpcuser, rpcpassword, rpcauth, torpassword, esplorauserpass, reorgwebhooksecret) report only that they changed — their values are redacted in the log, never printed.
Hot-reloadable (applied live):
| Key(s) | Effect on reload |
|---|---|
debug, debugexclude | Log verbosity/categories change immediately (the env-filter is swapped live). |
timeout | New peer-handshake timeout for subsequent connections. |
blocksonly | Toggles transaction-relay suppression. |
maxuploadtarget | New rolling 24h upload cap. |
v2transport, v2only | Adjusts BIP 324 v2 transport / v2-only peering for new connections. |
externalip, whitelist | Replaces advertised external addresses / -whitelist permission set. |
rpcextendederrors, rpcdefaultunits | Switches RPC error-payload shape / default amount unit. |
maxconnections, maxinboundperip | New limits govern subsequent connections (existing peers above a lowered cap are not dropped). |
bantime | New ban duration applies to bans created after the change. |
minrelaytxfee, maxmempool, dustrelayfee, datacarrier, datacarriersize, mempoolfullrbf, limitancestorcount, limitdescendantcount, mempoolexpiry, permitbaremultisig | Mempool/relay policy swapped atomically; governs subsequent transaction admissions (already-admitted entries are not re-evaluated). |
connect, addnode, seednode | Newly-added peers are registered and dialed immediately; existing connections are untouched. Removing an entry does not disconnect that peer (use disconnectnode), matching Core's -addnode. Note: -connect's exclusivity (connect ONLY to these peers, suppress automatic outbound + DNS seeding) is a startup-time decision and is not re-evaluated on reload — adding -connect live dials the new peer but does not put a running node into connect-only mode (restart for that). |
peerblockfilters | Toggles NODE_COMPACT_FILTERS advertisement for new handshakes (still gated on a complete blockfilterindex). |
addrindexsubscriptions | New address-index subscription cap; applied to subsequent subscriptions (lowering it does not evict existing subscribers). |
reorgwebhook, reorgwebhooksecret | Adds, changes, or removes the reorg webhook URL/signing secret; the next reorg uses the new target. |
persistmempool, maxshutdownsecs | No restart needed — the new value is read from the reloaded config at shutdown time (governs the next shutdown, not an in-flight one). |
rpcuser, rpcpassword, rpcauth | RPC credentials rotate live on every listener surface; subsequent requests are checked against the new set. The auto-generated cookie is preserved. Values are redacted in the reload log. |
logformat(json vs text) is not hot-reloadable — only verbosity is. Changing the format requires a restart.
Credential rotation caveat. Removing
rpcuser/rpcpasswordfrom a node started without a cookie (i.e. one started with a static user/pass) leaves no credentials at all — the RPC interface then rejects everything until you restore a credential or restart (a restart regenerates the cookie). satd logs a warning when a reload lands in this state. The cookie file (rpccookiefile/rpccookieperms) and therpcdisableauthmTLS toggle remain restart-only.
Restart required (reported, not applied): network selection, datadir/blocksdir, all RPC/P2P/Esplora/Electrum ports and binds, the RPC cookie file (rpccookiefile/rpccookieperms) and rpcdisableauth, TLS/mTLS paths and the mTLS CA (the cert/key contents reload via SIGUSR1 — see below), dbcache/prune/storageprofile/reindex, index enable/disable (txindex/addressindex/blockfilterindex), DNS-seed bootstrap (dns/dnsseed/forcednsseed/fixedseeds/asmap), Tor (proxy/onion/torcontrol/listenonion), consensus, and assumevalid/stopatheight. These are wired into long-lived state at startup (a bound socket, an opened database, the chain identity) and cannot be swapped without restarting the relevant socket/engine/process.
Live TLS Certificate Reload (SIGUSR1)
Send SIGUSR1 — kill -USR1 <pid> — to reload the TLS server certificates from their already-configured paths (rpctlscert/rpctlskey, esploratlscert/esploratlskey, electrumtlscert/electrumtlskey) without restarting. Every TLS surface re-reads its leaf cert/key from disk and swaps it in atomically. This is purpose-built for infrastructure that auto-rotates certificates on short TTLs (cert-manager, Vault, ACME sidecars): point a renewal hook (or a systemd path unit watching the cert file) at kill -USR1.
- New handshakes use the new cert; in-flight connections keep theirs — no dropped connections, no socket rebind.
- It reloads the leaf cert/key only. Changing the cert/key paths, or rotating the mTLS client CA (
rpcmtlsclientcaetc.), still requires a restart — CAs are long-lived and don't rotate on the short TTLs this targets. - Safe: a reload that fails (unreadable, malformed, or a cert/key that don't match) is logged per-surface and the previous, still-valid certificate is kept — the listener is never left without a usable cert. Each surface reloads independently; one failure doesn't affect the others.
- Distinct from
SIGHUP(config reload) on purpose: cert rotation is frequent and automation-driven, so it gets a dedicated signal that doesn't re-readbitcoin.confor run the config diff/apply machinery.
Difference from Bitcoin Core. Core has no
SIGUSR1handler and no native TLS (its RPC is HTTP-only behind a sidecar). satd's native TLS makes in-place cert reload meaningful. SeeCORE_DIFFERENCES.md.
Initial Block Download & Fast Sync
This chapter covers getting a satd node to the chain tip: AssumeUTXO fast
sync, the script-verification skip knobs (assumevalid), satd's dual-engine
shadow verification, and the IBD performance/storage tuning flags — with the
points that differ from Bitcoin Core called out throughout.
For the exhaustive per-key table (defaults, reload disposition, Core-vs-satd) see the Configuration Flag Reference; this chapter is the how-and-why.
How satd syncs: the IBD pipeline
satd does not download and verify blocks one-at-a-time in lockstep. IBD is a pipeline that keeps the network, disk, and CPU all busy at once:
- Swarm-style block download. Like BitTorrent, satd fetches blocks in
parallel from many peers rather than serially from one, so download
throughput scales with peer bandwidth instead of a single peer's round-trip.
-maxaheadbounds how far ahead of the connect tip the swarm may stage blocks. - Background prefetch workers.
-prefetchworkersthreads pre-read blocks from the flat files (without holding the chainstate lock), deserialize them, compute txids, run the context-free transaction checks, and speculatively resolve (cache-warm) each block's UTXO inputs — all off the connect thread, so when the connect thread reaches a block its inputs are already hot in cache. - Speculative script verification. Ahead of connection, prefetch workers
pre-verify scripts; verified transactions are marked so the connect thread
doesn't re-verify them. (In
assumevalidmode this lets the connect step skip straight to applying UTXO changes for the trusted range.) - Asynchronous shadow verification. The second consensus engine (see below) runs on its own worker pool, never on the connect path, so dual-engine cross-checking adds essentially no wall-clock cost.
The net effect: network I/O, block pre-processing, script verification, and chainstate writes overlap instead of serializing.
AssumeUTXO fast sync
AssumeUTXO lets a node become usable in minutes instead of days by loading a UTXO-set snapshot at a recent height, serving wallets/queries from it immediately, and validating the historical chain (genesis → snapshot) in the background. satd's implementation is Bitcoin Core-compatible and fully shipped.
Loading a snapshot
loadtxoutset <path>(RPC) — load a UTXO snapshot file. satd splits into two chainstates: the snapshot chainstate becomes the active tip (wallets, Esplora, Electrum, RPC all serve from it right away), while a background chainstate validates from genesis up to the snapshot's anchor. When background validation completes, the snapshot is marked validated and the node is a normal fully-validated node.--fast-start=<url|path>(startup flag) — the one-flag UX: satd downloads the snapshot (or reads a local file), waits for header sync to reach the snapshot's anchor, and callsloadtxoutsetautomatically — no manual step. Remote sources must behttps://(plainhttp://is refused; TLS certs are validated). Pair with--fast-start-sha256=<hex>to pin the download's digest. Download progress renders in the pre-RPC startup TUI gauge.getchainstates(RPC, Core 27+ compatible) — observe progress: a node with no snapshot reports a single fully-validated chainstate; afterloadtxoutsetit reports a second (background) chainstate, and the snapshot entry carriessnapshot_blockhash+validated: falseuntil background validation finishes.dumptxoutset <path>(RPC) — emit a Bitcoin Core-compatible UTXO snapshot from your own node.
Trust model
The snapshot file is verified against satd's hardcoded anchor hash at load, so
a tampered or wrong-height snapshot is rejected regardless of where it came from.
satd deliberately hosts no snapshots and does no P2P snapshot fetch — the
operator names a trusted https:// source (or a file). The historical chain is
still fully validated in the background; AssumeUTXO shortens time-to-usable, it
does not skip validation permanently.
Difference from Bitcoin Core. The RPCs (
loadtxoutset,getchainstates,dumptxoutset) and the two-chainstate model match Core. The--fast-startone-flag download-verify-load UX (and--fast-start-sha256) is a satd extension; Core requires a manualloadtxoutsetagainst a file you fetched yourself.
Script-verification skip: assumevalid
-assumevalid controls how much script verification IBD performs. satd accepts
three forms — and the third is a satd extension:
| Value | Meaning | Compat |
|---|---|---|
-assumevalid=<blockhash> | Skip script checks at or below that block (the hash must be in the block index first). A sensible per-network default ships in the binary (e.g. mainnet height 840,000), exactly like Core. | Core |
-assumevalid=0 | Verify everything — no skipping. | Core |
-assumevalid=all | Skip script checks for blocks older than a cutoff age, fully verifying recent and new blocks. The cutoff is -assumevalidage (default 86400 s / 24 h). | satd extension |
Difference from Bitcoin Core. Core's
-assumevalidis a block hash (or0). satd adds theallkeyword plus-assumevalidage, so you can say "trust the deep chain, verify the last day" without pinning a specific hash — useful for recurring fast re-syncs.assumevalidskipping is independent of AssumeUTXO (which is about the UTXO set, not script checks), though they compose.
Consensus engine & shadow verification
satd ships two independent script-verification engines — the C++
libbitcoinconsensus FFI and a from-scratch Rust verifier — and can run them
together, cross-checking every script. This has no equivalent in Bitcoin Core.
Read the name as "which engine is the shadow." In <engine>-shadow, the
named engine is the shadow (the non-authoritative cross-checker); the other
engine is primary/authoritative — its verdict is what the node actually acts
on, and the shadow only re-checks in the background and logs any disagreement. So,
counter-intuitively at first:
rust-shadow→ the Rust engine is the shadow → C++ is primary.cpp-shadow→ the C++ engine is the shadow → Rust is primary.
-consensus=<mode>:
| Mode | Primary (authoritative) | Shadow (cross-check) |
|---|---|---|
rust-shadow (default) | C++ libbitcoinconsensus | Rust (logs mismatches) |
cpp-shadow | Rust | C++ (logs mismatches) |
cpp | C++ libbitcoinconsensus | — (single engine) |
rust | Rust — single engine, no cross-check | — (single engine) |
The Rust engine is no toy: it passes Bitcoin Core's script test suite and has
been shadow-validated against libbitcoinconsensus across the entire mainnet
chain (genesis → ~945k) with zero divergence. It is also typically faster
than the C++ FFI — it avoids the per-call FFI marshaling overhead and uses a
process-global, verification-only cached secp256k1 context, which is why
cpp-shadow (Rust authoritative, C++ shadow) is the high-performance pairing.
Given all that, why is rust-shadow (C++ authoritative) still the default? Pure
conservatism: keeping a second, independently-written engine as the authoritative
check is satd's core safety property, and C++ libbitcoinconsensus is the most
battle-tested implementation in existence. The plan is to promote Rust to primary
as it accrues more authoritative-in-production mileage — cpp-shadow is exactly
that step. The single-engine rust mode is the one to be cautious with: not
because the engine is unproven, but because running either engine alone forgoes
the dual-engine cross-check that is satd's core safety property. satd prints a
caution at startup when you select the single-engine rust mode.
The shadow engine runs on a bounded background worker pool, so it consumes spare CPU without slowing block connection — shadow verification is essentially free in wall-clock terms. Two knobs tune it:
-shadowworkers=<n>(default 4) — background shadow-verification threads.-shadowqueuesize=<n>(default 4194304) — shadow work-queue capacity. When the queue is full, shadow work is dropped (the authoritative engine still verifies every script — correctness is never affected) and an aggregated WARN is emitted at most once per 5 s.
Difference from Bitcoin Core. Core has a single C++ engine and no shadow mode. satd's default cross-checks two engines at runtime;
-consensus,-shadowworkers, and-shadowqueuesizeare all satd-specific.
IBD performance & storage tuning
These bound or accelerate IBD. The full defaults/semantics are in the Configuration Flag Reference; the Core-relevant notes:
| Flag | Default | Notes |
|---|---|---|
-dbcache=<MB|auto> | 450 | Write-cache size. auto (satd) spawns a controller that resizes RocksDB block cache + CoinCache against /proc/meminfo pressure — Core's -dbcache is a static number only. |
-par=<n> | — | Script-verification threads (Core name). satd's connect path manages its own parallelism, so -par does not size it directly — but when -shadowworkers is unset, a positive -par value is used as the fallback shadow-verification worker count (otherwise the default of 4 applies). |
-prefetchworkers=<n> | CPU cores | (satd) IBD block-prefetch worker threads. |
-maxahead=<n|N%|all> | 50000 | (satd) how many blocks IBD may stage ahead of the connect tip. |
-storageprofile=<ssd|hdd> | ssd | (satd) RocksDB tuning class for the storage medium. |
-maxopenfiles=<n> | 2048 | (satd) RocksDB max_open_files cap (-1 = unlimited). |
-rocksdbbackgroundjobs / -rocksdbsubcompactions / -rocksdbwalmb | from profile | (satd) advanced RocksDB overrides. |
-compactionl0at=<n> / -ibdl0pauseat=<n> | 16 / 64 | (satd) force chainstate compaction at N L0 SST files; pause the IBD connector at N L0 files to let compaction catch up. |
-compactionintervalsecs / -compactiondiagintervalsecs | 1800 / 60 | (satd) periodic forced-compaction and pending-compaction diagnostics (0 disables). |
-stallwatchdogsecs / -stallabortsecs | 300 / 300 | (satd) if the tip doesn't advance for N seconds, dump forensics, then abort after a further grace period — turns a silent IBD wedge into a loud, debuggable failure. |
-dbcache / -prune / -txindex / -assumevalid / -reindex keep Core's
names and meanings; the rest of the table is satd-specific tuning that Core has no
equivalent for.
Reindexing
-reindex— rebuild both the block index and the chainstate from the block files on disk (Core-compatible).-reindex-chainstate— rebuild only the UTXO set / chainstate from the existing block files, preserving the flat block files (Core-compatible). Faster than a full-reindexwhen only the chainstate is suspect.
A reindex on a synced mainnet node runs for hours; the shipped systemd unit
handles this without tripping the start timeout (see Packaging →
"Reindex resilience").
Differences from Bitcoin Core at a glance
assumevalid=all+assumevalidage— verify-recent-only mode (Core: hash or0).- Dual-engine shadow verification (
-consensus,-shadowworkers,-shadowqueuesize) — default cross-checks C++ and Rust engines; Core has one engine. --fast-start/--fast-start-sha256— one-flag AssumeUTXO download-verify-load (Core: manualloadtxoutset).-dbcache=auto— adaptive cache sizing (Core: static).- satd-only IBD/storage knobs —
-prefetchworkers,-maxahead,-storageprofile,-maxopenfiles, the-rocksdb*and-compaction*family,-ibdl0pauseat, and the stall watchdog. -paris accepted for config compatibility; it does not size the connect path, but a positive value feeds-shadowworkerswhen that flag is unset.
Disk Footprint & Indices
A fully-indexed satd node (-txindex=1 -addressindex=1 -blockfilterindex=basic)
uses more disk for its indices than a bitcoind + electrs/Fulcrum + esplora
stack uses summed together. This is by design, and this chapter explains where
the bytes go and what you get for them.
This is the one place the manual's "shared index / no duplicate index" language can be misread. To be unambiguous:
"Shared" means a single source of truth: one RocksDB, updated atomically with the chain, that every API surface reads. satd indexes more relationships, in more directions, to serve more protocols from one consistent store, so the aggregate on-disk index is larger. You trade disk for consistency and single-process operation.
If you only need a validating node, none of this applies: a consensus-only satd
(-txindex=0 -addressindex=0, filters off) has a chainstate comparable to Core's
and carries none of the index column families below.
Where the bytes go
satd keeps everything in one RocksDB with multiple column families (CFs). The indices are append-mostly: rows are added as blocks connect and removed only on disconnect (reorg), so there is no tombstone debt accumulating over time. The figures below are representative of a fully-indexed mainnet node in mid-2026; your numbers will track the chain's growth.
| Column family | Role | Keyed by | Row size | Approx. on disk |
|---|---|---|---|---|
addr_funding_v2 | every output paying a script | scripthash[16] ‖ height ‖ txid ‖ vout | 64 B | ~200 GB |
tx_index | txid → containing block | txid[32] | 64 B | ~140 GB |
addr_spending_v2 | every input spending a script | scripthash[16] ‖ height ‖ txid ‖ vin | 92 B | ~140 GB |
outpoint_spend | UTXO → the input that spent it | prev_txid[32] ‖ vout | 76 B | ~100 GB |
block_filter / _header | BIP 158 compact filters | type ‖ height | ~30 KB / 37 B | ~30 GB |
coins | the live UTXO set | txid[32] ‖ vout | ~28 B varint | ~tens of MB |
undo | per-block disconnect data | block_hash[32] | ~28 B / input | small (rolling) |
The three address/txid indices plus outpoint_spend are the bulk. The UTXO set
itself (coins) is tiny — it lives mostly in the in-memory coin cache and serializes
to a few tens of MB on disk.
Note on mid-reindex sizes. During a
-reindex/-reindex-chainstate, RocksDB's write-ahead compaction falls behind the write firehose, sotx_indexin particular can read substantially larger than its settled size (uncompacted L0 SSTs + bloom filters + index blocks). Measure the per-CF footprint after the node has idled and background compaction has drained — see Compaction below.
Why it is larger than bitcoind + electrs + esplora
Three structural reasons, all deliberate:
1. satd stores the spend graph in both directions
Every spend writes two rows:
addr_spending_v2— keyed by script (scripthash ‖ height ‖ …), answering "show me everything address A spent."outpoint_spend— keyed by outpoint (prev_txid ‖ vout), answering "what input spent this specific UTXO" in a single keyed read.
electrs/Fulcrum keep essentially one spend representation and derive the other direction on demand. satd pays the disk to keep both materialized so both queries are O(1). This is the single biggest source of the overage — and, ironically, the thing the "no duplicate index" tagline understates: the duplication is internal and intentional.
2. satd indexes a superset of what any one external tool does
The often-quoted "30–180 GB" figure is electrs/Fulcrum's address index alone.
satd's address index alone (addr_funding + addr_spending) already exceeds that
range — and satd additionally carries a Core-style tx_index, an outpoint_spend
reverse index, and BIP 158 filters in the same database, because one binary serves
Electrum and Esplora and getrawtransaction and compact-filter clients.
You are not comparing satd's index to electrs's index; you are comparing it to
electrs plus Core's txindex plus a spend index plus a filter index,
fused into one store.
3. satd trades pointer compactness for self-containment
tx_index stores the full 32-byte block hash as its value, where Core's txindex
stores a ~12-byte on-disk position (CDiskTxPos). That is ~20 extra bytes per
transaction (~24 GB across the chain) and one extra indirection on read — in
exchange, the index is independent of block-file layout and survives block-file
re-packing. satd's keys are also fixed-width binary tuned for prefix seeks rather
than byte-minimal, which costs a little space and buys fast range scans.
What satd already does to keep it down
The schema is near the information-theoretic floor for what it indexes:
- 16-byte scripthash prefix, not 32. Address rows key on the first half of
sha256(scriptPubKey), halving the dominant field of every address row. Collisions are vanishingly unlikely and are resolved against the full script on read. - Varint-packed UTXOs. The
coinsCF uses a compact varint encoding (~28 B typical vs ~43 B for a naive struct). - Fixed-width keys, no delimiters. Heights are big-endian so range scans return in chain order with no secondary sort.
So the size is row_count × ~70 B, and row_count is "every output and every
spend in Bitcoin's history" — genuine data, not per-row bloat or dead weight.
What the disk buys you
| Property | satd (shared store) | bitcoind + electrs/Fulcrum |
|---|---|---|
| Index vs. tip consistency | Always atomic — index update is in the same WriteBatch as the block | Index lags the node; reorg-window races are possible |
| Build cost | Index built inside connect_block validation | Second process re-scans every block to build a parallel DB |
| Lookup path | O(1) keyed read, in-process function call | Cross-process RPC + the indexer's own lookup |
| Spend-by-outpoint | O(1) (outpoint_spend) | Often derived / scanned |
| Operational surface | One process, one config, one backup, one reindex | Two+ daemons to wire, monitor, and keep in lockstep |
| TLS / auth | Native on every surface | Usually a separate reverse proxy |
| Disk | Larger in aggregate | Smaller per-tool, but you run several |
The headline is consistency and operational simplicity, bought with disk: a read on any surface — Electrum, Esplora, JSON-RPC — can never observe an index out of sync with the chain tip, because there is no second copy to fall behind. You scale read throughput by running more nodes, not more index processes (see API Scaling & Runtimes).
Choosing what to index
The indices are opt-in per surface. Match the disk to what you actually serve:
| You want… | Flags | Heavy CFs pulled in |
|---|---|---|
| Validating node only | (defaults; indices off) | none |
getrawtransaction <txid> anywhere | -txindex=1 | tx_index |
| Electrum / Esplora address history | -addressindex=1 (implies -txindex=1 for Electrum) | addr_funding_v2, addr_spending_v2, outpoint_spend, tx_index |
| BIP 157/158 light-client service | -blockfilterindex=basic -peerblockfilters=1 | block_filter, block_filter_header |
Turning a surface off means its CF is never written, and the disk is never spent.
Compaction
RocksDB background compaction runs continuously and is not disabled by
satd's bulk-load reindex mode (only the WAL is). When reindex writes stop, the
background jobs drain the L0 backlog on their own — no manual step is required.
satd additionally force-compacts only the coins CF on a timer
(compaction_interval_secs, default 30 min, L0-triggered); there is no
satd-level forced full compaction of the large index CFs — they rely on RocksDB
auto-compaction.
Because the index CFs are append-mostly (little to no deletion outside reorgs),
expect compaction to reclaim the reindex-era L0/overlap debt — a moderate drop —
not a collapse. Most of the footprint is genuine index data. satd logs a per-CF
compaction diagnostic (pending-compaction-bytes) every
compaction_diag_interval_secs (default 60 s); let those settle toward zero before
taking a "true" size measurement.
API Scaling & Runtimes
satd is a single, unified process: one RocksDB instance, one chainstate, and all the API surfaces (JSON-RPC, Esplora, Electrum, the streaming APIs, MCP, metrics) served from the same daemon. This chapter explains how that process is internally partitioned into two runtimes so heavy API load can't endanger consensus, which knobs tune each, and how to scale out when one node isn't enough.
The guiding principle: bound the blast radius of the remotely-consumed API surfaces so they can never starve or stall the consensus core. Default behavior is unchanged and Bitcoin Core-compatible; everything here is opt-in or a safe-by-default backstop.
The two runtimes
satd runs on two separate tokio runtimes, and the split is structural — not a policy or a priority hint:
Core (consensus) runtime
Carries everything that must never be starved: P2P, block connection, mempool acceptance, plus:
- The main JSON-RPC listener (
-rpcport, read and write). It carries the block-connecting control methods (generate*,submitblock,submitheader,preciousblock,loadtxoutset), which must originate on the core runtime to preserve address-index / SSE event ordering. Keeping JSON-RPC here also makes it a "break-glass" admin endpoint that public API load cannot starve. - The MCP server — it exposes block-connecting tools (
generate_blocks) and broadcast, so it stays on the core runtime for the same reason.
Isolated API runtime (--api-threads)
A separate, bounded runtime for the read- and streaming-oriented surfaces, so a flood on any of them cannot contend with the threads that connect blocks:
- Esplora REST + SSE
- Electrum protocol server
- events gRPC + ZMQ sinks, and the streaming WS/SSE (
streamws) - the Prometheus
/metrics+/healthz+/readyzendpoint - the opt-in read-only JSON-RPC listener (
-rpcreadonlybind)
--api-threads sizes this pool. Default max(2, cores/4) worker threads
(clamped to 1024). A flood on any consumption surface therefore cannot starve
block connection or mempool acceptance — the isolation is structural. SIGHUP /
SIGUSR1 reload reach the relocated surfaces unchanged.
Admission control & tuning knobs
Every remotely-consumed surface bounds concurrency and backlog and sheds over-budget work (it never queues unboundedly — that would let a consumer backpressure the node). Shedding runs ahead of authentication and request-body buffering, so a flood — authenticated or not — is bounded before it does work. All knobs are clamped to a sane ceiling so a fat-fingered value can't panic the daemon at boot.
| Surface | Knobs | Default | Over-budget response |
|---|---|---|---|
| Isolated API runtime size | --api-threads | max(2, cores/4) | — (sizing) |
| JSON-RPC (main) | -rpcthreads (in-flight), -rpcworkqueue (backlog) | 16 / 64 | HTTP 429 + Retry-After |
| Read-only JSON-RPC | -rpcreadonlythreads, -rpcreadonlyworkqueue | inherit main | HTTP 429 + Retry-After |
| events gRPC | -eventsgrpcmaxconns, -eventsgrpcmaxsubscriptions | 64 / 256 | gRPC RESOURCE_EXHAUSTED |
| streaming WS/SSE | -streamwsmaxconns, -streamwsmaxsubscriptions, -streamwsmaxmessagebytes | 256 / 256 / 262144 | connection refused / 429 |
| Esplora | -esploramaxconns, -esplorasseconns | 256 / = maxconns | HTTP 429 |
| Electrum | -electrummaxconns, -electrummaxsubsperconn | 64 / 1000 | connection refused |
-rpcthreads / -rpcworkqueue are recognized from Bitcoin Core (a Core-shaped
config carrying them loads): in-flight calls are capped at -rpcthreads, and the
waiting backlog at -rpcthreads + -rpcworkqueue. Per-token rate limits and watch
quotas (see Authentication & Authorization) layer on top of
these per-surface caps.
Scaling read RPC on one node: the read-only listener
-rpcreadonlybind adds a second JSON-RPC listener on the isolated API runtime
that dispatches only read and mempool-submit methods (sendrawtransaction) and
rejects block-connecting / node-control methods with JSON-RPC error -32001. The
method filter is fail-closed (an unclassified method is rejected, never
served), and a release-safe invariant guard asserts block connection never
originates on the API runtime.
It has its own bind, source-IP allowlist (-rpcreadonlyallowip), admission
budget (-rpcreadonlythreads / -rpcreadonlyworkqueue), and TLS/mTLS
(-rpcreadonlytlsbind / …tlscert / …tlskey / …mtls / …mtlsclientca /
…mtlsclientallow); it reuses the main listener's authentication. This lets you
put read RPC traffic behind a load balancer without exposing the control
plane — the write/admin methods stay on the core-runtime listener you keep
private.
Scaling beyond one node — run multiple nodes
The vertical levers above (--api-threads, the per-surface admission caps, the
read-only listener behind a load balancer) scale the API surfaces up to the
capacity of a single node. Because satd is a unified process over one
chainstate, there is no in-process read-replica mode: you cannot add API
capacity beyond what one node's API runtime can serve.
When you need more than that, run multiple independent satd nodes behind a load balancer. Each node is a full node maintaining its own chainstate and mempool; together they serve more aggregate read/stream traffic than any single node can.
Clients must tolerate transient divergence between nodes
Independent nodes are only eventually consistent with each other. At any instant two nodes can legitimately differ, and a load balancer can route consecutive requests to different backends. Design clients to expect:
- Tip skew — one node may be a block (or briefly more) ahead of another;
getblockcount/ chain-tip height can go backwards across two requests routed to different nodes. - Mempool divergence — a just-broadcast transaction may be visible on the node that received it but not yet on others; fee estimates and mempool contents differ between nodes.
- Reorg timing skew — nodes can adopt a reorg at slightly different moments,
so a tx's confirmed/unconfirmed status (and
confirmations) can differ transiently. - Streaming cursors are per-node — a streaming cursor /
seq/instance_idis only meaningful against the node that issued it; do not resume a cursor against a different backend.
Practical guidance:
- Don't assume monotonic or read-your-writes consistency across requests that may hit different backends. Use sticky sessions (pin a client/session to one backend) where read-your-writes matters — e.g. submit a tx and then poll for it on the same node.
- Broadcast deliberately. Send a transaction to one chosen node (or fan it out to all), then rely on P2P propagation; don't assume every node already has a tx another node just accepted.
- Use confirmation thresholds, not single-node point reads, for irreversibility decisions; confirm across nodes if you need cross-node agreement.
- Health-gate the pool. Route only to nodes that are
/readyzand near the network tip; drop a node that has fallen behind so it doesn't serve stale reads.
This is the same operational model as running multiple Bitcoin Core nodes behind a balancer — satd's contribution is that a single node already isolates its API surfaces from consensus, so you reach for multiple nodes only for genuine horizontal throughput, not to protect the node from its own API load.
Authentication & Authorization
satd has one authentication model shared by every API surface — JSON-RPC,
Esplora, the streaming APIs (events gRPC + streamws), and the MCP server — plus
full backward compatibility with Bitcoin Core's cookie / rpcuser / rpcauth
credentials.
There are two layers, and understanding the split is the key to operating satd safely:
- Core-compatible operator auth — cookie file,
-rpcuser/-rpcpassword, and-rpcauth. This is the default and behaves exactly like Bitcoin Core. It is all-or-nothing: a valid operator credential is the operator — full access to everything. - The unified bearer-token layer (
satd-auth) — opt-in, capability- scoped, per-token rate-limited and quota-bounded bearer tokens loaded from an-authfile. This is what makes satd safe to expose to multiple, partially-trusted consumers (a BTCPay instance, a watchtower, an AI agent) without handing each one operator keys.
The default is pure Bitcoin Core. With no
-authfileconfigured, the bearer layer is entirely inert: the only credentials that work are the Core-compatible ones, and every authenticated request is the full-capability operator. You opt into scoped tokens deliberately, per surface. The capability gate is not even installed on a surface that has no bearer tokens enabled — it is a zero-cost no-op for the default path.
How the bearer layer differs from Core-style auth
| Core-style operator auth | Unified bearer tokens | |
|---|---|---|
| Credentials | .cookie file, -rpcuser/-rpcpassword, -rpcauth (HMAC) | Opaque high-entropy tokens, presented as Authorization: Bearer <token> |
| Granularity | All-or-nothing — the operator, full access | Per-token capabilities (e.g. read-only, Esplora-only, stream-only) |
| Multi-tenant | No — one shared identity | Yes — many tokens, each with its own id, scope, quota, rate limit, expiry |
| Rate / quota limits | None (operator is unlimited) | Per-token request rate (429/RESOURCE_EXHAUSTED) and watch-set quota |
| Where defined | CLI flags / bitcoin.conf / generated cookie | A TOML -authfile (reloadable on SIGHUP) |
| Default | On (cookie auto-generated) | Off until -authfile is set and the surface opts in |
| Compatibility | Bitcoin Core wire-identical | satd extension |
Both coexist. On a bearer-enabled surface the operator (Basic) credential is
tried first, so existing Core tooling is never affected; a Bearer token is
consulted only when the request isn't a valid operator Basic credential. A
matching cookie / userpass / rpcauth always resolves to the full-capability
operator.
Capabilities
A bearer token carries a set of capabilities; a surface enforces the one it requires (fail-closed — an unknown method or a missing principal requires the write capability, which a read-only token does not hold).
| Capability | String | Grants |
|---|---|---|
| RPC read | rpc:read | Read-only JSON-RPC methods (classified by the same table the read-only listener uses). |
| RPC write | rpc:write | Mutating / control / mining JSON-RPC, and any unclassified method (fail-closed). |
| Esplora read | esplora:read | The Esplora REST + SSE surface. |
| Stream subscribe | stream:subscribe | Open a streaming subscription (events gRPC, streamws). |
| Stream watch | stream:watch | Register outpoint/script/descriptor/txid watches (also bounded by the token's watch quota). |
| MCP | mcp:* | The MCP server (single capability — no per-tool split). |
The operator and loopback-trust principals implicitly hold all capabilities.
The authfile
-authfile=<path> points at a TOML file of bearer tokens. The plaintext token is
never stored — only its SHA-256 digest.
version = 1
# Read-only integration: REST + Esplora reads, rate-capped.
[[token]]
id = "btcpay" # logging/accounting id — never the secret
hash = "sha256:<64-hex SHA-256 of the token>"
capabilities = ["rpc:read", "esplora:read"]
watch_quota = 50000 # optional; omit for unlimited
rate_limit = "200/s" # optional; omit for unlimited
# Watchtower: streaming subscribe + watch registration, expires end-2026.
[[token]]
id = "watchtower"
hash = "sha256:<64-hex>"
capabilities = ["stream:subscribe", "stream:watch"]
watch_quota = 10000
expires = 2026-12-31T00:00:00Z # unquoted RFC 3339 datetime, or unix seconds
# AI agent: full MCP tool access.
[[token]]
id = "agent"
hash = "sha256:<64-hex>"
capabilities = ["mcp:*"]
Rules:
version = 1is required. Each[[token]]needs a uniqueidand ahashof the formsha256:<64 hex>.capabilitiesdefaults to empty (a token that can authenticate but is denied everything).watch_quota,rate_limit("<n>/s"), andexpiresare optional; omitting a limit means unlimited.- An unknown capability string, a duplicate
id/hash, or a wrongversionaborts the load with a clear error (recognize-reject, never silent). - File permissions (Unix) must expose no group/world or execute bits —
0600or0400, like a cookie or an SSH private key. A0644/0640file is rejected. - Generate a token's hash with, e.g.:
TOKEN=$(openssl rand -hex 32) # the secret you give the client printf 'sha256:%s\n' "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)" - Reload on
SIGHUP— editing the file and reloading swaps the token table atomically; removing a[[token]]revokes it immediately. A parse/permission error keeps the last-good table (a bad reload never drops auth).
Presenting a token
Clients send the raw token in a standard header (scheme is case-insensitive):
Authorization: Bearer <token>
Verification computes SHA-256(token) and looks the digest up in the loaded
table (with a constant-time guard), then checks expiry. A blank token can never
authenticate.
Quotas & rate limits
- Rate limit — a per-token token bucket (
"<n>/s", burst = rate). Over-budget requests are shed, never queued (a slow/abusive consumer must never backpressure the node): HTTP 429 withRetry-Afteron JSON-RPC / Esplora / MCP, gRPCRESOURCE_EXHAUSTEDon events gRPC, and a connection-time throttle onstreamws. - Watch quota — the streaming watch-set is metered in units (one scripthash = one unit; prefix watches are priced by coarseness). A token holds units via an RAII lease, so a disconnect releases its quota automatically. Over-quota watch adds are rejected cleanly without tearing down the subscription.
Operator and loopback principals are unlimited.
Per-surface enablement
Bearer support is opt-in per surface: the surface flag turns it on, and it
requires -authfile. satd refuses to start if a surface flag is set without an
authfile.
| Surface | Enable flag | Capability gate | Default without the flag |
|---|---|---|---|
| JSON-RPC (read/write listeners) | -rpcauthbearer | rpc:read / rpc:write | Core Basic auth (cookie/userpass/rpcauth) |
| Esplora REST / SSE | -esploraauthbearer | esplora:read | -esploraauth Basic, loopback-unauth default |
| events gRPC | -eventsgrpcauth | stream:subscribe / stream:watch | loopback-trust |
streaming WS/SSE (streamws) | -streamwsauth | stream:subscribe / stream:watch | loopback-trust |
| MCP (HTTP) | -mcpauth | mcp:* | loopback-trust |
The read-only JSON-RPC listener (-rpcreadonlybind) does not honor bearer
tokens. The Electrum surface's client-cert (mTLS) principal is a documented
future seam, not yet live.
Exposing a surface remotely
Binding the streaming / MCP surfaces to a routable address requires auth — the node refuses an unauthenticated remote bind. The chain is:
-eventsgrpcallowremote → requires -eventsgrpcauth → requires -authfile
-streamwsallowremote → requires -streamwsauth → requires -authfile
-mcpallowremote → requires -mcpauth → requires -authfile
For a proxy- or mTLS-terminated deployment, bind loopback and omit the
*-allow-remote flag. JSON-RPC remote exposure is governed by Core's existing
-rpcbind/-rpcallowip (there is no separate allow-remote flag for it).
Transport TLS / mTLS
Native TLS and mutual TLS (see the Configuration chapter's
*tls*/*mtls* keys) compose underneath this layer: mTLS gates the
connection, and a bearer token presented over it further refines the principal's
capabilities. satd terminates TLS natively on the RPC, Esplora, and Electrum
surfaces, so no sidecar is required.
Integrator APIs
Developer- and integrator-facing surfaces that go beyond Bitcoin Core's JSON-RPC contract. These are satd extensions; the Core-compatible RPC methods they build on remain governed by the stability policy.
The push-based streaming consumption API (gRPC / WebSocket / ZMQ, cursor-resumable watch subscriptions) is specified separately as a forward-looking protocol spec.
Authentication. JSON-RPC keeps Bitcoin Core's cookie /
rpcuser/rpcauthcredentials by default; capability-scoped bearer tokens (-rpcauthbearer,rpc:read/rpc:write) are an opt-in addition. See Authentication & Authorization.
Mempool-Based Fee Estimation
estimatesmartfeesupports an optionalmodeparam (historical,mempool,blend).satdnever hard-errors on fee estimation; it falls back to the min-relay floor withconfidence: lowrather than breaking downstream applications.
Mempool Subscription Stream
subscribemempoolJSON-RPC WS stream emitting structured events:enter,leave_confirmed,leave_evicted, andleave_replaced.- Includes explicit eviction reasons and RBF replacement linkage.
Satoshis-as-Integers
- To prevent IEEE 754 float precision errors, operators can pass
amounts=satsto any RPC request to receive exact integer satoshi values instead of BTC decimals.
Persistent Reorg Log & Webhook
- A persistent, append-only JSONL log at
$datadir/<network>/reorg.log(the network-specific datadir subdirectory; directly under$datadironly on mainnet) survives restarts. - Optional HTTP POST on reorgs via
--reorg-webhook=<url>.
Client-Side PSBT Signing
sat-cli signpsbtwithkeyis a client-side command that reads a WIF or xpriv from stdin and signs Taproot key-path, SegWit, or Legacy inputs locally. Because the private key is never passed over JSON-RPC, thesatddaemon stays strictly keyless while allowing operators to securely sign PSBTs via their CLI terminal.
sat-tui
sat-tui is the operator dashboard for satd: a curses-style terminal UI
that connects over JSON-RPC and shows what the node is doing. It is
deliberately read-only — there is no way to change node state from the
TUI. Bitcoin Core has no equivalent shipped surface.
This document is the reference for what the TUI shows. It is not a walkthrough; for "what should I look at first" guidance, see Observability & Metrics.
Running
sat-tui is built as part of the workspace and ships in every release
tarball alongside satd and sat-cli.
sat-tui # mainnet, default RPC port (8332)
sat-tui --regtest # regtest, port 18443
sat-tui --testnet # testnet, port 18332
sat-tui --signet # signet, port 38332
Authentication
Same precedence as sat-cli:
--rpcuser+--rpcpasswordif both provided.- Cookie file at
--rpccookiefileif provided. - Auto-detected cookie under
--datadir(default~/.bitcoin) for the active network.
If the cookie rotates while the TUI is running (for example, satd was restarted), the RPC client re-reads the cookie file and retries once before surfacing the failure as a "Connecting to satd…" splash.
Other CLI flags
| Flag | Default | Meaning |
|---|---|---|
--rpcconnect <host> | 127.0.0.1 | RPC host. |
--rpcport <port> | per-network default | Override the auto-detected port. |
--datadir <path> | ~/.bitcoin | Used to locate the cookie file. |
--rpcuser <user> | — | Userpass auth (with --rpcpassword). |
--rpcpassword <pass> | — | Userpass auth (with --rpcuser). |
--rpccookiefile <path> | auto | Override cookie path. |
The TUI exits cleanly on q or Ctrl-C and restores the terminal mode.
Connection states
Before any view is shown, sat-tui is in one of three connection states:
- "Connecting to satd…" (yellow, centered) — RPC is unreachable, or has returned only failures so far. Common during cold start, satd restart, or when auth is misconfigured.
- Startup splash — satd is up but is still starting (header scan,
reindex, address-index backfill). The splash is sourced from satd's
getstartupinfoRPC; see Startup splash below. - Active view —
getblockchaininfosucceeded; one of the four main views is rendered.
A red ✕ stale indicator in the title bar means the last successful
poll is more than ~3 seconds old. Treat it as "RPC is degraded right
now"; the TUI will recover automatically when polling resumes.
Views
There are four main views, plus a startup splash and three modal overlays.
The active view is auto-selected from chain state (is_ibd): IBD view
during initial block download, Steady view once synced. The operator can
force a view with 1 / 2 / 3 / 4; press the same key again to
return to auto-detect.
Startup splash
Shown while satd is in startup (header scan, reindex, address-index backfill). The splash is one panel:
| Field | Meaning |
|---|---|
| Phase | Current startup phase (e.g. reindex_scan, reindex_connect, headers, verify). |
| Status | Free-form human-readable description from satd. |
| Gauge | Progress through the current phase, 0–100%. |
| Elapsed | Wall-clock time since this phase began. |
| Rate | Items/sec — blocks, headers, or whatever the phase is iterating over. |
| ETA | Estimated remaining time for this phase only, not whole startup. |
ETAs are intentionally per-phase: phase 1 (e.g. reindex header scan) and phase 2 (block replay) have very different per-item costs, and a unified estimate misleads until phase 2 dominates.
IBD view (1)
Shown while the node is in initial block download. Five panels stacked vertically:
Title bar
Chain name, satd version, is_ibd indicator.
Progress block
- Blocks / target — connected blocks vs. the highest block any peer has advertised.
- blk/s — blocks connected per second, EMA-smoothed.
- hdr/s — headers received per second.
- ETA — server-side estimate from satd's
getibdprogressRPC. The server estimate accounts for ~50× variation in per-block validation cost across history (early empty blocks vs. modern weight-bound blocks); a naive "blocks remaining ÷ blk/s" calculation will be wildly wrong, especially in the first few hundred thousand blocks. - Peers — connected peer count.
Block map
A bitmap of download state per block group (one cell ≈ many blocks):
| Glyph | Colour | Meaning |
|---|---|---|
█ | green | Connected — validated and in the chain. |
░ | cyan | Downloaded — on disk, waiting for sequential connection. |
▓ | yellow | In flight — requested from a peer, not yet received. |
· | dim | Pending — queued for download, not yet requested. |
A healthy IBD shows a leading wave of █ followed by ░, with a
narrow ▓ band at the frontier. Long stretches of ▓ mean a peer is
slow or unresponsive; long stretches of · mean we are bandwidth-bound
or under peer-count pressure.
Sync rate + stats
Two sparklines (~90 seconds of history):
- blk/s connected (yellow) — rate of blocks fully validated.
- blk/s downloaded (cyan) — rate of blocks pulled from peers, all peers summed.
Plus a stats panel: Headers, Connected, Stored, In-Flight, Remaining.
Peers table
| Column | Meaning |
|---|---|
| Addr | Peer IP and port. |
| Agent | Subversion string (/Satoshi:25.1.0/, /satd:0.1.0/, …). |
| Recv | Blocks received from this peer this session. |
| Assigned | Blocks currently assigned to this peer for download. |
| Rate | Per-peer blk/s, EMA-smoothed. — below 0.1 blk/s. |
Up / Down highlights a row.
Steady view (2)
Default view once is_ibd=false. Six stacked panels.
Title bar
Health dot, uptime.
| Symbol | Meaning |
|---|---|
● ready (green) | Polling is fresh, node is at tip. |
○ syncing (yellow) | Polling is fresh, node is still syncing minor lag. |
✕ stale (red) | Last poll is older than ~3s. RPC may be degraded. |
Chain + Latest block (split)
Chain (left half):
- Height — current tip height.
- Difficulty — raw difficulty value.
- Hash Rate — network hashrate from
getmininginfo(H/s). - Last Block — seconds since the tip block's timestamp. > 1 hour is unusual.
Latest block (right half):
- Hash — tip hash, truncated.
- Txs — transaction count.
- Size / Weight — bytes / weight units.
- Fees — total miner fees collected (BTC).
- Avg Rate — average effective fee rate across all txs (sat/vB).
Mempool + Fees (split)
Mempool (left half):
- Txs — unconfirmed transactions.
- Size — total bytes.
- Min Rate — current mempool minimum fee. 0.0 is the default (min-relay floor, ~1 sat/vB). A non-zero value means the mempool is full and is evicting low-fee txs; new txs need at least this much to enter.
- Tx Rate — recent tx-entry rate from
getchaintxstats(tx/s). - Size distribution — sparkline of vbyte buckets (0, 100, 250, 500, 1k, 5k, 10k, 50k+).
Fees (right half):
Fee tier estimates from estimatefees (mempool.space convention):
- High — next-block target (1-block confirmation).
- Medium — ~30 minute target (3-block).
- Low — ~1 hour target (6-block).
- None — economy / min-relay floor.
- Mode —
historical,mempool, orblend(which data source the estimator is using). - Confidence —
high(green) /medium(yellow) /low(red) for the High tier specifically.
UTXO + Network (split)
UTXO (left half):
- UTXOs — total unspent outputs.
- Total — sum of UTXO values (BTC).
- Supply — fraction of the 21M cap. Asymptotic; never reaches 100%.
- Age distribution — sparkline by UTXO age: <1h, 1h–1d, 1d–1w, 1w–1m, 1m–3m, 3m–1y, 1y–3y, 3y+.
Network (right half):
- Peers — inbound and outbound counts.
Peers table
Same controls as IBD's table; columns differ:
| Column | Meaning |
|---|---|
| Addr | Peer IP:port. |
| Agent | Subversion. |
| Height | Peer's best-known block height. |
| Recv | Total bytes transferred with this peer. |
Services row
A single line summarising satd's wallet-server surfaces, sourced from
getserverstatus and getindexinfo:
addr-idx <state> esplora <state> electrum <state>
addr-idx states:
| Display | Meaning |
|---|---|
⬤ synced (green) | Address-history index is at tip. Esplora and Electrum are safe to serve history. |
⬤ syncing (yellow) | Backfill in progress. Address queries may return partial history. |
⬤ backfill pass N/2 XX% (C/S) ETA … (green) | Active backfill with progress. C/S is cursor / snapshot height. |
⬤ backfill paused … (yellow) | Backfill paused. Resume with sat-cli resumeindex address. |
⬭ backfill FAILED — <err> (red) | Backfill errored. Check journalctl / satd logs. |
⬭ off (gray) | Address index disabled (-addressindex=0). |
⬭ - (dim) | Status unknown — older satd, transient RPC error. |
esplora and electrum states:
| Display | Meaning |
|---|---|
⬭ <bind:port> (green) | Bound and serving. |
(tls <bind:port>) (cyan) | Electrum TLS bind, additional column. |
⬭ off (gray) | Disabled in config or auto-disabled (e.g. address index off). |
⬭ - (dim) | Unknown. |
Footer
Keybindings hint and an unclean-shutdown indicator (⚠ unclean shutdown)
if last_shutdown from getsysteminfo is dirty.
Mempool view (3)
Drill-down on unconfirmed transactions. Five panels.
Title bar
Health dot, uptime.
Summary strip
- Txs — unconfirmed transaction count.
- Bytes — total mempool bytes.
- Min / Max fees — feerate range across mempool entries.
- Δ last Ns — entries added / removed in the last polling window.
Feerate histogram
Bars per feerate bucket (1–2, 2–5, 5–10, 10–20, 20–50,
50–100, 100–200, 200–500, 500+ sat/vB) with vbyte counts.
Bars are coloured by fee tier so the visual matches the Steady view's
Fees panel.
Trend + Top-N (split)
Trend (left): sparklines for Bytes, Txs, MinFee over ~40 minutes.
Top-N (right): scrollable table of the top 50 unconfirmed txs by ancestor feerate.
| Column | Meaning |
|---|---|
# | Rank within top 50. |
vsize | Virtual size (vbytes). |
anc sat/vB | Ancestor-adjusted effective feerate. Accounts for CPFP — a low-fee child gets pulled in by a high-fee parent. |
A/D | Ancestor count / descendant count (chain depth in either direction). |
age | Time since the tx entered the mempool. |
Up / Down scroll.
Footer
Keybindings.
Chain view (4)
Long-horizon information that doesn't change every block. Three rows of two panels each.
Halvings | Retarget
Halvings:
- Subsidy epoch — index (0 = pre-first-halving).
- Subsidy — current block reward in BTC. Formula
50 >> halvings, saturates at 0 after halving 64. - Halving in — blocks until the next halving.
- Halving ETA — estimated wall-clock time at the 10-min target.
- Progress bar through the current 210,000-block subsidy era.
Retarget:
- Blocks to retarget — blocks until the next 2,016-block boundary.
- Retarget ETA — wall-clock estimate at 10-min target.
- Block time (epoch) — observed average seconds per block within the current 2,016-block epoch. Empty at the start of an epoch.
- Δ Est — predicted difficulty adjustment at the next retarget, clamped to ±300% (Bitcoin's hard limit). Positive = blocks are coming in faster than the 10-min target → difficulty will rise.
- Progress bar through the current 2,016-block epoch.
Supply | Chain Security
Supply:
- Issued — BTC currently in the UTXO set.
- % issued — fraction of the 21M cap.
- Remaining —
21M − issued. - Inflation: realized / forward — annualised issuance rate at the current subsidy and at the post-next-halving subsidy.
Chain Security:
- Chain work — cumulative work, in
log₂(work)bits. Computed fromchainwork(which is a 256-bit hex string in the RPC) without materialising the full integer. - Rewrite at hashrate — wall-clock seconds for the current network
hashrate to redo the entire chain's work. Formula
2^(bits − log₂(hps)). This is a back-of-envelope rewrite cost; reorgs of any meaningful depth are economically and physically infeasible. - Network hashrate — same number as the Steady view's Chain panel.
Peer clients | Trivia
Peer clients: distribution of peers by user-agent string. Top 5 agents, plus an "other" bucket. Useful for spotting an unusually homogeneous peer set or an unexpected dominant client.
Trivia: subsidy era name, halving date, next halving block height. Light reading.
Modal overlays
Modals are drawn over the active view. Esc or the toggle key closes
them.
Help (h / ?)
Context-sensitive keybindings for the active view.
Reorg history (r)
Last 7 days of reorg events from getreorghistory. Up to 40 entries.
| Column | Meaning |
|---|---|
| depth | Blocks displaced. Coloured: 1 = yellow, 2–3 = light red, 4+ = red. |
| fork height | Height at which the old and new chains diverged. |
| old tip / new tip | Block hashes, truncated. |
| −N +M blocks | Disconnected vs. reconnected counts. |
| age | Time since the reorg. |
A persistent file copy lives at $datadir/<network>/reorg.log (the
network-specific datadir subdirectory; directly under $datadir only on
mainnet) and is written by satd regardless of TUI state — the modal is a
viewer, not the source of truth.
Warnings
Centred 80% × 70% overlay that appears automatically when there are
visible warnings from getwarnings. Border colour is red (any Error
severity present) or yellow (Warn-only).
| Field | Meaning |
|---|---|
[ERROR] / [WARN] | Severity. |
| ID | Warning identifier (cyan). |
first seen Ns ago · ×count | Age and recurrence. |
| message | Human-readable description. |
aacknowledges and dismisses every currently visible warning for this session.wre-shows everything previously dismissed.
Dismissal is per-session. If satd clears a warning ID and re-emits it, the modal reappears.
Keybindings
| Key | Effect |
|---|---|
q | Quit. Closes Help / Reorg modal first if open. |
Ctrl-C | Quit. |
h or ? | Toggle Help overlay. |
r | Toggle Reorg history. |
1 | IBD view (or back to auto). |
2 | Steady view (or back to auto). |
3 | Mempool view (or back to auto). |
4 | Chain view (or back to auto). |
a | Acknowledge all visible warnings. |
w | Re-show dismissed warnings. |
Esc | Close Help or Reorg modal. |
Up / Down | Scroll peers (IBD / Steady / Chain) or top-N (Mempool). |
Polling and refresh
The TUI does not push commands to satd; it polls. The render loop runs every 250 ms regardless of polling state, so the UI stays responsive even when RPC is slow.
| Cadence | RPC calls |
|---|---|
| 1.5 s | getblockchaininfo, getpeerinfo, getmempoolinfo, getconnectioncount, getsysteminfo, getwarnings. |
| 3 s | getibdprogress (during IBD only — heavy: full bitmap and per-peer breakdown). |
| ~5 s | getindexinfo, getserverstatus, plus the steady-state batch (estimatefees, getmininginfo, getchaintxstats, uptime, getblockstats, getrawmempool (verbose), gettxoutsetinfo, getreorghistory, getmempoolhistory). |
| per epoch | getblockhash + getblockheader to anchor the current 2,016-block epoch's start time. Refreshed only when the epoch floor advances. |
If a steady-state RPC has not returned within ~3 s, the title bar shows
stale. The view continues to render; the UI just makes it visible
that what you are looking at is older than the polling cadence implies.
Failure modes
| What you see | What it means |
|---|---|
Connecting to satd… | RPC unreachable, returning errors, or only getstartupinfo is responding. |
Auth retry, then Connecting… | Cookie was rotated and the second attempt also failed — common during a satd restart. Will recover. |
Stale indicator (✕ stale) | Polling is alive but a recent call hasn't returned. Investigate if persistent. |
Empty / dashed fields (—, -) | The RPC backing that field hasn't returned yet, or returned an error. |
| Warnings modal won't dismiss | The warning is still active in satd. Dismissal is per-session; resolve at the source. |
The TUI never panics on RPC errors — it surfaces them visibly and keeps polling.
See also
- Observability & Metrics and Configuration, Tuning & Reload — the broader operator surfaces (CLI, RPC, observability, tuning).
CORE_DIFFERENCES.md— what satd does differently from Bitcoin Core.- Esplora REST API — Esplora REST endpoint reference.
sat-cli help— every JSON-RPC method exposed by satd, including the ones the TUI uses.
satd Esplora REST API
satd ships a native Esplora-compatible REST server, on by default,
listening on 127.0.0.1:3000. Wire shapes match upstream
blockstream/esplora /
mempool.space byte-for-byte
within the endpoint set listed below.
Like the Electrum server, it is a query layer over satd's own chainstate and
shared address-history index — not a separate indexer process (electrs /
esplora-electrs / Fulcrum) running beside the node with its own copy of the data.
One RocksDB, updated atomically inside the node's connect_block /
disconnect_block path, backs the node and every API surface, so a read can never
observe an index out of sync with the tip. satd's combined index is larger on disk
than a standalone electrs/Fulcrum index — the trade is disk for consistency and
single-process operation; see Disk Footprint & Indices. See
Native Protocol Architecture for the rationale.
This document covers what's implemented today. The implementation lives
in the esplora-handlers/ workspace crate; routes are registered in
esplora-handlers/src/router.rs and shape parity is locked behind the
canary CI requirement in STABILITY_POLICY.md.
Last verified against routes: 2026-05-05.
Authentication. The Esplora surface defaults to unauthenticated loopback. For Basic auth (
--esploraauth) or capability-scoped bearer tokens (--esploraauthbearer,esplora:read), see Authentication & Authorization.
Configuration
| CLI flag | Default | Notes |
|---|---|---|
--esplora=<bool> | 1 | Disable with --esplora=0. Disabling stops the listener; address-index data is still maintained for RPC consumers. |
--esplorabind=<addr:port> | 127.0.0.1:3000 | Bind address. Use 0.0.0.0:3000 to expose — see the Auth section before doing this. |
--esploraprefix=<path> | / | Mount under a path (e.g. /api) for blockstream.info-style deployments. Must start with /. |
--esploraauth=<scheme> | none | One of none / cookie / userpass. none runs the listener unauthenticated. cookie reuses the daemon cookie file. userpass requires --esplorauserpass=user:pass. |
--esplorauserpass=<user:pass> | (none) | Static credentials, only used when --esploraauth=userpass. |
--esploracookiefile=<path> | (auto) | Override the path to the cookie file when --esploraauth=cookie. Default is the same .cookie file the JSON-RPC server uses. |
--esploracors=<origin> | (none) | Repeat for multiple. Use * for any origin. |
--esplorarequesttimeout=<seconds> | 30 | Per-request timeout. |
--esploramaxconns=<n> | 256 | Concurrent in-flight requests cap. 0 disables. (Does not bound the lifetime of long-lived SSE streams; see Live updates.) |
--esplorasseconns=<n> | same as --esploramaxconns | Hard cap on simultaneously-open SSE streams (/blocks/sse, /address/:addr/sse, /scripthash/:hash/sse). Each open stream holds a permit until client disconnect; over-cap connections receive 503. 0 disables the cap. |
POST /tx carries a hard-wired 1 MiB body limit at the route layer —
witness-heavy 400 KB raw txs hex-encode to ~800 KB, so 1 MiB is enough
margin and well under any consensus block limit. There is no operator
flag to change this.
Esplora requires --addressindex=1 (auto-enabled if not set; see
the address-index docs) and --txindex=1 (auto-enabled by the
reconciliation in satd/src/config.rs). Both flags are on by default.
Endpoints
Chain
| Method | URL | Returns |
|---|---|---|
| GET | /blocks/tip/hash | text/plain — current best-chain tip hash (display hex, 64 chars). |
| GET | /blocks/tip/height | text/plain — current tip height. |
| GET | /blocks | JSON array of up to 10 most-recent block summaries, descending. |
| GET | /blocks/:start_height | JSON array of up to 10 summaries ending at start_height inclusive, descending. |
| GET | /block-height/:height | text/plain — block hash at the active-chain height, or 404. |
Block
| Method | URL | Returns |
|---|---|---|
| GET | /block/:hash | JSON: {id, height, version, timestamp, mediantime, tx_count, size, weight, merkle_root, previousblockhash, nonce, bits, difficulty}. |
| GET | /block/:hash/header | text/plain — 80-byte serialized header, hex-encoded. |
| GET | /block/:hash/raw | application/octet-stream — raw block bytes. |
| GET | /block/:hash/status | JSON: {in_best_chain, height?, next_best?}. |
| GET | /block/:hash/txs | JSON: first 25 txs in full Esplora shape ({txid, version, locktime, vin, vout, size, weight, fee, status}). |
| GET | /block/:hash/txs/:start_index | JSON: 25 txs starting at start_index. Empty array past the end. |
| GET | /block/:hash/txid/:index | text/plain — txid at the given block-tx index. |
| GET | /block/:hash/txids | JSON: array of every txid in the block. |
Transaction
| Method | URL | Returns |
|---|---|---|
| GET | /tx/:txid | JSON: full tx (vin/vout/status/fee). 404 if unknown. |
| GET | /tx/:txid/status | JSON: {confirmed, block_height?, block_hash?, block_time?}. |
| GET | /tx/:txid/hex | text/plain — hex-encoded serialized tx. |
| GET | /tx/:txid/raw | application/octet-stream — raw tx bytes. |
| POST | /tx | Body: hex-encoded tx. Returns the txid as plain text on accept. Bad hex / mempool reject → 400. |
| GET | /tx/:txid/outspend/:vout | JSON: {spent, txid?, vin?, status?}. |
| GET | /tx/:txid/outspends | JSON: array of outspends, one per output, vout-ordered. |
| GET | /tx/:txid/merkle-proof | JSON: {block_height, merkle: [hex...], pos}. |
| GET | /tx/:txid/merkleblock-proof | text/plain — hex-encoded P2P MerkleBlock for the given tx. |
Address & Scripthash
The address-string and scripthash endpoint families share handlers; only the parser differs. Scripthashes are 32-byte sha256 of the scriptPubKey, hex-encoded in natural byte order (NOT reversed — Esplora's scripthash format differs from Electrum's).
| Method | URL | Returns |
|---|---|---|
| GET | /address/:address /scripthash/:hash | JSON: {address, chain_stats, mempool_stats}. Each *_stats block: {tx_count, funded_txo_count, funded_txo_sum, spent_txo_count, spent_txo_sum}. |
| GET | /address/:address/txs /scripthash/:hash/txs | JSON: up to 50 mempool txs followed by first 25 confirmed (newest first). |
| GET | /address/:address/txs/chain /scripthash/:hash/txs/chain | JSON: 25 confirmed txs, newest first. |
| GET | /address/:address/txs/chain/:last_seen_txid /scripthash/:hash/txs/chain/:last_seen_txid | JSON: next 25 confirmed txs strictly older than last_seen_txid. Unknown cursor → empty (not 404). |
| GET | /address/:address/txs/mempool /scripthash/:hash/txs/mempool | JSON: up to 50 mempool txs. No paging. |
| GET | /address/:address/utxo /scripthash/:hash/utxo | JSON: live UTXOs (confirmed + mempool funding) with {txid, vout, value, status}. |
Wrong-network addresses → 400. Malformed addresses → 400. Bad scripthash hex (non-hex or wrong length) → 400.
Mempool & Fee
| Method | URL | Returns |
|---|---|---|
| GET | /mempool | JSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate. |
| GET | /mempool/txids | JSON: array of every mempool txid. |
| GET | /mempool/recent | JSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}. |
| GET | /fee-estimates | JSON: object mapping confirmation target (string) to feerate (sat/vB, float). Standard targets: 1..25, 144, 504, 1008. Floor 1.0 sat/vB. |
Root
| Method | URL | Returns |
|---|---|---|
| GET | / | JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings. |
Live updates (Server-Sent Events)
| Method | URL | Stream |
|---|---|---|
| GET | /blocks/sse | One block event per BlockConnected. Body: {hash, height}. |
| GET | /address/:addr/sse | One status event per status-hash change for the address. Body: {address, status_hash}. |
| GET | /scripthash/:hash/sse | Parallel scripthash variant. The address field carries the scripthash hex. |
Connections receive a :keep-alive heartbeat every 25 seconds so idle
streams survive intermediate proxy timeouts (Caddy default 30s, nginx
default 60s).
Per-scripthash subscriptions consume from the registry capped by
--addrindexsubscriptions=N (default 10000); over-cap subscribe
attempts return 503.
Total open SSE streams across all three routes are capped by
--esplorasseconns=N (default same as --esploramaxconns). Each
stream holds a permit until client disconnect — distinct from the
request-handling cap, which doesn't bound long-lived streaming
bodies. Over-cap connections receive 503 immediately at the SSE
entry point.
A subscriber that lags the broadcast channel skips ahead — the
broadcast guarantees no panic but may drop intermediate events.
Clients are expected to refresh state via the standard endpoints
(/address/:addr or /blocks/tip/{hash,height}) on reconnect.
Wire-shape gotchas
- Hex byte order. Block hashes, txids, and merkle siblings are
hex-encoded in display (reversed) byte order — same as Bitcoin
Core's
getblockhash/getrawtransaction. Scripthash hex is the natural byte order ofsha256(scriptPubKey)(NOT reversed — this differs from Electrum's wire format). - Pagination cursors.
/address/:addr/txs/chain/:last_seen_txidstarts the next page strictly after the cursor in the descending list. An unknown cursor returns an empty array (clients with stale state get[], not 404). - Combined
/txs. Returns "up to 50 mempool + first 25 confirmed", in that order. Mempool entries appear in the index's HashSet iteration order (not strictly time-ordered). feefield on tx JSON.nullwhen at least one prevout cannot be resolved (e.g. txindex disabled, prev tx pruned).Some(0)for coinbase. Otherwisesum_inputs - sum_outputs.- Mempool UTXOs in
/utxo. Outputs created by mempool txs appear withstatus.confirmed: falseand no block fields. Spent-in-mempool outputs are excluded. - Tx confirmation status on outspends. Confirmed-side spends carry
full
status(block_{height,hash,time}). Mempool spends carrystatus: { confirmed: false }.
Auth
Default is
none(unauthenticated). Loopback-only deployments (--esplorabind=127.0.0.1:3000) are usually fine. Before binding to a non-loopback address (e.g.0.0.0.0:3000), set an auth mode explicitly —POST /txis a broadcast endpoint and an unauthenticated public listener will accept any tx submission.
Three auth modes are available via --esploraauth=<mode>:
-
none(default) — no auth. Listener accepts every request. -
cookie— reuses the same.cookiefile the JSON-RPC server creates. Clients pass it via HTTP Basic Auth as__cookie__:<token>(the form Bitcoin Core-compatible tooling generates). Override the cookie path with--esploracookiefile=<path>.satd --esplora=1 --esploraauth=cookie -
userpass— static credentials supplied via--esplorauserpass=<user>:<pass>. Constant-time compare; case-insensitive HTTP scheme.satd --esplora=1 --esploraauth=userpass --esplorauserpass=admin:hunter2
In cookie and userpass modes the daemon refuses to start if the
auth source can't be established (cookie file unreadable; missing
--esplorauserpass).
CORS
--esploracors=<origin> enables CORS for the listed origins. *
allows any origin. Allowed methods: GET, POST. Allowed headers:
Content-Type, Authorization. Auth still applies — CORS only
opens up cross-origin browsers, it doesn't bypass auth.
Bench harness
scripts/run-esplora-bench.sh spins up a regtest node, mines warmup
blocks, then drives ESPLORA_BENCH_REQS (default 200) requests
against each implemented endpoint. Reports p50 / p90 / p99 latency
per endpoint. See the script's header for env knobs. Not a CI gate;
operator regression check.
Compatibility statement
The implemented endpoints aim for byte-for-byte parity with upstream blockstream.info / mempool.space within these constraints:
- Standard scripts only: scriptpubkey_type strings cover
p2pk,p2pkh,p2sh,v0_p2wpkh,v0_p2wsh,v1_p2tr,op_return,multisig,unknown(matches upstream). Non-standard scripts serialize withscriptpubkey_address: null. - Mempool ordering in
/address/:addr/txs/mempoolis HashSet iteration order, not strictly time-ordered. Upstream's contract is "up to 50", not a specific order. - Fee histogram bucketing uses fixed boundaries spanning realistic mainnet fee regimes: 1, 2, 3, 5, 8, 10, 15, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000 sat/vB.
- WebSocket subscriptions are not implemented; SSE is the supported live-updates transport. Most consumers (BDK, mempool.space SDK) accept SSE as a drop-in replacement.
- High-history scripts. Address-history endpoints
(
/address/:addr,/address/:addr/txs/chain[/:cursor],/address/:addr/utxo, scripthash variants) load the full confirmed-history row set for the scripthash on every request and sort it in memory. For typical wallet-sized scripts this is sub-millisecond; for high-activity scripts (exchange hot wallets, mining pools, popular donation addresses) the per-request cost can spike to multi-MB allocations and sub-second latency.--esploramaxconnsand--esplorarequesttimeoutbound blast radius. Public deployments serving such scripts should put the listener behind a per-IP rate limiter at the reverse-proxy layer; cursor-paginated index reads are tracked as future work. - Address prefix search (
/address-prefix/:prefix) is not implemented — would require a separate prefix index.
Electrum Protocol Server
satd ships a native Electrum protocol server (the electrum-proto crate),
serving the JSON-RPC-over-TCP protocol that BlueWallet, Sparrow, Nunchuk,
Electrum, and most hardware-wallet coordinators speak. It is a query layer over
satd's own chainstate and address-history index — not a separate electrs /
Fulcrum process with its own copy of the data. satd's combined index is larger on
disk than a standalone electrs/Fulcrum index — the trade is disk for consistency
and single-process operation (see Disk Footprint & Indices).
See Native Protocol Architecture for the "why
native + shared chainstate" rationale.
It is off by default. Enable with --electrum=1; it requires
--addressindex=1 (for scripthash history) and --txindex=1 (for the
confirmed-transaction and merkle-proof methods), both enforced at startup.
- Protocol version:
1.4— advertised as bothprotocol_minandprotocol_max(satd serves a single protocol version). - Transport: line-delimited JSON-RPC over plain TCP (default
127.0.0.1:50001) and/or TLS (default port 50002). Expose over Tor /.onionrather than directly on the LAN.
Authentication. Electrum is loopback by default. It supports native TLS and mutual TLS (
--electrumtlsbind+--electrummtls…); the unified bearer-token layer does not gate Electrum (client-cert principals are a documented future seam). See Authentication & Authorization.
Configuration
| CLI flag | Default | Notes |
|---|---|---|
--electrum=<0|1> | 0 | Enable the Electrum server. Requires --addressindex=1 and --txindex=1. |
--electrumbind=<addr:port> | 127.0.0.1:50001 | Plain-TCP listener bind. |
--electrumtlsbind=<addr:port> | none | TLS listener bind (standard port 50002). Requires cert + key. |
--electrumtlscert=<path> | none | PEM TLS certificate. |
--electrumtlskey=<path> | none | PEM TLS private key. |
--electrummtls=<0|1> | 0 | Require mutual TLS on the TLS listener. |
--electrummtlsclientca=<path> | none | PEM CA bundle to verify client certs when --electrummtls=1. |
--electrummtlsclientallow=<subj> | any CA-signed | Allowlist of accepted client-cert CN / DNS-SAN values. |
--electrummaxconns=<n> | 64 | Hard cap on simultaneously-open connections. |
--electrummaxsubsperconn=<n> | 1000 | Per-connection scripthash subscription cap. |
--electrumrequesttimeout=<secs> | 30 | Per-request handler timeout. |
--electrummaxbatchrequests=<n> | 100 | Max requests per JSON-RPC batch line. Wallets (e.g. Sparrow) batch their whole gap-limit window of scripthash.subscribe calls at scan time, so a low cap fails the scan. |
--electrummaxbroadcastpackagetxs=<n> | 25 | Max txs per blockchain.transaction.broadcast_package. |
--electrumfeehistogramttl=<secs> | 10 | TTL for the mempool.get_fee_histogram cache. |
--electrumbanner=<text> | powered by satd <version> | Override for server.banner. |
The server runs on satd's isolated API runtime (--api-threads),
so Electrum load cannot starve block connection.
Supported methods
A scripthash is the SHA-256 of an output scriptPubKey, reversed (hex),
exactly as in the Electrum protocol.
Server / session
| Method | Description |
|---|---|
server.version | Negotiate client/server software + protocol version. |
server.ping | Keepalive; returns null. |
server.banner | Server banner text (configurable via --electrumbanner). |
server.donation_address | Configured donation address (empty if unset). |
server.features | Feature/identity dict: genesis hash, protocol_min/protocol_max (both 1.4), hosts, etc. |
server.peers.subscribe | Peer-server discovery list (satd returns an empty set — no peer gossip). |
Headers & blocks
| Method | Description |
|---|---|
blockchain.headers.subscribe | Subscribe to new-tip notifications; returns the current tip header and pushes on each new block. |
blockchain.headers.get | Fetch a header by height. |
blockchain.block.header | A block header (with an optional merkle proof to a checkpoint). |
blockchain.block.headers | A contiguous range of headers (with optional checkpoint proof). |
Scripthash (address) queries
| Method | Description |
|---|---|
blockchain.scripthash.get_history | Confirmed + mempool history for a scripthash. |
blockchain.scripthash.get_balance | Confirmed + unconfirmed balance. |
blockchain.scripthash.listunspent | Unspent outputs for a scripthash. |
blockchain.scripthash.get_mempool | Mempool-only history for a scripthash. |
blockchain.scripthash.get_first_use | First block/tx that paid the scripthash (electrs-style extension). |
blockchain.scripthash.subscribe | Subscribe to a scripthash; pushes a new status hash whenever its history changes. |
blockchain.scripthash.unsubscribe | Cancel a scripthash subscription. |
Transactions
| Method | Description |
|---|---|
blockchain.transaction.get | Raw transaction by txid (verbose decode optional). Needs --txindex. |
blockchain.transaction.get_merkle | Merkle inclusion proof for a confirmed tx. Needs --txindex. |
blockchain.transaction.id_from_pos | Txid at a (height, position), optionally with a merkle proof. Needs --txindex. |
blockchain.transaction.broadcast | Submit a raw transaction to the network. |
blockchain.transaction.broadcast_package | Submit a package of transactions (bounded by --electrummaxbroadcastpackagetxs). |
Fees
| Method | Description |
|---|---|
blockchain.estimatefee | Estimated fee rate (BTC/kB) for a confirmation target. |
blockchain.relayfee | The node's minimum relay fee rate. |
mempool.get_fee_histogram | Mempool fee-rate histogram (cached; TTL --electrumfeehistogramttl). |
Subscriptions
Two push subscriptions are supported and counted against
--electrummaxsubsperconn:
blockchain.headers.subscribe— ablockchain.headers.subscribenotification on every new tip.blockchain.scripthash.subscribe— ablockchain.scripthash.subscribenotification carrying the new status hash whenever a watched scripthash's history changes (mempool or confirmed). Because the index is updated inside the sameconnect_block/disconnect_blockbatch as the chainstate, a subscriber can never observe a status out of sync with the tip.
Notes & differences
--txindexis required forblockchain.transaction.get/get_merkle/id_from_pos;--addressindex(on by default) backs everyscripthash.*method.- satd advertises a single protocol version (
protocol_min == protocol_max == 1.4); it does not negotiate a range. server.peers.subscribereturns an empty list — satd does not participate in Electrum peer gossip.- The protocol layer is vendored from
romanz/electrs(MIT; attribution inelectrum-proto/vendor/electrs.MIT) and adapted to satd'sAddressIndextrait over the shared RocksDB.
Streaming Consumption API
The Streaming Consumption API is satd's push-based surface for downstream consumers — wallets, Lightning nodes, exchanges, watchtowers, explorers, and other L2 projects. It pairs a real-time event firehose (blocks and mempool transitions) with live, cursor-resumable watch subscriptions keyed on outpoints, scripts, descriptors, and transaction ids.
It exists because the incumbent ways to consume a node each leave the same three gaps — descriptor lifecycle, outpoint-level subscriptions, and cursor-based event replay — that every serious consumer ends up reinventing. satd serves all three natively, in-process, as consensus ground truth rather than reconstructed from a ZMQ side-channel.
Status: shipped. The full surface described here is implemented — the
NodeEventbus and gRPCSubscribefirehose, the Core-compatible ZMQ PUB sink, the bidirectionalWatchcontrol channel, the live outpoint/script/txid/prefix matcher, durable replay cursors, the JSON/WebSocket
- SSE transports (
--streamws), and the descriptor convenience layer. This chapter is the integrator guide; the authoritative, wire-level protocol specification isdocs/api/streaming.md.
The base primitive: outpoint subscription
The key generalization is that outpoint subscription is the base primitive. Lightning channel-close detection, watchtower triggers, exchange deposit confirmation, and theft monitoring all reduce to "tell me when this outpoint is spent." Address-watching is outpoint-watching with a derivation rule layered on top. The API builds down to outpoints and layers scripts, descriptors, and transaction-id watches on as conveniences over the same matcher.
Transports
One schema (the satd.events.v1 protobuf definition is the source of truth),
three transports:
- gRPC native (
satd.events.v1, tonic) — the primary transport for programmatic consumers. A server-streamingSubscribe(firehose) and a bidirectionalWatch(firehose + managed watch-set). - JSON-over-WebSocket (
GET /ws) — a hand-mapped JSON rendering of the same tagged-unions, with a client→server control channel mirroringWatch. - Server-Sent Events (
GET /sse) — a read-only JSON firehose (no control channel) for browser /curlconsumers.
A Core-compatible ZMQ PUB sink remains for legacy parity; it carries the firehose bodies only (not per-subscriber watch matches) and uses Core's per-topic sequence numbers.
WebSocket and SSE bind a dedicated --streamws port rather than upgrading on
the Core-compat JSON-RPC port — keeping the differentiated stream a distinct
service on a distinct port. Every streaming listener (--streamws and the gRPC
NodeEventStream) runs on the isolated API tokio runtime (--api-threads),
never the core block-connecting runtime: a flood of streaming clients can never
contend with the threads that connect blocks and accept mempool transactions. See
API Scaling & Runtimes for the runtime split, the admission
caps, and how to scale beyond one node.
Subscriptions and watch-sets
The gRPC service offers the server-streaming Subscribe (the firehose, with
cursor replay) plus a bidirectional Watch whose client→server messages are
a tagged union — SetCursor, SetCategories, and Add/Remove for scripts,
outpoints, transactions, script-prefixes, and descriptors. New subscription
kinds slot in additively without protocol breakage.
Match events delivered on the per-subscriber Watch channel include:
OutpointSpent— an outpoint was spent (in mempool or a connected block).ScriptMatched— a script was funded or spent (both sides).TxidMatched/TxidReplaced/TxidEvicted/TxidUnconfirmed/TxidDepthReached/TxidFinalized— transaction lifecycle and confirmation-depth alarms.PrefixMatched— a privacy-preserving script-prefix match.
The matcher is decoupled from the consensus path: a dedicated task subscribes
to the existing chain/mempool broadcasts and re-reads blocks and accepted
transactions the node already holds, then scans the watch-set and delivers
matches. A node with no subscribers pays nothing (gated behind a lock-free
has_watchers() check), and a slow client's matches are dropped-with-notice,
never stalling the matcher or blocking consensus.
Descriptor convenience layer
AddDescriptor takes a rust-miniscript-parseable, public-key-only descriptor
plus a gap_limit window; the server expands it over [start, start + gap_limit), derives the watch scripts, and registers them with the matcher.
Expansion is bounded (MAX_DESCRIPTOR_WINDOW = 1000) and rejects any
secret-bearing descriptor at the type level, so no signing material can ever be
submitted — the node stays keyless. Gap-limit advancement is a client concern by
design: the client drives a sliding window by issuing a fresh AddDescriptor
with an advanced start and Remove-ing the trailing scripts.
Cursors & replay
Reconnect-with-cursor is the single highest-value primitive — the one replay mechanism for every subscription type, subsuming Electrum's subscribe-then- get-history dance and Esplora's per-address pagination.
- Confirmed-side replay is exact: the cursor is
(height, tx_index)and replay runs straight from the block index, no extra log. - Mempool-side replay is best-effort within a bounded in-memory window
(the mempool isn't durable); only the high-water
seqis persisted. - Process restart is detected through
Cursor.instance_id; the per-publisherseqresets on restart and is the mempool-side watermark only, never a durable confirmed-side cursor.
Reorgs are not a separate event type: they are carried on ChainEvent as a
first-class Reorg marker followed by the per-block disconnect/connect sequence.
Authentication & quotas
The streaming API adds no new auth surface — it reuses the unified auth layer
wholesale (full details in Authentication & Authorization).
With no token store configured the transports are open (loopback-trust, matching
the existing events-gRPC behavior); a remote bind requires a token store
(-streamwsauth/-eventsgrpcauth → -authfile).
| Action | Capability | Quota |
|---|---|---|
| Open a stream; receive the firehose | stream:subscribe | — |
AddScripts / AddOutpoints / AddTransactions / AddDescriptor | stream:watch | per-token watch quota + per-add rate limit |
Remove* | — | releases each item's unit immediately |
The quota unit is one watched item (N items = N units); each item holds an
RAII WatchLease so Remove* returns its unit immediately, making long-lived
clients that rotate a sliding watch-set viable without exhausting quota.
Over-quota adds are rejected cleanly (RESOURCE_EXHAUSTED on gRPC / 429 on WS)
without tearing down the subscription.
Operator limits
Every remote-facing streaming surface is bounded so it cannot be driven to
fd / memory / task exhaustion. All are restart-classified config; 0 means
unlimited.
| Key | Default | Bounds |
|---|---|---|
streamwsmaxconns | 256 | concurrent /ws + /sse connections |
streamwsmaxsubscriptions | 256 | watch-set size per WS connection |
streamwsmaxmessagebytes | 262144 | a single inbound WS control frame |
eventsgrpcmaxconns | 64 | concurrent gRPC streams |
eventsgrpcmaxsubscriptions | 256 | watch-set size per gRPC stream |
streammaxresyncblocks | 10000 | blocks the matcher will rescan after a lag, bounding catch-up |
Admission shedding runs ahead of authentication and request-body buffering, so a connection flood — authenticated or not — is bounded before it does work.
Consensus-safety invariants
These are structural guarantees, not policies:
- The event bus is publish-only out of
connect_block/accept_tx; the matcher only reads data the node already holds and adds no code to, and takes no lock on, the consensus path. - A slow client never backpressures the publisher — degradation is
drop-with-notice (
broadcastsend is non-blocking and lossy; per-subscriber delivery is non-blockingtry_send). - Streaming listeners run on the API runtime only, never the core block-connecting runtime.
MCP Server
satd ships a native Model Context Protocol server (the mcp crate, built on
rmcp) that exposes the node's query, ops, and transaction-construction surfaces
as MCP tools for AI agents and other MCP clients. It lets an LLM-driven client
inspect chain/mempool/peer state, estimate fees, decode and build transactions,
and run operator actions through a structured, typed tool interface instead of
raw JSON-RPC.
It is off by default — enable it with --mcp plus --mcpport.
Transport
MCP is served over a single Streamable HTTP listener (which also serves
legacy SSE clients). The MCP server is part of the running satd process, so
clients attach to an already-running daemon over the network.
| Setting | Default | Notes |
|---|---|---|
--mcpport | (off) | Port to serve MCP on; enables the listener. |
--mcpbind | 127.0.0.1 | Bind address. Non-loopback requires auth and TLS. |
--mcpcert / --mcpkey | (none) | PEM cert/key — enables HTTPS. Required for any non-loopback bind. |
--mcpmtls | false | Require client certs (mTLS). Needs --mcpcert/--mcpkey + --mcpmtlsclientca. |
--mcpmtlsclientca | (none) | PEM CA bundle that client certs must chain to. |
--mcpmtlsclientallow | (any) | Optional allowlist of client-cert CN / DNS-SAN values. |
The listener runs on satd's core (consensus) tokio runtime, not the isolated API runtime — deliberately, because MCP exposes block-connecting and broadcast tools (see Posture below).
Transport security (TLS)
The MCP listener is plaintext HTTP only when bound to loopback. Set --mcpcert
and --mcpkey to serve HTTPS instead — this is mandatory for any
non-loopback bind, so a bearer token is never sent in cleartext over the
network. satd refuses to start a routable MCP listener without TLS.
TLS uses the same tls_config layer as the RPC / Esplora / Electrum surfaces
(hot-reloadable on SIGUSR1). For mutual TLS, add --mcpmtls --mcpmtlsclientca <ca.pem>; clients without a cert that chains to the CA are rejected at the
handshake. Narrow further with --mcpmtlsclientallow <CN> (repeatable /
comma-separated). mTLS is additive — the --mcpauth bearer layer still runs
on top.
Authentication
MCP uses the unified auth system:
- Loopback default — with
--mcpauthoff, the server performs no per-request auth check (loopback-trust). Only valid for a loopback bind. - Bearer —
--mcpauth(which requires--authfile) requiresAuthorization: Bearer <token>resolving to a principal that holds themcp:*capability; otherwise the server returns401withWWW-Authenticate: Bearer, and applies the token's rate limit (429+Retry-Afteron throttle). - Remote exposure is gated — a non-loopback
--mcpbindrequires--mcpallowremote(→--mcpauth→--authfile) and TLS (--mcpcert/--mcpkey). satd refuses to start a routable MCP listener that isn't both authenticated and encrypted.
MCP is gated by the single mcp:* capability — there is no read-only-vs-
mutating split inside MCP, so any token with mcp:* can call every tool.
Posture: MCP is not read-only
Unlike a pure query API, MCP exposes state-changing tools — send_transaction
(broadcasts to the network), generate_blocks (mines/connects blocks; regtest),
and manage_peer (disconnect/ban/unban/add), plus transaction
construction/signing. Treat an mcp:* token as a privileged credential, and keep
the listener loopback-bound unless you've deliberately put auth and TLS in
front of it.
Connecting a client
Enable the listener on the node, then point the client at the URL.
Enable the listener
In bitcoin.conf:
mcp=1
mcpport=18888
# mcpbind=127.0.0.1 # default: loopback only
or on the command line:
satd --datadir=/path/to/node --mcp --mcpport=18888
The server is then reachable at http://127.0.0.1:18888/. For remote use, add
TLS and auth — issue a token that holds the mcp:* capability:
satd --datadir=/path/to/node --mcp --mcpport=18888 \
--mcpbind=0.0.0.0 --mcpallowremote \
--mcpauth --authfile=/etc/satd/auth.toml \
--mcpcert=/etc/satd/mcp.crt --mcpkey=/etc/satd/mcp.key
The server is then reachable at https://NODE_HOST:18888/. Clients
authenticate with an Authorization: Bearer <token> header. See
Authentication and Transport security.
Claude Code
# Loopback daemon, no auth:
claude mcp add --transport http satd http://127.0.0.1:18888/
# Authenticated, TLS daemon — pass the bearer token as a header:
claude mcp add --transport http satd https://NODE_HOST:18888/ \
--header "Authorization: Bearer YOUR_TOKEN"
Append --scope project to write a shared, committable .mcp.json instead of
your personal config; inspect with claude mcp list and the in-session /mcp.
The equivalent .mcp.json entry:
{
"mcpServers": {
"satd": {
"type": "http",
"url": "https://NODE_HOST:18888/",
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
}
}
}
Codex CLI
Add to ~/.codex/config.toml (or .codex/config.toml in a trusted project):
[mcp_servers.satd]
url = "https://NODE_HOST:18888/"
# Authenticated daemon — supply the bearer token:
http_headers = { Authorization = "Bearer YOUR_TOKEN" }
If you terminate TLS with a self-signed cert, configure the client to trust it (or front satd with a reverse proxy holding a CA-issued cert). mTLS clients additionally present their own cert/key per their MCP-client documentation.
Tools
The server registers the following tools (each returns a text result).
Node status / ops
get_node_status— chain height, sync progress, mempool summary, peers, difficulty, uptime.get_system_info— process RSS, UTXO-cache stats, DB info.get_config— effective post-merge config (secrets redacted).get_metrics_snapshot— current Prometheus metrics as text.get_health/get_readiness— liveness / readiness (mirror/healthz&/readyz).get_reorg_history— persisted reorg events. Param:since_secs(default 86400).
Blockchain / block
get_block— block by hash or height. Params:identifier,verbosity(summary/full/raw).get_block_header— header by hash or height. Params:identifier,raw.get_block_stats— fees, sizes, tx counts, UTXO/SegWit stats. Param:identifier.get_chain_info— tips, tx rate over a window, difficulty. Param:window(default 30).search_block_range— headers for a range (max 100). Params:start_height,end_height.
Transaction (query / decode)
get_transaction— lookup bytxid(chain + mempool); optionalblockhashhint.decode_raw_transaction— decode hex tx to JSON. Param:hex_tx.decode_script— decode a hex script (opcodes/type/addresses). Param:hex_script.
Mempool
get_mempool_overview— size, byte usage, fee histogram, policy.list_mempool_transactions— list withsort_by(fee_rate/time/size),limit(≤100),min_fee_rate.get_mempool_entry— one tx; optionalinclude_relatives(ancestors/descendants).get_mempool_entries_bulk— detail for manytxids(missing → null).get_mempool_history— windowed snapshots. Param:since_secs(default 3600).subscribe_mempool_snapshot— most recent mempool events. Param:limit(≤50).
Fees
estimate_fee— rates for multipletargets(default[1,3,6,12,25]), in BTC/kvB and sat/vB.
Network / peers
get_peer_info— connected peers. Param:summary(default true).manage_peer— mutating:add/disconnect/ban/unban. Params:action,address.get_ban_list— banned peers with timestamps/reasons.
Transaction construction (mutating)
create_transaction— build an unsigned raw tx. Params:inputs,outputs,locktime.sign_transaction— sign with WIF keys client-side. Params:hex_tx,private_keys,prevtxs,sighash.send_transaction— broadcast a signed raw tx. Param:hex_tx.psbt_workflow— PSBTcreate/decode/analyze/combine/finalize/update/convert/join.
Mining
get_mining_info— difficulty, network hashrate, height.generate_blocks— mine blocks (regtest only). Params:count,address.get_block_template— mining template.
UTXO / address
get_utxo— single UTXO bytxid/vout(null if spent).get_utxo_set_stats— total UTXOs, total value, best block.validate_address— validate + classify (P2PKH/P2SH/P2WPKH/P2WSH/P2TR), script hex, witness info.
Native Protocol Architecture
satd serves the Electrum protocol, the Esplora REST API, and the BIP 157/158
compact-filter service as native subsystems inside the satd binary, gated
by runtime flags (--electrum=1, --esplora=1, --blockfilterindex=basic --peerblockfilters=1). The block-filter-index Cargo feature additionally
allows compiling out the BIP 158 codec entirely for a consensus-only build.
This chapter documents the architecture and the design rationale behind that
choice. For operator flags and tuning see Configuration, Tuning &
Reload; for the wire surfaces see the Esplora REST
API and Electrum Protocol Server chapters; for how
these surfaces authenticate (and the unified bearer-token layer) see
Authentication & Authorization; for
the catalog of shipped surfaces see
CORE_DIFFERENCES.md
§"Native protocol surfaces".
The architectural story — and the headline differentiator over the bitcoind +
electrs status quo — is that Electrum and Esplora are query layers over
satd's chainstate, not a separate process maintaining a parallel index.
Why native + shared chainstate, not bundled electrs
A bundled-electrs companion solves install-friction but inherits the
architectural costs of the two-process world: a second copy of the
address-history data living in its own database, parallel block re-scanning to
build it, and a reorg-window race where the Electrum view lags the chainstate.
None of those go away by vendoring electrs alongside satd.
Native + shared chainstate gives:
- One RocksDB instance. Same WAL, same crash recovery, same backup target.
- No duplicate scriptPubKey scanning. The address-history index is updated inside the existing
connect_block/disconnect_blockloop — no second pass over the blocks to build a parallel database. - Atomic reorg consistency. The index update lives in the same
WriteBatchas the chainstate update, so protocol handlers can never observe an index out of sync with the tip. - Sub-millisecond, O(1) index lookups. Address history, outpoint-spend, and txid lookups are direct keyed reads on fixed-width keys — function calls, not RPC, and not range scans over a derived view.
- Native TLS. No need to configure or bundle reverse proxy sidecars like nginx just to terminate TLS for these protocol servers.
That's the architectural claim worth making in the announcement. A bundled-electrs approach can't earn it.
Because one node serves Electrum and Esplora and getrawtransaction and
BIP 158 from a single store, satd's aggregate index is larger on disk than
any one external indexer — and larger than bitcoind + txindex + electrs summed.
That disk buys a tip-consistent, single-process, single-backup deployment. The
full byte-level accounting — what each column family stores, why, and what query
it powers — is in Disk Footprint & Indices.
Why a single binary, not separate companion binaries (for v1)
Originally this design proposed separate sat-electrum and sat-esplora
companion binaries. Revisited: a single satd binary with feature flags is
simpler to ship, package, document, and operate, and the failure-isolation
arguments for separation are weaker than they look in modern Rust + tokio code
with bounded subscription queues, request timeouts, and per-connection limits.
Concretely:
- One systemd unit, one Docker image, one log stream, one PID.
- One dbcache budget, one memory allocator, no double-counting RAM.
- No RocksDB-secondary-mode coordination problem — RocksDB doesn't allow concurrent writers; secondary-mode read-only access works but adds lag and schema-coordination headaches.
- Runtime flags address the "don't pay for what you don't use" concern. Esplora and Electrum are always compiled into the
satdbinary and are gated at runtime by--esplora/--electrum(Esplora on by default, Electrum off). The only build-time switch is--no-default-features, which compiles out the BIP 158 block-filter-index codec — it does not remove the protocol servers.
The case for separation gets stronger if Electrum subscriptions turn out to be the dominant memory pressure point in production (mobile wallets subscribing to thousands of scripthashes). Mitigation in v1: bounded subscription cap, per-connection memory accounting, easily-flippable feature flag. If pressure becomes real, a v1.x companion-binary split is cheap because the workspace is already structured as library crates (see "Workspace structure" below).
Future split into companion binaries (v2)
If operational data demands process isolation in v2 — e.g. Electrum
subscription RAM pressure competing with UTXO cache, or a desire for tighter
security boundaries on Tor-exposed protocol surfaces — the workspace structure
supports adding sat-electrum and sat-esplora companion binaries that open
the RocksDB datadir in secondary mode (read-only with WAL replay). Same
library code, different deployment shape. v1.x release, not a rewrite.
This is explicitly deferred. Single-binary v1 is the simpler thing.
Implementation strategy for Electrum + Esplora
Vendor electrs's protocol code, write the index ourselves
Neither romanz/electrs nor Blockstream/electrs is published as a usable library:
romanz's internal modules are private (mod, not pub mod), Blockstream's is
pub mod but git-only and never API-stable. In both, RocksDB access is
hardcoded — there is no Store trait we could implement against satd's
chainstate. The literal "import as crates" approach doesn't exist.
The realistic path is to vendor specific source files from romanz/electrs (MIT licensed, with attribution and license headers preserved) for the well-tested wire protocol layer, and write the index ourselves against satd's RocksDB. Vendor-worthy files (~1500 LOC total):
electrum.rs— Electrum wire-protocol parsing + JSON-RPC method dispatch.status.rs— subscription state machine (ScriptHashStatus).merkle.rs— Electrum merkle-proof construction.types.rs— wire types.
Refactor their Index dependency from a concrete type to a small trait we own
(~4-5 methods: funding_for(scripthash), spending_for(scripthash),
txids_at(height), header_at(height), plus mempool variants).
Esplora REST handlers are a smaller protocol — no upstream borrow needed. Direct
handler implementation against the same Index trait.
Workspace structure
The code is built as library crates so binary count is a packaging decision, not an architectural one:
node-index— address-history index over RocksDB. The load-bearing crate; both protocols depend on it.electrum-proto— vendored Electrum protocol layer, depends on theIndextrait fromnode-index.esplora-handlers— Esplora REST handlers, depends on the sameIndextrait.satd(binary) — links all three library crates; the Esplora and Electrum servers are started/stopped by the runtime flags--esplora/--electrum.
Future companion binaries (sat-electrum, sat-esplora per "Future split"
above) reuse the same library crates with thin main.rs shells.
Index column-family layout
The address-history index is two RocksDB column families — addr_funding_v2 and
addr_spending_v2 — keyed by
(scripthash_prefix[16], height_be[4], txid[32], vout/vin_be[4]). See
node-index/src/keys.rs. The index is on by default (--addressindex=1); opt
out with --addressindex=0. Esplora and Electrum auto-require it. After an
AssumeUTXO fast-start, history is backfilled lazily and opt-in via
backfillindex address (and backfillindex blockfilter for the BIP 158 index);
the node remains usable with partial history.
Effort estimate (historical, for reference)
The pre-implementation estimate, recorded for posterity:
- Address-history index (
node-indexcrate): ~3-5 weeks. Column-family layout, IBD-time backfill, online maintenance on connect / disconnect, reorg correctness, mempool tracking. - Esplora REST (native,
esplora-handlerscrate): ~4-8 weeks on top of the index. - Electrum (vendored protocol code,
electrum-protocrate): ~3-5 weeks of vendoring + adaptation, parallelizable with Esplora.
Both protocols and the index landed in the timeframe estimated.
Alternatives considered and rejected
- Bundle electrs as a
sat-electrumcompanion binary. Marginal user-visible UX delta over separately-installed electrs (one install vs. two; auto-wired defaults). Does not fix the duplicate-index, parallel-block-rescan, or reorg-race problems — those are architectural, not packaging. Doesn't earn the headline. - Fork Blockstream/electrs and swap the storage layer. ~4-6 weeks Electrum-only, ~8-10 with Esplora REST kept working. Inherits Blockstream's three-DB layout, bincode rows, and Liquid feature flags. Larger surface to maintain forever; less clean conceptually than vendoring just the protocol layer.
- Full reimplementation of Electrum protocol. ~12-16 weeks. Defensible but pays the cost of re-deriving well-tested wire-protocol parsing for no gain over vendoring.
Packaging satd
This document is the authoritative reference for downstream packagers (Umbrel, Start9, RaspiBlitz, MyNode, BTCPay, Debian/Fedora/Alpine, Homebrew, Nix). It describes file layout, signals, ports, config surface, runtime model, and the contract satd offers a packager.
The user-facing operator surfaces are documented elsewhere in this manual —
see Observability & Metrics and Configuration, Tuning &
Reload. The deviation catalog vs. Bitcoin Core is in
CORE_DIFFERENCES.md.
The not-yet-shipped ecosystem/packaging direction is tracked in
ROADMAP.md.
Document status
This is PACKAGING.md v1. It covers what shipped today: the
container, Type=notify systemd unit (with EXTEND_TIMEOUT_USEC
heartbeats so reindex doesn't fight the start timeout), OpenRC and
runit unit equivalents, on-disk layout, operational surface, release
pipeline, signing across all three surfaces, reproducible build via
Nix, CycloneDX SBOMs per binary, and a cargo-deny supply-chain gate.
Updated: 2026-05-07.
Binaries
satd ships two binaries:
| Binary | Purpose |
|---|---|
satd | Daemon. Long-running process; opens RocksDB, runs P2P + RPC + optional protocol surfaces. |
sat-cli | JSON-RPC CLI client. Bitcoin Core-compatible flag shape (-rpcuser, -rpcpassword, -rpccookiefile, network selectors). |
A third binary, sat-tui, is a curses-style operator dashboard. It is
optional; packagers who don't want it can skip it.
There are deliberately no separate sat-electrum / sat-esplora
companion binaries. Both protocols are subsystems of satd itself,
gated by runtime flags (--electrum=1, --esplora=1). One process,
one RocksDB, one log stream, one PID. This is a load-bearing design
choice — see the Native Protocol Architecture
chapter.
File layout
$DATADIR/ # default: $HOME/.bitcoin (Core-compat)
└── <network>/ # one of: <empty for mainnet>, testnet3, signet, regtest
├── blocks/
│ ├── blk00000.dat # flat-file block storage (state)
│ ├── blk00001.dat
│ └── ...
├── chainstate/ # RocksDB instance (state)
│ ├── *.sst # SST files — the bulk of disk usage
│ ├── CURRENT, MANIFEST-* # RocksDB metadata
│ └── ...
├── .cookie # RPC cookie auth (auto-generated, mode 0600)
├── mempool_history.log # rolling mempool snapshot (state, derived-OK)
├── reorg.log # persistent reorg ledger (state, append-only)
├── debug.log # rotating diagnostic log (derived)
├── bitcoin.conf # optional config file (Core-compat name)
└── satd.conf # alternative config name (also accepted)
State — must be backed up to preserve consensus history:
blocks/, chainstate/, reorg.log. These are load-bearing.
Derived / safe to nuke — regenerate from blocks/ via
--reindex or --reindex-chainstate: everything inside
chainstate/ (the RocksDB instance), mempool_history.log,
debug.log, and the various *.complete index marker files inside
chainstate/.
Single-instance RocksDB. Unlike Bitcoin Core, satd does not
maintain separate LevelDB databases for the txindex, address index,
or BIP 158 filter index. They are column families inside the one
RocksDB instance, written atomically with each connect_block
batch. This means:
- Backup is simpler (one directory).
- Index updates can never be visible without the corresponding tip
update — the whole
WriteBatcheither commits or it doesn't. - An
--reindex-chainstaterebuilds everything in chainstate (UTXO + indexes) but preserves the flat files.
Process model
- One process. PID file is whatever the supervisor records; satd does not write its own PID file by default.
tokioasync runtime; many tasks but a fixed-size worker pool.rayonfor script verification (CPU-bound parallelism).- File descriptors: RocksDB keeps many SST files mmapped; budget
LimitNOFILE=65536minimum. The systemd unit and the Docker image both pre-set this.
Signals
| Signal | Behaviour |
|---|---|
SIGTERM | Clean shutdown. Flush RocksDB, fsync undo files, drain mempool snapshot, close listeners. May take up to 10 minutes under heavy IBD load — most shutdowns are sub-second. |
SIGINT | Identical to SIGTERM. |
SIGHUP | Live config reload. Re-reads bitcoin.conf and applies the hot-reloadable subset without dropping the P2P swarm or flushing chainstate. satd logs to stdout (no debug.log), so SIGHUP is repurposed from Core's log-reopen to config reload — see Configuration, Tuning & Reload. |
SIGUSR1 | Live TLS certificate reload. Re-reads the configured TLS leaf cert/key from disk and swaps it in atomically on every TLS surface, without restarting or dropping connections. |
SIGKILL | RocksDB recovers via WAL replay on next start. Avoid; one botched shutdown = one corrupted chainstate is the failure mode to design against. |
Container supervisors should set a stop grace period of at least 10
minutes (--stop-timeout=600 for docker run, terminationGracePeriodSeconds: 600
for Kubernetes). The systemd unit ships TimeoutStopSec=10min for the
same reason.
Network ports (defaults)
| Service | Mainnet | Testnet | Signet | Regtest |
|---|---|---|---|---|
| P2P | 8333 | 18333 | 38333 | 18444 |
| JSON-RPC | 8332 | 18332 | 38332 | 18443 |
Esplora REST (--esplora) | configurable, e.g. 3000 | — | — | — |
Electrum (--electrum) | configurable, e.g. 50001 | — | — | — |
Metrics + health (--metricsport) | configurable, e.g. 9332 | — | — | — |
The default RPC bind is loopback. Esplora, Electrum, and the metrics endpoint are off by default; turn them on per-deployment.
Health and readiness
When --metricsport=<port> is configured, satd exposes three
unauthenticated HTTP endpoints on that port (default bind 127.0.0.1):
| Endpoint | Meaning |
|---|---|
GET /healthz | Process is alive and the event loop is responsive. Cheap. |
GET /readyz | RocksDB is open, headers are syncing, peers > 0. Returns 503 during IBD. |
GET /metrics | Prometheus exposition format. |
These are the right surfaces to wire to a Docker HEALTHCHECK,
Kubernetes liveness/readiness probes, or a systemd ExecStartPost=
poll. The shipped Type=notify unit (see §"systemd" below) uses
sd_notify(READY=1) for startup signalling; poll-based readiness
against /readyz works equally well for non-systemd supervisors.
Configuration
Two files are accepted, both with Bitcoin Core's key=value /
[network] syntax:
bitcoin.conf— Core-compat name. Same shape, same precedence.satd.conf— preferred when running side-by-side with a Core install; identical syntax.
Resolution order: --conf=<path> if given, else <datadir>/bitcoin.conf,
else <datadir>/satd.conf. CLI flags always win over file values.
The full flag matrix is in Configuration, Tuning &
Reload. The container ships a sensible
mainnet-loopback default; everything is overridable via -e SATD_* …
see "Container" below.
Container
The repository ships a multi-stage Dockerfile at the repo root.
Build:
docker build -t satd:dev .
Properties of the image:
- Base:
debian:bookworm-slim. - Runtime user:
satd(UID/GID 2121, deliberately non-1000 to avoid bind-mount UID clash with the host operator user). - PID 1:
tini, so SIGTERM forwards to satd cleanly. - Datadir:
/var/lib/satd. Marked as aVOLUME. - Exposed ports:
8333(P2P),8332(RPC). Other ports are off by default; map them with-pper deployment.
Example mainnet run with persistent state, RPC on loopback, metrics on loopback:
docker volume create satd-data
docker run -d --name satd \
--restart unless-stopped \
--stop-timeout 600 \
-v satd-data:/var/lib/satd \
-p 8333:8333 \
-p 127.0.0.1:8332:8332 \
-p 127.0.0.1:9332:9332 \
satd:dev \
--rpcbind=0.0.0.0 --rpcallowip=127.0.0.0/8 \
--metricsport=9332 --metricsbind=0.0.0.0
CLI:
docker exec satd sat-cli getblockchaininfo
Multi-arch images. Tag-triggered releases publish linux/amd64
and linux/arm64 to ghcr.io/epochbtc/satd via the workflow at
.github/workflows/release.yml. Tags follow docker/metadata-action
defaults: <MAJOR>.<MINOR>.<PATCH>, <MAJOR>.<MINOR>, and latest
on every release.
docker pull ghcr.io/epochbtc/satd:0.1.0
docker pull ghcr.io/epochbtc/satd:latest
These images are signed with cosign keyless OIDC, attested to the Rekor transparency log (verifier command under "Signed releases" below).
systemd
The repository ships contrib/systemd/satd.service. Install:
sudo install -Dm644 contrib/systemd/satd.service /etc/systemd/system/satd.service
sudo install -Dm755 target/release/satd /usr/local/bin/satd
sudo install -Dm755 target/release/sat-cli /usr/local/bin/sat-cli
sudo useradd --system --home /var/lib/satd --shell /usr/sbin/nologin satd
sudo systemctl daemon-reload
sudo systemctl enable --now satd
The unit ships with restrictive hardening (read-only /, private /tmp,
syscall filter, no new privileges). A packager who needs to relax any
of those — for example to write to a non-/var/lib/satd datadir —
should override via a drop-in:
# /etc/systemd/system/satd.service.d/datadir.conf
[Service]
ExecStart=
ExecStart=/usr/local/bin/satd --datadir=/srv/bitcoin
ReadWritePaths=
ReadWritePaths=/srv/bitcoin
The unit is Type=notify. satd calls sd_notify(READY=1) after every
listener (RPC, P2P, optional Esplora / Electrum / MCP / events
surfaces) is bound, so dependent units (Tor onion services pointing
at the RPC port, watchtower processes, monitoring agents) start at
the right moment instead of racing the bind sequence.
Reindex resilience
--reindex-chainstate on a fully-synced mainnet node runs for hours.
satd handles this without help from the operator:
- The unit sets a finite
TimeoutStartSec=3min— deliberately notinfinity. It is large enough for the first heartbeat (at 30s) to land and push the deadline out, and small enough that a pre-heartbeat startup wedge is killed in bounded time.EXTEND_TIMEOUT_USEConly works against a finiteTimeoutStartSec; an infinite startup timeout would let a wedged process hang indefinitely beforeREADY=1. - Every 30s during the pre-bind phase, satd emits
sd_notify(EXTEND_TIMEOUT_USEC=120000000, STATUS=...). TheEXTEND_TIMEOUT_USECresets systemd's internal kill-deadline; theSTATUSline shows live phase + progress insystemctl status satd. - The heartbeat IS the liveness check. If satd goes silent for >120s (genuinely stuck, not just slow), systemd kills the unit and the on-failure restart loop kicks in.
$ systemctl status satd
● satd.service — Bitcoin full node
Loaded: loaded (/etc/systemd/system/satd.service; enabled)
Active: activating (start) since Wed 2026-05-07 18:44:19 UTC
Status: "Replaying blocks (350000/800000, 43%)"
Main PID: 12345 (satd)
This is identical behaviour to Bitcoin Core's bitcoind.service since v22.
Running multiple networks side by side
Until a satd@.service template unit lands, operators who need
signet, regtest, and mainnet on the same host can copy the unit
under different names with per-instance drop-ins:
# Mainnet — the default unit installed above (satd.service).
# Signet on the same host:
sudo cp contrib/systemd/satd.service \
/etc/systemd/system/satd-signet.service
# /etc/systemd/system/satd-signet.service.d/instance.conf
sudo install -Dm644 /dev/stdin \
/etc/systemd/system/satd-signet.service.d/instance.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/local/bin/satd --signet --datadir=/var/lib/satd-signet
StateDirectory=
StateDirectory=satd-signet
ReadWritePaths=
ReadWritePaths=/var/lib/satd-signet
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now satd-signet
Same pattern for --regtest. Each instance gets its own datadir, its
own satd-<network> user (or share the satd user — your call), and
its own RPC port (set via --rpcport=<n> in the drop-in).
A native satd@.service template unit (systemctl start satd@signet)
is a candidate for v0.1.x once we have signal that the drop-in pattern
isn't enough.
OpenRC
Alpine, Gentoo (with the openrc profile), Artix, and other distros
that use OpenRC. The repository ships contrib/openrc/init.d/satd.
sudo install -Dm755 contrib/openrc/init.d/satd /etc/init.d/satd
sudo install -Dm755 target/release/satd /usr/local/bin/satd
sudo install -Dm755 target/release/sat-cli /usr/local/bin/sat-cli
sudo adduser -S -H -h /var/lib/satd -s /sbin/nologin satd
sudo install -d -m 0750 -o satd -g satd /var/lib/satd
sudo rc-update add satd default
sudo rc-service satd start
OpenRC has no notify protocol — it considers the service "started"
once the daemon backgrounds via start-stop-daemon. That means
reindex doesn't fight any startup timeout the way it does on systemd;
the unit is just running for the entire reindex.
Per-instance config can be set via /etc/conf.d/satd:
# /etc/conf.d/satd
satd_args="--prune=550 --txindex=0"
runit
Void Linux, Artix-runit, and any s6-rc-compatible setup. The
repository ships contrib/runit/satd/run and a log helper at
contrib/runit/satd/log/run.
sudo install -Dm755 contrib/runit/satd/run /etc/sv/satd/run
sudo install -Dm755 contrib/runit/satd/log/run /etc/sv/satd/log/run
sudo install -Dm755 target/release/satd /usr/local/bin/satd
sudo install -Dm755 target/release/sat-cli /usr/local/bin/sat-cli
sudo useradd --system --home /var/lib/satd --shell /sbin/nologin satd
sudo install -d -m 0750 -o satd -g satd /var/lib/satd
sudo ln -s /etc/sv/satd /var/service/satd
runit supervises foreground processes; no daemonization, no readiness gate, no timeouts to fight with. Reindex runs as long as it needs to.
Resource budget
Mainnet, fresh IBD, no optional indexes:
| Resource | Pi 5 (8 GB) target | Server target |
|---|---|---|
| Disk (chainstate + blocks) | ~700 GB at 2026-05 tip | same |
| RAM peak during IBD | ~3 GB | unbounded by dbcache |
| RAM steady-state | ~1.5 GB | ~2 GB |
| CPU during IBD | 4 cores ≈ saturated | scales with cores |
| Network during IBD | 50–200 Mbps | network-bound |
Optional indexes (--txindex, --addressindex, --blockfilterindex)
each add disk and a one-time backfill cost. Turning them on after the
fact runs an online backfill — there is no "stop, reindex from scratch"
ceremony; see node/src/index/<index>/backfill.rs for the cursors.
Pruning
--prune=<MiB> works the same shape as Bitcoin Core. Minimum 550 MiB.
Indexes that scan historical blocks (--txindex, --addressindex,
--blockfilterindex) require unpruned blocks. satd refuses to start
with a conflicting combination — same shape as Core.
Reproducible build via Nix
The repo ships a Nix flake at flake.nix that produces deterministic
satd and sat-cli binaries on x86_64-linux and aarch64-linux.
Quickstart for a packager who already has Nix with flakes enabled:
# Build (produces ./result/bin/{satd, sat-cli})
nix build .#satd
# Hash the built binaries
sha256sum result/bin/satd result/bin/sat-cli
# Drop into a dev shell with the full toolchain (clang, libclang,
# cmake, openssl, rustc, cargo, rustfmt, clippy, cargo-watch,
# cargo-nextest)
nix develop
The toolchain pin (rust-toolchain.toml at the repo root) is the
single source of truth — both rustup and the flake read it.
What "reproducible" means in v1
- Two
nix buildinvocations of the same commit on two hosts produce byte-identicalresult/bin/satd. CI proves this on every PR that touchesflake.nix/flake.lock/rust-toolchain.toml/Cargo.lockvia.github/workflows/nix.yml(a two-runner pair build + a third compare job that asserts SHA256 equality). - Local repro is one command:
contrib/repro/diff-build.sh /path/to/clone-A /path/to/clone-B. Runsnix buildin each, hashes the outputs, and falls back todiffoscopewhen they diverge. - Out of scope for v1: matching the rustup-stable tarball binary
(the one
.github/workflows/release.ymlships) byte-for-byte. That requires aligning linker / debug-info / build-id behaviour between two different build drivers; tractable but a separate PR.
Determinism hazards addressed
| Hazard | How the flake handles it |
|---|---|
rocksdb-sys bindgen output | rustPlatform.bindgenHook sets up libclang + the stdenv's system include paths so bindgen's translation-unit parse is reproducible. Output is deterministic for a fixed libclang version. |
| RocksDB native code | We use nixpkgs's pre-built rocksdb (via ROCKSDB_LIB_DIR / ROCKSDB_INCLUDE_DIR) rather than the librocksdb-sys-vendored C++ tree. nixpkgs builds rocksdb portably (no -march=native), so cross-runner CPU variance is a non-issue. The tradeoff is a minor version mismatch between librocksdb-sys's pinned 10.4.2 and whatever nixpkgs ships (regenerated bindings either way; major API drift would surface as a compile error). |
cc-rs C/C++ compiles (secp256k1, bitcoinconsensus) | Compiler version pinned via nixpkgs; SOURCE_DATE_EPOCH respected by cc-rs for any timestamped output. |
OUT_DIR paths in generated code | crane builds inside a content-addressed /build/source; paths are stable across hosts. |
| Linker build-id | RUSTFLAGS=-C link-arg=-Wl,--build-id=none drops the per-build random ID. |
Cargo --release profile | CARGO_PROFILE_RELEASE_STRIP=symbols strips deterministically inside the derivation. |
tonic_build / proto generation | events/proto/*.proto files included in the source filter; protoc is vendored via protoc-bin-vendored so no host protoc dep. |
Gating policy
The Nix workflow runs on tag pushes (v*), workflow_dispatch,
and PRs that change flake-specific files (the flake itself,
rust-toolchain.toml, the workflow, the repro helper under
contrib/repro/). It deliberately does not trigger on
Cargo.lock / Cargo.toml edits — every dependency bump touches
those and burns hosted-runner minutes for low-signal runs.
The Nix and Release workflows fire in parallel at tag-cut time and don't gate each other; a Nix-side failure means the released tarball can't claim Nix-rebuilt provenance for that tag and should be fix-forwarded.
Reconsider both the trigger scope (broader PR-trigger) and a hard
Release-gates-on-Nix dependency once the repo flips public (Actions
minutes free).
flake.lock
The first PR that lands the flake intentionally does not commit
flake.lock because the maintainer who lands it does not have Nix
on their workstation. The CI workflow is gated to workflow_dispatch
- flake-touching PRs + tag pushes; the first
workflow_dispatchrun by a Nix-capable maintainer (or from a CI runner) will generate the lock, after which it should be committed and the PR description updated. Subsequent PRs run against the committed lock.
Renovate (or a manual cadence) bumps the lock weekly. Bumps that
change flake.lock re-trigger the repro check; if reproducibility
breaks under a new input revision, the bump is reverted and the
hazard is investigated.
What's intentionally not in this flake
- macOS reproducibility (
aarch64-darwin) — deferred until the repo flips public; macOS builds in the release workflow are also currently disabled for the same reason. - musl targets — same
rocksdb-sys + muslcross-toolchain reason the release workflow defers them. - A NixOS module / Home Manager output — packagers should write their own service definitions; the contract this doc describes is the input.
- A maintainer-owned binary cache (Cachix) — adds a key-custody
surface we're not taking on yet. The CI uses the ephemeral
magic-nix-cacheaction for speedup only.
Bitcoin Core uses Guix; satd targets Nix as the primary reproducible build because the workspace is pure-Cargo and Nix integration is substantially shorter to specify. A Guix manifest may follow if a downstream packager needs it.
Release artifacts
Tag-triggered (v*) releases produce, per tag, via
.github/workflows/release.yml running on hosted GitHub runners:
-
satd-<version>-<target>.tar.zstfor the targets currently shipped:x86_64-unknown-linux-gnuaarch64-unknown-linux-gnux86_64-unknown-linux-musl(statically-linked musl)aarch64-unknown-linux-musl(statically-linked musl)aarch64-apple-darwin(macOS Apple Silicon)
x86_64-apple-darwinis not built in the standard release matrix — macos-13 is being deprecated by GitHub and Apple Silicon is the targeted macOS surface. Operators who need x86_64 darwin can cross-compile from an arm64 darwin host (cargo build --release --target=x86_64-apple-darwin).Each tarball contains stripped
satd+sat-clibinaries and the authoritative reference docs (README.md,PACKAGING.md,CORE_DIFFERENCES.md,STABILITY_POLICY.md), plus aMANIFESTfile pinning the build commit, target triple, Rust toolchain version, and build timestamp. -
A per-tarball
*.sha256file alongside each artifact, plus an aggregateSHA256SUMScovering tarballs and SBOMs in the release. -
A multi-arch container at
ghcr.io/epochbtc/satd:<version>coveringlinux/amd64+linux/arm64. -
CycloneDX 1.5 JSON SBOMs for each shipped binary:
satd-v<version>.cdx.jsonsat-cli-v<version>.cdx.json
Each ships with a
*.sha256next to it (already inSHA256SUMS) and a*.minisigproduced by the same maintainer-sidecontrib/release/sign-tarballs.shflow that signs the tarballs.
The release workflow is triggered on tag pushes (v*) and manual triggers (workflow_dispatch), building the full set of binary, container, and SBOM artifacts in parallel.
Signed releases
Three independent signing surfaces. Verifier commands and key custody
details live in SECURITY.md.
-
Tarballs — minisign Ed25519. Each
.tar.zstships with a detached.minisig. Pubkeys (primary + cold spare) inSECURITY.md. Maintainer signs offline with passphrases gated by 1Password + YubiKey 2FA — the signing key is never present in CI. Maintainer runbook:contrib/release/sign-tarballs.sh <tag>. -
Container image — cosign keyless OIDC. No signing key in custody. The merge-manifest CI job mints a short-lived cert from GitHub Actions OIDC and the attestation is logged to Rekor. Verify with:
cosign verify ghcr.io/epochbtc/satd:<version> \ --certificate-identity-regexp \ 'https://github.com/epochbtc/satd/.github/workflows/release.yml@refs/tags/v.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com -
Git tags — SSH signatures. Annotated tags are signed by the maintainer's SSH key. Source-of-truth for the trusted pubkey set is
https://github.com/bkeroack.keys(delegating to GitHub avoids a stale pinned file as machines rotate). Verify with the bundled helper:contrib/release/verify-tag.sh v0.1.0
Software Bill of Materials
Each release ships a CycloneDX 1.5 JSON SBOM per binary:
# Authenticate the SBOM (same key + recipe as the tarballs)
minisign -Vm satd-v0.1.0.cdx.json \
-P RWQeP6MczCgPh6tU03GEMm4HsnGbXte3VT2Bc52TBSR7Q+X7WnL5vfQ3
# Enumerate dependencies — name, version, license
jq -r '.components[] | "\(.name) \(.version) \(.licenses[0].license.id // .licenses[0].license.name // "?")"' \
satd-v0.1.0.cdx.json | sort
The SBOM is generated from the same Cargo.lock that produced the
released binary; the cargo cyclonedx invocation lives in the sbom
job in .github/workflows/release.yml. The dep graph is identical
across the gnu-linux release targets currently shipped
(x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu), so a
single SBOM per binary covers both tarballs.
If a future release adds musl or macOS targets — which can resolve
different platform-specific deps (e.g. libc shim crates,
security-framework on darwin) — the workflow will need to emit a
per-target SBOM and the artifact filenames will gain a target-triple
suffix. Track this when re-enabling the deferred targets in the
release matrix.
Supply-chain policy
deny.toml at the repo root encodes the supply-chain policy enforced
by cargo-deny:
- Advisories — every RustSec advisory against any dep in the
workspace fails CI by default. Exceptions are documented in
[advisories.ignore]with areasonfield naming the rationale. - Licenses — permissive only (MIT / Apache-2.0 / BSD / ISC / Unicode / CC0 / Zlib / Unlicense / MPL-2.0 family). GPL-* and AGPL-* are denied implicitly.
- Bans — wildcards on crates.io deps are denied; workspace-internal
path = "../foo"deps are allowed viaallow-wildcard-pathsbecause every workspace crate ispublish = false. - Sources — only
https://github.com/rust-lang/crates.io-index. Git deps require an explicit allowlist entry.
The policy runs as a hard gate in two places:
.github/workflows/deny.yml— every PR that touchesCargo.toml,Cargo.lock,deny.toml, or the workflow itself.- The
supply-chain-gatejob inside.github/workflows/release.yml— every release artifact (tarballs, SBOMs, container)needs:it. A new RustSec advisory landed during a quiet period between merges cannot ship a release.
Known deferred items
cargo-auditable— Embedding the dependency manifest directly into the compiled binaries for improved runtime supply-chain verification.
Stability contract
Shipped surfaces (RPC method shapes, CLI flag shape, bitcoin.conf
syntax, file layout described above) are governed by
STABILITY_POLICY.md. Tier 1 (Core-compat) is the strongest: a
breaking change requires a deliberate, scoped proposal with a
demonstrated migration story for downstreams.
Packaging contacts
If you are packaging satd for an ecosystem (Umbrel, Start9, Debian,
Nix, Homebrew, etc.) and need a contract change, file an issue tagged
packaging against the epochbtc/satd repo. We treat packaging
breakage as a P1.
Versioning
This document is versioned alongside satd. Changes that shift the contract (file layout, signals, default ports) are called out in the release notes for the version that ships them.
| Version | Notable changes |
|---|---|
| 0.1.0 (current) | Initial PACKAGING.md. Dockerfile + systemd unit shipped. Tag-triggered release workflow on hosted runners produces tarballs (gnu-linux + Apple Silicon) and a multi-arch GHCR image. Signing across all three surfaces (minisign tarballs, cosign keyless image, SSH-signed tags) shipped. Nix flake (flake.nix) shipped for reproducible builds with two-runner CI verification (x86_64-linux, aarch64-linux). CycloneDX 1.5 SBOMs per binary + cargo-deny supply-chain gate (PR-time on dep-graph PRs, hard gate at tag time) shipped. systemd unit upgraded to Type=notify with sd_notify heartbeats so --reindex-chainstate doesn't fight TimeoutStartSec; OpenRC and runit unit equivalents shipped. |
Configuration Flag Reference
This chapter is the complete reference for every configuration key satd
recognizes — what it does, its default, whether it reloads live on SIGHUP, and
whether it is Bitcoin Core-compatible or a satd extension.
For how configuration is sourced and the live-reload mechanics, see
Configuration, Tuning & Reload. This chapter is the flat
per-key index. The auth-related keys (authfile, *authbearer/*auth,
*allowremote, cookie/rpcuser/rpcauth) are explained in context in
Authentication & Authorization; the sync / consensus /
storage-tuning keys (assumevalid, consensus, shadow*, dbcache,
prefetchworkers, maxahead, storageprofile, the rocksdb* / compaction*
family, reindex) in Initial Block Download & Fast Sync.
How satd reads configuration
Goal: drop in your existing Bitcoin Core bitcoin.conf and have it just
work. satd reads Core's configuration surface directly — same
bitcoin.conf / satd.conf key=value + [network] section syntax and the
same CLI flag names (-datadir, -rpcport, …). Supported-flag names and
semantics track Bitcoin Core v30.
- Resolution order:
-conf=<path>if given, else<datadir>/bitcoin.conf, else<datadir>/satd.conf. CLI flags always win over file values. - What happens to each config-file key (the four-way disposition that makes
drop-in safe):
- Honored — satd implements it. The common operator surface.
- Skipped with a warning — a recognized Core v30 option satd doesn't
implement but is safe to skip. The node still starts; a
WARNline names the ignored key (and the satd equivalent, if any). This is what lets a realbitcoin.confboot unedited. - Rejected at load — a small set where silently skipping would mislead you about the node's security / exposure / privacy posture (see Unsupported Core keys). Fail-closed with guidance.
- Rejected as a typo — a key that is neither a satd option nor a known
Core v30 option. Rejected so a fat-fingered security option (e.g.
rpcusser=) can't silently disable auth.
- Never silently ignored. Skipped keys always warn; nothing a config asks for is dropped without the operator being told.
-profile=<preset>seeds a hardware/role profile (archival,pruned-home,mining,regtest-dev,signet-watchtower); explicit flags override the profile's values.
Compatibility is pinned to Bitcoin Core v30 — and only v30. The drop-in target is a frozen, verifiable surface, not "whatever Core ships next." Keys Core adds in v31 or later (e.g.
limitclustercount,limitclustersize,privatebroadcast,txospenderindex) are not recognized and are rejected as typos until this pin is deliberately bumped. Keys Core removed at or before v30 (e.g.upnp,maxorphantx) are likewise not honored. If you migrate abitcoin.conffrom a newer Core, a v31+ key will fail to start satd with an "unknown key" error — that is intentional, not a bug.
Building on satd? Don't poll or shell-hook — stream. This reference is for operating the node. If you are writing software that consumes node state (blocks, mempool, address activity, reorgs), the supported integration path is the Streaming Consumption API (gRPC / WebSocket / ZMQ): reorg-safe, durable cursor replay, decoupled from consensus. The Core
*notifyshell hooks and ad-hoc RPC polling are provided for compatibility and quick scripts only — they have no delivery guarantee, no replay, and no reorg awareness.
Legend
- Reload —
hot: applied live onSIGHUP(systemctl reload satd).restart: wired into long-lived state at startup; reported as "restart required" on reload, never silently ignored. (TLS certificate contents reload viaSIGUSR1even where the key isrestart— see Live TLS Certificate Reload.) - Compat —
core: same key name and substantially the same semantics as Bitcoin Core.satd: a satd-specific extension (no Core equivalent, or satd-only semantics). Best-effort classification; a key "modeled on" Core behavior but without a Core flag of the same name issatd.
Every key listed in the per-category tables below is honored (disposition #1 — satd implements it). Recognized Core v30 keys satd does not honor are not in these tables; they are enumerated, with their warn-and-skip or fail-closed disposition, under Unsupported Core keys: skipped vs rejected. So: in a table here ⇒ supported; in the unsupported-keys section ⇒ warn-and-continue (or rejected); in neither ⇒ rejected as a typo.
Network selection
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
regtest | off | restart | core | Use the regtest network. |
testnet | off | restart | core | Use the testnet network. |
testnet4 | off | restart | core | Use the testnet4 network. |
signet | off | restart | core | Use the signet network. |
chain | main | restart | core | Unified network selector: main|test|signet|regtest|testnet4. Alternative to the per-net flags. |
The bare selectors (signet=1, testnet4=1, …) and chain= are honored both on
the command line and in bitcoin.conf (Bitcoin Core parity). CLI selectors take
precedence over the config file. Selecting more than one network — two bare
selectors, or a chain= that disagrees with a bare selector — is a startup
error rather than a silent pick.
Filesystem
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
datadir | platform default | restart | core | Data directory. |
blocksdir | <datadir>/blocks | restart | core | Alternative location for blocks/ and flat-file undo data. |
conf | bitcoin.conf in datadir | restart | core | Config file path. |
includeconf | none | restart | core | Additional config file to splice in; honored only inside a config file. |
pid | none | restart | core | Write PID to file. |
profile | none | restart | satd | Named preset: archival|pruned-home|mining|regtest-dev|signet-watchtower; CLI flags override it. |
Daemon control
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
daemon | off | restart | core | Run in background; accepted for compatibility (no-op — use systemd). |
server | on | restart | core | Accept RPC commands; accepted for compatibility (always on). |
logformat | text | restart | satd | Log output format: text or json. Only verbosity hot-reloads, not the format. |
logtimestamps | on | restart | core | Prepend a timestamp to each log line. Disable (-nologtimestamps) when journald / the container runtime already stamps lines. |
logthreadnames | off | restart | core | Prepend the originating thread name to each log line. |
logsourcelocations | off | restart | core | Prepend source file:line to each log line. |
debug | none | hot | core | Enable debug logging for a category (repeatable; bare/all/1 = everything). |
debugexclude | none | hot | core | Disable debug logging for a category debug would otherwise enable. |
loglevel | info | hot | core | Global verbosity (trace/debug/info/warn/error) or a per-category override (net:debug). Maps onto satd's tracing filter: a bare level sets the default for targets not already overridden — it does not lower a more specific -debug/RUST_LOG directive (so -debug=net -loglevel=error still logs net at debug). A category:level pair overrides that subsystem. |
allowignoredconf | off | restart | core | Suppress startup warnings about includeconf files satd had to ignore. |
maxshutdownsecs | 30 | hot | satd | Max graceful-shutdown flush duration (seconds) before force exit. |
RPC server
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpcport | 8332 (network-dependent) | restart | core | RPC server port. Defaults: main 8332, test 18332, testnet4 48332, signet 38332, regtest 18443. |
rpcbind | 127.0.0.1:<rpcport> | restart | core | Bind plain-HTTP JSON-RPC to address (repeatable). Non-loopback requires rpcallowip. |
rpcallowip | loopback only | restart | core | Per-request source-IP allowlist for JSON-RPC (repeatable). |
rpcuser | none | hot | core | RPC username. |
rpcpassword | none | hot | core | RPC password. |
rpcthreads | 16 | restart | core | Max concurrent in-flight RPC method calls. |
rpcworkqueue | 64 | restart | core | Max queued RPC requests beyond rpcthreads before HTTP 429 (Core returns 503 — documented divergence). |
apithreads | max(2, cores/4) | restart | satd | Worker threads for the isolated API runtime (Esplora/Electrum/events gRPC/metrics). |
rpcreadonlybind | none | restart | satd | Bind an opt-in read-only JSON-RPC listener (reads + mempool submit) on the API runtime. |
rpcreadonlyport | 8330 | restart | satd | Default port for rpcreadonlybind entries without an explicit port. |
rpcreadonlyallowip | loopback only | restart | satd | Source-IP allowlist for the read-only listener. |
rpcreadonlythreads | = rpcthreads | restart | satd | Max in-flight calls on the read-only listener. |
rpcreadonlyworkqueue | = rpcworkqueue | restart | satd | Read-only listener work-queue depth before HTTP 429. |
rpcreadonlytlsbind | none | restart | satd | TLS bind for the read-only listener (requires cert+key). |
rpcreadonlytlscert | none | restart | satd | PEM certificate (chain) for the read-only TLS listener. |
rpcreadonlytlskey | none | restart | satd | PEM private key for the read-only TLS listener. |
rpcreadonlymtls | false | restart | satd | Require a client cert (mTLS) on the read-only TLS surface. |
rpcreadonlymtlsclientca | none | restart | satd | CA bundle client certs must chain to on the read-only TLS surface. |
rpcreadonlymtlsclientallow | any CA-signed | restart | satd | Allowlist of client-cert subjects on the read-only TLS surface. |
rpcauth | none | hot | core | HMAC-SHA256 RPC credential user:salt$hash (Core rpcauth format; repeatable). |
authfile | none | restart | satd | Path to unified-auth bearer-token file (TOML); enables the opt-in bearer-auth layer. Token contents reload live. |
rpcauthbearer | false | restart | satd | Honor Authorization: Bearer tokens on the JSON-RPC listeners (requires authfile). |
rpccookiefile | $DATADIR/.cookie | restart | core | Override the auto-generated cookie file path. |
rpccookieperms | owner (0600) | restart | core | Cookie file permissions: owner(0600)|group(0640)|all(0644). |
rpcdefaultunits | btc | hot | satd | Default units for RPC amount fields: btc (Core-compatible) or sats. |
rpcdisableauth | false | restart | satd | Disable HTTP Basic auth on the JSON-RPC TLS surface; only valid with rpcmtls=1. |
rpcextendederrors | off | hot | satd | Emit structured error payloads (category/suggestion/debug) on RPC errors. |
RPC TLS
(satd-specific — Core's RPC is HTTP-only behind a TLS-terminating sidecar.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpctlsbind | none | restart | satd | Bind the JSON-RPC TLS listener (requires cert+key). |
rpctlscert | none | restart | satd | PEM TLS certificate for the JSON-RPC server. |
rpctlskey | none | restart | satd | PEM TLS private key for the JSON-RPC server. |
rpctlshandshaketimeout | 10 | restart | satd | Per-handshake timeout (seconds) for the JSON-RPC TLS surface. |
rpcmtls | false | restart | satd | Require mutual TLS on the JSON-RPC TLS listener. |
rpcmtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when rpcmtls=1. |
rpcmtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
P2P
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
listen | on | restart | core | Accept P2P connections. |
networkactive | on | hot | core | Start with P2P networking enabled. =0 boots with networking paused (no inbound accepts, no outbound dials); toggle at runtime with the setnetworkactive RPC. |
blocksonly | false | hot | core | Suppress P2P transaction relay; locally-submitted txs still relayed. |
v2transport | true | hot | core | Offer/accept BIP 324 v2 encrypted transport (Core default since v26). |
v2only | false | hot | satd | Refuse peers that do not speak BIP 324 v2 (privacy / anti-surveillance lever). |
externalip | none | hot | core | External address to advertise to peers (repeatable). |
whitelist | none | hot | core | Grant net permissions to peers by source subnet (repeatable). |
whitelistrelay | on | hot | core | Grant relay to whitelisted peers with default permissions (relay their txes even under -blocksonly). Entries with an explicit perms@ prefix are unaffected. |
whitelistforcerelay | off | hot | core | Grant forcerelay to whitelisted peers with default permissions. Entries with an explicit perms@ prefix are unaffected. |
whitebind | none | restart | core | Bind an extra permissioned P2P listener (repeatable). |
asmap | none | restart | core | asmap file for ASN-based addrman bucketing (eclipse resistance). |
port | network default | restart | core | P2P listen port. |
bind | 0.0.0.0 | restart | core | Bind P2P to this address. |
connect | none | hot | core | Connect only to specific peer(s) (repeatable). Connect-only exclusivity is a startup decision (restart to change). |
addnode | none | hot | core | Add a node to connect to (does not disable DNS seeding). |
seednode | none | hot | core | One-shot seed peer connected at startup to bootstrap discovery. |
maxconnections | 125 | hot | core | Maximum total connections. |
maxinboundperip | 3 | hot | satd | Max simultaneous inbound peers from one source IP (Core-style flood guard; no Core flag). |
maxuploadtarget | 0 (unlimited) | hot | core | Soft cap (bytes/24h) on historical block upload. |
dns | true | restart | core | Allow DNS lookups for -addnode/-seednode/-connect. |
dnsseed | true | restart | core | Query DNS seeds for peer addresses (requires dns). |
forcednsseed | false | restart | core | Always query DNS seeds even with a populated address book. |
fixedseeds | true | restart | core | Allow the compiled-in fixed-seed fallback. |
bantime | 86400 | hot | core | Ban duration in seconds. |
timeout | 5000 ms | hot | core | P2P connection timeout in milliseconds (accepts 5s/5000ms). |
onlynet | all | restart | core | Restrict to network types: ipv4, ipv6, onion. |
signetseednode | built-in seeds | restart | core | Additional signet seed node (repeatable; signet only). |
signetchallenge | default signet | restart | core | Custom signet challenge script, hex (BIP 325; signet only). |
BIP35
mempoolrequests. satd answers a peer'smempoolmessage (which asks us to announce our entire mempool) only for peers granted themempoolnet permission —-whitelist=mempool@<subnet>,all@<subnet>, or a bare-whitelist=<subnet>entry (whose implicit permission set includesmempool, as in Core). It is not implied bynoban@. The response honors the requesting peer's fee filter, and dumps to one peer are rate-limited (at most one per 30 s). satd does not advertiseNODE_BLOOM(BIP37 bloom filters are unsupported);mempoolrequests from peers without the permission are ignored — softer than Bitcoin Core with bloom disabled, which disconnects such peers unless they havenoban.
Proxy / Tor
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
proxy | none | restart | core | SOCKS5 proxy for all outbound connections. |
proxyrandomize | on | restart | core | Use fresh random SOCKS5 credentials per connection so Tor isolates each peer on its own circuit (IsolateSOCKSAuth). Relies on Tor's default SocksPort isolation; a no-op on a non-Tor SOCKS proxy (or one with IsolateSOCKSAuth disabled), where credentials are simply not negotiated. Set =0 to opt out. |
onion | = -proxy | restart | core | SOCKS5 proxy for .onion connections. |
torcontrol | 127.0.0.1:9051 | restart | core | Tor control port for the hidden service. Auth is negotiated via PROTOCOLINFO: SAFECOOKIE (stock-Tor default) when no password is set, else password, else null. |
torpassword | none | restart | core | Tor control port password (for a HashedControlPassword setup). Leave unset to use SAFECOOKIE cookie auth. |
listenonion | off (on if torcontrol set) | restart | core | Create a Tor v3 hidden service via the control port. |
Consensus
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
assumevalid | per-network hash | restart | core | Skip script verification up to HASH (0=verify all, all=skip old blocks). |
assumevalidage | 86400 | restart | satd | With assumevalid=all, still verify scripts for blocks newer than SECS. |
checkpoints | on | restart | core | Enforce the built-in block checkpoints. -checkpoints=0 disables checkpoint validation. |
stopatheight | none | restart | core | Stop once the active-chain tip reaches HEIGHT. |
consensus | rust-shadow | restart | satd | Consensus engine: cpp|rust|rust-shadow|cpp-shadow. |
Indexing
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
txindex | off | restart | core | Maintain a full transaction index. |
addressindex | on | restart | satd | Maintain an address-history index (backs native Electrum/Esplora). |
addrindexsubscriptions | 10000 | hot | satd | Max concurrent per-scripthash status subscriptions. |
blockfilterindex | off | restart | core | BIP 158 compact-block-filter index (basic/0/1). |
peerblockfilters | off | hot | core | Advertise NODE_COMPACT_FILTERS and serve BIP 157 filters; implies blockfilterindex=basic. |
Mempool / relay policy
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
mempoolfullrbf | on | hot | satd | Enable full replace-by-fee. Core removed this flag in v28 (full-RBF is now unconditional there); satd retains it as a toggle. |
maxmempool | 300 MB | hot | core | Maximum mempool size in MB. |
minrelaytxfee | 1000 sat/kvB | hot | core | Minimum relay fee rate. |
dustrelayfee | 3000 sat/kvB | hot | core | Dust relay fee rate. |
datacarrier | on | hot | core | Accept OP_RETURN outputs. |
datacarriersize | 83 bytes | hot | core | Maximum OP_RETURN size in bytes (0 = reject all). |
limitancestorcount | 25 | hot | core | Maximum unconfirmed ancestor count. |
limitdescendantcount | 25 | hot | core | Maximum unconfirmed descendant count. |
mempoolexpiry | 336 h | hot | core | Mempool entry expiry in hours. |
persistmempool | on | hot | core | Persist the mempool to mempool.dat across restarts. |
rebroadcastinterval | 0 (auto) | hot | satd | Seconds between rebroadcasts of unconfirmed local transactions (those submitted here via sendrawtransaction, the MCP tool, Esplora POST /tx, or Electrum transaction.broadcast). 0 = auto: a randomized 10–15 min interval per pass, matching Bitcoin Core. A locally-submitted tx is re-announced until enough peers take it (see broadcastconfirmpeers) or it leaves the mempool, so it still propagates if no peer was connected at submit time; the pending set is persisted in mempool.dat so it also survives restarts. A SIGHUP interval change applies after the in-flight sleep completes. |
broadcastconfirmpeers | 1 | hot | satd | Distinct peer IPs that must take a locally-broadcast tx — fetch it from us via getdata (primary signal) or announce it back via inv — before it is considered propagated and rebroadcast stops. Counted per IP, not per connection, so a reconnecting host is one witness. Raising it demands wider observed propagation before giving up retries. |
permitbaremultisig | on | hot | core | Allow bare multisig outputs. |
acceptnonstdtxn | off | hot | core | Relay and accept non-standard transactions (bypass the standardness relay checks — oversize, dust, OP_RETURN/datacarrier, non-standard scripts). Consensus rules are never relaxed. Intended for test/dev networks. |
Esplora
(satd-specific — native Esplora REST server. See Esplora REST API.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
esplora | on | restart | satd | Run the native Esplora REST server (requires addressindex=1). |
esplorabind | 127.0.0.1:3000 | restart | satd | Bind the Esplora REST listener. |
esploratlsbind | none | restart | satd | Bind the Esplora TLS listener (requires cert+key). |
esploratlscert | none | restart | satd | PEM TLS certificate for the Esplora server. |
esploratlskey | none | restart | satd | PEM TLS private key for the Esplora server. |
esploramtls | false | restart | satd | Require mutual TLS on the Esplora TLS listener. |
esploramtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when esploramtls=1. |
esploramtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
esploraprefix | / | restart | satd | URL prefix to mount the API under (/api for blockstream-style). |
esploracors | none | restart | satd | Allowed CORS origin (repeatable). |
esplorarequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). |
esploramaxconns | 256 | restart | satd | Hard cap on concurrent in-flight Esplora requests. |
esplorasseconns | = esploramaxconns | restart | satd | Hard cap on simultaneously-open SSE streams (0 disables SSE). |
esploraauth | none | restart | satd | Esplora auth mode: none|cookie|userpass. |
esploraauthbearer | false | restart | satd | Honor bearer tokens (esplora:read) on the Esplora server (requires authfile). |
esploracookiefile | shared .cookie | restart | satd | Cookie file when esploraauth=cookie. |
esplorauserpass | none | restart | satd | Static user:pass when esploraauth=userpass. |
Electrum
(satd-specific — native Electrum protocol server.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
electrum | off | restart | satd | Run the native Electrum protocol server (requires addressindex=1 and txindex=1). |
electrumbind | 127.0.0.1:50001 | restart | satd | Bind the Electrum plain-TCP listener. |
electrumtlsbind | none (std port 50002) | restart | satd | Bind the Electrum TLS listener (requires cert+key). |
electrumtlscert | none | restart | satd | PEM TLS certificate for the Electrum server. |
electrumtlskey | none | restart | satd | PEM TLS private key for the Electrum server. |
electrummtls | false | restart | satd | Require mutual TLS on the Electrum TLS listener. |
electrummtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when electrummtls=1. |
electrummtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
electrummaxconns | 64 | restart | satd | Hard cap on simultaneously-open Electrum connections. |
electrummaxsubsperconn | 1000 | restart | satd | Per-connection scripthash subscription cap. |
electrumrequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). |
electrummaxbatchrequests | 100 | restart | satd | Max requests per JSON-RPC batch line. Wallets (Sparrow) batch their whole gap-limit window of subscribes at scan time. |
electrummaxbroadcastpackagetxs | 25 | restart | satd | Max txs per blockchain.transaction.broadcast_package. |
electrumfeehistogramttl | 10 | restart | satd | TTL (seconds) for the mempool.get_fee_histogram cache. |
electrumbanner | powered by satd <ver> | restart | satd | Override for server.banner. |
Storage / pruning / reindex
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
prune | 0 (no pruning) | restart | core | Prune block data to target size in MB. |
reindex | off | restart | core | Rebuild block index and chain state from block files on disk. |
reindexchainstate | off | restart | core | Rebuild the UTXO set from existing block files (Core -reindex-chainstate). |
checkblockindex | off (on for regtest) | restart | core | Audit block-index / active-chain consistency at startup (Core -checkblockindex). |
dbcache | 450 MB (or auto) | restart | core | Total write-cache size in MB, or auto for adaptive sizing. |
storageprofile | ssd | restart | satd | Storage class for chainstate tuning: ssd or hdd. |
prefetchworkers | CPU cores | restart | satd | Number of IBD prefetch worker threads. |
maxahead | 50000 | restart | satd | Max blocks ahead during IBD: number, N%, or all. |
maxopenfiles | 2048 | restart | satd | RocksDB max_open_files cap; -1 = unlimited. |
rocksdbbackgroundjobs | from storageprofile | restart | satd | Override RocksDB max_background_jobs (advanced). |
rocksdbsubcompactions | from storageprofile | restart | satd | Override RocksDB max_subcompactions (advanced). |
rocksdbwalmb | from storageprofile | restart | satd | Override RocksDB max_total_wal_size in MB (advanced). |
compactiondiagintervalsecs | 60 (0 disables) | restart | satd | Per-CF pending-compaction diagnostic log interval. |
compactionintervalsecs | 1800 (0 disables) | restart | satd | Periodic forced-compaction interval in seconds. |
compactionl0at | 16 | restart | satd | Force chainstate compaction when L0 SST count ≥ N. |
ibdl0pauseat | 64 (0 disables) | restart | satd | Pause the IBD connector when chainstate L0 SST count ≥ N. |
stallwatchdogsecs | 300 (0 disables) | restart | satd | Stall-watchdog forensic-dump threshold (seconds without tip advance). |
stallabortsecs | 300 | restart | satd | Additional grace after the forensics dump before abort(). |
shadowqueuesize | 4194304 | restart | satd | Shadow-verification queue capacity. |
shadowworkers | 4 | restart | satd | Shadow-verification worker threads. |
Mining
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blockmaxweight | 4000000 | restart | core | Maximum block weight for templates. |
blockmintxfee | 1000 sat/kvB | restart | core | Minimum tx fee for the block template. |
par | — | restart | core | Script-verification threads; accepted for compatibility (no-op). |
Events
(satd-specific event bus. The eventszmq* spelling is satd's — Core uses
per-topic -zmqpub*=<addr> flags — but the hashtx/hashblock payloads are
Core ZMQ wire-format compatible.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
eventsnodeid | auto (persisted to <datadir>/node_id) | restart | satd | Stable per-node identifier (32-char hex) stamped on events envelopes. |
eventsregion | none | restart | satd | Optional region tag (≤8 ASCII bytes) on events envelopes. |
eventsgrpcbind | off | restart | satd | host:port to bind the events gRPC streaming server. |
eventsgrpcallowremote | false | restart | satd | Permit eventsgrpcbind on a non-loopback address (requires eventsgrpcauth). |
eventsgrpcauth | false | restart | satd | Require bearer tokens (stream:subscribe) on events gRPC (requires authfile). |
eventsgrpcmaxconns | 64 (0 disables) | restart | satd | Hard cap on simultaneously-open events gRPC connections. |
eventsgrpcmaxsubscriptions | 256 (0 disables) | restart | satd | Hard cap on concurrent events gRPC Subscribe streams. |
streamws | off | restart | satd | host:port for the streaming JSON-over-WebSocket + SSE transport (/ws + /sse). |
streamwsallowremote | false | restart | satd | Permit streamws on a non-loopback address (requires streamwsauth). |
streamwsauth | false | restart | satd | Require bearer tokens (stream:subscribe) on streamws (requires authfile). |
streamwsmaxconns | 256 | restart | satd | Hard cap on simultaneously-open streamws connections. |
streamwsmaxsubscriptions | 256 | restart | satd | Hard cap on watch-set entries per streamws connection. |
streamwsmaxmessagebytes | 262144 | restart | satd | Cap on a single inbound WebSocket message/frame in bytes. |
streammaxresyncblocks | 10000 (0 disables) | restart | satd | Max blocks the watch matcher re-scans in one catch-up after lagging. |
streamprefixminbits | 8 | restart | satd | Minimum bit-length for a privacy-preserving script-prefix watch. |
streamprefixmaxbits | 32 | restart | satd | Maximum bit-length for a script-prefix watch (range [min, 32]). |
eventszmqbind | off | restart | satd | ZMQ endpoint for the events PUB sink. |
eventszmqhashtx | on when bound | restart | satd | Enable the Core wire-format hashtx topic. |
eventszmqhashblock | on when bound | restart | satd | Enable the Core wire-format hashblock topic. |
eventszmqmpevict | on when bound | restart | satd | Enable mpevict topic (mempool eviction w/ reason; JSON). |
eventszmqmpreplace | on when bound | restart | satd | Enable mpreplace topic (RBF replacement; JSON). |
eventszmqmpconfirm | on when bound | restart | satd | Enable mpconfirm topic (mempool tx confirmed; JSON). |
eventszmqnodeevent | on when bound | restart | satd | Enable nodeevent topic (full envelope JSON). |
Webhooks / notifications
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blocknotify | none | restart | core | Shell command run on each new best block; %s is replaced by the block hash. Commands run serially on a dedicated subscriber task (a slow hook never stalls block connection — notifications coalesce instead). The command body is not logged (it may embed credentials). |
alertnotify | none | restart | core | Shell command run on each new node warning; %s is replaced by the warning text. Deduped by warning id (a repeated condition fires once, not per repeat). Runs serially like blocknotify. |
startupnotify | none | restart | core | Shell command run once after the node finishes starting up (no %s). Detached — a slow hook doesn't delay the daemon. Prefer a systemd ExecStartPost=. |
shutdownnotify | none | restart | core | Shell command run once at the start of a graceful shutdown, before the final flush (no %s). Bounded by maxshutdownsecs so a hung hook can't wedge teardown. Prefer a systemd ExecStopPost=. |
reorgwebhook | none | hot | satd | HTTP(S) endpoint receiving a POST on reorg detection. |
reorgwebhooksecret | none | hot | satd | HMAC-SHA256 secret signing webhook bodies via X-Satd-Signature. |
Notifications are convenience, not the integration path. The
*notifyshell hooks (blocknotify,alertnotify,startupnotify,shutdownnotify) exist for drop-in Bitcoin Core compatibility and quick scripts. They are best-effort, fire-and-forget shell execs with no delivery guarantee, no replay, and no reorg awareness. To build on satd, use the Streaming Consumption API (gRPC / WebSocket / ZMQ) — it is reorg-safe, offers durable cursor replay, and is decoupled from consensus. For lifecycle actions, prefer your service manager (systemdExecStartPost=/ExecStopPost=). satd honors these four hooks; onlywalletnotifyis unsupported (satd is keyless — watch scripts via the streaming/Esplora API). A node started with any of these hooks logs this guidance at startup.
MCP
(satd-specific — Model Context Protocol server.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
mcp | off | restart | satd | Enable the MCP server. |
mcpport | none | restart | satd | Enable the MCP HTTP transport on this port. |
mcpbind | 127.0.0.1 | restart | satd | MCP HTTP bind address (non-loopback requires auth + TLS). |
mcpcert | none | restart | satd | PEM TLS certificate for the MCP server (enables HTTPS; requires mcpkey). Required for any non-loopback bind. |
mcpkey | none | restart | satd | PEM TLS private key for the MCP server (requires mcpcert). |
mcpmtls | false | restart | satd | Require mutual TLS on the MCP listener (requires mcpcert/mcpkey + mcpmtlsclientca). |
mcpmtlsclientca | none | restart | satd | PEM CA bundle that client certs must chain to when mcpmtls. |
mcpmtlsclientallow | any | restart | satd | Allowlist of accepted client-cert CN / DNS-SAN values. |
mcpauth | false | restart | satd | Require bearer tokens (mcp:*) on the MCP HTTP server (requires authfile). |
mcpallowremote | false | restart | satd | Permit a non-loopback MCP HTTP bind (requires mcpauth + TLS). |
Metrics / health
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
metricsport | none | restart | satd | Enable Prometheus /metrics + /healthz + /readyz on this port (unauthenticated). |
metricsbind | 127.0.0.1 | restart | satd | Metrics/health HTTP bind address. |
Unsupported Core keys: skipped vs rejected
A Core v30 option satd doesn't honor is handled
one of two ways so that an existing bitcoin.conf still drops in.
Skipped with a warning (the node still starts)
Recognized Core v30 options satd doesn't implement but that are safe to skip
are ignored with a startup WARN line — the node boots without them. The warning
names the satd equivalent where one exists. This covers the low-value long tail,
e.g.:
| Key(s) | Warning guidance |
|---|---|
rest | satd ships native Esplora REST instead of Core's /rest/; enable with -esplora (on by default). |
zmqpub* (hashtx/hashblock/rawtx/rawblock/sequence + *hwm) | Core's per-topic ZMQ is replaced by the events bus (-eventszmqbind + -eventszmqhashtx/-eventszmqhashblock, Core wire-format). |
peerbloomfilters | BIP37 unsupported (privacy/DoS); use BIP157/158 (-blockfilterindex/-peerblockfilters). |
natpmp | satd doesn't implement PCP/NAT-PMP port mapping; configure port forwarding externally. (upnp was removed in Core v29 and is rejected as unknown — same as Core v30.) |
debuglogfile, shrinkdebugfile, printtoconsole, logratelimit | satd logs to stdout/journald; no debug.log. |
logtimemicros | satd's logger always emits sub-second timestamps; there is no seconds-only mode, so the toggle has no effect. Use -logtimestamps=0 to drop timestamps entirely. |
maxorphantx | Removed in Core v30 too. |
wallet, walletdir, walletnotify, … | satd is keyless (no wallet); use external wallets + PSBT, and watch scripts via the streaming/Esplora API. |
coinstatsindex, loadblock, checkblocks/checklevel, bytespersigop, maxsigcachesize, blockversion, printpriority, txreconciliation, discover, persistmempoolv1, acceptstalefeeestimates, blocksxor, settings, daemonwait, deprecatedrpc, rpcdoccheck, … | Recognized Core v30 options satd does not implement; skipped (generic warning). |
Rejected at load (fail-closed)
A small set stays fatal, because silently skipping them would mislead you about the node's security / exposure / privacy posture. Each rejects with an actionable message:
| Key(s) | Reason |
|---|---|
i2psam, i2pacceptincoming | I2P out of scope — skipping would route traffic over clearnet instead of the privacy network you configured. Tor is satd's anonymity network (-proxy/-onion/-torcontrol). |
rpcwhitelist, rpcwhitelistdefault | satd uses capability-scoped bearer tokens (-authfile); skipping would leave RPC less restricted than your Core config intends. See Authentication & Authorization. |
Typos
A key that is neither a satd option nor a known Core v30 option is rejected
at load as a likely typo — this is what stops a fat-fingered rpcusser= from
silently disabling authentication. Note this also catches Core v31+ keys:
the compatibility surface is frozen at v30, so a key Core only added later is
treated as unknown until the pin is deliberately bumped.
Compatibility scope. "Supported" is the commonly-used Core v30 operator surface, with semantics pinned to Core v30 only (not later releases). The long tail is skipped-with-warning rather than honored. To consume node events from your own software, use the Streaming Consumption API, not the
*notifyhooks or RPC polling.