Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 (default 127.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 /metrics providing deep insights into P2P traffic, block validation times, mempool depth, and RocksDB performance. P2P wire volume is exported as the satd_net_bytes_sent_total / satd_net_bytes_recv_total counters (peer count via satd_peer_connections).
  • Includes GET /healthz and GET /readyz endpoints 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 /metrics over RPC polling for monitoring. The Bitcoin Core RPCs getnettotals (byte totals) and getpeerinfo (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.log text 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

FlagDefaultNotes
--addressindex=<0|1>1Builds the scripthash history index over RocksDB. Required for Esplora/Electrum.
--esplora=<0|1>1Enables the native Esplora REST API (loopback unauthenticated by default).
--electrum=<0|1>0Enables the native Electrum protocol server.
--blockfilterindex=<0|1|basic>0Builds the BIP 158 compact block filter index.
--peerblockfilters=<0|1>0Advertises NODE_COMPACT_FILTERS (bit 6) and serves BIP 157 P2P queries.
--rpctlsbind=<addr:port>NoneEnables native TLS for JSON-RPC, eliminating the need for a TLS-terminating sidecar. Requires --rpctlscert and --rpctlskey.
--electrumtlsbind=<addr:port>NoneEnables native TLS for the Electrum server. Requires --electrumtlscert and --electrumtlskey.
--esploratlsbind=<addr:port>NoneEnables native TLS for the Esplora REST API. Requires --esploratlscert and --esploratlskey.
--v2transport=<0|1>1Enables BIP 324 v2 encrypted P2P transport. Offers/accepts the ElligatorSwift + ChaCha20-Poly1305 v2 handshake, transparently falling back to v1.
--v2only=<0|1>0satd-specific privacy/anti-surveillance flag. If 1, strictly refuses or immediately disconnects any peer not using the v2 encrypted P2P transport.
--dbcache=autoNoneSpawns 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:

FlagDefaultNotes
--datacarrier=<0|1>1If set to 0, strictly rejects all transactions containing OP_RETURN outputs from entering the mempool or being relayed.
--datacarriersize=<bytes>83The maximum permitted size of an OP_RETURN script. Anything larger is rejected as non-standard.
--dustrelayfee=<sat/kvB>3000The threshold used to calculate dust. Raising this forces spam transactions creating tiny, unspendable UTXOs to pay significantly higher fees.
--permitbaremultisig=<0|1>1If 0, rejects complex, non-standard bare multisig setups often used for data-storage hacks.
--limitancestorcount=<N>25Maximum unconfirmed ancestor count.

Live Config Reload (SIGHUP)

Edit bitcoin.conf and send SIGHUPkill -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 SIGHUP to reopen debug.log for logrotate. satd has no debug.log — it logs to stdout, delegating rotation/retention to systemd-journald or the container runtime — so SIGHUP is repurposed for config reload. See CORE_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, debugexcludeLog verbosity/categories change immediately (the env-filter is swapped live).
timeoutNew peer-handshake timeout for subsequent connections.
blocksonlyToggles transaction-relay suppression.
maxuploadtargetNew rolling 24h upload cap.
v2transport, v2onlyAdjusts BIP 324 v2 transport / v2-only peering for new connections.
externalip, whitelistReplaces advertised external addresses / -whitelist permission set.
rpcextendederrors, rpcdefaultunitsSwitches RPC error-payload shape / default amount unit.
maxconnections, maxinboundperipNew limits govern subsequent connections (existing peers above a lowered cap are not dropped).
bantimeNew ban duration applies to bans created after the change.
minrelaytxfee, maxmempool, dustrelayfee, datacarrier, datacarriersize, mempoolfullrbf, limitancestorcount, limitdescendantcount, mempoolexpiry, permitbaremultisigMempool/relay policy swapped atomically; governs subsequent transaction admissions (already-admitted entries are not re-evaluated).
connect, addnode, seednodeNewly-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).
peerblockfiltersToggles NODE_COMPACT_FILTERS advertisement for new handshakes (still gated on a complete blockfilterindex).
addrindexsubscriptionsNew address-index subscription cap; applied to subsequent subscriptions (lowering it does not evict existing subscribers).
reorgwebhook, reorgwebhooksecretAdds, changes, or removes the reorg webhook URL/signing secret; the next reorg uses the new target.
persistmempool, maxshutdownsecsNo 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, rpcauthRPC 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/rpcpassword from 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 the rpcdisableauth mTLS 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 SIGUSR1kill -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 (rpcmtlsclientca etc.), 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-read bitcoin.conf or run the config diff/apply machinery.

Difference from Bitcoin Core. Core has no SIGUSR1 handler and no native TLS (its RPC is HTTP-only behind a sidecar). satd's native TLS makes in-place cert reload meaningful. See CORE_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. -maxahead bounds how far ahead of the connect tip the swarm may stage blocks.
  • Background prefetch workers. -prefetchworkers threads 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 assumevalid mode 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 calls loadtxoutset automatically — no manual step. Remote sources must be https:// (plain http:// 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; after loadtxoutset it reports a second (background) chainstate, and the snapshot entry carries snapshot_blockhash + validated: false until 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-start one-flag download-verify-load UX (and --fast-start-sha256) is a satd extension; Core requires a manual loadtxoutset against 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:

ValueMeaningCompat
-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=0Verify everything — no skipping.Core
-assumevalid=allSkip 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 -assumevalid is a block hash (or 0). satd adds the all keyword 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. assumevalid skipping 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>:

ModePrimary (authoritative)Shadow (cross-check)
rust-shadow (default)C++ libbitcoinconsensusRust (logs mismatches)
cpp-shadowRustC++ (logs mismatches)
cppC++ libbitcoinconsensus— (single engine)
rustRust — 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 -shadowqueuesize are 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:

FlagDefaultNotes
-dbcache=<MB|auto>450Write-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 / -rocksdbwalmbfrom 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 / -compactiondiagintervalsecs1800 / 60(satd) periodic forced-compaction and pending-compaction diagnostics (0 disables).
-stallwatchdogsecs / -stallabortsecs300 / 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 -reindex when 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 or 0).
  • 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: manual loadtxoutset).
  • -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.
  • -par is accepted for config compatibility; it does not size the connect path, but a positive value feeds -shadowworkers when 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 familyRoleKeyed byRow sizeApprox. on disk
addr_funding_v2every output paying a scriptscripthash[16] ‖ height ‖ txid ‖ vout64 B~200 GB
tx_indextxid → containing blocktxid[32]64 B~140 GB
addr_spending_v2every input spending a scriptscripthash[16] ‖ height ‖ txid ‖ vin92 B~140 GB
outpoint_spendUTXO → the input that spent itprev_txid[32] ‖ vout76 B~100 GB
block_filter / _headerBIP 158 compact filterstype ‖ height~30 KB / 37 B~30 GB
coinsthe live UTXO settxid[32] ‖ vout~28 B varint~tens of MB
undoper-block disconnect datablock_hash[32]~28 B / inputsmall (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, so tx_index in 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 coins CF 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

Propertysatd (shared store)bitcoind + electrs/Fulcrum
Index vs. tip consistencyAlways atomic — index update is in the same WriteBatch as the blockIndex lags the node; reorg-window races are possible
Build costIndex built inside connect_block validationSecond process re-scans every block to build a parallel DB
Lookup pathO(1) keyed read, in-process function callCross-process RPC + the indexer's own lookup
Spend-by-outpointO(1) (outpoint_spend)Often derived / scanned
Operational surfaceOne process, one config, one backup, one reindexTwo+ daemons to wire, monitor, and keep in lockstep
TLS / authNative on every surfaceUsually a separate reverse proxy
DiskLarger in aggregateSmaller 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…FlagsHeavy CFs pulled in
Validating node only(defaults; indices off)none
getrawtransaction <txid> anywhere-txindex=1tx_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=1block_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 + /readyz endpoint
  • 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.

SurfaceKnobsDefaultOver-budget response
Isolated API runtime size--api-threadsmax(2, cores/4)— (sizing)
JSON-RPC (main)-rpcthreads (in-flight), -rpcworkqueue (backlog)16 / 64HTTP 429 + Retry-After
Read-only JSON-RPC-rpcreadonlythreads, -rpcreadonlyworkqueueinherit mainHTTP 429 + Retry-After
events gRPC-eventsgrpcmaxconns, -eventsgrpcmaxsubscriptions64 / 256gRPC RESOURCE_EXHAUSTED
streaming WS/SSE-streamwsmaxconns, -streamwsmaxsubscriptions, -streamwsmaxmessagebytes256 / 256 / 262144connection refused / 429
Esplora-esploramaxconns, -esplorasseconns256 / = maxconnsHTTP 429
Electrum-electrummaxconns, -electrummaxsubsperconn64 / 1000connection 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_id is 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 /readyz and 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:

  1. 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.
  2. 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 -authfile configured, 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 authUnified bearer tokens
Credentials.cookie file, -rpcuser/-rpcpassword, -rpcauth (HMAC)Opaque high-entropy tokens, presented as Authorization: Bearer <token>
GranularityAll-or-nothing — the operator, full accessPer-token capabilities (e.g. read-only, Esplora-only, stream-only)
Multi-tenantNo — one shared identityYes — many tokens, each with its own id, scope, quota, rate limit, expiry
Rate / quota limitsNone (operator is unlimited)Per-token request rate (429/RESOURCE_EXHAUSTED) and watch-set quota
Where definedCLI flags / bitcoin.conf / generated cookieA TOML -authfile (reloadable on SIGHUP)
DefaultOn (cookie auto-generated)Off until -authfile is set and the surface opts in
CompatibilityBitcoin Core wire-identicalsatd 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).

CapabilityStringGrants
RPC readrpc:readRead-only JSON-RPC methods (classified by the same table the read-only listener uses).
RPC writerpc:writeMutating / control / mining JSON-RPC, and any unclassified method (fail-closed).
Esplora readesplora:readThe Esplora REST + SSE surface.
Stream subscribestream:subscribeOpen a streaming subscription (events gRPC, streamws).
Stream watchstream:watchRegister outpoint/script/descriptor/txid watches (also bounded by the token's watch quota).
MCPmcp:*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 = 1 is required. Each [[token]] needs a unique id and a hash of the form sha256:<64 hex>. capabilities defaults to empty (a token that can authenticate but is denied everything). watch_quota, rate_limit ("<n>/s"), and expires are optional; omitting a limit means unlimited.
  • An unknown capability string, a duplicate id/hash, or a wrong version aborts the load with a clear error (recognize-reject, never silent).
  • File permissions (Unix) must expose no group/world or execute bits — 0600 or 0400, like a cookie or an SSH private key. A 0644/0640 file 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 with Retry-After on JSON-RPC / Esplora / MCP, gRPC RESOURCE_EXHAUSTED on events gRPC, and a connection-time throttle on streamws.
  • 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.

SurfaceEnable flagCapability gateDefault without the flag
JSON-RPC (read/write listeners)-rpcauthbearerrpc:read / rpc:writeCore Basic auth (cookie/userpass/rpcauth)
Esplora REST / SSE-esploraauthbeareresplora:read-esploraauth Basic, loopback-unauth default
events gRPC-eventsgrpcauthstream:subscribe / stream:watchloopback-trust
streaming WS/SSE (streamws)-streamwsauthstream:subscribe / stream:watchloopback-trust
MCP (HTTP)-mcpauthmcp:*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 / rpcauth credentials by default; capability-scoped bearer tokens (-rpcauthbearer, rpc:read / rpc:write) are an opt-in addition. See Authentication & Authorization.

Mempool-Based Fee Estimation

  • estimatesmartfee supports an optional mode param (historical, mempool, blend).
  • satd never hard-errors on fee estimation; it falls back to the min-relay floor with confidence: low rather than breaking downstream applications.

Mempool Subscription Stream

  • subscribemempool JSON-RPC WS stream emitting structured events: enter, leave_confirmed, leave_evicted, and leave_replaced.
  • Includes explicit eviction reasons and RBF replacement linkage.

Satoshis-as-Integers

  • To prevent IEEE 754 float precision errors, operators can pass amounts=sats to 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 $datadir only on mainnet) survives restarts.
  • Optional HTTP POST on reorgs via --reorg-webhook=<url>.

Client-Side PSBT Signing

  • sat-cli signpsbtwithkey is 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, the satd daemon 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:

  1. --rpcuser + --rpcpassword if both provided.
  2. Cookie file at --rpccookiefile if provided.
  3. 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

FlagDefaultMeaning
--rpcconnect <host>127.0.0.1RPC host.
--rpcport <port>per-network defaultOverride the auto-detected port.
--datadir <path>~/.bitcoinUsed to locate the cookie file.
--rpcuser <user>Userpass auth (with --rpcpassword).
--rpcpassword <pass>Userpass auth (with --rpcuser).
--rpccookiefile <path>autoOverride 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 getstartupinfo RPC; see Startup splash below.
  • Active viewgetblockchaininfo succeeded; 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:

FieldMeaning
PhaseCurrent startup phase (e.g. reindex_scan, reindex_connect, headers, verify).
StatusFree-form human-readable description from satd.
GaugeProgress through the current phase, 0–100%.
ElapsedWall-clock time since this phase began.
RateItems/sec — blocks, headers, or whatever the phase is iterating over.
ETAEstimated 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 getibdprogress RPC. 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):

GlyphColourMeaning
greenConnected — validated and in the chain.
cyanDownloaded — on disk, waiting for sequential connection.
yellowIn flight — requested from a peer, not yet received.
·dimPending — 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

ColumnMeaning
AddrPeer IP and port.
AgentSubversion string (/Satoshi:25.1.0/, /satd:0.1.0/, …).
RecvBlocks received from this peer this session.
AssignedBlocks currently assigned to this peer for download.
RatePer-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.

SymbolMeaning
● 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.
  • Modehistorical, mempool, or blend (which data source the estimator is using).
  • Confidencehigh (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:

ColumnMeaning
AddrPeer IP:port.
AgentSubversion.
HeightPeer's best-known block height.
RecvTotal 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:

DisplayMeaning
⬤ 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:

DisplayMeaning
⬭ <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.

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.

ColumnMeaning
#Rank within top 50.
vsizeVirtual size (vbytes).
anc sat/vBAncestor-adjusted effective feerate. Accounts for CPFP — a low-fee child gets pulled in by a high-fee parent.
A/DAncestor count / descendant count (chain depth in either direction).
ageTime since the tx entered the mempool.

Up / Down scroll.

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.
  • Remaining21M − 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 from chainwork (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.

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.

ColumnMeaning
depthBlocks displaced. Coloured: 1 = yellow, 2–3 = light red, 4+ = red.
fork heightHeight at which the old and new chains diverged.
old tip / new tipBlock hashes, truncated.
−N +M blocksDisconnected vs. reconnected counts.
ageTime 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).

FieldMeaning
[ERROR] / [WARN]Severity.
IDWarning identifier (cyan).
first seen Ns ago · ×countAge and recurrence.
messageHuman-readable description.
  • a acknowledges and dismisses every currently visible warning for this session.
  • w re-shows everything previously dismissed.

Dismissal is per-session. If satd clears a warning ID and re-emits it, the modal reappears.

Keybindings

KeyEffect
qQuit. Closes Help / Reorg modal first if open.
Ctrl-CQuit.
h or ?Toggle Help overlay.
rToggle Reorg history.
1IBD view (or back to auto).
2Steady view (or back to auto).
3Mempool view (or back to auto).
4Chain view (or back to auto).
aAcknowledge all visible warnings.
wRe-show dismissed warnings.
EscClose Help or Reorg modal.
Up / DownScroll 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.

CadenceRPC calls
1.5 sgetblockchaininfo, getpeerinfo, getmempoolinfo, getconnectioncount, getsysteminfo, getwarnings.
3 sgetibdprogress (during IBD only — heavy: full bitmap and per-peer breakdown).
~5 sgetindexinfo, getserverstatus, plus the steady-state batch (estimatefees, getmininginfo, getchaintxstats, uptime, getblockstats, getrawmempool (verbose), gettxoutsetinfo, getreorghistory, getmempoolhistory).
per epochgetblockhash + 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 seeWhat 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 dismissThe 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

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 flagDefaultNotes
--esplora=<bool>1Disable with --esplora=0. Disabling stops the listener; address-index data is still maintained for RPC consumers.
--esplorabind=<addr:port>127.0.0.1:3000Bind 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>noneOne 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>30Per-request timeout.
--esploramaxconns=<n>256Concurrent in-flight requests cap. 0 disables. (Does not bound the lifetime of long-lived SSE streams; see Live updates.)
--esplorasseconns=<n>same as --esploramaxconnsHard 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

MethodURLReturns
GET/blocks/tip/hashtext/plain — current best-chain tip hash (display hex, 64 chars).
GET/blocks/tip/heighttext/plain — current tip height.
GET/blocksJSON array of up to 10 most-recent block summaries, descending.
GET/blocks/:start_heightJSON array of up to 10 summaries ending at start_height inclusive, descending.
GET/block-height/:heighttext/plain — block hash at the active-chain height, or 404.

Block

MethodURLReturns
GET/block/:hashJSON: {id, height, version, timestamp, mediantime, tx_count, size, weight, merkle_root, previousblockhash, nonce, bits, difficulty}.
GET/block/:hash/headertext/plain — 80-byte serialized header, hex-encoded.
GET/block/:hash/rawapplication/octet-stream — raw block bytes.
GET/block/:hash/statusJSON: {in_best_chain, height?, next_best?}.
GET/block/:hash/txsJSON: first 25 txs in full Esplora shape ({txid, version, locktime, vin, vout, size, weight, fee, status}).
GET/block/:hash/txs/:start_indexJSON: 25 txs starting at start_index. Empty array past the end.
GET/block/:hash/txid/:indextext/plain — txid at the given block-tx index.
GET/block/:hash/txidsJSON: array of every txid in the block.

Transaction

MethodURLReturns
GET/tx/:txidJSON: full tx (vin/vout/status/fee). 404 if unknown.
GET/tx/:txid/statusJSON: {confirmed, block_height?, block_hash?, block_time?}.
GET/tx/:txid/hextext/plain — hex-encoded serialized tx.
GET/tx/:txid/rawapplication/octet-stream — raw tx bytes.
POST/txBody: hex-encoded tx. Returns the txid as plain text on accept. Bad hex / mempool reject → 400.
GET/tx/:txid/outspend/:voutJSON: {spent, txid?, vin?, status?}.
GET/tx/:txid/outspendsJSON: array of outspends, one per output, vout-ordered.
GET/tx/:txid/merkle-proofJSON: {block_height, merkle: [hex...], pos}.
GET/tx/:txid/merkleblock-prooftext/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).

MethodURLReturns
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

MethodURLReturns
GET/mempoolJSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate.
GET/mempool/txidsJSON: array of every mempool txid.
GET/mempool/recentJSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}.
GET/fee-estimatesJSON: object mapping confirmation target (string) to feerate (sat/vB, float). Standard targets: 1..25, 144, 504, 1008. Floor 1.0 sat/vB.

Root

MethodURLReturns
GET/JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings.

Live updates (Server-Sent Events)

MethodURLStream
GET/blocks/sseOne block event per BlockConnected. Body: {hash, height}.
GET/address/:addr/sseOne status event per status-hash change for the address. Body: {address, status_hash}.
GET/scripthash/:hash/sseParallel 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 of sha256(scriptPubKey) (NOT reversed — this differs from Electrum's wire format).
  • Pagination cursors. /address/:addr/txs/chain/:last_seen_txid starts 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).
  • fee field on tx JSON. null when at least one prevout cannot be resolved (e.g. txindex disabled, prev tx pruned). Some(0) for coinbase. Otherwise sum_inputs - sum_outputs.
  • Mempool UTXOs in /utxo. Outputs created by mempool txs appear with status.confirmed: false and 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 carry status: { 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 explicitlyPOST /tx is a broadcast endpoint and an unauthenticated public listener will accept any tx submission.

Three auth modes are available via --esploraauth=<mode>:

  1. none (default) — no auth. Listener accepts every request.

  2. cookie — reuses the same .cookie file 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
    
  3. 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 with scriptpubkey_address: null.
  • Mempool ordering in /address/:addr/txs/mempool is 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. --esploramaxconns and --esplorarequesttimeout bound 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 both protocol_min and protocol_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 / .onion rather 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 flagDefaultNotes
--electrum=<0|1>0Enable the Electrum server. Requires --addressindex=1 and --txindex=1.
--electrumbind=<addr:port>127.0.0.1:50001Plain-TCP listener bind.
--electrumtlsbind=<addr:port>noneTLS listener bind (standard port 50002). Requires cert + key.
--electrumtlscert=<path>nonePEM TLS certificate.
--electrumtlskey=<path>nonePEM TLS private key.
--electrummtls=<0|1>0Require mutual TLS on the TLS listener.
--electrummtlsclientca=<path>nonePEM CA bundle to verify client certs when --electrummtls=1.
--electrummtlsclientallow=<subj>any CA-signedAllowlist of accepted client-cert CN / DNS-SAN values.
--electrummaxconns=<n>64Hard cap on simultaneously-open connections.
--electrummaxsubsperconn=<n>1000Per-connection scripthash subscription cap.
--electrumrequesttimeout=<secs>30Per-request handler timeout.
--electrummaxbatchrequests=<n>100Max 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>25Max txs per blockchain.transaction.broadcast_package.
--electrumfeehistogramttl=<secs>10TTL 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

MethodDescription
server.versionNegotiate client/server software + protocol version.
server.pingKeepalive; returns null.
server.bannerServer banner text (configurable via --electrumbanner).
server.donation_addressConfigured donation address (empty if unset).
server.featuresFeature/identity dict: genesis hash, protocol_min/protocol_max (both 1.4), hosts, etc.
server.peers.subscribePeer-server discovery list (satd returns an empty set — no peer gossip).

Headers & blocks

MethodDescription
blockchain.headers.subscribeSubscribe to new-tip notifications; returns the current tip header and pushes on each new block.
blockchain.headers.getFetch a header by height.
blockchain.block.headerA block header (with an optional merkle proof to a checkpoint).
blockchain.block.headersA contiguous range of headers (with optional checkpoint proof).

Scripthash (address) queries

MethodDescription
blockchain.scripthash.get_historyConfirmed + mempool history for a scripthash.
blockchain.scripthash.get_balanceConfirmed + unconfirmed balance.
blockchain.scripthash.listunspentUnspent outputs for a scripthash.
blockchain.scripthash.get_mempoolMempool-only history for a scripthash.
blockchain.scripthash.get_first_useFirst block/tx that paid the scripthash (electrs-style extension).
blockchain.scripthash.subscribeSubscribe to a scripthash; pushes a new status hash whenever its history changes.
blockchain.scripthash.unsubscribeCancel a scripthash subscription.

Transactions

MethodDescription
blockchain.transaction.getRaw transaction by txid (verbose decode optional). Needs --txindex.
blockchain.transaction.get_merkleMerkle inclusion proof for a confirmed tx. Needs --txindex.
blockchain.transaction.id_from_posTxid at a (height, position), optionally with a merkle proof. Needs --txindex.
blockchain.transaction.broadcastSubmit a raw transaction to the network.
blockchain.transaction.broadcast_packageSubmit a package of transactions (bounded by --electrummaxbroadcastpackagetxs).

Fees

MethodDescription
blockchain.estimatefeeEstimated fee rate (BTC/kB) for a confirmation target.
blockchain.relayfeeThe node's minimum relay fee rate.
mempool.get_fee_histogramMempool fee-rate histogram (cached; TTL --electrumfeehistogramttl).

Subscriptions

Two push subscriptions are supported and counted against --electrummaxsubsperconn:

  • blockchain.headers.subscribe — a blockchain.headers.subscribe notification on every new tip.
  • blockchain.scripthash.subscribe — a blockchain.scripthash.subscribe notification carrying the new status hash whenever a watched scripthash's history changes (mempool or confirmed). Because the index is updated inside the same connect_block / disconnect_block batch as the chainstate, a subscriber can never observe a status out of sync with the tip.

Notes & differences

  • --txindex is required for blockchain.transaction.get / get_merkle / id_from_pos; --addressindex (on by default) backs every scripthash.* method.
  • satd advertises a single protocol version (protocol_min == protocol_max == 1.4); it does not negotiate a range.
  • server.peers.subscribe returns an empty list — satd does not participate in Electrum peer gossip.
  • The protocol layer is vendored from romanz/electrs (MIT; attribution in electrum-proto/vendor/electrs.MIT) and adapted to satd's AddressIndex trait 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 NodeEvent bus and gRPC Subscribe firehose, the Core-compatible ZMQ PUB sink, the bidirectional Watch control 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 is docs/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:

  1. gRPC native (satd.events.v1, tonic) — the primary transport for programmatic consumers. A server-streaming Subscribe (firehose) and a bidirectional Watch (firehose + managed watch-set).
  2. JSON-over-WebSocket (GET /ws) — a hand-mapped JSON rendering of the same tagged-unions, with a client→server control channel mirroring Watch.
  3. Server-Sent Events (GET /sse) — a read-only JSON firehose (no control channel) for browser / curl consumers.

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 seq is persisted.
  • Process restart is detected through Cursor.instance_id; the per-publisher seq resets 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).

ActionCapabilityQuota
Open a stream; receive the firehosestream:subscribe
AddScripts / AddOutpoints / AddTransactions / AddDescriptorstream:watchper-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.

KeyDefaultBounds
streamwsmaxconns256concurrent /ws + /sse connections
streamwsmaxsubscriptions256watch-set size per WS connection
streamwsmaxmessagebytes262144a single inbound WS control frame
eventsgrpcmaxconns64concurrent gRPC streams
eventsgrpcmaxsubscriptions256watch-set size per gRPC stream
streammaxresyncblocks10000blocks 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 (broadcast send is non-blocking and lossy; per-subscriber delivery is non-blocking try_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.

SettingDefaultNotes
--mcpport(off)Port to serve MCP on; enables the listener.
--mcpbind127.0.0.1Bind address. Non-loopback requires auth and TLS.
--mcpcert / --mcpkey(none)PEM cert/key — enables HTTPS. Required for any non-loopback bind.
--mcpmtlsfalseRequire 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 --mcpauth off, the server performs no per-request auth check (loopback-trust). Only valid for a loopback bind.
  • Bearer--mcpauth (which requires --authfile) requires Authorization: Bearer <token> resolving to a principal that holds the mcp:* capability; otherwise the server returns 401 with WWW-Authenticate: Bearer, and applies the token's rate limit (429 + Retry-After on throttle).
  • Remote exposure is gated — a non-loopback --mcpbind requires --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 by txid (chain + mempool); optional blockhash hint.
  • 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 with sort_by (fee_rate/time/size), limit (≤100), min_fee_rate.
  • get_mempool_entry — one tx; optional include_relatives (ancestors/descendants).
  • get_mempool_entries_bulk — detail for many txids (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 multiple targets (default [1,3,6,12,25]), in BTC/kvB and sat/vB.

Network / peers

  • get_peer_info — connected peers. Param: summary (default true).
  • manage_peermutating: 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_transactionbroadcast a signed raw tx. Param: hex_tx.
  • psbt_workflow — PSBT create/decode/analyze/combine/finalize/update/convert/join.

Mining

  • get_mining_info — difficulty, network hashrate, height.
  • generate_blocksmine blocks (regtest only). Params: count, address.
  • get_block_template — mining template.

UTXO / address

  • get_utxo — single UTXO by txid/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_block loop — no second pass over the blocks to build a parallel database.
  • Atomic reorg consistency. The index update lives in the same WriteBatch as 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 satd binary 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 the Index trait from node-index.
  • esplora-handlers — Esplora REST handlers, depends on the same Index trait.
  • 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-index crate): ~3-5 weeks. Column-family layout, IBD-time backfill, online maintenance on connect / disconnect, reorg correctness, mempool tracking.
  • Esplora REST (native, esplora-handlers crate): ~4-8 weeks on top of the index.
  • Electrum (vendored protocol code, electrum-proto crate): ~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-electrum companion 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:

BinaryPurpose
satdDaemon. Long-running process; opens RocksDB, runs P2P + RPC + optional protocol surfaces.
sat-cliJSON-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 WriteBatch either commits or it doesn't.
  • An --reindex-chainstate rebuilds 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.
  • tokio async runtime; many tasks but a fixed-size worker pool.
  • rayon for script verification (CPU-bound parallelism).
  • File descriptors: RocksDB keeps many SST files mmapped; budget LimitNOFILE=65536 minimum. The systemd unit and the Docker image both pre-set this.

Signals

SignalBehaviour
SIGTERMClean 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.
SIGINTIdentical to SIGTERM.
SIGHUPLive 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.
SIGUSR1Live 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.
SIGKILLRocksDB 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)

ServiceMainnetTestnetSignetRegtest
P2P8333183333833318444
JSON-RPC8332183323833218443
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):

EndpointMeaning
GET /healthzProcess is alive and the event loop is responsive. Cheap.
GET /readyzRocksDB is open, headers are syncing, peers > 0. Returns 503 during IBD.
GET /metricsPrometheus 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 a VOLUME.
  • Exposed ports: 8333 (P2P), 8332 (RPC). Other ports are off by default; map them with -p per 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 not infinity. 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_USEC only works against a finite TimeoutStartSec; an infinite startup timeout would let a wedged process hang indefinitely before READY=1.
  • Every 30s during the pre-bind phase, satd emits sd_notify(EXTEND_TIMEOUT_USEC=120000000, STATUS=...). The EXTEND_TIMEOUT_USEC resets systemd's internal kill-deadline; the STATUS line shows live phase + progress in systemctl 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:

ResourcePi 5 (8 GB) targetServer target
Disk (chainstate + blocks)~700 GB at 2026-05 tipsame
RAM peak during IBD~3 GBunbounded by dbcache
RAM steady-state~1.5 GB~2 GB
CPU during IBD4 cores ≈ saturatedscales with cores
Network during IBD50–200 Mbpsnetwork-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 build invocations of the same commit on two hosts produce byte-identical result/bin/satd. CI proves this on every PR that touches flake.nix / flake.lock / rust-toolchain.toml / Cargo.lock via .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. Runs nix build in each, hashes the outputs, and falls back to diffoscope when they diverge.
  • Out of scope for v1: matching the rustup-stable tarball binary (the one .github/workflows/release.yml ships) 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

HazardHow the flake handles it
rocksdb-sys bindgen outputrustPlatform.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 codeWe 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 codecrane builds inside a content-addressed /build/source; paths are stable across hosts.
Linker build-idRUSTFLAGS=-C link-arg=-Wl,--build-id=none drops the per-build random ID.
Cargo --release profileCARGO_PROFILE_RELEASE_STRIP=symbols strips deterministically inside the derivation.
tonic_build / proto generationevents/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_dispatch run 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 + musl cross-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-cache action 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.zst for the targets currently shipped:

    • x86_64-unknown-linux-gnu
    • aarch64-unknown-linux-gnu
    • x86_64-unknown-linux-musl (statically-linked musl)
    • aarch64-unknown-linux-musl (statically-linked musl)
    • aarch64-apple-darwin (macOS Apple Silicon)

    x86_64-apple-darwin is 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-cli binaries and the authoritative reference docs (README.md, PACKAGING.md, CORE_DIFFERENCES.md, STABILITY_POLICY.md), plus a MANIFEST file pinning the build commit, target triple, Rust toolchain version, and build timestamp.

  • A per-tarball *.sha256 file alongside each artifact, plus an aggregate SHA256SUMS covering tarballs and SBOMs in the release.

  • A multi-arch container at ghcr.io/epochbtc/satd:<version> covering linux/amd64 + linux/arm64.

  • CycloneDX 1.5 JSON SBOMs for each shipped binary:

    • satd-v<version>.cdx.json
    • sat-cli-v<version>.cdx.json

    Each ships with a *.sha256 next to it (already in SHA256SUMS) and a *.minisig produced by the same maintainer-side contrib/release/sign-tarballs.sh flow 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.zst ships with a detached .minisig. Pubkeys (primary + cold spare) in SECURITY.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 a reason field 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 via allow-wildcard-paths because every workspace crate is publish = 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 touches Cargo.toml, Cargo.lock, deny.toml, or the workflow itself.
  • The supply-chain-gate job 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.

VersionNotable 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):
    1. Honored — satd implements it. The common operator surface.
    2. Skipped with a warning — a recognized Core v30 option satd doesn't implement but is safe to skip. The node still starts; a WARN line names the ignored key (and the satd equivalent, if any). This is what lets a real bitcoin.conf boot unedited.
    3. 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.
    4. 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 a bitcoin.conf from 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 *notify shell 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

  • Reloadhot: applied live on SIGHUP (systemctl reload satd). restart: wired into long-lived state at startup; reported as "restart required" on reload, never silently ignored. (TLS certificate contents reload via SIGUSR1 even where the key is restart — see Live TLS Certificate Reload.)
  • Compatcore: 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 is satd.

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

KeyDefaultReloadCompatDescription
regtestoffrestartcoreUse the regtest network.
testnetoffrestartcoreUse the testnet network.
testnet4offrestartcoreUse the testnet4 network.
signetoffrestartcoreUse the signet network.
chainmainrestartcoreUnified 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

KeyDefaultReloadCompatDescription
datadirplatform defaultrestartcoreData directory.
blocksdir<datadir>/blocksrestartcoreAlternative location for blocks/ and flat-file undo data.
confbitcoin.conf in datadirrestartcoreConfig file path.
includeconfnonerestartcoreAdditional config file to splice in; honored only inside a config file.
pidnonerestartcoreWrite PID to file.
profilenonerestartsatdNamed preset: archival|pruned-home|mining|regtest-dev|signet-watchtower; CLI flags override it.

Daemon control

KeyDefaultReloadCompatDescription
daemonoffrestartcoreRun in background; accepted for compatibility (no-op — use systemd).
serveronrestartcoreAccept RPC commands; accepted for compatibility (always on).
logformattextrestartsatdLog output format: text or json. Only verbosity hot-reloads, not the format.
logtimestampsonrestartcorePrepend a timestamp to each log line. Disable (-nologtimestamps) when journald / the container runtime already stamps lines.
logthreadnamesoffrestartcorePrepend the originating thread name to each log line.
logsourcelocationsoffrestartcorePrepend source file:line to each log line.
debugnonehotcoreEnable debug logging for a category (repeatable; bare/all/1 = everything).
debugexcludenonehotcoreDisable debug logging for a category debug would otherwise enable.
loglevelinfohotcoreGlobal 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.
allowignoredconfoffrestartcoreSuppress startup warnings about includeconf files satd had to ignore.
maxshutdownsecs30hotsatdMax graceful-shutdown flush duration (seconds) before force exit.

RPC server

KeyDefaultReloadCompatDescription
rpcport8332 (network-dependent)restartcoreRPC server port. Defaults: main 8332, test 18332, testnet4 48332, signet 38332, regtest 18443.
rpcbind127.0.0.1:<rpcport>restartcoreBind plain-HTTP JSON-RPC to address (repeatable). Non-loopback requires rpcallowip.
rpcallowiploopback onlyrestartcorePer-request source-IP allowlist for JSON-RPC (repeatable).
rpcusernonehotcoreRPC username.
rpcpasswordnonehotcoreRPC password.
rpcthreads16restartcoreMax concurrent in-flight RPC method calls.
rpcworkqueue64restartcoreMax queued RPC requests beyond rpcthreads before HTTP 429 (Core returns 503 — documented divergence).
apithreadsmax(2, cores/4)restartsatdWorker threads for the isolated API runtime (Esplora/Electrum/events gRPC/metrics).
rpcreadonlybindnonerestartsatdBind an opt-in read-only JSON-RPC listener (reads + mempool submit) on the API runtime.
rpcreadonlyport8330restartsatdDefault port for rpcreadonlybind entries without an explicit port.
rpcreadonlyallowiploopback onlyrestartsatdSource-IP allowlist for the read-only listener.
rpcreadonlythreads= rpcthreadsrestartsatdMax in-flight calls on the read-only listener.
rpcreadonlyworkqueue= rpcworkqueuerestartsatdRead-only listener work-queue depth before HTTP 429.
rpcreadonlytlsbindnonerestartsatdTLS bind for the read-only listener (requires cert+key).
rpcreadonlytlscertnonerestartsatdPEM certificate (chain) for the read-only TLS listener.
rpcreadonlytlskeynonerestartsatdPEM private key for the read-only TLS listener.
rpcreadonlymtlsfalserestartsatdRequire a client cert (mTLS) on the read-only TLS surface.
rpcreadonlymtlsclientcanonerestartsatdCA bundle client certs must chain to on the read-only TLS surface.
rpcreadonlymtlsclientallowany CA-signedrestartsatdAllowlist of client-cert subjects on the read-only TLS surface.
rpcauthnonehotcoreHMAC-SHA256 RPC credential user:salt$hash (Core rpcauth format; repeatable).
authfilenonerestartsatdPath to unified-auth bearer-token file (TOML); enables the opt-in bearer-auth layer. Token contents reload live.
rpcauthbearerfalserestartsatdHonor Authorization: Bearer tokens on the JSON-RPC listeners (requires authfile).
rpccookiefile$DATADIR/.cookierestartcoreOverride the auto-generated cookie file path.
rpccookiepermsowner (0600)restartcoreCookie file permissions: owner(0600)|group(0640)|all(0644).
rpcdefaultunitsbtchotsatdDefault units for RPC amount fields: btc (Core-compatible) or sats.
rpcdisableauthfalserestartsatdDisable HTTP Basic auth on the JSON-RPC TLS surface; only valid with rpcmtls=1.
rpcextendederrorsoffhotsatdEmit structured error payloads (category/suggestion/debug) on RPC errors.

RPC TLS

(satd-specific — Core's RPC is HTTP-only behind a TLS-terminating sidecar.)

KeyDefaultReloadCompatDescription
rpctlsbindnonerestartsatdBind the JSON-RPC TLS listener (requires cert+key).
rpctlscertnonerestartsatdPEM TLS certificate for the JSON-RPC server.
rpctlskeynonerestartsatdPEM TLS private key for the JSON-RPC server.
rpctlshandshaketimeout10restartsatdPer-handshake timeout (seconds) for the JSON-RPC TLS surface.
rpcmtlsfalserestartsatdRequire mutual TLS on the JSON-RPC TLS listener.
rpcmtlsclientcanonerestartsatdPEM CA bundle to verify client certs when rpcmtls=1.
rpcmtlsclientallowany CA-signedrestartsatdAllowlist of accepted client-cert CN/DNS-SAN values.

P2P

KeyDefaultReloadCompatDescription
listenonrestartcoreAccept P2P connections.
networkactiveonhotcoreStart with P2P networking enabled. =0 boots with networking paused (no inbound accepts, no outbound dials); toggle at runtime with the setnetworkactive RPC.
blocksonlyfalsehotcoreSuppress P2P transaction relay; locally-submitted txs still relayed.
v2transporttruehotcoreOffer/accept BIP 324 v2 encrypted transport (Core default since v26).
v2onlyfalsehotsatdRefuse peers that do not speak BIP 324 v2 (privacy / anti-surveillance lever).
externalipnonehotcoreExternal address to advertise to peers (repeatable).
whitelistnonehotcoreGrant net permissions to peers by source subnet (repeatable).
whitelistrelayonhotcoreGrant relay to whitelisted peers with default permissions (relay their txes even under -blocksonly). Entries with an explicit perms@ prefix are unaffected.
whitelistforcerelayoffhotcoreGrant forcerelay to whitelisted peers with default permissions. Entries with an explicit perms@ prefix are unaffected.
whitebindnonerestartcoreBind an extra permissioned P2P listener (repeatable).
asmapnonerestartcoreasmap file for ASN-based addrman bucketing (eclipse resistance).
portnetwork defaultrestartcoreP2P listen port.
bind0.0.0.0restartcoreBind P2P to this address.
connectnonehotcoreConnect only to specific peer(s) (repeatable). Connect-only exclusivity is a startup decision (restart to change).
addnodenonehotcoreAdd a node to connect to (does not disable DNS seeding).
seednodenonehotcoreOne-shot seed peer connected at startup to bootstrap discovery.
maxconnections125hotcoreMaximum total connections.
maxinboundperip3hotsatdMax simultaneous inbound peers from one source IP (Core-style flood guard; no Core flag).
maxuploadtarget0 (unlimited)hotcoreSoft cap (bytes/24h) on historical block upload.
dnstruerestartcoreAllow DNS lookups for -addnode/-seednode/-connect.
dnsseedtruerestartcoreQuery DNS seeds for peer addresses (requires dns).
forcednsseedfalserestartcoreAlways query DNS seeds even with a populated address book.
fixedseedstruerestartcoreAllow the compiled-in fixed-seed fallback.
bantime86400hotcoreBan duration in seconds.
timeout5000 mshotcoreP2P connection timeout in milliseconds (accepts 5s/5000ms).
onlynetallrestartcoreRestrict to network types: ipv4, ipv6, onion.
signetseednodebuilt-in seedsrestartcoreAdditional signet seed node (repeatable; signet only).
signetchallengedefault signetrestartcoreCustom signet challenge script, hex (BIP 325; signet only).

BIP35 mempool requests. satd answers a peer's mempool message (which asks us to announce our entire mempool) only for peers granted the mempool net permission — -whitelist=mempool@<subnet>, all@<subnet>, or a bare -whitelist=<subnet> entry (whose implicit permission set includes mempool, as in Core). It is not implied by noban@. 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 advertise NODE_BLOOM (BIP37 bloom filters are unsupported); mempool requests from peers without the permission are ignored — softer than Bitcoin Core with bloom disabled, which disconnects such peers unless they have noban.

Proxy / Tor

KeyDefaultReloadCompatDescription
proxynonerestartcoreSOCKS5 proxy for all outbound connections.
proxyrandomizeonrestartcoreUse 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= -proxyrestartcoreSOCKS5 proxy for .onion connections.
torcontrol127.0.0.1:9051restartcoreTor control port for the hidden service. Auth is negotiated via PROTOCOLINFO: SAFECOOKIE (stock-Tor default) when no password is set, else password, else null.
torpasswordnonerestartcoreTor control port password (for a HashedControlPassword setup). Leave unset to use SAFECOOKIE cookie auth.
listenonionoff (on if torcontrol set)restartcoreCreate a Tor v3 hidden service via the control port.

Consensus

KeyDefaultReloadCompatDescription
assumevalidper-network hashrestartcoreSkip script verification up to HASH (0=verify all, all=skip old blocks).
assumevalidage86400restartsatdWith assumevalid=all, still verify scripts for blocks newer than SECS.
checkpointsonrestartcoreEnforce the built-in block checkpoints. -checkpoints=0 disables checkpoint validation.
stopatheightnonerestartcoreStop once the active-chain tip reaches HEIGHT.
consensusrust-shadowrestartsatdConsensus engine: cpp|rust|rust-shadow|cpp-shadow.

Indexing

KeyDefaultReloadCompatDescription
txindexoffrestartcoreMaintain a full transaction index.
addressindexonrestartsatdMaintain an address-history index (backs native Electrum/Esplora).
addrindexsubscriptions10000hotsatdMax concurrent per-scripthash status subscriptions.
blockfilterindexoffrestartcoreBIP 158 compact-block-filter index (basic/0/1).
peerblockfiltersoffhotcoreAdvertise NODE_COMPACT_FILTERS and serve BIP 157 filters; implies blockfilterindex=basic.

Mempool / relay policy

KeyDefaultReloadCompatDescription
mempoolfullrbfonhotsatdEnable full replace-by-fee. Core removed this flag in v28 (full-RBF is now unconditional there); satd retains it as a toggle.
maxmempool300 MBhotcoreMaximum mempool size in MB.
minrelaytxfee1000 sat/kvBhotcoreMinimum relay fee rate.
dustrelayfee3000 sat/kvBhotcoreDust relay fee rate.
datacarrieronhotcoreAccept OP_RETURN outputs.
datacarriersize83 byteshotcoreMaximum OP_RETURN size in bytes (0 = reject all).
limitancestorcount25hotcoreMaximum unconfirmed ancestor count.
limitdescendantcount25hotcoreMaximum unconfirmed descendant count.
mempoolexpiry336 hhotcoreMempool entry expiry in hours.
persistmempoolonhotcorePersist the mempool to mempool.dat across restarts.
rebroadcastinterval0 (auto)hotsatdSeconds 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.
broadcastconfirmpeers1hotsatdDistinct 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.
permitbaremultisigonhotcoreAllow bare multisig outputs.
acceptnonstdtxnoffhotcoreRelay 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.)

KeyDefaultReloadCompatDescription
esploraonrestartsatdRun the native Esplora REST server (requires addressindex=1).
esplorabind127.0.0.1:3000restartsatdBind the Esplora REST listener.
esploratlsbindnonerestartsatdBind the Esplora TLS listener (requires cert+key).
esploratlscertnonerestartsatdPEM TLS certificate for the Esplora server.
esploratlskeynonerestartsatdPEM TLS private key for the Esplora server.
esploramtlsfalserestartsatdRequire mutual TLS on the Esplora TLS listener.
esploramtlsclientcanonerestartsatdPEM CA bundle to verify client certs when esploramtls=1.
esploramtlsclientallowany CA-signedrestartsatdAllowlist of accepted client-cert CN/DNS-SAN values.
esploraprefix/restartsatdURL prefix to mount the API under (/api for blockstream-style).
esploracorsnonerestartsatdAllowed CORS origin (repeatable).
esplorarequesttimeout30restartsatdPer-request handler timeout (seconds).
esploramaxconns256restartsatdHard cap on concurrent in-flight Esplora requests.
esplorasseconns= esploramaxconnsrestartsatdHard cap on simultaneously-open SSE streams (0 disables SSE).
esploraauthnonerestartsatdEsplora auth mode: none|cookie|userpass.
esploraauthbearerfalserestartsatdHonor bearer tokens (esplora:read) on the Esplora server (requires authfile).
esploracookiefileshared .cookierestartsatdCookie file when esploraauth=cookie.
esplorauserpassnonerestartsatdStatic user:pass when esploraauth=userpass.

Electrum

(satd-specific — native Electrum protocol server.)

KeyDefaultReloadCompatDescription
electrumoffrestartsatdRun the native Electrum protocol server (requires addressindex=1 and txindex=1).
electrumbind127.0.0.1:50001restartsatdBind the Electrum plain-TCP listener.
electrumtlsbindnone (std port 50002)restartsatdBind the Electrum TLS listener (requires cert+key).
electrumtlscertnonerestartsatdPEM TLS certificate for the Electrum server.
electrumtlskeynonerestartsatdPEM TLS private key for the Electrum server.
electrummtlsfalserestartsatdRequire mutual TLS on the Electrum TLS listener.
electrummtlsclientcanonerestartsatdPEM CA bundle to verify client certs when electrummtls=1.
electrummtlsclientallowany CA-signedrestartsatdAllowlist of accepted client-cert CN/DNS-SAN values.
electrummaxconns64restartsatdHard cap on simultaneously-open Electrum connections.
electrummaxsubsperconn1000restartsatdPer-connection scripthash subscription cap.
electrumrequesttimeout30restartsatdPer-request handler timeout (seconds).
electrummaxbatchrequests100restartsatdMax requests per JSON-RPC batch line. Wallets (Sparrow) batch their whole gap-limit window of subscribes at scan time.
electrummaxbroadcastpackagetxs25restartsatdMax txs per blockchain.transaction.broadcast_package.
electrumfeehistogramttl10restartsatdTTL (seconds) for the mempool.get_fee_histogram cache.
electrumbannerpowered by satd <ver>restartsatdOverride for server.banner.

Storage / pruning / reindex

KeyDefaultReloadCompatDescription
prune0 (no pruning)restartcorePrune block data to target size in MB.
reindexoffrestartcoreRebuild block index and chain state from block files on disk.
reindexchainstateoffrestartcoreRebuild the UTXO set from existing block files (Core -reindex-chainstate).
checkblockindexoff (on for regtest)restartcoreAudit block-index / active-chain consistency at startup (Core -checkblockindex).
dbcache450 MB (or auto)restartcoreTotal write-cache size in MB, or auto for adaptive sizing.
storageprofilessdrestartsatdStorage class for chainstate tuning: ssd or hdd.
prefetchworkersCPU coresrestartsatdNumber of IBD prefetch worker threads.
maxahead50000restartsatdMax blocks ahead during IBD: number, N%, or all.
maxopenfiles2048restartsatdRocksDB max_open_files cap; -1 = unlimited.
rocksdbbackgroundjobsfrom storageprofilerestartsatdOverride RocksDB max_background_jobs (advanced).
rocksdbsubcompactionsfrom storageprofilerestartsatdOverride RocksDB max_subcompactions (advanced).
rocksdbwalmbfrom storageprofilerestartsatdOverride RocksDB max_total_wal_size in MB (advanced).
compactiondiagintervalsecs60 (0 disables)restartsatdPer-CF pending-compaction diagnostic log interval.
compactionintervalsecs1800 (0 disables)restartsatdPeriodic forced-compaction interval in seconds.
compactionl0at16restartsatdForce chainstate compaction when L0 SST count ≥ N.
ibdl0pauseat64 (0 disables)restartsatdPause the IBD connector when chainstate L0 SST count ≥ N.
stallwatchdogsecs300 (0 disables)restartsatdStall-watchdog forensic-dump threshold (seconds without tip advance).
stallabortsecs300restartsatdAdditional grace after the forensics dump before abort().
shadowqueuesize4194304restartsatdShadow-verification queue capacity.
shadowworkers4restartsatdShadow-verification worker threads.

Mining

KeyDefaultReloadCompatDescription
blockmaxweight4000000restartcoreMaximum block weight for templates.
blockmintxfee1000 sat/kvBrestartcoreMinimum tx fee for the block template.
parrestartcoreScript-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.)

KeyDefaultReloadCompatDescription
eventsnodeidauto (persisted to <datadir>/node_id)restartsatdStable per-node identifier (32-char hex) stamped on events envelopes.
eventsregionnonerestartsatdOptional region tag (≤8 ASCII bytes) on events envelopes.
eventsgrpcbindoffrestartsatdhost:port to bind the events gRPC streaming server.
eventsgrpcallowremotefalserestartsatdPermit eventsgrpcbind on a non-loopback address (requires eventsgrpcauth).
eventsgrpcauthfalserestartsatdRequire bearer tokens (stream:subscribe) on events gRPC (requires authfile).
eventsgrpcmaxconns64 (0 disables)restartsatdHard cap on simultaneously-open events gRPC connections.
eventsgrpcmaxsubscriptions256 (0 disables)restartsatdHard cap on concurrent events gRPC Subscribe streams.
streamwsoffrestartsatdhost:port for the streaming JSON-over-WebSocket + SSE transport (/ws + /sse).
streamwsallowremotefalserestartsatdPermit streamws on a non-loopback address (requires streamwsauth).
streamwsauthfalserestartsatdRequire bearer tokens (stream:subscribe) on streamws (requires authfile).
streamwsmaxconns256restartsatdHard cap on simultaneously-open streamws connections.
streamwsmaxsubscriptions256restartsatdHard cap on watch-set entries per streamws connection.
streamwsmaxmessagebytes262144restartsatdCap on a single inbound WebSocket message/frame in bytes.
streammaxresyncblocks10000 (0 disables)restartsatdMax blocks the watch matcher re-scans in one catch-up after lagging.
streamprefixminbits8restartsatdMinimum bit-length for a privacy-preserving script-prefix watch.
streamprefixmaxbits32restartsatdMaximum bit-length for a script-prefix watch (range [min, 32]).
eventszmqbindoffrestartsatdZMQ endpoint for the events PUB sink.
eventszmqhashtxon when boundrestartsatdEnable the Core wire-format hashtx topic.
eventszmqhashblockon when boundrestartsatdEnable the Core wire-format hashblock topic.
eventszmqmpevicton when boundrestartsatdEnable mpevict topic (mempool eviction w/ reason; JSON).
eventszmqmpreplaceon when boundrestartsatdEnable mpreplace topic (RBF replacement; JSON).
eventszmqmpconfirmon when boundrestartsatdEnable mpconfirm topic (mempool tx confirmed; JSON).
eventszmqnodeeventon when boundrestartsatdEnable nodeevent topic (full envelope JSON).

Webhooks / notifications

KeyDefaultReloadCompatDescription
blocknotifynonerestartcoreShell 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).
alertnotifynonerestartcoreShell 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.
startupnotifynonerestartcoreShell command run once after the node finishes starting up (no %s). Detached — a slow hook doesn't delay the daemon. Prefer a systemd ExecStartPost=.
shutdownnotifynonerestartcoreShell 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=.
reorgwebhooknonehotsatdHTTP(S) endpoint receiving a POST on reorg detection.
reorgwebhooksecretnonehotsatdHMAC-SHA256 secret signing webhook bodies via X-Satd-Signature.

Notifications are convenience, not the integration path. The *notify shell 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 (systemd ExecStartPost= / ExecStopPost=). satd honors these four hooks; only walletnotify is 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.)

KeyDefaultReloadCompatDescription
mcpoffrestartsatdEnable the MCP server.
mcpportnonerestartsatdEnable the MCP HTTP transport on this port.
mcpbind127.0.0.1restartsatdMCP HTTP bind address (non-loopback requires auth + TLS).
mcpcertnonerestartsatdPEM TLS certificate for the MCP server (enables HTTPS; requires mcpkey). Required for any non-loopback bind.
mcpkeynonerestartsatdPEM TLS private key for the MCP server (requires mcpcert).
mcpmtlsfalserestartsatdRequire mutual TLS on the MCP listener (requires mcpcert/mcpkey + mcpmtlsclientca).
mcpmtlsclientcanonerestartsatdPEM CA bundle that client certs must chain to when mcpmtls.
mcpmtlsclientallowanyrestartsatdAllowlist of accepted client-cert CN / DNS-SAN values.
mcpauthfalserestartsatdRequire bearer tokens (mcp:*) on the MCP HTTP server (requires authfile).
mcpallowremotefalserestartsatdPermit a non-loopback MCP HTTP bind (requires mcpauth + TLS).

Metrics / health

KeyDefaultReloadCompatDescription
metricsportnonerestartsatdEnable Prometheus /metrics + /healthz + /readyz on this port (unauthenticated).
metricsbind127.0.0.1restartsatdMetrics/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
restsatd 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).
peerbloomfiltersBIP37 unsupported (privacy/DoS); use BIP157/158 (-blockfilterindex/-peerblockfilters).
natpmpsatd 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, logratelimitsatd logs to stdout/journald; no debug.log.
logtimemicrossatd'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.
maxorphantxRemoved 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, i2pacceptincomingI2P 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, rpcwhitelistdefaultsatd 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 *notify hooks or RPC polling.