satd Operator Manual
satd is a Bitcoin Core-compatible full node written in Rust. It is designed
for the people who run node infrastructure: self-custodians on home servers and
Raspberry Pis, downstream packagers (Umbrel, Start9, RaspiBlitz, MyNode), and
integrators who build wallets, Lightning nodes, and explorers on a node they
control.
This manual is the reference for operators, integrators, and packagers. It catalogs every shipped 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
Every API service in satd is a query layer over the same RocksDB store and
chainstate the node itself uses. That covers JSON-RPC, Esplora, Electrum,
BIP 157/158 filters, the streaming APIs, and MCP. The store is updated
atomically inside block connection, so there is no second process and no second
copy of the data. A satd deployment replaces the usual assembly of bitcoind,
electrs, an Esplora indexer, and exporters with a single process that shares
the node's storage across all surfaces.
This removes two failure modes of external indexers: the parallel block re-scan, and the reorg-window race where the indexer's view lags the node. Every surface reads one tip-consistent store.
The trade-off is disk. Serving Electrum, Esplora, getrawtransaction, and
BIP 158 from one node makes satd's aggregate on-disk index larger than a
standalone external index. See Disk Footprint & Indices
for the byte-level accounting, and API Scaling & Runtimes for
the scale-out trade-off.
How this manual is organized
- Getting Started: installing satd from the Umbrel community store, the StartOS package, an appliance image, the container image or a release tarball.
- Operating: the day-to-day surfaces. Observability and
metrics; configuration, tuning, and live
reload; initial block download and AssumeUTXO fast
sync; API scaling and the two-runtime model;
authentication and authorization, covering
Core-compatible credentials and the unified bearer-token layer; the
JSON-RPC extensions; and the
sat-tuiterminal dashboard. - Protocol Surfaces: the Esplora REST API and Electrum
protocol references, the streaming consumption
API, the silent-payments integrator
guide, and the MCP server. Each runs as a native,
shared-chainstate subsystem of
satditself rather than as a companion process. The Disk Footprint & Indices chapter covers what the single shared store costs and provides. - Architecture: the guided code tour, a slide deck that walks the source module by module with verbatim snippets and Bitcoin Core comparisons.
- Packaging & Deployment: the authoritative packaging contract for downstream distributions. File layout, signals, ports, the release and signing pipeline, and reproducible builds.
- Reference: the Configuration Flag Reference. Every recognized config key, its default, its reload disposition, and whether it is Bitcoin Core-compatible or a satd extension.
Related documents (in the repository)
These live at the repository root rather than in this manual:
CORE_DIFFERENCES.md: the catalog of intentional deviations from Bitcoin Core.STABILITY_POLICY.md: the tiered stability contract and deprecation policy.SECURITY.md: signing keys, verification commands, and vulnerability reporting.MANIFESTO.md: node sovereignty, the monoculture risk, and the conservative BIP policy.ROADMAP.md: upcoming operator features and research areas not yet shipped.docs/api/streaming.md: the wire-level specification of the streaming-consumption API. It is a protocol spec; this manual documents the shipped surface.docs/api/webhooks.md: the alert-webhook delivery contract — headers, signature (with test vectors), retry and drop semantics. Written for anyone building a receiver.
Installing satd
Pick the one that matches the machine you have:
| You have | Install | Updates |
|---|---|---|
| An Umbrel | the satd community app store | Umbrel's app store |
| A StartOS server | the StartOS package, sideloaded until Start9 lists it | sideload each version, then StartOS's marketplace |
| A hypervisor, a mini-PC or a spare SSD | the appliance image: a bootable VM with satd, wallets and Lightning | boot a newer image |
| Docker | the container image | pull a newer tag |
| A Linux or macOS host you manage | a release tarball | replace the binaries |
Every one runs the same signed release. The store packages contain only satd; the appliance image adds third-party software for evaluation, on a best-effort basis (see Appliance & Reference Stack).
Umbrel
satd is in its own community app store rather than Umbrel's official one:
- In umbrelOS, open the App Store, then ⋯ → Community App Stores.
- Add
https://github.com/epochbtc/umbrel-apps. - Open the satd store and install satd.
The app takes its own host ports, clear of every other app in the Umbrel store, so it installs alongside Bitcoin Node, Fulcrum and Ride The Lightning:
| Port | Surface |
|---|---|
| 8430 | the app page, through Umbrel's proxy and login |
| 8431 | Esplora, TLS (from 0.6.0) |
| 8433 | Bitcoin P2P |
| 50012 | Electrum, TLS |
| 8436 | JSON-RPC, TLS |
| 8439 | MCP, TLS and a bearer token |
From 0.6.0, opening the app shows satd's status page: sync progress, whether a wallet can connect yet, and the connection strings to use. The 0.5.2 package opens onto Esplora instead, served through Umbrel's proxy on 8430, and has no status page.
Point a wallet at umbrel.local:50012 over SSL. Sparrow and Electrum pin the
certificate on first use; a client that verifies against a CA needs the
install's, and the MCP token lives beside it. The status page never shows
either, since it carries nothing secret, so reading them takes SSH:
sudo cat ~/umbrel/app-data/epochbtc-satd/data/tls/ca.crt
sudo cat ~/umbrel/app-data/epochbtc-satd/data/secrets/mcp-token
See Trusting it for importing the CA. Other
apps on the device reach JSON-RPC in plain text on the app network, as
epochbtc-satd_server_1:8332 with the cookie at APP_SATD_RPC_COOKIE_FILE,
which is how they reach Bitcoin Node too.
Earlier builds of the package used 8333, 50002, 3001, 8336 and 8339. A client configured against those needs the new port.
Umbrel backups leave out the chain and chainstate, which the node downloads again on its own, and keep the CA and the MCP token.
The package is supported on x86_64. It has not been installed on an
aarch64 Umbrel, since umbrelOS ships aarch64 only as a Raspberry Pi image.
StartOS
The package has been submitted to Start9's community registry and is not listed yet. Until it is, build it from its repository and sideload it:
git clone https://github.com/epochbtc/satd-startos
cd satd-startos
make x86 # or `make` for x86_64 and aarch64
make needs Node 22, start-cli 2.0, both squashfs-tools-ng and
squashfs-tools, a container runtime and a packaging workspace; the
repository's README.md covers each under Building. Then sideload satd_x86_64.s9pk (or
satd_aarch64.s9pk) from the StartOS web interface's sideload page, or with
start-cli:
start-cli package install -s satd_x86_64.s9pk
The package targets StartOS 0.4.0.x and runs on x86_64 and aarch64. Its
Instructions tab covers the interfaces, the actions and what the package
does not do. StartOS lists each interface's addresses itself, and exports the
status page as the package's UI from 0.6.0.
Backups leave out the chain, chainstate and the AssumeUTXO background chainstate, and keep the CA, the MCP token and the configuration, so a restore comes back with the certificates clients already trust.
Appliance image
A bootable VM that starts on signet with every satd surface on and TLS
everywhere. core is headless, about 600 MB; desktop adds XFCE with
Sparrow, Electrum and Liana already pointed at the node, about 1.4 GB. Each
comes for amd64 and arm64; on arm64 the desktop carries Sparrow alone.
Images are attached to the GitHub release, alongside the tarballs, from 0.6.0 on, and signed with the same minisign key:
ver=0.6.0
flavor=core # or desktop
arch=amd64 # or arm64, for Apple Silicon and arm64 servers
base="https://github.com/epochbtc/satd/releases/download/v$ver"
img="satd-appliance-$ver-$flavor-$arch.qcow2"
curl -fLO "$base/$img"
curl -fLO "$base/$img.minisig"
minisign -Vm "$img" -P RWQeP6MczCgPh6tU03GEMm4HsnGbXte3VT2Bc52TBSR7Q+X7WnL5vfQ3
Pick the architecture of the host: under emulation the node syncs slowly enough to be unpleasant.
| Hypervisor | File |
|---|---|
| QEMU/KVM, virt-manager, Proxmox, UTM | .qcow2, as downloaded; it is already compressed |
| VirtualBox, VMware | .ova (desktop flavour) |
An arm64 guest is UEFI-only. UTM on macOS handles that; with plain QEMU,
pass -machine virt and an AAVMF_CODE.fd in pflash.
Give it 2 vCPU and 4 GB of memory for signet, or 4+ vCPU, 16 GB and a 2 TB disk for a fully indexed mainnet. The root filesystem grows to the disk on first boot, so attach a large virtual disk from the start.
First boot
First boot creates everything that must be unique to the install: the
certificate authority and certificate, the MCP token and a console password.
The console shows the login, satd-user, and the generated password, which
must be changed at first login.
Then:
satd-appliance status # network, sync progress, overlays, certificate
satd-appliance tls export-ca > satd-ca.crt # import on the machines that connect
sudo satd-appliance set-network mainnet # refuses below 1.5 TB free
sudo satd-appliance enable lightning # LND (Neutrino) and Ride The Lightning
sudo satd-appliance ssh enable # sshd is off by default
Once the CA is imported, the status page is at
https://satd.local:9336/status from another machine. Wallets connect to
satd.local, whose name is on the certificate.
The firewall is default-deny inbound; P2P, the TLS surfaces, the proxy ports and mDNS are open. Appliance & Reference Stack covers the overlays, TLS and building an image yourself.
Container
A multi-arch image (linux/amd64, linux/arm64) is published for every
release at ghcr.io/epochbtc/satd:<version>, with latest following the
newest stable release. It is signed with cosign; see
Signed releases.
A signet node, streaming its sync to the terminal:
docker run --rm -it -v satd-signet:/var/lib/satd \
ghcr.io/epochbtc/satd:latest --signet --datadir=/var/lib/satd
Query it from another terminal (signet RPC is on 38332, cookie auth):
docker exec <container> sat-cli \
--rpcport=38332 --rpccookiefile=/var/lib/satd/signet/.cookie \
chain info
Drop --rm and keep the named volume to resume the sync later. For a
long-running deployment, see Container, or the
docker-compose reference stack, which adds
TLS on every surface.
Release tarball
Each release carries satd-<version>-<target>.tar.zst for
x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, the statically
linked x86_64-unknown-linux-musl and aarch64-unknown-linux-musl, and
aarch64-apple-darwin:
ver=0.5.2
target=x86_64-unknown-linux-musl
gh release download "v$ver" -R epochbtc/satd --pattern "satd-$ver-$target.tar.zst*"
minisign -Vm "satd-$ver-$target.tar.zst" \
-P RWQeP6MczCgPh6tU03GEMm4HsnGbXte3VT2Bc52TBSR7Q+X7WnL5vfQ3
tar --zstd -xf "satd-$ver-$target.tar.zst"
Without gh, download the same files from the
release page. SECURITY.md has
the cold spare key and what to do if a signature fails. To run it as a
service, see systemd, OpenRC or
runit; to build from source, see the
repository README.
Observability & Metrics
satd ships three observability surfaces: a native terminal dashboard, a
Prometheus endpoint, and structured logs. None of them needs an external
exporter or a log-parsing sidecar.
Native TUI (sat-tui)
satd ships with a native Ratatui-based terminal interface that shows node
progress in real time:
- IBD bitmap: block download and verification progress.
- Peer stats: connected peers, their latency, and block delivery rates.
- Mempool status: live mempool depth and fee percentiles.
The full sat-tui reference, with every view, panel, field, and keybinding, is
in the Terminal UI chapter.
Prometheus Metrics Endpoint
The metrics and health server starts only when a port is set. Use
--metricsport=<port> to enable it. --metricsbind=<addr> sets the bind
address alone (default 127.0.0.1) and does not enable the server on its own.
The listener binds <metricsbind>:<metricsport>.
Over TLS
The metrics listener speaks plain HTTP and has no authentication, which is right for loopback and wrong for a LAN. To scrape from another machine, add a TLS listener beside it on its own port:
satd --metricsport=9332 \
--metricstlsbind=0.0.0.0:9336 \
--metricstlscert=/path/fullchain.pem --metricstlskey=/path/server.key
It serves the same endpoints as the plain listener. The plain listener keeps
running, so container healthchecks and local tools are unaffected, and
--metricstlsbind refuses to start without --metricsport. Leave
--metricsbind at 127.0.0.1 so the only way in from the network is the TLS
port. A bad certificate path or a port that cannot be bound stops satd at
startup. The certificate reloads on SIGUSR1, like satd's other TLS
listeners.
TLS encrypts the scrape but does not decide who may scrape. For that, require
a client certificate with --metricsmtls=1 and
--metricsmtlsclientca=<ca.pem>, and optionally narrow it to named clients
with --metricsmtlsclientallow=<cn>[,<cn>...].
Prometheus supports both directly:
scrape_configs:
- job_name: satd
scheme: https
tls_config:
ca_file: satd-ca.crt
server_name: satd.local
# With --metricsmtls=1:
# cert_file: prometheus.crt
# key_file: prometheus.key
static_configs:
- targets: ['satd.local:9336']
The TLS listener serves at most 64 connections at once, gives a client 10 seconds to finish the handshake and 30 seconds to send its request headers.
The GET /metrics endpoint serves native Prometheus metrics covering P2P
traffic, block validation times, mempool depth, and RocksDB performance. P2P
wire volume is exported as the satd_net_bytes_sent_total and
satd_net_bytes_recv_total counters, and peer count as
satd_peer_connections. The GET /healthz and GET /readyz endpoints exist
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.
For dashboards and alerting, scrape /metrics rather than polling RPC. The
Bitcoin Core methods getnettotals (byte totals) and getpeerinfo
(bytessent, bytesrecv, lastsend, lastrecv, and the per-message-type
breakdowns bytessent_per_msg / bytesrecv_per_msg) are populated and
accurate for steady-state traffic, but they exist for Core compatibility. The Prometheus
endpoint is a counter model built for time-series tooling (rates, retention,
labels) and does not consume an RPC worker on every scrape.
Note. The RPC byte counters cover post-handshake traffic only. The one-time handshake bytes are not included, so absolute socket totals read marginally lower than the kernel's.
The per-message tallies sum to
bytessent/bytesrecvfor the same peer once a message is fully accounted for. The peer total is bumped before the per-type tally, so agetpeerinfothat lands mid-message can observe the breakdown trailing the total by one message's bytes — during IBD, by as much as a block. Treat the equality as a resting invariant, not something to alert on. They are on-wire sizes for the transport in use, so the same message costs fewer bytes on a BIP 324 v2 link than on v1. Message types satd has no variant for, undecodable frames, and v2 decoy packets are all counted under*other*, matching Core.
Compact block relay
Each block that arrives as a BIP 152 compact block, whether a high-bandwidth
peer pushed it or the node asked for it after a headers announcement, logs
one line at info when it is reconstructed:
compact block reconstructed hash=… height=… peer=… prefilled=1 prefilled_bytes=… mempool=2841 mempool_bytes=… extra=0 extra_bytes=0 requested=3 fetched_bytes=… redundant_prefilled=0 round_trip=true elapsed_ms=…
prefilled, mempool, extra and requested count where the block's
transactions came from: sent with the block, found in the mempool, found among
recently replaced or policy-refused transactions (blockreconstructionextratxn),
or requested with getblocktxn. round_trip=false means the block was built
without asking the peer for anything. A reconstruction that is given up logs
compact block abandoned with reason=merkle (the filled block failed its
merkle check, so the full block was requested), reason=timeout or
reason=invalid.
The same numbers are counters:
| Metric | Labels | Meaning |
|---|---|---|
satd_net_compact_block_reconstructions_total | outcome = direct, round_trip, fallback, invalid | Compact blocks received, by how they ended. |
satd_net_compact_block_fetched_bytes_total | — | Transaction bytes received in blocktxn messages. |
satd_net_compact_block_txs_total | source = prefilled, mempool, extra, requested | Transactions of reconstructed blocks, by where they came from. |
satd_net_compact_block_sent_total | kind = announce, getdata | cmpctblock messages sent. |
The share of blocks reconstructed without a round trip is
direct / (direct + round_trip + fallback).
Stratum server
While the Stratum server runs, its counters are exported. A node
with --stratum=0 exports none of these families.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
satd_stratum_connections | gauge | — | Open Stratum connections, V1 and V2. |
satd_stratum_miners | gauge | — | Authorized Stratum V1 connections plus open Stratum V2 channels. |
satd_stratum_shares_total | counter | result = accepted, rejected, stale | Shares submitted, by result. |
satd_stratum_blocks_found_total | counter | — | Blocks found by miners that joined the active chain. |
satd_stratum_hashrate_hashes_per_second | gauge | — | Estimated hashrate of the connected miners, from the shares accepted over the last ten minutes. |
Block-template checks
Every block template the node builds is checked before it is used (see Stratum → Template checks).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
satd_template_checks_total | counter | check = structural, full; result = valid, invalid, superseded | Template checks by tier and result. Any invalid is a node bug. superseded means the chain moved during the check, which then says nothing either way. |
There are no per-miner series. A worker name is whatever the miner sends, so a
label on it would let a miner create series without limit; getstratuminfo
lists each miner instead. A miner that stops hashing shows as
satd_stratum_miners holding steady while the accepted-share rate falls to
zero.
Status page
--statuspage=1 adds a browser page to the metrics listener, a simplified,
single-page sat-tui: whether the node is syncing or ready, sync progress and
its ETA, AssumeUTXO background validation, each index and whether a wallet can
connect yet, the latest block, the mempool, fee estimates and peer counts. It
is off by default, and it needs --metricsport, since that is the listener it
is served on.
statuspage=1
metricsport=9332
statusadvertise=electrum=ssl://node.local:50002
statusadvertise=esplora=https://node.local:3001/api
Open http://<metricsbind>:<metricsport>/status. The page is complete as
served and works with JavaScript off. With JavaScript on it refreshes itself
from /status.json every 5 seconds, pauses while the tab is hidden, backs off
when the node stops answering, and after about 12 seconds without an answer
says stale instead of going on showing the last reading.
What the top line means:
| Shown | Means |
|---|---|
| stalled | The node cannot connect the next block. /readyz is 503. |
| syncing headers | The newest header is more than a day old, so the header chain is still downloading. /readyz can read 200 here, because blocks keep pace with the headers known so far. |
| syncing blocks | Blocks trail the headers by more than /readyz allows. |
| validating history | Serving from an AssumeUTXO snapshot while the history behind it validates. |
| building indexes | At the tip, with an enabled index still incomplete, so Electrum and Esplora answers that read through it are partial. |
| ready | At the tip, every enabled index complete. |
| starting | The node is still loading, before it has a chain to show. /readyz is 503; the page shows what the node is doing, and nothing else is served yet. |
| rebuilding chainstate | A -reindex-chainstate or -reindex is rebuilding from the block files on disk. /readyz is 503; the page shows the rebuild's progress, rate and ETA, and nothing else is served until it finishes. |
The last two come from a startup server that holds the metrics listener from
the moment the startup RPC answers, so the page, /healthz (200), /readyz
(503) and /metrics (satd_build_info and the satd_startup_* gauges)
answer through a rebuild that takes hours. It hands the bound port to the
running node's listener before the full RPC starts; an open tab switches to
the running node's view on its next refresh. During startup /status.json
carries getstartupinfo's object as startup instead of snapshot.
The page says ready only when /readyz is 200. Sync progress is weighted
by how much work each part of the chain takes, so it trails the plain
height ratio for most of a mainnet sync, as the time does.
Connection strings come only from statusadvertise. The page never builds
one from the request's Host header, which a client can set to anything and
which names the proxy in front of the node rather than the port a wallet
should dial. Set one per surface a user should connect to, as they should type
it. Leave them out where the platform shows its own addresses.
It is unauthenticated, like /metrics, and safe that way. It carries no
cookie, password, token or key, no peer or listen address, and no warning or
error text, which can name host paths; warnings appear by id, and
getwarnings has the detail. Peers appear as counts and client versions.
Every value is escaped, and the page is served with a
Content-Security-Policy that allows its own script and requests only.
Building it takes no lock that block connection needs.
/status.json is internal to the page and unstable: any field may change in
any release. Monitor with /metrics and /readyz.
Index readiness
Each DB-backed index exports whether it is switched on, whether it is ready to serve, and what its deferred backfill is doing. The silent-payment family:
| Metric | Meaning |
|---|---|
satd_spindex_enabled | 1 if the silent-payment tweak index is enabled at runtime |
satd_spindex_synced | 1 if the tweak-serving surfaces will return data — enabled, complete on disk, and no backfill in flight. Matches getsatdindexinfo's silentpayments.synced |
satd_spindex_backfill_state{state="…"} | one series per lifecycle state, exactly one of them 1 |
satd_spindex_backfill_progress_ratio | fraction of the deferred backfill walked, over [taproot activation, snapshot] |
The address and block-filter indexes export the same readiness shape —
satd_addrindex_synced / satd_addrindex_backfill_state{state="…"}
(alongside the existing satd_addrindex_enabled and row counters), and
satd_filterindex_enabled / satd_filterindex_synced /
satd_filterindex_backfill_state{state="…"}. synced matches the
corresponding getsatdindexinfo predicate in each case: for the address index it
means the Electrum / Esplora address surfaces will serve; for the filter index
it means BIP 157 peers and getblockfilter will be served. A failed or stuck
backfill on any of the three is alertable with the same rules shown below —
substitute the family prefix.
Do not alert on the progress ratio alone. 0.0 is not an error signal: it
covers an index that is switched off, one built inline from a genesis sync (which
never needs a backfill and stays at 0.0 while being perfectly complete), a
backfill that has only just started, and one that failed near taproot
activation. Alert on the state series instead, which distinguishes them:
# The backfill failed.
satd_spindex_backfill_state{state="failed"} == 1
and ignoring(state) satd_spindex_enabled == 1
# Enabled but never going to become ready on its own: no backfill was ever
# started. This is the state an existing datadir lands in when the index is
# switched on without running `backfillindex silentpayment`.
satd_spindex_backfill_state{state="idle"} == 1
and ignoring(state) satd_spindex_enabled == 1
and ignoring(state) satd_spindex_synced == 0
# Running, but not making progress — stuck rather than merely slow.
satd_spindex_backfill_state{state="running"} == 1
and ignoring(state) satd_spindex_enabled == 1
and ignoring(state) delta(satd_spindex_backfill_progress_ratio[30m]) == 0
Three things worth copying exactly rather than paraphrasing:
- The
satd_spindex_enabled == 1guard belongs on every one of them, including thefailedrule. The state series is derived from the persisted cursor, which outlives the config: switchsilentpaymentindexback off after a failed backfill and the node keeps exportingsatd_spindex_backfill_state{state="failed"} 1for ever, because the cursor stays on disk and is deliberately not auto-resumed. Without the guard that pages continuously for an index the operator has turned off. ignoring(state)is required.andmatches on identical label sets, andsatd_spindex_backfill_state{state="running"} == 1carries astatelabel thatsatd_spindex_enableddoes not. Plainandfinds no matching series and the rule silently evaluates to nothing, for ever — it will sit at "0 active" in the Prometheus UI, which reads as healthy.delta(), notrate(). The ratio is a gauge.rate()treats the drop back to0.0when a backfill restarts as a counter reset and compensates for it, which is not what you want here.
Give the last two a for: of at least an hour in the alert rule. A backfill
that has not committed its first 1000-block batch yet legitimately shows no
progress.
All seven state series are always present, so a rule can reference
state="failed" before it has ever fired — the same reason satd_alert_active
pre-registers at 0 (see below).
Node-health alerts
Metrics tell you what the node is doing; health alerts tell you when it has stopped doing it. satd watches six conditions about itself and reports each one through three surfaces at once, so they can never disagree:
- a
statusevent on the Streaming Consumption API (category bit 16 — see §7.8 of the wire spec), - an entry in
getwarnings(and therefore ingetblockchaininfo.warningsand the TUI), which also fires the Core-compatiblealertnotifyhook, - a
satd_alert_active{kind="..."}gauge on/metrics.
One-shot events are the exception to the middle surface. ibd_complete and
deep_reorg describe something that happened; there is no state for anything
to later clear. They fire alertnotify and emit their status event, but they
do not create a getwarnings entry. An entry nothing clears would sit in
getblockchaininfo.warnings for the life of the process and hold the TUI's
warning modal open — which on signet and testnet4, where reorgs several blocks
deep are ordinary, would happen on the first one and never stop. The durable
record of a reorg is the reorg log (getreorghistory), not the warnings set.
Because they have no standing condition to dedupe against, one-shot events are
rate-limited on the alertnotify hook instead: one exec per minute per event
id, reporting the worst occurrence in that window. A run of reorgs otherwise
queues one shell exec each on a channel drained one at a time, which grows
without bound and pushes the hook further and further behind real time. Keeping
the first occurrence and counting the rest would be worse still — a depth-3
reorg would claim the window and a depth-200 reorg a second later would be
reduced to an increment, so a script that halts trading on alertnotify would
hear about the harmless one and not the serious one. So:
- the first occurrence pages immediately;
- an occurrence strictly deeper than anything already paged in the window escalates through at once, capped at one escalation per window;
- the rest are held, and the worst of them pages when the window closes,
carrying a count and the window as measured —
rolled back 41 blocks [3 more in the previous 74s]; - a burst that stops is drained by the detector poll, not left waiting for a next occurrence.
None of this touches the status event or getreorghistory, which carry every
occurrence unthrottled. If you are building on reorg data, read those.
The warnings set itself is capped at 256 distinct ids, with anything past
the cap counted in a single warnings.truncated row. Almost every id is a
fixed string, but a few embed an identifier — a block whose stored data is
unreadable gets one per block — and a storage fault across many blocks would
otherwise fill getwarnings, the TUI modal, and the hook. Ids already active
keep updating, and the node log carries every one in full.
| Condition | Severity | Raises when | Clears when |
|---|---|---|---|
ibd_complete | info | initial block download finishes | one-shot |
tip_stall | critical | the tip has not moved for alerttipstallseconds | the next block connects, the tip is seen to have moved, or the threshold no longer considers the tip stalled |
disk_low | critical | free space below alertdiskfreemb | free space reaches 1.5× the floor, or the floor is lowered below the current reading |
mempool_congested | warning | mempool at alertmempoolfullpct of its cap | occupancy drops below 75 % of the raise line, or the threshold is raised above the current occupancy |
peer_floor | warning | fewer than alertpeerfloor peers for 60 s (after a 90 s startup grace) | at or above the floor for 60 s |
deep_reorg | critical | a reorg rolled back ≥ alertreorgdepth blocks (default 3 on mainnet, 10 on test networks, off on regtest) | one-shot |
template_invalid | critical | a block template the node built failed its validity check — a block found on it would be rejected. This is a node bug; the log line names the reject reason and the offending transaction | a template passes again: the full check, if the full check was the one that failed |
Every standing condition raises once on entry and clears once on
recovery — you get a pair of events, not a stream of repeats — and the gap
between the raise and clear lines (a ratio, a hold time, or both) means a value
sitting on the threshold does not flap your pager. ibd_complete and
deep_reorg describe things that happened rather than states that persist, so
they are one-shot: they never clear, and for the same reason they never enter
getwarnings at all.
template_invalid is raised by the template check itself, not by the detector
poll, and has no threshold to configure. It is evaluated whenever a template is
built — by getblocktemplate, the generate RPCs or the Stratum server — so
after a restart it stands again only once a template fails again.
Thresholds are configured with the alert* keys in the
Configuration Reference; all of them are
hot-reloadable, and setting one to 0 disables that detector. Each event
carries a details map with the numbers behind it (free bytes and the floor,
seconds since the last block and the tip height, the reorg's true depth and
fork height, the mempool's current mempoolminfee), so an alert is actionable
without a follow-up query. The watched path is deliberately not in the event —
it goes to every status subscriber and into push-notification bodies, and an
absolute datadir path usually names the account it runs under. The node logs it
instead.
Retuning a threshold always clears its own alert. The gap between each raise
and clear line stops a value hovering at the threshold from flapping, but it
would otherwise trap the operator who raises a threshold because the alert is
firing: the unchanged reading lands between the new raise line and the new clear
line, where neither fires. So a standing condition also clears when the
threshold moves such that it would no longer raise. Without this,
mempool_congested in particular was inescapable — alertmempoolfullpct clamps
at 100 and the clear line is 75 % of the raise line, so past 75 % occupancy no
setting could clear it.
alertreorgdepth defaults to 3 on mainnet, where a reorg that deep costs real
hashrate and invalidates transactions merchants have started treating as
settled. Signet, testnet and testnet4 default to 10: those chains are not
economically secured, and reorgs a few blocks deep are an ordinary consequence
of thin, volatile hashrate rather than an incident. Defaulting them to mainnet's
sensitivity would run -alertnotify for the network working as designed, and an
alert that fires during normal operation is one you learn to ignore — which
costs you the mainnet alert too. It is raised rather than switched off because
past the 6-confirmation convention a wallet has been told something false, and
that is worth reporting on any chain. Regtest is off entirely; its test suites
reorg deliberately. Set alertreorgdepth=3 explicitly if you want mainnet
sensitivity on a test network.
alertpeerfloor defaults to 3 everywhere except regtest, where it is 0
(disabled) because running with no peers at all is a regtest node's normal
operating state rather than a fault. Signet keeps the floor: it is a public
network with real peers, and a detector defaulted off is indistinguishable from
a healthy one — satd_alert_active{kind="peer_floor"} reads 0 either way. Set
alertpeerfloor=0 explicitly on a deliberately isolated signet node.
Where the floor is active, a node that has never seen a peer gets a 90 s startup grace, and the ordinary hold begins when that grace expires or when the first peer arrives, whichever comes first. The grace defers the start of the hold rather than shortening it, so a node still dialing out does not page anyone on the way up.
Durability. Health events are not replayable: they carry no resume cursor,
and a from_cursor reconnect never yields one. Instead the detectors
re-evaluate from scratch on startup and re-raise anything still standing, so a
consumer that was disconnected across a restart still learns about a live
problem. A condition that both raised and cleared while nothing was listening is
stale by definition and is not reconstructed. For the same reason, a subscriber
that attaches after a condition raised will not see it until the condition
changes — check getwarnings for current state on connect.
Two of the gauges are useful independently of alerting:
satd_tip_last_connect_age_seconds (seconds since the last connected block)
and satd_disk_free_bytes (free space on the watched directory). The latter is
omitted rather than reported as zero when the filesystem cannot be
interrogated.
Alert webhooks
The three surfaces above all require something to be watching the node. A
webhook pushes instead: point alertfile=<path> at a TOML file and satd POSTs
each matching event to your endpoint.
version = 1
[[webhook]]
id = "pager" # unique; appears in the X-Satd-Hook header and metric labels
url = "https://alerts.example/satd"
secret = "a-long-random-string" # required — it signs every delivery
categories = ["status"] # status | chain | mempool | heartbeat
kinds = ["tip_stall", "disk_low"] # optional, status only
min_severity = "warning" # optional, status only
[[webhook]]
id = "deadman"
url = "https://hc-ping.example/abc123"
secret = "another-long-random-string"
categories = ["heartbeat"]
heartbeat_interval_secs = 300 # one ping per 5 min, not the bus's 1 Hz
The file must be mode 0600 — it holds signing secrets, and satd refuses to
read a group- or world-accessible one. Its contents are re-read on every
SIGHUP, so hooks can be added, edited, or removed without a restart; the
path is fixed at startup. A parse error on reload keeps the last-good hook
set and logs why, because alerting that silently stopped after a typo is the
worse failure.
categories is required — a hook without it would receive nothing, which is
never what anyone meant to configure. It selects from the node's firehose:
| Category | Delivers | Rate |
|---|---|---|
status | node-health transitions (the six conditions above) | a handful per week on a healthy node |
chain | every block connect, disconnect, and reorg | one per block |
mempool | every transaction entering or leaving the mempool — all of them, not just yours | thousands per minute on mainnet |
heartbeat | liveness pings, downsampled to heartbeat_interval_secs | whatever interval you set |
kinds and min_severity narrow status and apply to nothing else; both are
checked after the category, so kinds without "status" in categories
matches nothing. The streaming API's tweaks category is rejected here
rather than ignored — it is per-block bulk data and an HTTP receiver is the
wrong consumer for it.
mempool is almost never the right choice for a webhook: it is the whole
network's traffic, not yours. To be told about your transactions, use the
streaming API's Watch stream, which matches on scripts, outpoints, txids and
silent-payment scan keys. A webhook hook cannot filter that way and is not
meant to — see Streaming.
The normative wire contract — every header, the signature scheme with test
vectors, and the exact retry semantics — is
docs/api/webhooks.md.
What follows is the working summary.
What arrives
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Satd-Signature: sha256=<hex HMAC-SHA256(secret, canonical string)>
X-Satd-Timestamp: 1753400000
X-Satd-Delivery: <node_id>-<instance_id>-<seq>
X-Satd-Hook: pager
X-Satd-Attempt: 1
X-Satd-Webhook-Version: 2
{"schema_version":1,"stamp":{...},"body":{"category":"status", ...}}
The body is byte-identical to the JSON a WebSocket subscriber receives for the same event, so a receiver parses webhook bodies and streaming frames with one code path. Delivery metadata rides in headers and never in the body — which is what makes the signature stable across retries.
Verifying a delivery
The signature covers a canonical string, not the bare body — five fields joined by newlines, with the raw body last:
"2" LF <X-Satd-Timestamp> LF <X-Satd-Delivery> LF <X-Satd-Hook> LF <raw body>
Signing only the body would leave X-Satd-Delivery unauthenticated and
predictable, and that header is the one this page tells you to deduplicate on.
One captured (body, signature) pair could then be replayed under forged future
delivery ids, poisoning your dedup cache so the real alerts were discarded on
arrival while satd counted them delivered. Binding the id, the hook, and a
timestamp into the signed material closes that.
So a receiver must:
- Read
X-Satd-Timestampand reject anything older than 600 seconds. This is not optional — it is what stops a captured delivery being a permanent replay token. A delivery still being retried after the window ages out by design; a 20-minute-old "disk is filling" alert is not worth acting on. - Rebuild the canonical string above from the raw body, before parsing it.
- Compare the HMAC in constant time.
- Deduplicate on
X-Satd-Delivery— stable across retries of one event, and unique across restarts, so a retry and a genuine repeat are distinguishable. - Reply
2xxto acknowledge.
Upgrading from the pre-release v1 scheme. Earlier drafts signed the raw body alone and sent
X-Satd-Webhook-Version: 1. The legacyreorgwebhook=keys still use exactly that, unchanged, and still report version1— branch on the version header rather than assuming one scheme.
The X-Satd-Delivery value is opaque; do not parse it. Deduplicate on the whole
header: it is unique per event and stable across the retries of one delivery, so
the only duplicate you can receive is a retry of something you already saw.
Delivery behavior
-
Serial and in-order per hook — one request in flight at a time, so events arrive in the order the node produced them.
-
Retried with backoff on 5xx, 408, 429, timeouts, and connection failures: 1 s doubling to a 5-minute ceiling — but not forever. A delivery is abandoned once it ages past the 600 s freshness window it was signed with, which lands somewhere around the tenth or eleventh attempt. That is deliberate: the signed timestamp is the only staleness signal a receiver checks, so a delivery that could no longer pass that check is not worth sending. The practical consequence is that a receiver down for longer than ten minutes — a relay redeploy, a restart that takes a while — loses the events raised during the outage. Alert on
satd_alertwebhook_dropped_totalif that matters to you; the detectors re-raise standing health conditions, so those recover on their own, but chain and mempool events do not. Any other 4xx is treated as permanent, counted, and skipped — a receiver answering 404 forever must not pin the queue and turn every later event into a drop. The skip is counted insatd_alertwebhook_dropped_totaland logged. -
Redirects are not followed. A 3xx is a permanent drop. The URL in the alertfile is where the signed body goes; following a redirect would move it — signature, hook identity and all — to a host you never named, and the useful targets for that are exactly the ones you cannot see: a cloud metadata endpoint, an RFC1918 admin port, the node's own RPC. If your receiver moves, update the alertfile.
-
Bounded queue. A hook that falls far enough behind drops events. They are counted in
satd_alertwebhook_dropped_totaland logged; nothing is held for later and nothing is inserted into the stream to tell the receiver. -
Nothing reaches consensus. Deliveries run on the isolated API runtime and the event fan-in never blocks, so a stalled endpoint cannot affect block connection. Measured on a regtest node connecting 20 blocks: 11.23 ms with no webhook configured, 11.24 ms with every event going to a receiver that accepts the connection and never answers.
-
Best-effort, and that is the whole contract. Nothing is persisted. A webhook fires when something happens and is retried while your endpoint is briefly unreachable; that is all it promises. A node that was down did not deliver those events and will not go back for them — when it comes up, its hooks resume at the live head. Health alerts are the exception, and they get it for free: the detectors re-evaluate at startup and re-raise anything still true (see Durability above), so a standing problem still reaches you.
If you need guaranteed delivery, resumability across downtime, or history, use the Streaming Consumption API. That is the recommended way to integrate with satd and it does all three properly — real cursors, backpressure, and a bounded
RescanBlocks. Webhooks are for automation you are happy to miss occasionally: page me, poke a script, ping a dead-man's switch. -
chainalerts are suppressed during initial block download. A node syncing from scratch does not POST its entire block history.status,heartbeatandmempoolkeep flowing: health alerts stay live because "this node is unhealthy" is exactly as true mid-sync, and the heartbeat keeps flowing so an external dead-man's switch does not declare a syncing node dead. Note the consequence formempoolhooks — a mainnet mempool subscription is thousands of events a minute, and a multi-day sync does not quiet it. What was suppressed is counted insatd_alertwebhook_dropped_total. The suppression is latched on leaving IBD once, so a node whose tip later goes stale keeps alerting — which is the whole point of a stalled-tip alert.
Plaintext http:// is accepted for loopback and private-network targets. For a
public host, use https:// or set allow_insecure_http = true on the hook.
Per-hook counters are exported: satd_alertwebhook_delivered_total,
_failed_attempts_total, _dropped_total (events lost, not held —
there is no dead-letter queue),
_queue_depth, and _last_success_age_seconds, all labelled hook="<id>".
Nothing is exported when no hook is configured.
Writing a receiver
A receiver is an HTTP endpoint that verifies the signature and acts. What it does with an alert — page someone, open a ticket, forward to a push service, or just log it — is yours to decide; satd's job ends at the delivery.
Two things are worth getting right, and both are covered with test vectors in the webhook reference:
- Verify
X-Satd-Signatureover the raw body, in constant time, before parsing. Key order and whitespace are part of the signed bytes, so a re-serialized body will not verify — and parsing unauthenticated input is the thing to avoid. - Deduplicate on
X-Satd-Delivery. satd retries, so the same id arrives again whenever a response is lost after you already acted on it. The id is inside the signature, so a forged one cannot poison your dedup window.
A condition and its later recovery share a collapse_id, so a receiver that
surfaces alerts to a human can replace the alert with its recovery rather than
stacking a second message beneath it.
Note. The older
reorgwebhook=/reorgwebhooksecret=keys still work and are now served by this dispatcher, with their originalReorgRecordpayload and v1 body-only signature unchanged.One behavior did change: redirects are no longer followed. A receiver that answers 301/302 — an
http→httpsproxy hop, a trailing-slash redirect, a load balancer that relocates — used to be chased and now classifies as a permanent drop. If your reorg endpoint relies on a redirect, pointreorgwebhook=at the final URL; otherwise every reorg record is silently discarded. Everything else about the payload and signature is byte-identical, so a receiver on a stable URL needs no edits.New deployments should prefer an
alertfilehook withcategories = ["chain"], which delivers the standard event envelope.
Structured JSON Logging
satd logs to stdout. Use --log-format=json to switch from the text format
to structured, machine-parseable JSON in place of a traditional debug.log
stream. The JSON output feeds Datadog, ELK, or custom log-alerting pipelines
directly. Trace IDs let an operator follow a single block through prefetch,
connect, and flush.
- Flag:
--log-format=json|text
Reorg Notifications
satd records every reorg it performs, so exchanges and custodians can read
reorg history from the node instead of reconstructing it externally.
- Persistent log. An append-only JSONL log at
$datadir/<network>/reorg.log, the network-specific datadir subdirectory. The log sits directly under$datadironly on mainnet. It survives restarts and is backed by an in-memory 256-record ring. - Query method.
getreorghistory [since_secs]returns recent reorgs. - Webhook. Use
--reorg-webhook=<url>to send an HTTP POST on each reorg. Set--reorg-webhook-secret=<secret>to have satd sign the body with HMAC-SHA256 in anX-Satd-Signature: sha256=...header, which the receiver can use to verify integrity.
Difference from Bitcoin Core. Core's
getchaintipsreflects only the currently known tips; a reorg that happened yesterday leaves no record. satd persists reorg history natively.
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, with semantics pinned to Core v30. A recognized option that satd
does not implement is skipped with a startup warning rather than aborting. See
the Configuration Flag
Reference for the exact
disposition of every key.
On top of that compatibility, satd adds hardware-profile presets, a set of
mempool policy options under direct operator control, and live reload of both
configuration (SIGHUP) and TLS certificates (SIGUSR1).
Repeated options follow Core's rule, which differs by source: an option
given twice on the command line takes the last value, while the same key
repeated in bitcoin.conf takes the first. Core's own comment calls
this asymmetry a backwards-compatibility quirk, but tooling depends on it — the
command-line rule is what lets a wrapper append an override onto a base command
line. Options that are genuinely repeatable (bind, connect, addnode,
whitebind, rpcallowip, …) accumulate instead of overriding, across both
sources: the command line's values first, then the config file's, as Core's
GetSettingsList merges them. A negation (-noconnect, -norpcauth) drops
the values before it, and when it is the last value on the command line it
drops the config file's values too; a value given after it brings the config
file's back, a quirk Core keeps for compatibility. satd's own list options
(the *mtlsclientallow lists, esploracors, mcpallowedhost,
statusadvertise, rpcreadonlybind and rpcreadonlyallowip) are the
exception: a command-line list replaces the config file's.
Related chapters: Observability & Metrics for the TUI,
Prometheus, and structured logs; JSON-RPC
Extensions for the satd-specific developer APIs;
API Scaling & Runtimes for the two-runtime model and the
--api-threads and admission-control options. The Configuration Flag
Reference indexes every flag with its default, its reload
disposition, and whether it is Core-compatible or a satd extension.
Configuration & Tuning
--profile Presets
The --profile=<preset> flag replaces manual tuning of -dbcache,
-maxmempool, and connection limits with a single choice of hardware profile:
archival: maximizes indexing and P2P serving. Disables pruning.pruned-home: fits Raspberry Pi and home servers. Enables pruning and bounds memory.mining: optimizes block template generation latency.regtest-dev: fast, isolated environment for local development.
Indexing & Protocol Flags
| Flag | Default | Notes |
|---|---|---|
--addressindex=<0|1> | 1 | Builds the scripthash history index over RocksDB. Required for Esplora/Electrum. |
--esplora=<0|1> | 1 | Enables the native Esplora REST API (loopback unauthenticated by default). |
--electrum=<0|1> | 0 | Enables the native Electrum protocol server. |
--blockfilterindex=<0|1|basic> | 0 | Builds the BIP 158 compact block filter index. |
--peerblockfilters=<0|1> | 0 | Advertises NODE_COMPACT_FILTERS (bit 6) and serves BIP 157 P2P queries. |
--rpctlsbind=<addr:port> | None | Enables native TLS for JSON-RPC; no TLS-terminating sidecar is needed. Requires --rpctlscert and --rpctlskey. A non-empty --rpcallowip gates this listener too. |
--electrumtlsbind=<addr:port> | None | Enables native TLS for the Electrum server. Requires --electrumtlscert and --electrumtlskey. |
--esploratlsbind=<addr:port> | None | Enables native TLS for the Esplora REST API. Requires --esploratlscert and --esploratlskey. |
--v2transport=<0|1> | 1 | Enables BIP 324 v2 encrypted P2P transport. Offers and accepts the ElligatorSwift + ChaCha20-Poly1305 v2 handshake, and falls back to v1. |
--v2only=<0|1> | 0 | satd-specific privacy flag. If 1, refuses or immediately disconnects any peer not using the v2 encrypted P2P transport. |
--dbcache=auto | None | Spawns the adaptive dbcache resizing task, which scales the RocksDB block cache and the CoinCache clean-LRU in response to system memory pressure. |
Mempool Policy Sovereignty
The operator decides what the node's hardware validates and relays. satd exposes these policies as ordinary options, where filtering spam or unwanted data with Bitcoin Core requires a patched fork such as Bitcoin Knots:
| Flag | Default | Notes |
|---|---|---|
--datacarrier=<0|1> | 1 | If set to 0, rejects all transactions containing OP_RETURN outputs from entering the mempool or being relayed. |
--datacarriersize=<bytes> | 83 | The maximum permitted size of an OP_RETURN script. Anything larger is rejected as non-standard. |
--dustrelayfee=<sat/kvB> | 3000 | The threshold used to calculate dust. Raising it forces transactions that create tiny, unspendable UTXOs to pay higher fees. |
--permitbaremultisig=<0|1> | 1 | If 0, rejects non-standard bare multisig setups, a construction often used for data storage. |
--limitclustercount=<N> | 64 | Do not accept a transaction whose cluster (the unconfirmed transactions connected to it through spends, itself included) would hold more than N; it is rejected as too-large-cluster. This is the limit that gates admission; 64 is also the maximum, so the option can only lower it. |
--limitclustersize=<N> | 101 | Do not accept a transaction whose cluster would come to more than N kvB of sigop-adjusted virtual size; it is rejected as too-large-cluster. --maxmempool must leave room for 40 such clusters. |
--limitancestorcount=<N> | 25 | Maximum unconfirmed ancestor count. Deprecated in Bitcoin Core v31 and superseded by --limitclustercount; accepted for config compatibility but no longer gates admission. |
--limitdescendantcount=<N> | 25 | Maximum unconfirmed descendant count. Deprecated alongside --limitancestorcount, and likewise no longer gates admission. |
How sat-cli finds the node
sat-cli reads the node's bitcoin.conf the way bitcoin-cli does, so a
node configured entirely in its config file needs no connection flags:
sat-cli -datadir=/path/to/datadir getblockcount
- Which file.
-conf=<file>(relative paths are under the data directory), else<datadir>/bitcoin.conf; the data directory is-datadiror~/.bitcoin. A missing default file is fine; a missing-conffile is an error. - Which chain.
-chain=<name>,-regtest,-testnet,-testnet4or-signeton the command line, else the file's top-levelchain=orregtest=1/testnet=1/testnet4=1/signet=1, else mainnet. More than one selector is an error. - Which settings.
rpcconnect,rpcport,rpcuser,rpcpasswordandrpccookiefilecome from the command line first, then the active chain's section ([main],[test],[testnet4],[signet],[regtest]), then the top level of the file; the first value in a scope wins. A top-levelrpcportapplies on every chain, as it does for satd itself. The file is read with satd's rules:#starts a comment anywhere on a line, and anrpcpasswordline holding a#is an error. - Which credentials. A non-empty
rpcpasswordfrom either source means user/password authentication, withrpcuserlooked up the same way; otherwise the cookie file, whose relative path is under the chain's data directory. satd writes no cookie whenrpcuserandrpcpasswordare both set, so this is howsat-clireaches such a node. With-rpcwait, a cookie that does not exist yet is read again until the node writes it. -generateis refused: it mines to a wallet address inbitcoin-cli, and satd has no wallet. Usegeneratetoaddressorgeneratetodescriptor.
Live Config Reload (SIGHUP)
Edit bitcoin.conf, then send SIGHUP with kill -HUP <pid> or systemctl reload satd. satd re-reads the file and applies the hot-reloadable options
without restarting. The P2P swarm and chainstate are untouched.
CLI flags remain authoritative across reloads. Only the config file is re-read, so a flag passed on the command line always wins over the same key in the file.
Difference from Bitcoin Core. Core uses
SIGHUPto reopendebug.logfor logrotate. satd has nodebug.log: it logs to stdout and leaves rotation and retention to systemd-journald or the container runtime, soSIGHUPis repurposed for config reload. SeeCORE_DIFFERENCES.md.
A reload sent while satd is still starting is held and applied as soon as startup finishes.
A reload that fails to parse, such as a typo or an invalid value, is logged and
the running config is kept; satd never exits on a bad reload. A
recognized-but-unsupported Core option is skipped with a warning, not an error.
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 keys (applied live)
| Key(s) | Effect on reload |
|---|---|
debug, debugexclude | Log verbosity and categories change immediately; the env-filter is swapped live. |
timeout | New peer-handshake timeout for subsequent connections. |
blocksonly | Turns transaction-relay suppression on or off. |
maxuploadtarget | New rolling 24h upload cap. |
v2transport, v2only | Adjusts BIP 324 v2 transport and v2-only peering for new connections. |
externalip, whitelist | Replaces advertised external addresses and the -whitelist permission set. |
rpcextendederrors, rpcdefaultunits | Switches the RPC error-payload shape and the default amount unit. |
maxconnections, maxinboundperip | New limits govern subsequent connections. Existing peers above a lowered cap are not dropped. |
bantime | New ban duration applies to bans created after the change. |
minrelaytxfee, maxmempool, dustrelayfee, datacarrier, datacarriersize, mempoolfullrbf, limitclustercount, limitclustersize, limitancestorcount, limitdescendantcount, mempoolexpiry, permitbaremultisig | Mempool and relay policy is swapped atomically and governs subsequent transaction admissions. Already-admitted entries are not re-evaluated, except that a lower maxmempool evicts the lowest-ranked entries (by descendant score, as Core) at once, down to the new size. |
connect, addnode, seednode | Newly added peers are registered as at startup and dialed immediately; existing connections are untouched. A new addnode entry is an added node, listed by getaddednodeinfo and removable with addnode remove, and a new addnode or connect host name that does not resolve yet is kept and looked up again on every reconnect tick. Removing an entry does not disconnect that peer (use disconnectnode) or remove the added node (use addnode remove), matching Core's -addnode. The exclusivity of -connect (connect only to these peers, with automatic outbound and DNS seeding suppressed) is a startup-time decision and is not re-evaluated on reload. Adding -connect live dials the new peer but does not put a running node into connect-only mode; restart for that. |
peerblockfilters | Turns NODE_COMPACT_FILTERS advertisement on or off for new handshakes, still gated on a complete blockfilterindex. |
addrindexsubscriptions | New address-index subscription cap, applied to subsequent subscriptions. Lowering it does not evict existing subscribers. |
reorgwebhook, reorgwebhooksecret | Adds, changes, or removes the reorg webhook URL and signing secret. The next reorg uses the new target. |
persistmempool, maxshutdownsecs | No restart needed. The value is read from the reloaded config at shutdown time and governs the next shutdown, not an in-flight one. |
rpcuser, rpcpassword, rpcauth | RPC credentials rotate live on every listener surface; subsequent requests are checked against the new set. The auto-generated cookie is preserved. Values are redacted in the reload log. |
Note.
logformat(json vs text) is not hot-reloadable; only verbosity is. Changing the format requires a restart.
Warning. Removing
rpcuser/rpcpasswordfrom a node started with a static user/pass (that is, without a cookie) leaves no credentials at all. The RPC interface then rejects everything until you restore a credential or restart; a restart regenerates the cookie. satd logs a warning when a reload lands in this state. The cookie file (rpccookiefile/rpccookieperms) and therpcdisableauthmTLS option remain restart-only.
Restart required (reported, not applied)
The following are wired into long-lived state at startup, such as a bound socket, an opened database, or the chain identity. They cannot be swapped without restarting the relevant socket, engine, or process:
- network selection
datadirandblocksdir- all RPC, P2P, Esplora, and Electrum ports and binds
- the RPC cookie file (
rpccookiefile/rpccookieperms) andrpcdisableauth - TLS/mTLS paths and the mTLS CA (the cert and key contents reload via
SIGUSR1; see below) dbcache,prune,storageprofile, and reindexpruneopreturn,pruneopreturnsizeandpruneopreturnpeers, OP_RETURN data pruning, andrestoreopreturns, which undoes it- index enable/disable (
txindex/addressindex/blockfilterindex) - DNS-seed bootstrap (
dns/dnsseed/forcednsseed/fixedseeds/asmap) - Tor (
proxy/onion/torcontrol/listenonion) consensusassumevalidandstopatheightnap,napstorerecoverandnapmaxattestations, the attestation publisher, andnapaggregator, the aggregatoruacomment— the user agent is sent once per connection, in the version message, so a change reaches a peer only on a new connection
Live TLS Certificate Reload (SIGUSR1)
Send SIGUSR1 with kill -USR1 <pid> to reload the TLS server certificates
from their already-configured paths (rpctlscert/rpctlskey,
esploratlscert/esploratlskey, electrumtlscert/electrumtlskey) without
restarting. Every TLS surface re-reads its leaf cert and key from disk and
swaps them in atomically.
This exists 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, so no connections drop and no socket rebinds.
- Only the leaf cert and key reload. Changing the cert or key paths, or
rotating the mTLS client CA (
rpcmtlsclientcaand friends), still requires a restart. CAs are long-lived and do not rotate on the short TTLs this signal targets. - A reload that fails, whether unreadable, malformed, or a cert/key pair that does not 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 does not affect the others.
- Cert rotation is frequent and automation-driven, so it gets a dedicated
signal distinct from
SIGHUP.SIGUSR1does not re-readbitcoin.confand does not run the config diff/apply machinery.
Difference from Bitcoin Core. Core has no
SIGUSR1handler and no native TLS; its RPC is HTTP-only behind a sidecar. satd's native TLS makes in-place cert reload meaningful. SeeCORE_DIFFERENCES.md.
Transaction-Filtering Policy
satd ships an optional transaction-filtering policy language, total and statically cost-bounded. It lets an operator describe transaction shapes to withhold: from relay, from block templates, or both. It never changes what the node accepts as valid.
The principle to understand first: filtering cannot prevent confirmation; it can only decline to assist it.
Note. This chapter is for operators. For the architecture, the quarantine data model, the invariants, the cost and fuel system, and the reasoning behind the quarantine-only stance, see
satd-policy/DESIGN.md.
A transaction shape with real economic demand will confirm through other relay paths or direct miner submission, whatever your policy says. Policy gives you a local, reversible, observable way to decline to help. The observability surfaces below let you watch filtered transactions confirm anyway, block after block, in your own data. That ceiling is what keeps filtering an operator preference rather than an illusion of control.
The quarantine model
There is no reject. Every verdict is quarantine (hold the transaction
back along a scope) or allow (an explicit exemption). A transaction that
matches no rule is acting: relayed and mineable, exactly as without a
policy.
- Acting class: fully assisted. Relayed to peers, served on request, selected into block templates, and visible on every standard surface.
- Quarantine class: held in the same physical mempool but withheld along a
scope:
relay: not announced, not served, not in BIP35mempoolreplies, not rebroadcast. The transaction is still held, so it can be promoted later without loss.template: not selected into block templates and not counted by fee estimation, but still relayed. This scope means "relay neutrally, decline to mine."- both: withheld from everything. A bare
quarantinedefaults to this.
Consensus is untouched by construction: quarantine changes relay and template assistance, never validity. A block containing a quarantined transaction still validates and connects normally.
Rules, first-match-wins, and infectious propagation
A policy file is a version 1 declaration followed by rules. Each rule is
quarantine <name> [on <scope>] when <condition> or allow <name> when <condition>. Rules are evaluated top to bottom and the first match wins. Put
exceptions first.
allowshields a matching transaction from all later quarantine rules. Use it when the whole transaction is yours or trusted. For a narrow carve-out ("spare this one output class"), put a condition inside the matching expression instead; see the cookbook'sdust-stormrule.allowis also capped to the standardness set: it can forgive standardness relay checks, never consensus.- A transaction inherits the union of its quarantined in-mempool ancestors' scopes (infectious propagation): the node will not announce or mine a child whose parent it withholds. This is automatic.
Configuration and reload
Point the node at a policy file with the policyfile=/path/to/policy.txt
config key or the -policyfile flag. The path must be absolute. On startup, a
bad file is fatal. A file that trips the Lightning-enforcement danger gate
(below) is also fatal, unless allowdangerousfilters=1.
The file is live-reloadable on SIGHUP. Every signal re-reads and recompiles
the contents, even if the path is unchanged. The whole mempool is then
re-placed synchronously (promote, demote, evict), and promoted transactions are
re-announced on a bounded drain. A reload that fails to compile keeps the
last-good ruleset and logs the error; there is never a partial apply. To drop
the engine entirely and promote everything back to acting, remove policyfile=
and reload. Re-placement changes only placement, never validity, so a reload is
lossless apart from ordinary budget eviction.
The quarantine class has its own byte budget, the quarantinemempool=<MB>
config key (megabytes, default 50). It is accounted and fee-rate-evicted
separately from the acting mempool, so neither class can crowd the other out.
Lint a policy file offline before you deploy it:
sat-cli policylint /path/to/policy.txt # parse, typecheck, cost report
sat-cli policylint --explain /path/to/policy.txt # plain-English rendering per rule
policylint reports L2 safety in two tiers. The advisory tier never blocks;
silence it with --no-advisories. It flags rules that mention time-sensitive
Lightning and L2 shapes: anchor outputs, witness-size caps, OP_CSV and
OP_CLTV. The danger gate is stricter. It evaluates each rule against
synthetic Lightning enforcement transactions and reports the rules that quarantine
one. A rule that would withhold relay for an enforcement shape makes
policylint exit non-zero (code 3) and makes the node refuse to load the
policy. That refusal is the default. An on template match warns but does not
block, because the transaction still relays.
The danger gate and allowdangerousfilters
The gate exists because a too-broad relay filter can degrade Lightning enforcement network-wide (E1, below), and that failure is silent for the user whose justice transaction never confirms. Detection mirrors the protocols. BOLT-3 legacy and anchor enforcement scripts are spec-mandated, so they are matched exactly. A taproot-channel key-path force-close is indistinguishable from any P2TR key-path spend, so a rule broad enough to catch generic P2TR keyspends is flagged as sweeping them. Taproot script-path enforcement reveals a recognizable tapleaf.
To run such a rule deliberately, set allowdangerousfilters=1 (config key or
flag). The rule then loads with a loud warning instead of being refused. The
gate does not make E1 go away: it cannot catch a rule that snags enforcement
through an angle the probes do not model, so the network-scale critique below
still stands. The gate removes the most common operator mistake. For a loaded
policy, getpolicyinfo reports a danger section with the findings and
whether they are allowed.
Cookbook
These examples span postures on purpose. They demonstrate the language; they are not a starter policy to deploy wholesale. They share one discipline: filter on a self-identifying protocol marker or a distinctive economic shape, never on a structural feature that legitimate traffic also has. Taproot script-path spends, OP_RETURN, large witnesses, and dust all have legitimate uses, and no rule below triggers on any of them alone.
version 1
# ── Permissive: my own submissions are never filtered (allow, posture 1) ──
allow own-submissions when tx.source == rpc or tx.source == mcp
# ── Resource protection: cheap, oversized generic OP_RETURN (posture 2) ──
# Not "OP_RETURN is bad": bulk data that does not pay its way. Small markers
# (OpenTimestamps ~40B) and well-paying data both pass freely.
quarantine cheap-bulk-opreturn when
any output (out.script_type == op_return and out.op_return_size > 83)
and tx.fee_rate < node.min_relay_fee * 3
# ── Resource protection: dust storms, with L2 anchors carved out IN-PLACE ─
# The `!= p2a` carve-out lives inside the expression, not in an `allow`, so a
# real inscription that merely *has* a P2A output is not exempted wholesale.
quarantine dust-storm when
count outputs (out.is_dust and out.script_type != p2a) >= 5
# ── Mine-neutral (template-only) big witness (posture 3) ─────────────────
# Relays like everyone else; excluded only from blocks this node builds.
# Zero relay impact, so a false positive cannot degrade propagation.
quarantine no-mine-big-witness on template when
tx.total_witness_size > 100kb
# ── Restrictive content quarantine: a self-identifying marker (posture 4) ─
# Keys on the protocol's own tapleaf marker, NOT on script-path spends in
# general, so Lightning/vault/MuSig tapscript spends are untouched.
quarantine ordinals when
any input (in.leaf_script.contains_ops(script(OP_FALSE OP_IF push(0x6f7264))))
sat-cli policylint --explain renders each of these as a sentence. Use it to
audit any ruleset handed to you.
The ceiling: what precise filtering cannot reach
- Secret-keyed obfuscation. A marker keyed by something not on-chain cannot be matched without the key; only structural and economic shape remains.
- Migration to standard output types. When a protocol moves to plain P2WSH, its transactions are byte-identical to legitimate P2WSH; filtering them means filtering all of it. A structural filter has to chase each move, and every move raises the collateral floor.
- Untagged anchors. Some anchors (zk-proof anchors, for example) expose no marker. The only handle is a structural template, whose precision equals its uniqueness.
Because every verdict is quarantine rather than reject, a too-broad rule is
survivable. Watch it accumulate legitimate traffic in quarantine, then fix it
losslessly with one SIGHUP. Hard rejection would have made every such
judgment call irreversible and invisible.
Observability
The quarantine class exists to make policy consequences visible. The quarantine
view appears only on the dedicated surfaces below. Every standard mempool
surface (getrawmempool, getmempoolinfo, getmempoolentry, Electrum,
Esplora, the standard MCP mempool tools) presents the acting class only. To a
Core-compatible client, the node behaves exactly like one whose relay policy
refused the transaction. Quarantine never leaks into a Core-compatible
response.
| Surface | What it tells you |
|---|---|
getpolicyinfo | Ruleset path, sha256, and version; per-rule match counters since load; fuel-backstop count; quarantine-class totals. |
getquarantineinfo | The comparison surface: a per-rule rollup (count, bytes, fee-rate span), the confirmed-anyway count (quarantined transactions later mined), and a foregone-fees estimate in sat (what declining to mine is costing you). |
listquarantine [rule] [count] [skip] | The quarantine class as a paged list (txid, rule, scope, time, fee). |
getquarantineentry <txid> | The getmempoolentry analogue for a held transaction. |
policytest <rawtx-hex> | Dry-runs a transaction against the live ruleset: a per-rule trace (matched, decisive), the verdict, and the placement it would receive. The testmempoolaccept analogue for policy. |
sat-tui | The mempool view (3) adds a Quarantine panel: held count and bytes against the budget, confirmed-anyway count, foregone fees, and a per-rule table. The steady view adds the held count to its Txs line. See sat-tui. |
| MCP tools | get_policy_info, get_quarantine_info, list_quarantine, and get_quarantine_entry mirror the JSON-RPC methods. |
| Prometheus | satd_policy_evaluations_total, satd_policy_quarantined_total{rule,scope}, satd_policy_allows_total{rule}, satd_policy_fuel_exhausted_total, satd_policy_reload_failures_total, satd_policy_promoted_total / _demoted_total, satd_policy_quarantine_confirmed_total, gauges for quarantine bytes/count/budget, and satd_policy_foregone_fees_sat. All silent until a ruleset loads. |
Differential tests verify that the standard surfaces and the metrics page are byte-identical whether or not the quarantine class is occupied. A node with no policy is indistinguishable from the same node before this feature existed.
Node-local consequences
Quarantine-only filtering repairs most of the collateral damage filtering does to your own node. Not all of it:
- Compact-block relay is unaffected. Quarantined transactions stay in the
one physical pool, so BIP 152 reconstruction finds them with no extra round
trip. Only transactions evicted from the quarantine budget cost a
getblocktxn, and that cost is bounded by the budget, not by how aggressive your rules are. - Fee estimation is scope-correct. The smart-fee simulator counts the
template class only, so a transaction you quarantine
on templatedoes not inflate the fees you quote to wallets. - Bandwidth. Peers still INV you transactions you quarantine, because no wire protocol expresses arbitrary predicates. You download each once and hold it; there is no re-download churn. Only post-eviction re-announcements cost extra fetches.
- Your own transactions. A locally submitted transaction that draws a
relay-scope verdict is refused at submission with the rule named, rather than
held silently. You never have an invisible transaction of your own. To
override and hold it anyway, pass
allowquarantined=trueon the submit call;getquarantineentryis then authoritative for it.
Network-scale effects
At the consensus layer this changes nothing. A filtering supermajority of nodes and miners still cannot orphan a block containing a filtered transaction; any valid transaction confirms eventually, so long as some hashrate accepts it. But Bitcoin's practical guarantees emerge from relay-layer behavior, and wide adoption of a filtering DSL changes three real things. We publish the strongest case against wide filtering alongside the tool:
- E1: propagation predictability is load-bearing for L2 security. Lightning enforcement (justice transactions, force-close plus anchor CPFP) assumes that a sufficiently-fee'd transaction percolates to hashrate before a timelock expires. That rests on relay-policy homogeneity. A popular copy-paste ruleset whose witness-size cap or anchor rule happens to match force-close or penalty transactions can silently degrade L2 enforcement network-wide. The user whose justice transaction never confirms does not learn why. This is the critique to take most seriously. The strict-by-default danger gate (above) is a partial mitigation: it refuses, by default, the rules whose match against Lightning enforcement traffic it can prove. It does not close E1. It cannot detect a rule that catches enforcement through an angle its probes do not model, and taproot key-path force-closes are indistinguishable from ordinary P2TR spends. The gate raises the floor; it is not a guarantee.
- E2: policy-change friction was an unintentional stabilizer. Today, a mass policy shift needs a Core release or an implementation switch. A DSL plus a viral gist converts policy into a fast, memetic equilibrium with less review than a Core PR. Zero-conf acceptance died through the full-RBF policy rollout, with no consensus change required.
- E3: effective filtering feeds the miner-direct-submission loop. Every shape that stops propagating over p2p creates demand for out-of-band channels (accelerators, direct miner APIs). That shifts power to identified, pressure-able miner endpoints: weaker censorship resistance where it binds, worse submission privacy, and a moat for large miners.
A caveat on the quarantine model: from peers' perspective, a quarantining node and a hard-rejecting node are indistinguishable, because neither propagates the transaction. E1 through E3 are therefore not softened by the quarantine-only design. What quarantine changes is the local picture: collateral damage to your node is repaired, and policy consequences become visible and reversible. The filtering capability itself is exactly as strong as a hard reject.
Two structural observations are acknowledged rather than disputed. The tool is
asymmetric: quarantine is unbounded while allow is capped at standardness,
so at scale it is a ratchet toward more restrictive relay, never more
permissive. And shipped examples become defaults, which is why the cookbook
above is posture-balanced rather than an anti-data starter kit.
Initial Block Download & Fast Sync
This chapter covers getting a satd node to the chain tip: AssumeUTXO fast sync,
the assumevalid script-verification skip options, dual-engine shadow
verification, and the IBD performance and storage tuning flags. Points that
differ from Bitcoin Core are called out throughout.
For the per-key table of defaults, reload disposition, and Core-vs-satd status, see the Configuration Flag Reference. This chapter explains how the pieces work and when to use them.
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, the disk, and the CPU busy at the same time.
- Parallel block download. satd fetches blocks from many peers at once, the
way BitTorrent downloads pieces from a swarm. Download throughput scales with
aggregate peer bandwidth instead of one peer's round-trip time.
-maxaheadbounds how far ahead of the connect tip downloaded blocks may be staged. The 256 blocks just above the connect tip are always fetched, even with the window full, so the block the connector needs next is never held back by it. As in Bitcoin Core, blocks are asked only of peers that advertiseNODE_NETWORK, orNODE_NETWORK_LIMITEDfor blocks within 286 of their tip. A pruned satd advertisesNODE_NETWORK_LIMITEDitself and serves no block more than 290 below its tip, as Core does. - Background prefetch workers.
-prefetchworkersthreads pre-read blocks from the flat files without holding the chainstate lock. They deserialize each block, compute txids, run the context-free transaction validation, and resolve the block's UTXO inputs into the coin cache. All of this happens off the connect thread. When the connect thread reaches a block, its inputs are already cached. - Speculative script verification. Prefetch workers verify scripts ahead of
connection and mark the transactions they verified. The connect thread does
not verify them again. In
assumevalidmode, the connect step can then go 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. It adds almost no wall-clock cost.
Network I/O, block pre-processing, script verification, and chainstate writes overlap instead of running one after another.
AssumeUTXO fast sync
AssumeUTXO makes a node usable in minutes instead of days. The node loads a UTXO-set snapshot at a recent height and serves wallets and queries from it immediately. It validates the historical chain from genesis to the snapshot in the background. satd's implementation is shipped and Bitcoin Core-compatible.
Loading a snapshot
loadtxoutset <path>(RPC) loads a UTXO snapshot file. The node then holds two chainstates. The snapshot chainstate becomes the active tip; wallets, Esplora, Electrum, and RPC serve from it immediately. 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) automates the sequence. satd downloads the snapshot (or reads a local file), waits for header sync to reach the snapshot's anchor, and callsloadtxoutsetitself. Remote sources must behttps://; plainhttp://is refused, and TLS certificates are verified. Use--fast-start-sha256=<hex>to pin the download's digest. Download progress appears in the pre-RPC startup TUI gauge.getchainstates(RPC, Core 27+ compatible) reports progress. A node with no snapshot reports a single fully-validated chainstate. Afterloadtxoutsetit reports a second, background chainstate, and the snapshot entry carriessnapshot_blockhashandvalidated: falseuntil background validation finishes.dumptxoutset <path>(RPC) writes a Bitcoin Core-compatible UTXO snapshot from your own node.
Trust model
At load, satd verifies the snapshot file against a hardcoded anchor hash. A
tampered or wrong-height snapshot is rejected no matter where it came from.
satd hosts no snapshots and does no P2P snapshot fetch; the operator names a
trusted https:// source or a local file. The historical chain is still fully
validated in the background. AssumeUTXO shortens the time to a usable node; it
does not skip validation.
Difference from Bitcoin Core. The RPCs (
loadtxoutset,getchainstates,dumptxoutset) and the two-chainstate model match Core. The--fast-startdownload-verify-load flag and--fast-start-sha256are satd extensions. Core requires a manualloadtxoutsetagainst a file you fetched yourself.
Where to get a snapshot
satd hosts none, and does not name one for you. The anchors compiled into
the binary decide which snapshots are loadable at all — currently mainnet
heights 840,000, 880,000, 910,000 and 935,000, copied verbatim from Bitcoin
Core's m_assumeutxo_data. Signet, testnet and regtest have no anchors, so
fast-start is mainnet-only.
Several people publish the utxo-<height>.dat files Core's dumptxoutset
produces; Jameson Lopp's mirror and https://bitcoin-snapshots.jaonoctus.dev/
are two that have been around a while. Any of them will do, because the host
is trusted for availability only:
satd --fast-start=https://<host>/utxo-880000.dat \
--fast-start-sha256=<sha256 of that file>
--fast-start-sha256 pins what you downloaded, so a truncated or swapped
file fails before it is parsed. That check is a convenience; the one that
matters is the anchor comparison above, which satd performs against a hash
compiled into the binary and which no snapshot host can influence. A
snapshot from a hostile mirror is rejected at load.
Pick the highest anchor height a published snapshot exists for: the higher the base, the less history the background validation has left to walk.
--fast-start-sha256is the file's SHA-256, not the anchor hash.hash_serialized_3in the anchor table is a hash over the UTXO set, not over the file;sha256sum utxo-880000.datdoes not produce it. Take the file digest from the publisher, or compute it after downloading once.
Script-verification skip: assumevalid
-assumevalid controls how much script verification IBD performs. satd
accepts three forms. The third is a satd extension.
| Value | Meaning | Compat |
|---|---|---|
-assumevalid=<blockhash> | Skip script verification for that block's ancestors, under Core's conditions (below). A per-network default ships in the binary (for example, mainnet height 840,000), as in Core. | Core |
-assumevalid=0 | Verify everything; no skipping. | Core |
-assumevalid=all | Skip script verification for blocks older than a cutoff age; verify recent and new blocks in full. The cutoff is -assumevalidage (default 86400 s, 24 h). | satd extension |
With a block hash, a block's scripts are skipped only when all of these
hold, as in Core's ConnectBlock:
- the assumevalid block is in the block index;
- the block is the assumevalid block or one of its ancestors;
- the block is on the best-header chain;
- the best header has at least the network's minimum chain work
(
-minimumchainworkoverrides it); - the best header is more than two weeks' worth of proof of work beyond the block.
Otherwise the block is verified in full: for example, a side-chain block at
or below the assumevalid height, or the most recent two weeks of the chain
while the headers are still close to it. When the decision changes, the log
gives Core's line: Enabling script verification at block #N (hash): <reason>. or Disabling script verification at block #N (hash).
One difference from Core: when the best header is on a branch that does not carry the assumevalid block, satd verifies every block, including blocks below the fork point that Core would still skip. That is stricter than Core, never looser.
Difference from Bitcoin Core. Core's
-assumevalidtakes a block hash or0. satd adds theallkeyword and-assumevalidage, which trust the deep chain and verify the last day without pinning a hash. This suits recurring fast re-syncs.assumevalidis independent of AssumeUTXO, which concerns the UTXO set rather than script verification; the two compose.
Consensus engine & shadow verification
satd ships two independent script-verification engines: Bitcoin Core's C++
script interpreter, built from Core's source through libbitcoinkernel, and a
from-scratch Rust verifier. It can run both
together and verify every script twice. Bitcoin Core has no equivalent.
Read a mode name as "which engine is the shadow". In <engine>-shadow, the
named engine is the shadow, the non-authoritative one. The other engine is
primary; its verdict is what the node acts on. The shadow re-verifies in the
background and logs any disagreement. So:
rust-shadow: the Rust engine is the shadow; C++ is primary.cpp-shadow: the C++ engine is the shadow; Rust is primary.
-consensus=<mode>:
| Mode | Primary (authoritative) | Shadow |
|---|---|---|
rust-shadow (default) | C++ (libbitcoinkernel) | Rust (logs mismatches) |
cpp-shadow | Rust | C++ (logs mismatches) |
cpp | C++ (libbitcoinkernel) | none (single engine) |
rust | Rust | none (single engine) |
The Rust engine passes Bitcoin Core's script test suite. Shadow verification
against Core's C++ interpreter (then libbitcoinconsensus) across the whole
mainnet chain, genesis to about height 945,000, found zero divergence. Both
engines hash a transaction's signature data once and reuse it for every input,
as Core's own CheckInputScripts does. (Up to 0.7.x the C++ engine was
libbitcoinconsensus, whose API made it redo that work for every input, so a
transaction with many inputs cost time quadratic in their number.)
rust-shadow (C++ primary) stays the default out of conservatism. Running two
independently written engines against each other is satd's core safety
property, and Core's interpreter is the most widely deployed implementation.
The plan is to promote the Rust engine to primary as it accumulates production
mileage; cpp-shadow is that step. Treat the single-engine rust mode with
care. The engine itself is proven, but either single-engine mode gives up the
dual-engine cross-verification. satd prints a caution at startup when the
single-engine rust mode is selected.
The shadow engine runs on a bounded background worker pool, so it consumes spare CPU without slowing block connection. Three flags tune it:
-shadowworkers=<n>(default 4): background shadow-verification threads.-shadowqueuesize=<n>(default 4194304): the most transactions the shadow work queue holds.-shadowqueuemb=<n>(default 128): the most memory, in MiB, the queued work may hold. Each queued transaction holds its serialized bytes and a copy of every output it spends. On a host with few cores the shadow engine can fall behind the primary for hours during IBD, and this budget, not the entry count, is what keeps the queue from growing into the process's memory limit.0removes the byte bound and leaves only-shadowqueuesize.
When either bound is reached, shadow work is dropped, and an aggregated WARN
is logged at most once per 5 s with the queue's depth and bytes. The primary
engine still verifies every script, so correctness is unaffected. Frequent
drops mean the shadow engine cannot keep up on this host; -consensus=rust or
-consensus=cpp runs a single engine.
Difference from Bitcoin Core. Core has a single C++ engine and no shadow mode. satd's default runs both engines at once.
-consensus,-shadowworkers,-shadowqueuesizeand-shadowqueuembare satd-specific.
IBD performance & storage tuning
These flags bound or accelerate IBD. Full defaults and semantics are in the Configuration Flag Reference.
| Flag | Default | Notes |
|---|---|---|
-dbcache=<MB|auto> | 450 | Write-cache size. auto (satd) starts a controller that resizes the RocksDB block cache and CoinCache against /proc/meminfo pressure. Core's -dbcache is a static number only. |
-par=<n> | unset | Script-verification threads (Core name). satd's connect path manages its own parallelism, so -par does not size it directly. When -shadowworkers is unset, a positive -par value is used as the shadow-verification worker count; otherwise the default of 4 applies. |
-prefetchworkers=<n> | CPU cores | (satd) IBD block-prefetch worker threads. |
-maxahead=<n|N%|all> | 50000 | (satd) How many blocks IBD may stage ahead of the connect tip. |
-storageprofile=<ssd|hdd> | ssd | (satd) RocksDB tuning class for the storage medium. |
-maxopenfiles=<n> | 2048 | (satd) RocksDB max_open_files cap (-1 = unlimited). |
-rocksdbbackgroundjobs / -rocksdbsubcompactions / -rocksdbwalmb | from profile | (satd) Advanced RocksDB overrides. |
-compactionl0at=<n> / -ibdl0pauseat=<n> | 16 / 64 | (satd) Force chainstate compaction at N L0 SST files; pause the IBD connector at N L0 files so compaction can catch up. |
-compactionintervalsecs / -compactiondiagintervalsecs | 1800 / 60 | (satd) Periodic forced compaction and pending-compaction diagnostics (0 disables). |
-stallwatchdogsecs / -stallabortsecs | 300 / 300 | (satd) If the tip does not advance for N seconds, dump forensics, then abort after a further grace period. A silent IBD wedge becomes a loud, debuggable failure. |
-dbcache, -prune, -txindex, -assumevalid, and -reindex keep Core's
names and meanings. The rest of the table is satd-specific tuning with no Core
equivalent.
Reindexing
-reindexrebuilds both the block index and the chainstate from the block files on disk (Core-compatible).-reindex-chainstaterebuilds only the chainstate (the UTXO set) from the existing block files, and preserves the flat block files (Core-compatible). It is faster than a full-reindexwhen only the chainstate is suspect.
Both work against block files written by Bitcoin Core, including the
XOR-obfuscated files Core v28.0+ produces by default. The key in
blocks/xor.dat is picked up automatically (see blocksxor in the
Configuration Flag Reference).
A reindex on a synced mainnet node runs for hours. The shipped systemd unit
handles this without tripping the start timeout; see "Reindex resilience" in
Packaging.
A rebuild that does not finish
Both rebuilds start by wiping what they rebuild, and the wipe marks the
chainstate current and every index complete before the first block is
replayed. A rebuild cut short, by a kill, a power cut, a container stop, or
-stopatheight below the chain's tip, therefore leaves a UTXO set and indexes
that end partway while reading as finished; and satd cannot connect the rest
by itself, because the block index already records those blocks as connected.
So satd writes a marker, .chainstate_rebuild in the network datadir, before
the wipe, and removes it only once the rebuild has reached the chain's tip. A
start that finds it acts on it:
| Unfinished rebuild | Plain start | -upgradechainstate=1 | -reindex-chainstate | -reindex |
|---|---|---|---|---|
-reindex-chainstate (or an upgrade) | refuses | restarts it from genesis | restarts it | runs |
-reindex | continues it | continues it | continues it | continues it |
A full -reindex that did not finish is finished on any start, whatever the
flags. It continues from the height it last flushed: satd scans the block
files again, checks each block it had already replayed against them (the
index entry's header, file position, height and chainwork), and connects the
rest. Nothing is downloaded and no block file is written. The replay flushes
at least every 1000 blocks, so a kill costs at most that much replay, plus the
scan. The scan reads each block's header, not the whole block: at most 4 KiB
per block.
It runs again from genesis instead when it cannot be continued: its wipe may not have finished, satd 0.6.0 started it (0.6.0 did not record whether the wipe had finished), the chainstate is from another schema, or the blocks it replayed no longer match the block files. In the last case satd says so and exits without changing anything, and the next start runs the reindex from genesis. A chainstate rebuild trusts the block index, so it cannot finish a full one.
An unfinished chainstate rebuild is not continued: its flags restart it from genesis. If the chainstate directory itself is gone (removed to resync), the marker protects nothing and is removed at the next start.
-reindex-chainstate -stopatheight=H stops short on purpose and is refused
all the same on the next plain start: the node could never advance past H.
A stop signal (SIGTERM or SIGINT) during a rebuild stops it at once: satd
abandons the block it is connecting, flushes what it had connected, keeps the
marker and exits 0, and the next start continues a full -reindex from there.
See "Signals" in Packaging.
Block files that end early
A full -reindex can only rebuild what the block files hold. It reads
blk00000.dat, blk00001.dat and on in order, stops at the first missing
number, and stops reading a file at a record that is cut off. A file lost in a
partial restore or an interrupted copy therefore ends the rebuilt chain at the
hole.
satd holds the replay to the highest tip the node is known to have reached
before the wipe: the chain database's tip, an unfinished rebuild's marker, or
the clean-shutdown marker, whichever is highest. When the block files end
below it, satd replays what they hold, then syncs the rest from peers as Core
would. It also logs an error, does not log the reindex as complete, and raises
the reindex.short warning (getwarnings, getblockchaininfo). The warning gives both heights and locates the hole: it names
the missing file, or the first file where stored blocks pick up again without
their parents. Restoring that file and running -reindex again saves the
download.
A pruned node is exempt: pruning deletes the oldest block files on purpose, so
its reindex always downloads the chain again. A block on the chain whose stored
bytes fail validation stops the replay too, with the reindex.halted warning.
Upgrading the chainstate unprompted
-upgradechainstate=1 runs -reindex-chainstate by itself when it is
needed and does nothing when it is not, so it can stay on for every start:
- the chainstate was written at an older schema (a release that changed the
storage format, such as 0.6.0's schema 7): satd logs one warning and
rebuilds the UTXO set and every enabled index from the block files, as
-reindex-chainstatewould; nothing is downloaded; - a chainstate rebuild did not finish: satd restarts it from genesis.
It never downgrades (a chainstate a newer satd wrote is refused, as without
the flag), and it refuses a pruned node, which does not have the blocks a
rebuild needs and must resync. An AssumeUTXO node's background chainstate is
not covered: finish or discard background validation before upgrading. The
app-store packages and the reference stack set upgradechainstate=1 in the
configuration they generate, which is how an app-store node crosses a schema
change without a shell.
Driving a reorg by hand
invalidateblock and reconsiderblock work as in Bitcoin Core, and reach the
node through sat-cli's raw-RPC passthrough:
sat-cli invalidateblock <blockhash>
sat-cli reconsiderblock <blockhash>
They are not listed in sat-cli --help — any method --help does not name is
forwarded verbatim, which is how Core-compatible tooling keeps working. That
makes them easy to miss when they are the tool you need.
Invalidating drives a reorg away from the named block and everything descended
from it. reconsiderblock clears the mark and re-activates the best chain.
Startup integrity checks
Before serving RPC or connecting to peers, satd checks two things about the chain it is about to present.
The height→hash index is audited for gaps at or below the tip and rebuilt in place from the tip's ancestry. It is derived state, so this is a repair and startup continues. Heights whose rows disagree with the tip's ancestry are logged but never overwritten — correcting one means choosing between branches by chainwork.
The tip's ancestry is walked back one retarget period, and every block in it must be one this chainstate actually connected. This is not repaired. A block in the tip's ancestry that was never connected means the UTXO set is missing every output it created, and the only way to recover those is to replay the block. satd reports the affected heights and exits:
FATAL: this node's UTXO set does not agree with the chain its tip claims.
* The tip stands on 8 block(s) that were never connected.
...
Refusing to start. Rebuild the UTXO set with -reindex-chainstate.
Serving in that state is worse than not starting: the tip is a real block on
the real chain, the height is correct, and gettxout answers confidently and
wrongly.
Two distinct faults can be reported, and they do not share a remedy. Blocks
that were never connected mean the UTXO set is missing deltas, which
-reindex-chainstate rebuilds. A broken parent pointer means the block index
itself is wrong; -reindex-chainstate trusts that index, so only a full
-reindex fixes it. Both can be present at once, and both are printed.
The exit status is 3, distinct from the 1 used for ordinary startup
failures such as a bad config key, so supervision and alerting can tell
"chainstate is damaged" from "the config file has a typo" without scraping
stderr. Because this never heals by restarting, the shipped units set
RestartPreventExitStatus=3 alongside Restart=always; without it the node
would restart every few seconds forever and the unit would never settle into
failed, so unit-state alerting would never fire. If you wrote your own unit,
add that line.
After a -reindex or -reindex-chainstate, the audit runs again against what
the replay actually rebuilt — a replay that stops short or reproduces the hole
fails the same way, in the same run, rather than serving until the next
restart.
On a pruned node none of these remedies can replay the missing blocks,
because the block data is gone. satd says so rather than naming a remedy that
cannot work: fetch the affected heights from a peer with getblockfrompeer and
repair them, or resync.
On an AssumeUTXO node the history below the snapshot base is legitimately
unvalidated until the background chainstate reaches it. That is recognised and
logged at INFO, not treated as damage.
A coin the database cannot read
If RocksDB cannot read a coin from the chainstate (an I/O error or a checksum
mismatch), or a coin's row does not decode, satd stops at once, as Bitcoin Core
does. This holds wherever a coin is looked up: block connection, the mempool,
gettxout, the Electrum and Esplora lookups, and the offline
satd-chainstate-audit below. The log and stderr say Error reading from database, shutting down, with the outpoint and the error. satd does not carry
on as if the coin were spent: that would refuse a valid block that spends it,
and record the block as invalid across restarts.
The stop is an abort() (the process ends with SIGABRT), so no shutdown
flush runs. The next start replays the blocks after the last flush from the
block files, as after any crash. If the error comes back after a restart, the
disk or the datadir is damaged: check the disk, then rebuild the UTXO set with
-reindex-chainstate, or audit a copy of the datadir first (below).
Auditing a suspect datadir offline
satd-chainstate-audit answers the question the startup checks cannot afford
to: does the UTXO set actually agree with the blocks on the active chain? It
walks the tip's ancestry, reads each block back from the flat files, and reports
every disagreement — coins that should exist and do not, spent coins still
present, height-index rows naming the wrong block, txindex rows pointing at the
wrong block, cumulative transaction counts that do not follow from their parent.
satd-chainstate-audit --datadir /path/to/datadir
satd-chainstate-audit --datadir /path/to/datadir --window 20000 --verbose
It takes the RocksDB lock, so the node must be stopped.
It issues no writes of its own, but it is not non-mutating: opening the
chainstate opens RocksDB read-write, so the WAL is replayed and truncated,
memtables may flush and compact, the MANIFEST is rewritten, obsolete files are
deleted, any missing column family is created, the legacy address-history
column families are dropped, and the schema version is stamped — after which
an older satd will no longer open that datadir. Opening the block files creates
xor.dat if absent. If the datadir
is evidence — which is the case this tool exists for — copy it and audit the
copy. The tool prints this warning on every run.
Note also that it is not included in the release tarballs or the Docker image;
build it from source (cargo build --release --bin satd-chainstate-audit).
Exit status is 0 when consistent, 1 when it could not run, 2 when it found
inconsistencies, so it scripts cleanly. A coin the database cannot read stops
the tool as it stops the node (see above): it prints Error reading from database with the outpoint and the RocksDB error, which names the damaged
file, and ends with SIGABRT before any report.
It diagnoses and does not repair. A missing coin is recoverable only by
replaying the block that created it: -reindex-chainstate, or
satd-chainstate-repair for a single block's lost delta. A broken parent
pointer is a block index fault and needs -reindex — -reindex-chainstate
trusts the same block index and cannot fix it.
--window bounds the walk, and its cost is not only one block read per height:
every output the window creates and every outpoint it spends is held in memory
until the end, so the default already runs to roughly a gigabyte on mainnet and
tens of thousands of blocks runs to many. Start at the default and widen only as
far as the search needs.
There is no --txindex flag: the tool reads the answer out of the datadir. An
absent txindex row counts as a fault only when the chainstate's own completeness
marker says the index was fully built, which rules out both shapes that would
otherwise produce a false alarm — a node that does not run -txindex at all
(satd's default), and one where -txindex=1 was switched on after the chain had
already synced without it, leaving every historical block without a row it was
never going to have. In that second case the audit says the rows went
unchecked rather than counting them clean, because "not looked at" and
"looked at and fine" are different answers.
This used to be a flag, and it was wrong in both directions. It defaulted to
true while satd's -txindex defaults to off, so the invocation the node
itself prints reported every transaction in the window as a missing row and
exited 2 against a perfectly healthy node; and passing false silently disabled
the txindex checks altogether, so a genuinely broken index came back
consistent. An auditor cannot be expected to know a stranger's -txindex
setting, and now does not have to.
Two states are reported but are not faults. Blocks the node pruned are
counted separately from blocks that could not be read: pruning deletes block
data deliberately, and treating that as damage would fail every healthy pruned
node — at the default window, for most of the range — and then recommend
-reindex-chainstate, which a pruned node refuses outright. On an AssumeUTXO
node the snapshot base is read from the background chainstate's marker, so
history below it is reported as not-yet-validated rather than as a hole.
Where a block could not be read, for either reason, the coin checks are skipped at and below that height — its spends are unknown, so a coin it spent would otherwise look missing. The tool prints a note when this applies. Verdicts above that height are unaffected: the walk runs newest-first, and a coin created at height H can only be spent at or above H.
Differences from Bitcoin Core at a glance
assumevalid=allwithassumevalidage: verify-recent-only mode. Core takes a hash or0.- Dual-engine shadow verification (
-consensus,-shadowworkers,-shadowqueuesize,-shadowqueuemb): the default runs the C++ and Rust engines together. Core has one engine. --fast-start/--fast-start-sha256: one-flag AssumeUTXO download-verify-load. Core requires a manualloadtxoutset.-dbcache=auto: adaptive cache sizing. Core's is static.- satd-only IBD and storage options:
-prefetchworkers,-maxahead,-storageprofile,-maxopenfiles, the-rocksdb*and-compaction*families,-ibdl0pauseat, and the stall watchdog. -paris accepted for config compatibility. It does not size the connect path, but a positive value feeds-shadowworkerswhen that flag is unset.
Disk Footprint & Indices
A fully-indexed satd node (-txindex=1 -addressindex=1 -blockfilterindex=basic)
uses more disk for its indices than a bitcoind + electrs/Fulcrum + esplora
stack uses in total. This is by design. This chapter explains where the bytes go
and what they pay for.
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 during a reorg, so no tombstone debt accumulates over time.
| Column family | Role | Keyed by | Row size | Before | After (projected) |
|---|---|---|---|---|---|
addr_spending_v3 | every input spending a script | scripthash[16] ‖ txseq[5] ‖ vin[3] | 32 B (was 92) | ~256 GB | ~89 GB |
spent | UTXO → the input that spent it | funding_txseq[5] ‖ vout[3] | 16 B (was 76) | ~186 GB | ~39 GB |
addr_funding_v3 | every output paying a script | scripthash[16] ‖ txseq[5] ‖ vout[3] | 32 B (was 64) | ~178 GB | ~89 GB |
tx_loc + txseq_txid | txid ↔ transaction ordinal | txid[32] / txseq[5] | 37 B each (was 64, one way) | ~79 GB | ~91 GB |
txseq_block | first ordinal of a block → height | txseq[5] | 9 B | — | ~10 MB |
undo | per-block disconnect data | block_hash[32] | ~33 B / input | ~74 GB | ~87 GB |
sp_tweaks | BIP 352 tweaks, one row per block from taproot activation | height | 73 B/eligible tx | ~13 GB | ~13 GB |
coins | the live UTXO set | txid[32] ‖ vout | ~29 B varint | ~10 GB | ~10 GB |
block_index | header and status per block | block_hash[32] | ~100 B | ~120 MB | ~120 MB |
block_filter / _header | BIP 158 compact filters | type ‖ height | ~30 KB / 37 B | ~30 GB | ~30 GB |
| total | ~826 GB | ~449 GB |
Read the two columns differently. Before is measured: the per-CF SST
totals of one fully-indexed mainnet node in August 2026, at height 963,000
with txindex, addressindex and silentpaymentindex all on. After is
a projection — the measured figure scaled by the change in row width — for
the chainstate schema that keys every index on a transaction ordinal. It
has not yet been re-measured on a synced node; when it is, this table gets
real numbers and this paragraph goes away.
The projection is conservative. Both columns are post-compression (LZ4 at L2–L5, Zstd at the bottom), and the dense ascending ordinals that replaced the 32-byte hashes compress considerably better than the hashes did — a hash is incompressible by construction — so the measured after-figure should land at or below the projection rather than above it.
Two rows in the after column go up, and both are deliberate. undo
gains five bytes per spend and coins about one per coin, because a coin
now carries the ordinal of the transaction that created it. That is what
lets connect_block key a spend row without a lookup: paying ~15 GB
across the two smallest families takes ~150 GB off the two largest. The
transaction index also goes up, because it is now two families instead of
one — that reverse map is what every other index drops its txid copies
for.
Two rows often surprise operators. undo is not a rolling window: satd
keeps the disconnect data for every block, so it grows with the chain.
coins is the live UTXO set, which is served from the in-memory coin
cache but still serializes to several GB.
The filter row is the one figure here that was always an estimate. The
measured node does not run blockfilterindex.
Note. During a
-reindexor-reindex-chainstate, RocksDB compaction falls behind the write rate, so the transaction index in particular can read much larger than its settled size (uncompacted L0 SSTs, bloom filters, and index blocks). Measure the per-CF footprint after the node has idled and background compaction has drained; see Compaction.
To take the same measurement on your own node, ask it:
sat-cli debug storage-footprint
It reports, per column family, the on-disk SST bytes, an estimate of the live
key count, their quotient (the effective post-compression cost of one row), and
the bytes RocksDB still owes to compaction. The RPC behind it is
getstoragefootprint; it reads RocksDB properties and LSM metadata only, with
no iteration, so it is safe on a live node under IBD — unlike
getblockfileaudit. Column families the running binary did not create — the
filter indexes on a node without -blockfilterindex, the backfill temp CF on a
node that never backfilled — are absent from the list rather than reported as
zero. Take the measurement after compaction has drained; see
Compaction.
Why it is larger than bitcoind + electrs + esplora
Three structural reasons.
1. satd stores the spend graph in both directions
Every spend writes two rows:
addr_spending_v3, keyed by script (scripthash ‖ txseq ‖ vin). It answers "show me everything address A spent."spent, keyed by the spent output's funding transaction and vout. It answers "what input spent this UTXO" in a single keyed read.
electrs and Fulcrum keep one spend representation and derive the other direction on demand. satd spends the disk to keep both materialized, so both queries are O(1). This duplication is internal and intentional, and it is the largest source of the overage.
2. satd indexes a superset of what any one external tool does
The often-quoted "30–180 GB" figure is the electrs/Fulcrum address index alone.
satd's address index alone (addr_funding_v3 + addr_spending_v3) already
exceeds that range. satd also carries a transaction index, a spent
reverse index, and BIP 158 filters in the same database, because one binary
serves Electrum, Esplora, getrawtransaction, and compact-filter clients. So
compare satd's indices to electrs plus Core's txindex plus a spend index plus
a filter index, fused into one store.
3. satd keys on transaction ordinals
Every index used to key on a 32-byte txid. A txid is a hash: incompressible, and written once per index that mentions the transaction. On a fully indexed node that repetition was the single largest line in the chainstate — the same identifiers stored five to seven times over.
An ordinal replaces it. Transactions are numbered in chain order from the
genesis coinbase at 0, so the transaction at position i of a block takes
nchaintx(parent) + i — the cumulative count satd already keeps per block for
getchaintxstats. Nothing new has to be counted. Five bytes hold 2^40 of
them, about 880 times the chain's current transaction count, and the encoding
is big-endian, so the byte order of a key is the order the transactions were
mined.
Two column families hold the mapping: tx_loc from txid to ordinal, and
txseq_txid back. The reverse map is the part that makes the rest work — a
row keys on an ordinal, and the storage layer resolves it to a txid, in one
batched lookup per scan, before the row leaves. A third, txseq_block, is one
small row per block that turns an ordinal back into a height. Because an
ordinal names the position inside the block as well as the block,
getrawtransaction also indexes straight into the block's transaction list
instead of reading the block and comparing every txid in it.
The ordinal is a storage encoding, not a new public identifier. Nothing on any wire changes.
Against Core, the comparison is with CDiskTxPos — an on-disk position of
about 12 bytes, compact but tied to the block-file layout. satd's 5-byte
ordinal is smaller and stays independent of that layout, so a block-file
re-pack does not invalidate the index.
The design is borrowed. rbitcoin stores the chain as a decomposed relational archive — separate bodies for outputs, input witnesses and spend annotations, with a dense 32-byte-per- transaction txid sidefile — and numbers everything by position rather than by hash. satd keeps Core-compatible flat block files and a UTXO set, so the two designs are not interchangeable, but the observation that the hashes are the footprint, and that one dense sidefile can carry them for every index at once, is rbitcoin's.
satd's keys are otherwise fixed-width binary tuned for prefix seeks rather than byte-minimal, which costs a little space and speeds up range scans.
What satd already does to keep the footprint down
The schema is close to the smallest encoding of what it indexes:
- 16-byte scripthash prefix, not 32. Address rows key on the first half of
sha256(scriptPubKey), which halves the dominant field of every address row. Collisions are extremely unlikely and are resolved against the full script on read. - Varint-packed UTXOs. The
coinsCF uses a compact varint encoding, about 28 B typical against about 43 B for a naive struct. - Fixed-width keys, no delimiters. Ordinals are big-endian, so range scans return rows in chain order with no secondary sort.
- One copy of each transaction identifier. Index rows name transactions by
a 5-byte ordinal; the 32-byte txid lives once, in
txseq_txid.
The size is row_count × ~32 B, and row_count is every output and every
spend in Bitcoin's history. The footprint is data, not per-row overhead.
What the disk buys you
| Property | satd (shared store) | bitcoind + electrs/Fulcrum |
|---|---|---|
| Index vs. tip consistency | Always atomic: the index update is in the same WriteBatch as the block | Index lags the node; reorg-window races are possible |
| Build cost | Index built inside connect_block validation | Second process re-scans every block to build a parallel DB |
| Lookup path | O(1) keyed read, in-process function call | Cross-process RPC plus the indexer's own lookup |
| Spend-by-outpoint | O(1) (spent) | Often derived or scanned |
| Operational surface | One process, one config, one backup, one reindex | Two or more processes to wire, monitor, and keep in lockstep |
| TLS / auth | Native on every surface | Usually a separate reverse proxy |
| Disk | Larger in aggregate | Smaller per tool, but you run several |
The disk pays for consistency and a single process to operate. 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. To scale read throughput, run more nodes rather than more index processes; see API Scaling & Runtimes.
Choosing what to index
The indices are opt-in per surface. Match the disk to what you serve:
| You want… | Flags | Heavy CFs pulled in |
|---|---|---|
| Validating node only | (defaults; indices off) | none |
getrawtransaction <txid> anywhere | -txindex=1 | tx_loc, txseq_txid, txseq_block |
| Electrum / Esplora address history | -addressindex=1 (implies -txindex=1 for Electrum) | addr_funding_v3, addr_spending_v3, spent, tx_loc, txseq_txid, txseq_block |
| BIP 157/158 light-client service | -blockfilterindex=basic -peerblockfilters=1 | block_filter, block_filter_header |
| BIP 352 silent-payment scanning or serving | -silentpaymentindex=1 | sp_tweaks |
When a surface is off, its CF is never written and the disk is never spent.
Silent-payment index
sp_tweaks holds one BIP 352 public tweak per eligible transaction, grouped
into one row per block. The silentpaymentindex option enables it, and it is
off by default. Two surfaces read it: the streaming tweaks firehose and
index-accelerated scan-key-watch rescans; the integrator guide for both is
Silent Payments (BIP 352).
The index starts at taproot activation, not at genesis, because pre-taproot blocks carry no silent payments. Each indexed block writes a row even with no eligible transaction, so an empty row means "indexed, none" rather than "not indexed". Every row embeds the hash of the block it describes, so a reader authenticates it without the height-to-hash index.
A node that syncs from genesis with the option set builds the index inline. To add the index to an existing datadir, run a backfill:
sat-cli backfillindex silentpayment
The backfill walks from taproot activation to the snapshot height it pinned at
start, and resumes across a restart. getsatdindexinfo reports a silentpayments
section with the synced flag and the backfill progress, including a
backfill.progress_ratio field. Progress is measured across that walked span,
not from genesis, so it starts near zero rather than near the fraction of the
chain that predates taproot — use the reported ratio rather than dividing
cursor_height by snapshot_height, which measures from genesis.
estimated_remaining_seconds is reported only while a backfill is both enabled
and running. A paused, cancelled, failed or disabled cursor reports 0 — its
progress is frozen while elapsed wall-clock keeps growing, so any estimate
derived from it would grow without bound for as long as the node stays up.
The estimate is measured over the current stint — the uninterrupted span since the running backfill last started walking blocks. It is not an average over the life of the job. Two consequences worth knowing:
- Time the backfill was not working is never counted. Pause it for two days and resume, or stop the daemon for a week and restart, and the estimate reflects the throughput it is achieving now, not the idle time in between.
- The estimate is unavailable for the first few seconds after a start, a
resume, or a daemon restart, and reads
0until the new stint has measured something.0here means "no estimate yet", not "nearly done"; thestateandcursor_heightfields are the ones to watch during that window.
This applies to all three backfills (address, basic block filter index,
silentpayments) — they share one estimator.
Until a backfill completes, the tweak-serving surfaces refuse a request rather than return a partial result.
Size and backfill time
The figures here are measurements from a synced mainnet node, taken in August 2026 over heights 709,632 to 962,151. That span is 252,520 blocks, holding 187,015,795 tweak entries.
| Measure | Value |
|---|---|
| Row content, full taproot era | ~13 GB |
| Mean eligible transactions per block, whole era | ~740 |
| Mean eligible transactions per block, recent blocks | ~260 |
| Mean row | ~54 KB |
| Growth at the recent rate | ~1 GB/year |
| Backfill wall-clock for 252,520 blocks | 6 h 46 m (~10 blocks/s) |
A transaction is eligible when it pays a taproot output and has at least one input whose public key the protocol can recover. Recoverable inputs are P2PKH, P2WPKH, P2SH-P2WPKH and key-path P2TR. A transaction funded only by P2WSH, bare multisig or script-path P2TR pays taproot and indexes nothing. Measured against the index, about 98% of taproot-paying transactions across the era are eligible.
The era mean is far above the recent rate. Blocks from the 2023 and 2024 inscription period average about a thousand eligible transactions, and the busiest carry several thousand. A return to that transaction pattern raises the growth rate again.
The backfill ran while the node stayed at the tip and served its other surfaces. It is bound by CPU rather than by disk. The walk does one elliptic curve multiplication per eligible transaction. Its measured throughput tracks the eligible-transaction count, not the block size, and roughly 70% of that 6 h 46 m was per-transaction work.
Note. A datadir where a backfill was interrupted and restarted can read larger than the figure above until compaction reclaims the superseded rows. The node measured here read about 15 GB for a 13 GB index for that reason.
Note. A
tweak_dust_limiton a subscription drops entries whose largest taproot output is below the limit. It filters less than its name suggests. At 330 sat, the dust threshold for a taproot output, it removes nothing measurable. At 546 sat it removes about 10%, and at 0.001 BTC about two thirds. The limit reduces the bandwidth a subscription uses. It does not reduce the index on disk.
OP_RETURN data pruning
Undoing it means downloading the stripped blocks again.
- Turning
pruneopreturnoff restores nothing.restoreopreturnsdownloads every stripped block again from peers (see Restoring stripped blocks);getblockfrompeerbrings back one.- Once anything is stripped, satd 0.6.x and earlier refuse to open the datadir (RocksDB reports
Column families not opened: opreturn_strip), so there is no rollback to them. This is deliberate: they would misread the zeroed data.- The node stops serving old blocks to peers, like a pruned node.
What it costs the network. A stripping node is no longer an archive peer.
It drops NODE_NETWORK and advertises only NODE_NETWORK_LIMITED, so new
nodes cannot sync the chain from it. It serves only the last 288 blocks: a
peer asking for an older one is disconnected, as Core does for a pruned node,
and a noban peer asking for a stripped block gets notfound. Headers and
BIP 157 filters are still served for the whole chain. It stays limited for as
long as any stripped block exists, so turning the option off does not by
itself bring NODE_NETWORK back; restoring every stripped block does (see
Restoring stripped blocks). An operator who wants to help the
network with initial block download should leave the option off.
pruneopreturnpeers=intact serves the blocks the node still holds intact at
any depth (and advertises NODE_INTACT_BLOCKS, experimental service bit 28),
but Bitcoin Core and most other nodes do not ask a limited peer for old
blocks, so it mainly helps getblockfrompeer callers and nodes such as
rbitcoin that do.
How it works
With pruneopreturn=N, a background worker walks the active chain below
tip − N − 2. In each block it zeroes the payload of every non-coinbase
OP_RETURN output whose scriptPubKey is at least pruneopreturnsize bytes long
(default 4096), keeping the leading OP_RETURN byte and every length, and
punches the zeroed range out of the block file (fallocate(PUNCH_HOLE) on
Linux, F_PUNCHHOLE on macOS). No offset moves. Before it touches a byte it
stores the block's original txids and a hash of its zeroed form, so every
surface still names each transaction by its real txid, and -reindex and
-reindex-chainstate replay the block from that record.
pruneopreturn=4320 # about 30 days; 52560 is about a year; 1 or all = 288
pruneopreturnsize=10KiB # 10k is 10,000 bytes, 10KiB is 10,240
The worker runs only on a quiet node: never during initial block download, a
reindex or an AssumeUTXO background validation. Blocks are fully validated
whatever the setting; only what is kept on disk afterwards changes. It works
alongside prune, the indexes, Esplora and Electrum.
Filesystem support. The blocks directory's filesystem must support hole
punching (ext4, XFS, btrfs, ZFS, tmpfs, APFS). satd probes it at startup and
refuses to start, naming the error, when it does not. On APFS the payload is
gone from the block file as on Linux, but giving the space back is
best-effort: APFS can allocate a hole again, as zeros, when it writes the
block beside it, so blocks_dir_allocated_bytes can drop by less than
bytes_freed_estimate. APFS local snapshots, which Time Machine takes, keep
the original bytes until they expire.
What it saves
Hole punching frees only the whole filesystem blocks inside each payload, usually 4 KiB.
| scriptPubKey size | freed on a 4 KiB-block filesystem |
|---|---|
| ≤ 83 B (Runes, Omni, OpenTimestamps, most of today's) | 0 |
| 210 B (median of today's >83 B outputs) | 0 |
| 4 KiB | 0 to 4 KiB depending on alignment, average ~0.5 KiB |
| 10 KiB | ~6 KiB on average |
| 100 KB (Core v30 standard maximum) | ~96 KB |
Today's chain gives back almost nothing: most OP_RETURN outputs are far smaller than a filesystem block. The option bounds what large data-carrier outputs cost if their use grows. A threshold below one filesystem block is accepted, with a startup note: such outputs free little or no disk, and each stripped transaction costs about 100 bytes of index.
This removes the data from the node's block files; it is not a secure erase, and copies, snapshots, backups and the last 288 blocks keep the original bytes.
What you lose
Every other surface keeps working (Esplora, Electrum, the JSON-RPC extensions, streaming).
- An archival peer, as above.
- The raw bytes of stripped transactions, on every surface:
getblockat verbosity 0 (Core'sBlock not available (pruned data), which LND's pruned-node fallback recognizes),getrawtransactionhex, Esplora/rawand/hex, Electrumblockchain.transaction.getwithoutverbose, andraw_txin streaming rescans. Their JSON views still work, with each stripped script shown as a bareOP_RETURNand its original size. - Watches on OP_RETURN script content cannot match a stripped output in a rescan. Live matching, as blocks connect, is unaffected.
- Deep reorgs. A reorg or
invalidateblockthat would disconnect a stripped block fails with an error naming it; nothing changes. - Script re-verification on reindex. The input scripts of stripped
transactions are not verified again on
-reindex; they were when the block first connected. Everything else is. - The
blk*.datfiles. Tools that parse them directly see zeroed payloads. Copying them with tools that are not sparse-aware (plaincpwithout--sparse=always,rsyncwithout-S) fills the holes back in on the copy.
Restoring stripped blocks
To make a stripping node a full archive peer again, remove the three
pruneopreturn* options and restart with:
restoreopreturns=1
The node then downloads every stripped block again, as a small initial block download, and writes each back in place over its stripped copy: the record goes, the raw block is served again, and the punched space is allocated again.
- It asks only connected peers that keep every block (
NODE_NETWORKwithNODE_WITNESS), never a limited peer, which may have stripped the same block. While it waits for one, its outbound connections keep looking for such peers. - It asks each peer for at most 16 blocks at a time, and works on at most
1024 at once. A peer that does not answer within 15 seconds has its
requests moved to another peer; one that answers
notfoundis not asked for that block again. - It runs only once the tip is caught up, and pauses while the node is behind. The node is not in initial block download meanwhile: relay, the mempool, mining and the indexes carry on as normal.
- Before each 1024 blocks it checks the blocks volume for room for what the holes still lack, plus 1 GiB, and waits (checking every minute) when there is not.
- It downloads about the total size of the stripped blocks, which
getopreturnpruneinforeports when the download starts. On a pruning node it restores the newest blocks first. - A restart carries on where it stopped.
restoreopreturns cannot be combined with pruneopreturn,
pruneopreturnsize or pruneopreturnpeers, at any value: satd refuses to
start, naming both. On a node with nothing stripped it does nothing, so it can
stay set.
Once the last stripped block is restored the node is a full node again,
within a minute and without a restart. It advertises NODE_NETWORK to every
peer that connects from then on; peers already connected keep the services
they saw when they connected. The strip records' column family is removed
from the datadir with the last record, and the log says that
restoreopreturns can be removed. Restarted without it, the node runs as an
ordinary unpruned satd.
getblockfrompeer <blockhash> <peer_id> restores a single stripped block the
same way. The worker strips it again on a later pass if the option is still
on. Restoring the last stripped block by hand, with the option off, also makes
the node a full node again.
Watching it
getopreturnpruneinfo reports the configuration, the worker's state
(running, ibd, reindex, background_validation or off; under
restoreopreturns, restoring, waiting_for_peers, waiting_for_disk,
waiting_for_tip or restored), how far its scan has reached, and what is
stripped:
blocks,transactions,outputsandbytes_zeroed, added up from the strip records;bytes_freed_estimate, the whole filesystem blocks every span is sure to contain: a cautious minimum on Linux, at most what APFS returns;blocks_dir_apparent_bytesandblocks_dir_allocated_bytes, a stat of the block files (cached for a minute). Their difference is the measurement of what the holes save;- under
restoreopreturns,restore:total_blockswhen the pass started,remaining_blocks,restored_blocks,bytes_downloaded,remaining_bytes,in_flight,peers(the connected peers it can ask),progress_ratioandestimated_remaining_seconds.sat-tuishows it in its services row (TUI). A log line every 1000 blocks restored gives the rate and an estimate of the time left.
getblockchaininfo carries the same totals under opreturn_prune, only on a
node that strips or holds stripped blocks. Its pruned field still means
-prune only. Prometheus gets satd_opreturn_strip_examined_height,
satd_opreturn_stripped_bytes_total and satd_opreturn_stripped_blocks_total,
and under restoreopreturns satd_opreturn_restore_remaining_blocks,
satd_opreturn_restore_restored_blocks_total and
satd_opreturn_restore_downloaded_bytes_total.
size_on_disk does not move: it counts the files' apparent size, as Core's
does. Holes do not show in getblockfileaudit either, whose referenced bytes
are apparent bytes.
Repairing lost block data
Block bodies live in the flat files under blocks/ (blk*.dat), not in
RocksDB. The block_index entry for a block records which file and offset its
record starts at. Those are two independently buffered write streams, so it is
possible — after a kernel panic or power loss, never after a clean shutdown or
a plain process crash — to end up with an index entry that survived while the
block bytes it points at did not.
The symptom is a single block that behaves as if it were pruned on a node that is not pruning:
$ sat-cli getblock 000000000000000000000b951399b504a52a3fdfa1d33bcde59ac6c019c4af1c 0
error code: -5: Block data not available
getblockheader still works and shows the block connected with a normal
confirmation count, because consensus never re-reads the body: the UTXO delta
was applied when the block connected. Nothing surfaces the hole until something
walks history — an index backfill fails at that height, or a peer's request for
the block cannot be served.
Fetch a fresh copy of just that block from a peer:
sat-cli getblockfrompeer <blockhash> # satd picks a peer
sat-cli getblockfrompeer <blockhash> <peer_id> # or name one from `getpeerinfo`
The call returns as soon as the request is sent; the repair happens when the
block arrives. Re-run getblock to confirm, and check the log for
Repaired block data from a peer-supplied copy.
The supplied block is authenticated before anything is written. Its hash must match a header already in the index — which is what carries the proof-of-work and difficulty checks made when that header was accepted — and its transactions must match the merkle root that hash commits to. Witnesses need their own chain, because the merkle root commits only to txids: when the coinbase carries a BIP 141 commitment it must hold exactly one 32-byte witness item and the commitment must verify; otherwise no transaction may carry witness data at all. Together those pin every byte, so a peer can only return the genuine block or be rejected. A peer whose reply fails is banned.
The same test decides whether there is anything to repair. A stored copy is
left alone only if it is the canonical block, not merely one that parses —
witness bytes are outside everything the block hash commits to, so a copy can
deserialize and hash correctly while still carrying a padded, truncated or
stripped witness. getblockfrompeer will replace such a copy; getblock on it
succeeds, so nothing else would ever surface it.
Blocks that are pruned or marked invalid are refused: those states are deliberate, and repopulating them would contradict the decision that produced them. A block you hold only the header for is not repaired either — it is downloaded through the normal path, which applies the checkpoint and signet checks and connects it.
A block the node has stored but not yet connected needs none of this. When
the connector reaches one whose record is gone, it asks a peer for the block
itself on the same route, logging
Stored block has no readable record; fetching it again from a peer, and
connects the copy. That matters on a pruned node, where getblockfrompeer
refuses any block above the tip, as Core's does. Before 0.6.0, pruning could
delete a file that still held such blocks, and the node stopped for good on
the first one.
To find holes ahead of time rather than discovering them through a failed backfill, use the block-file audit:
sat-cli debug blockfile-audit
It reports unresolved_entries for index entries whose record falls past the
end of its file, in one pass over the file metadata — as opposed to
getblockstats across every height, which reads and deserializes every block
body on the chain and cannot distinguish a data hole from an unknown block
(both return -5).
Note. satd fsyncs a block's record before its index entry is committed on every write path, so the window above is closed for blocks written by current versions. Datadirs that predate this may still carry a hole from an earlier crash; nothing audits or migrates them on upgrade.
Upgrading to chainstate schema 7
The chainstate is versioned, and a satd that cannot read an older layout refuses to open the datadir rather than misinterpret its rows. The refusal names the remedy:
Chainstate schema version mismatch: DB has v6, binary expects v7.
Run with --reindex-chainstate to rebuild from existing block files.
-reindex-chainstate discards the RocksDB chainstate and replays the block
files into a new one, rebuilding every enabled index inline as it goes. The
block files themselves are never touched, so nothing is downloaded again,
and because the old column families are dropped before the replay starts the
datadir never grows beyond its current size during the rebuild. It is a
single pass over the chain; budget roughly what an -assumevalid sync of the
same chain costs on your hardware.
With upgradechainstate=1 in the configuration, satd runs that rebuild by
itself on the first start, and restarts it if it is interrupted; the app-store
packages and the reference stack set it. See "Upgrading the chainstate
unprompted" in Initial Block Download & Fast Sync.
A rebuild that is refused before it starts (a pruned node, the replay's coverage check) leaves the chainstate as it was: the previous release can still open the datadir. Once the rebuild has started there is no way back but another rebuild: 0.5.x refuses a schema 7 chainstate as this release refuses theirs.
Two cases need more than that:
- Pruned nodes. The replay needs every block, and a pruned node does not
have them. Such a node has to resync;
-upgradechainstatesays so and refuses rather than starting a rebuild it cannot finish. - AssumeUTXO nodes. Unchanged from before: run
backfillindex addressonce background validation has reached the snapshot base, as the snapshot's own coins carry no history behind them.
Compaction
RocksDB background compaction runs continuously. satd's bulk-load reindex mode
does not disable it; only the WAL is disabled. When reindex writes stop, the
background jobs drain the L0 backlog on their own, with no manual step. satd
also force-compacts 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.
The index CFs are append-mostly, with little deletion outside reorgs. Expect
compaction to reclaim the reindex-era L0 and overlap debt: a moderate drop, not
a collapse, because most of the footprint is index data. satd logs a per-CF
pending-compaction-bytes diagnostic every compaction_diag_interval_secs
(default 60 s). Let those settle toward zero before taking a size measurement.
sat-cli debug storage-footprint reports the same number per family as
pending_MB, so you can check whether the backlog has drained without waiting
for the next diagnostic line.
API Scaling & Runtimes
satd is a single process: one RocksDB instance, one chainstate, and every API surface (JSON-RPC, Esplora, Electrum, the streaming APIs, MCP, metrics) in the same process as consensus. This chapter explains how that process is split into two runtimes so that API load cannot endanger consensus. It also covers the options that tune each runtime, and how to scale out when one node is not enough.
The design goal is to bound the remotely-consumed API surfaces so they can never starve or stall the consensus core. Default behavior is unchanged and Bitcoin Core-compatible; everything in this chapter is opt-in or a bounded default.
The two runtimes
satd runs two separate tokio runtimes. The split is structural, not a priority hint.
Core (consensus) runtime
This runtime carries everything that must never be starved: P2P, block connection, and mempool acceptance. It also carries:
- The main JSON-RPC listener (
-rpcport, read and write). It serves the block-connecting control methods (generate*,submitblock,submitheader,preciousblock,loadtxoutset), which must originate on the core runtime to preserve address-index and SSE event ordering. Keeping JSON-RPC here also means public API load cannot starve the admin interface. - 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 carries the read and streaming surfaces, so a flood on any of them cannot contend with the threads that connect blocks:
- Esplora REST and SSE
- the Electrum protocol server
- the events gRPC and ZMQ sinks (satd's
-eventszmqbindand Bitcoin Core's-zmqpub*), and the streaming WS/SSE (streamws) - the Prometheus
/metrics,/healthz, and/readyzendpoints - the opt-in read-only JSON-RPC listener (
-rpcreadonlybind)
Use --api-threads to size this pool. The default is max(2, cores/4) worker
threads, clamped to 1024. Because the isolation is structural, a flood on a
consumption surface cannot starve block connection or mempool acceptance.
SIGHUP and SIGUSR1 reload reach the relocated surfaces unchanged.
Admission control and tuning options
Every remotely-consumed surface bounds its concurrency and backlog, and sheds work that is over budget. Nothing queues without bound; an unbounded queue would let a consumer backpressure the node. Shedding runs ahead of authentication and request-body buffering, so a flood is bounded before it does work, authenticated or not. Every connection and socket cap is clamped to 100,000, so a mistyped value cannot panic satd at boot.
| Surface | Options | Default | Over-budget response |
|---|---|---|---|
| Isolated API runtime size | --api-threads | max(2, cores/4) | none (sizing only) |
| JSON-RPC (main) | -rpcthreads (in-flight), -rpcworkqueue (backlog) | 16 / 64 | HTTP 429 + Retry-After |
| Read-only JSON-RPC | -rpcreadonlythreads, -rpcreadonlyworkqueue | inherit main | HTTP 429 + Retry-After |
| events gRPC | -eventsgrpcmaxconns, -eventsgrpcmaxsubscriptions | 64 / 256 | gRPC RESOURCE_EXHAUSTED |
| streaming WS/SSE | -streamwsmaxconns, -streamwsmaxsubscriptions, -streamwsmaxmessagebytes | 256 / 256 / 262144 | connection refused / 429 |
| streaming WS/SSE sockets | --streamws-max-sockets | 1024 | connection closed at accept |
| Esplora | -esploramaxconns, -esplorasseconns | 256 / = maxconns | HTTP 429 |
| Esplora sockets (plain + TLS) | -esploramaxsockets | 1024 | connection closed at accept |
| Electrum | -electrummaxconns, -electrummaxsubsperconn | 64 / 10000 | connection refused |
| JSON-RPC sockets (each listener: main, read-only, and their TLS binds) | none (fixed) | 100 per listener | connection closed at accept |
A socket cap counts every open connection on a listener, idle keep-alive
connections and open WebSockets included, and is taken at accept, before
authentication; the
per-request caps above bound work in flight and never see a socket that
sends nothing. Esplora and streamws close a keep-alive connection that idles
for longer than -esplorarequesttimeout (30 s by default) and 30 s
respectively, so an idle client cannot hold a slot; an open SSE or
WebSocket stream is not idle in that sense. These listeners, like the
JSON-RPC ones, serve HTTP/1.1 only, and the idle budget runs from a
connection's first byte, so a client cannot dodge it by sending a partial
request head or by starting HTTP/2. For JSON-RPC the same role is
played by -rpcservertimeout (default 30 s), which closes a connection that
sits idle between requests and returns its slot; with -rpcservertimeout=0
an idle connection holds its slot until the client closes it, so 100 idle
connections from any client that can reach the listener lock out every other
client, sat-cli on loopback included. Keep the listener private
(-rpcbind on loopback, or -rpcallowip) and leave the timeout on. A peer
outside the listener's allowlist (-rpcallowip, or -rpcreadonlyallowip
on the read-only listeners) never takes a slot: the allowlist is checked at
accept, before the cap. On a plain-HTTP listener the peer is answered 403
and the connection closed within a few seconds, from a separate pool of 16
refusals in flight, past which it is dropped at accept; on a TLS listener it
is dropped at accept, before the handshake. The two listeners read an
empty allowlist differently, though — a plain bind falls back to
loopback-only, while a TLS bind stays reachable from wherever it is bound,
gated by TLS/mTLS and RPC auth rather than by source address. Set the
allowlist to gate a public TLS bind too. A listener that is
shedding logs the first few refusals in each minute and then a count, so a
flood cannot fill the log with the report of itself.
-rpcthreads and -rpcworkqueue are recognized from Bitcoin Core, so a
Core-shaped config that carries them loads. In-flight calls are capped at
-rpcthreads, and the waiting backlog at -rpcthreads + -rpcworkqueue.
Per-token rate limits and watch quotas layer on top of these per-surface caps;
see Authentication & Authorization.
Scaling read RPC on one node: the read-only listener
-rpcreadonlybind adds a second JSON-RPC listener on the isolated API runtime.
It dispatches only read methods and mempool submission (sendrawtransaction),
and rejects block-connecting and node-control methods with JSON-RPC error
-32001. The method filter fails closed: an unclassified method is rejected,
never served. A release-safe invariant guard asserts that block connection
never originates on the API runtime.
The read-only listener has its own bind address, source-IP allowlist
(-rpcreadonlyallowip), admission budget (-rpcreadonlythreads /
-rpcreadonlyworkqueue), and TLS/mTLS options (-rpcreadonlytlsbind /
…tlscert / …tlskey / …mtls / …mtlsclientca / …mtlsclientallow). It
reuses the main listener's authentication.
To scale read traffic, put this listener behind a load balancer and keep the core-runtime listener private. The write and admin methods never leave the private listener.
Scaling beyond one node
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. satd is one process over one chainstate, so
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 with its own chainstate and mempool. Together they serve more aggregate read and stream traffic than any single node can.
Clients must tolerate transient divergence between nodes
Independent nodes are eventually consistent with each other. At any instant, two nodes can differ, and a load balancer can route consecutive requests to different backends. Design clients to expect:
- Tip skew. One node may be a block (briefly more) ahead of another.
getblockcountand the chain-tip height can go backwards across two requests routed to different nodes. - Mempool divergence. A newly 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 transaction's confirmed status and its
confirmationscount can differ transiently. - Per-node streaming cursors. 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:
- Do not assume monotonic or read-your-writes consistency across requests that may hit different backends. Where read-your-writes matters, pin a client or session to one backend with sticky sessions: for example, submit a transaction and poll for it on the same node.
- Choose where to broadcast. Send a transaction to one chosen node, or fan it out to all, then rely on P2P propagation. Do not assume every node already has a transaction another node accepted a moment ago.
- Use confirmation thresholds rather than 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 pass
/readyzand are near the network tip. Drop a node that has fallen behind so it does not serve stale reads.
This is the same operational model as running multiple Bitcoin Core nodes behind a balancer. The difference is that a single satd node already isolates its API surfaces from consensus, so multiple nodes are for horizontal throughput, not for protecting a 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 and streamws), and the MCP server.
It also keeps full backward compatibility with Bitcoin Core's cookie,
rpcuser, and rpcauth credentials.
There are two layers:
- Core-compatible operator auth: the cookie file,
-rpcuser/-rpcpassword, and-rpcauth. This is the default and behaves exactly like Bitcoin Core. A valid operator credential has full access to everything, unless Core's-rpcwhitelistlimits that user to a list of JSON-RPC methods (see Per-user method allowlists). - The unified bearer-token layer (
satd-auth): opt-in, capability-scoped bearer tokens loaded from an-authfile, each rate-limited and quota-bounded. Scoped tokens let you expose the node to partially trusted consumers, such as a BTCPay instance or a watchtower, without giving any of them operator credentials.
Note. The default is pure Bitcoin Core behavior. With no
-authfileconfigured, the bearer layer is inert: the only credentials that work are the Core-compatible ones, and every authenticated request acts as the full-capability operator. Scoped tokens are opt-in, per surface. A surface with no bearer tokens enabled does not install the capability gate at all.
How the bearer layer differs from Core-style auth
| Core-style operator auth | Unified bearer tokens | |
|---|---|---|
| Credentials | .cookie file, -rpcuser/-rpcpassword, -rpcauth (HMAC) | Opaque high-entropy tokens, sent as Authorization: Bearer <token> |
| Granularity | Full operator access, or a per-user list of JSON-RPC methods with -rpcwhitelist | Per-token capabilities (for example read-only, Esplora-only, stream-only) |
| Multi-tenant | No; one shared identity | Yes; each token has its own id, scope, quota, rate limit, and expiry |
| Rate / quota limits | None; the operator is unlimited | Per-token request rate (429/RESOURCE_EXHAUSTED) and watch-set quota |
| Where defined | Flags, bitcoin.conf, or the generated cookie | A TOML -authfile, reloadable on SIGHUP |
| Default | On (cookie auto-generated) | Off until -authfile is set and the surface opts in |
| Compatibility | Bitcoin Core wire-identical | satd extension |
Both layers coexist. On a bearer-enabled surface the operator (Basic)
credential is tried first, so existing Core tooling is not affected. A
Bearer token is consulted only when the request does not carry a valid
operator Basic credential. A matching cookie, userpass, or rpcauth
credential always resolves to the full-capability operator.
A request whose credential does not authenticate is answered 401 after a
250 ms pause and logged at warn level as
ThreadRPCServer incorrect password attempt from <ip>:<port>, as in Core. A
request with no Authorization header gets its 401 at once and is not
logged. The warning is limited to 60 lines a minute per listener; the first
line of the next minute carries a suppressed count of what it left out.
Per-user method allowlists (rpcwhitelist)
satd implements Bitcoin Core's -rpcwhitelist and -rpcwhitelistdefault
with Core's semantics, so a bitcoin.conf that restricts RPC users works
unchanged.
rpcauth=monitor:<salt>$<hash>
rpcwhitelist=monitor:getblockcount,getbestblockhash,getblockchaininfo
rpcwhitelist=<user>:<method>,<method>,...limits<user>to the listed JSON-RPC methods. Methods are separated by commas or spaces, and names are case-sensitive. The option is repeatable. A user listed more than once keeps only the methods common to every list, and that includes a user listed on both the command line and inbitcoin.conf.rpcwhitelist=<user>orrpcwhitelist=<user>:gives that user an empty list: every call is refused.rpcwhitelistdefaultdecides what users with no list may call. It defaults to 1 as soon as anyrpcwhitelistis set, which means an unlisted user may call nothing. Setrpcwhitelistdefault=0to leave unlisted users unrestricted.rpcwhitelistdefault=1with no list at all refuses every user.- The user is the name the request authenticated as. That is
__cookie__for the cookie file, the-rpcusername, or an-rpcauthname. Under a deny default, list__cookie__ifsat-cliorbitcoin-clishould keep working with the cookie.
A refused request gets HTTP 403 with an empty body, as in Core, and the
method does not run. A batch is refused whole if any call in it is not
allowed. A body that is not JSON still gets the -32700 parse error first.
The refusal is logged as RPC User <name> not allowed to call method <m>.
The allowlists apply to every JSON-RPC listener, including the read-only listener, where both filters must allow a call. Four details go beyond Core:
- WebSocket. satd's JSON-RPC listeners also accept WebSocket upgrades. A user with a list, or any user under a deny default, is refused the upgrade with 403, because calls made over an open WebSocket are not checked against the list.
- Bearer tokens are not RPC users. A token is scoped by the
capabilities in its
-authfileentry, and-rpcwhitelistdoes not apply to it, even under a deny default. -rpcdisableauthis refused together with anyrpcwhitelistor withrpcwhitelistdefault=1. The auth-disabled mTLS surface authenticates no user, so it has no name to restrict.- Reload. The lists are fixed at startup. A
SIGHUPreports a changedrpcwhitelistas needing a restart, while credentials still rotate live.
For scoping that covers Esplora, the streaming APIs and MCP, and that carries rate limits and expiry, use bearer tokens instead.
Capabilities
A bearer token carries a set of capabilities, and each surface enforces the capability it requires. Enforcement fails closed: an unknown method, or a request with no principal, requires the write capability, which neither a read-only nor a submit-only token holds.
| Capability | String | Grants |
|---|---|---|
| RPC read | rpc:read | Read-only JSON-RPC methods (classified by the same table the read-only listener uses). |
| RPC write | rpc:write | Mutating, control, and mining JSON-RPC methods, plus any unclassified method (fail-closed). Implies rpc:submit. |
| RPC submit | rpc:submit | Mempool submission: sendrawtransaction, submitpackage, and the other methods the read-only listener classes as mempool-submit; also Esplora POST /tx (with esplora:read). Lets a broadcaster (a payment processor, a wallet backend) hand transactions to the mempool without holding node control. Implied by rpc:write. |
| Esplora read | esplora:read | The Esplora REST + SSE surface. Reads only: POST /tx additionally needs rpc:submit. |
| Stream subscribe | stream:subscribe | Open a streaming subscription (events gRPC, streamws). |
| Stream watch | stream:watch | Register outpoint/script/descriptor/txid watches, bounded by the token's watch quota. |
| MCP | mcp:* | The MCP server. One capability; there is no per-tool split. |
| Test clock | test:clock | setmocktime, which moves the node clock (regtest only). Not implied by rpc:write: it reaches the future-block check, mempool expiry and block-template timestamps, so it must be granted deliberately. |
| Test net | test:net | addconnection, which opens an outbound connection of a chosen type (regtest only). Not implied by rpc:write: it dials an address the caller chooses and picks the connection's type, which decides whether that peer is asked for transactions and whether it takes part in address relay — reshaping the peer set, not writing to the node. Also sendmsgtopeer, which writes arbitrary bytes onto a peer connection, and addpeeraddress, which writes an address of the caller's choosing into the address book the node dials from. Grant it deliberately. |
The operator and loopback-trust principals hold all capabilities.
The authfile
-authfile=<path> points at a TOML file of bearer tokens. The file stores
only the SHA-256 digest of each token, never the plaintext.
version = 1
# Payment processor: reads plus broadcast, rate-capped. No node control.
[[token]]
id = "btcpay" # logging/accounting id, not the secret
hash = "sha256:<64-hex SHA-256 of the token>"
capabilities = ["rpc:read", "rpc:submit", "esplora:read"]
watch_quota = 50000 # optional; omit for unlimited
rate_limit = "200/s" # optional; omit for unlimited
# Watchtower: streaming subscribe + watch registration, expires end-2026.
[[token]]
id = "watchtower"
hash = "sha256:<64-hex>"
capabilities = ["stream:subscribe", "stream:watch"]
watch_quota = 10000
expires = 2026-12-31T00:00:00Z # unquoted RFC 3339 datetime, or unix seconds
# AI agent: full MCP tool access.
[[token]]
id = "agent"
hash = "sha256:<64-hex>"
capabilities = ["mcp:*"]
Rules:
version = 1is required. Each[[token]]needs a uniqueidand ahashof the formsha256:<64 hex>.capabilitiesdefaults to empty; such a token can authenticate but is denied everything.watch_quota,rate_limit("<n>/s"), andexpiresare optional. An omitted limit means unlimited.- An unknown capability string, a duplicate
idorhash, or a wrongversionaborts the load with an error. Nothing is ignored silently. - On Unix the file must have no group, world, or execute permission bits:
0600or0400, like a cookie file or an SSH private key. A0644or0640file is rejected. - Generate a token and its hash with, for example:
TOKEN=$(openssl rand -hex 32) # the secret you give the client printf 'sha256:%s\n' "$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)" - Edit the file and send
SIGHUPto reload it. The reload swaps the token table atomically, and removing a[[token]]revokes it immediately. A parse or permission error keeps the last-good table, so a bad reload never drops auth. - Revocation and expiry reach WebSockets that are already open. A
WebSocket on the JSON-RPC listener resolves its token again for every
frame, so after a reload removes the token, or once its
expireshas passed, each call on the socket is answered in-band with error-32006(bearer token revoked or expired), and asubscribemempoolsubscription opened with it ends at its next event. A reload that changes the token's capabilities or rate limit applies to open sockets the same way.
Presenting a token
Clients send the raw token in a standard header. The scheme is case-insensitive.
Authorization: Bearer <token>
The server computes SHA-256(token), 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 equal to the rate). Requests over budget are shed, never queued, so a slow or abusive consumer cannot backpressure the node. JSON-RPC, Esplora, and MCP return HTTP 429 withRetry-After; events gRPC returnsRESOURCE_EXHAUSTED;streamwsthrottles at connection time. A JSON-RPC batch is charged per call, not once per request: a batch ofncalls costsn, and a call that crosses the budget is answered in-band with error-32005whosedata.retry_after_secsmatchesRetry-After. The earlier calls in that batch are still served. A WebSocket on the JSON-RPC listener is charged the same way: the upgrade costs one, and so does every call sent over the socket after it. - Watch quota. The streaming watch-set is metered in units. One
scripthash costs one unit, and prefix watches are priced by coarseness. A
token holds units through an RAII lease, so a disconnect releases its
quota automatically. A watch add over quota is refused with an in-band
WatchAddRejectedevent naming the refused items, without tearing down the subscription.
Operator and loopback principals are unlimited.
Per-surface enablement
Bearer support is opt-in per surface: the surface flag turns it on, and it
requires -authfile. satd refuses to start if a surface flag is set without
an authfile.
| Surface | Enable flag | Capability gate | Default without the flag |
|---|---|---|---|
| JSON-RPC (read/write listeners) | -rpcauthbearer | rpc:read / rpc:submit / rpc:write | Core Basic auth (cookie/userpass/rpcauth) |
| Esplora REST / SSE | -esploraauthbearer | esplora:read (+ rpc:submit for POST /tx) | -esploraauth Basic, loopback-unauth default |
| events gRPC | -eventsgrpcauth | stream:subscribe / stream:watch | loopback-trust |
streaming WS/SSE (streamws) | -streamwsauth | stream:subscribe / stream:watch | loopback-trust |
| MCP (HTTP) | -mcpauth | mcp:* | loopback-trust |
The read-only JSON-RPC listener (-rpcreadonlybind) does not honor bearer
tokens. Client-certificate (mTLS) principals for the Electrum surface are
planned but not yet implemented.
Exposing a surface remotely
Binding the streaming or 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
Where a bearer token is what authenticates a remote bind, the transport must also be encrypted — the token crosses the wire on every request, and so does everything the connection carries:
-eventsgrpcallowremote + -eventsgrpcauth → requires -eventsgrpctlscert / -eventsgrpctlskey
-mcpallowremote → requires -mcpcert / -mcpkey
-eventsgrpcmtls satisfies the events-gRPC requirement on its own: it already
requires the certificate and key, and it authenticates without a token.
MCP has one further requirement that is not an authentication gate: its
transport validates the Host header against an allowlist that defaults to
loopback, so a remote MCP bind also needs -mcpallowedhost naming every
hostname clients use, or it answers 403 before auth runs. See
MCP.
-streamwsallowremote is the exception, because that transport has no TLS of
its own. A remote streamws bind sends its bearer token in cleartext unless a
TLS-terminating proxy fronts it — prefer the loopback bind plus a proxy.
For a proxy-terminated or mTLS-terminated deployment, bind to 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. -rpcallowip also gates -rpctlsbind, and
-rpcreadonlyallowip gates -rpcreadonlytlsbind, but only once the list
has at least one entry: an empty allowlist means loopback-only on a plain
bind and "no source-address rule" on a TLS bind, which is gated by TLS/mTLS
and RPC auth instead.
Transport TLS / mTLS
Native TLS and mutual TLS 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. The *tls*/*mtls*
config keys are listed in the Configuration chapter.
JSON-RPC Extensions
satd preserves Bitcoin Core's JSON-RPC contract by default: the same method
names, response field names, and types, so existing clients work unchanged.
On top of that, satd adds opt-in extensions for developers and integrators.
Each extension is either enabled by a server flag (and is therefore
live-reloadable over SIGHUP) or exposed as an additional method or
parameter. None of them alters the default Core-compatible wire shape. All
are governed by the
stability policy.
The authoritative catalogue of where satd differs from Core is
CORE_DIFFERENCES.md.
For the push-based event firehose and cursor-resumable watch subscriptions (gRPC, WebSocket, SSE, ZMQ), see the Streaming Consumption API chapter. That is a distinct surface from the extensions described here.
Note. JSON-RPC keeps Bitcoin Core's cookie /
rpcuser/rpcauthcredentials by default. Capability-scoped bearer tokens (-rpcauthbearer,rpc:read/rpc:submit/rpc:write) are an opt-in addition. See Authentication & Authorization.
Satoshis-as-integers
Bitcoin Core emits every amount as an IEEE-754 double in whole BTC
(0.00001000), which loses precision near dust and at the supply boundary.
This is Core's long-standing
#3249, open since 2013.
satd can instead emit exact integer satoshis.
This is a server-wide default, --rpc-default-units=sats|btc
(rpcdefaultunits as a config key), not a per-request flag. The default is
btc, where output is byte-identical to Core: a fixed 8-decimal number,
formatted from the integer satoshi value so it is exact. Set it to sats
and amounts serialize as JSON integers everywhere. In that mode responses
also carry a _units: "sats" tag so a client can confirm the shape it
received. The tag is absent in the default btc mode, which stays
byte-for-byte compatible. The option is live-reloadable. A per-request
HTTP-header override is a planned follow-up.
Structured RPC errors
By default, error responses are byte-identical to Core's {code, message}.
--rpc-extended-errors (rpcextendederrors; default off, live-reloadable)
is a server-wide option. With it enabled, satd additionally populates the
JSON-RPC data object with machine-actionable fields:
category: a stable taxonomy string, for examplemempool.policy.feerate,validation.consensus,storage.not_found.suggestion: a concrete remediation hint, when one applies.debug: arbitrary structured detail (field positions, computed values), when present.
Category names are stable once shipped in a release: new names can be
added, and existing ones never change meaning. As with the units default,
this is a server-wide option, since the common deployment pattern is satd
driven only by satd-aware tooling. A per-request X-Satd-Extended-Errors
header is a planned follow-up.
Fee estimation
Core's estimatesmartfee conf_target [estimate_mode] is kept with its
exact response shape ({feerate, blocks, errors}) and is Core-compatible
by default. The optional mode argument accepts Core's economical /
conservative / unset vocabulary, all treated as the historical
estimator. It also accepts satd's own historical / mempool / blend
values.
satd also adds an estimatefees [targets] [mode] method (default mode
blend, default targets [1, 3, 6, 12, 24]). It simulates the next N
block templates from the current mempool, with ancestor-feerate
(CPFP-aware) package sorting. It never hard-errors; it always returns a
result. The response maps each target to a {feerate, confidence} pair,
where confidence is high | medium | low, and includes a feerate
histogram. This is the basis for Core's
#11500.
Mempool subscription stream
subscribemempool is a JSON-RPC WebSocket subscription, paired with
unsubscribemempool, that emits structured lifecycle events. Each event is
tagged by a kind field:
enter: a transaction was admitted to the mempool.leave_confirmed: it was confirmed in a block.leave_evicted: it was dropped, with an explicitreason(full_pool|expiry|block_conflict|policy|reorg).leave_replaced: it was RBF-replaced, carrying thereplacing_txid.
Bitcoin Core requires polling getrawmempool or rebuilding this state from
per-tx ZMQ frames. This stream carries explicit eviction reasons and RBF
replacement linkage directly. For the richer firehose with cursor replay,
see the Streaming Consumption API; subscribemempool is
the lightweight JSON-RPC option.
Bounded mempool aggregate
getmempoolsummary ( top_n ) returns the view a dashboard renders — a vsize
histogram over the whole acting mempool, plus the top_n transactions by
ancestor feerate (descending, ties broken by smaller vsize).
{
"size": 71594,
"bytes": 117777467,
"vsize_histogram": [
{ "min_vsize": 0, "max_vsize": 100, "count": 4127 },
{ "min_vsize": 50000, "max_vsize": null, "count": 6 }
],
"top": [
{ "txid": "…", "vsize": 141, "time": 1789255111,
"ancestorcount": 1, "ancestorsize": 141, "ancestorfees": 282,
"descendantcount": 1 }
]
}
The reply is bounded by top_n (default 50, ceiling 1000; above the ceiling is
an -8 error rather than a silent clamp) instead of by mempool size. The same
view built from getrawmempool verbose costs a reply of tens of MiB and
seconds of node CPU at a mainnet-sized mempool, essentially all of it discarded
by a caller drawing an aggregate — the per-entry depends and spentby arrays
alone are most of that payload.
size and bytes match getmempoolinfo. Row field names and semantics match
the getrawmempool verbose entry they derive from: ancestorcount and
descendantcount include the transaction itself, ancestorsize and
ancestorfees include its own vsize and fee. Bucket edges are half-open
[min_vsize, max_vsize) and travel with the counts, so a client labels its axis
from the response rather than hardcoding edges; the top bucket's max_vsize is
null. The method is read-only.
UTXO age distribution
gettxoutsetinfo adds utxo_age_distribution, the UTXO set counted by age in
blocks relative to the reply's height. On a regtest chain of 206 blocks, each
with one spendable coinbase output:
"utxo_age_distribution": {
"labels": ["<1h", "<1d", "<1w", "<1mo", "<6mo", "<1y", "<3y", "3y+"],
"counts": [6, 138, 62, 0, 0, 0, 0, 0],
"exact": true
}
The bucket edges are 6, 144, 1008, 4320, 25920, 51840 and 155520 blocks. The
counts sum to txouts. When exact is true the first four buckets are exact
counts. The last four are estimated from a per-1000-block histogram, to within
one 1000-block chunk at each edge. exact is false while the node is still
building its per-height window, a one-time background scan of the coins after
upgrading an existing datadir. Until then the first four buckets are estimated
the same way.
Silent-payment block data
getsilentpaymentblockdata "blockhash" ( verbosity dust_limit ) returns the
public BIP 352 tweak data for one block, from the tweak index
(-silentpaymentindex=1, default off). It is the JSON-RPC fallback for the
streaming tweaks category — the same bytes, for scripts, the
reference-implementation differential, and integrators not yet on an SDK.
verbosity 0(default) →{ "block_hash", "height", "tweaks": ["<33-byte hex>", …] }.verbosity 1→ each entry becomes{ "txid", "tweak", "max_value" }.dust_limit(sats, default0) drops entries whose largest taproot output value is below the floor.
Errors: -5 for an unknown or non-active block, -8 when the index is
disabled, and -1 when the block is not yet indexed at that height (the row is
absent — a height-by-height scanner cannot proceed past a gap, but unlike BIP
157 it cannot silently miss its own outputs either). The method is read-only. A
light client runs one ECDH per returned tweak locally, so the scan key never
reaches the node; for the streaming firehose with cursor replay, and the
integrator guide to every silent-payment consumption mode, see
Silent Payments (BIP 352).
OP_RETURN-pruned blocks
On a node running with pruneopreturn (see
OP_RETURN data pruning), old blocks
can hold transactions whose large OP_RETURN payloads were stripped from disk.
The RPCs read such a block through its strip record:
getblockat verbosity 1 is unchanged, txids and sizes included, and adds"opreturn_stripped": {"transactions": k, "outputs": m, "bytes": b}on a stripped block. At verbosity 0 a stripped block is refused with Core's-1 Block not available (pruned data).- A stripped transaction, in
getblock2 and 3 and verbosegetrawtransaction, has its realtxidandhash, nohex, and"opreturn_stripped": true. Each stripped output'sscriptPubKeyreads as the bareOP_RETURNit keeps (asmOP_RETURN,hex6a, typenulldata), plus"stripped_size", the original length. Fees and prevouts come from undo data and are unchanged. getrawtransactionhex, andgettxspendingprevout'sspendingtx, refuse a stripped transaction with-1 Transaction data not available (OP_RETURN data stripped).gettxoutproof,verifytxoutproof,getblockstatsandverifychainwork as on an intact block.- A reorg or
invalidateblockthat would disconnect a stripped block fails naming it;invalidateblockchecks first and changes nothing (-8). getblockfrompeerrestores a stripped block in place.
getopreturnpruneinfo (no arguments) reports the feature:
{
"enabled": true, "depth": 4320, "min_size": 4096,
"examined_below_height": 912345, "examine_target_height": 912345,
"blocks": 123, "transactions": 140, "outputs": 141,
"bytes_zeroed": 9876543, "bytes_freed_estimate": 9400000,
"peers": "limited", "worker": "running",
"filesystem_block_size": 4096, "probe": "ok",
"blocks_dir_apparent_bytes": 812345678901, "blocks_dir_allocated_bytes": 812336278901
}
Under restoreopreturns the response also carries restore, the download's
counts, and worker is one of restoring, waiting_for_peers,
waiting_for_disk, waiting_for_tip or restored:
"restore": {
"total_blocks": 123, "remaining_blocks": 40, "restored_blocks": 83,
"bytes_downloaded": 96000000, "remaining_bytes": 47000000,
"in_flight": 32, "peers": 2,
"progress_ratio": 0.675, "estimated_remaining_seconds": 240
}
progress_ratio is over the current pass. estimated_remaining_seconds is
null until the first block is restored, then the blocks left at the block
rate so far: a rough guide, since block sizes change along the chain. Once
the last block is restored, restore keeps the last pass's counts until the
node finishes.
getblockchaininfo carries the first nine of these, worker and restore
under opreturn_prune, only on a node that strips or still holds stripped
blocks.
Node attestation (NAP)
createnapattestation, submitnapattestation, listnapattestations and
removenapattestation publish coin-weighted statements signed by holders,
with -nap=1, and listnapaggregate lists those collected from publishers,
with -napaggregator=1. getnapreport reports the weight behind each value
of each key in the collected set, and replaynapsnapshot the same for a
snapshot that exportnapsnapshot wrote. They have a chapter of their own:
Node Attestation (NAP).
BIP 322 message verification
verifybip322 "address" "signature" "message" verifies a
BIP 322
generic signed message: a proof that whoever can spend address signed
message. Unlike verifymessage, which is Core's and covers P2PKH only, it
works for any address type, multisig and Taproot script paths included.
sat-cli verifybip322 "bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l" "smpAkcwRAIgZRfIY…" "Hello World"
{ "result": "valid", "format": "simple", "version": 0, "locktime": 0, "sequence": 0 }
- Signature formats.
smp…(simple: a witness stack) andful…(full: the signedto_signtransaction) as the BIP defines them. An unprefixed string, which most wallets made before the BIP was finalized in 2026 still emit, is read as Core's legacy format for a P2PKH address and as simple for anything else. Proof-of-funds (pof…) is refused with-8. resultisvalid,invalid, orinconclusive: the proof is valid only under rules BIP 322 treats as upgradeable, such as an unknown witness version, anOP_SUCCESSleaf or a reserved NOP. Notvalidcomes with areason. A signature that does not decode isinvalid, not an error.version,locktime,sequenceare the BIP's "valid at time T and age S": a full proof for a timelocked script setsto_sign's locktime or input sequence, and the proof holds only from that time or age.verifybip322reports them and does not judge them against the chain. A legacy result has none.- Rules. Verification runs satd's script interpreter under the BIP's
required rules (SIGHASH_ALL only, or SIGHASH_DEFAULT for Taproot; no
OP_CODESEPARATOR; LOW_S, STRICTENC, NULLFAIL, MINIMALDATA, CLEANSTACK and MINIMALIF, all for every script type), then its upgradeable ones. It is checked against the BIP's published test vectors and btcd's negative vectors.
Errors: -5 for an address that is not valid on this network. The method
is read-only and needs no chain state.
Stratum
getstratuminfo reports the Stratum mining server. It takes no
arguments, is read-only, and answers on a node with the server off too — with
enabled: false, null listeners and zero counters — so a monitor can poll it
unconditionally.
{
"enabled": true,
"listeners": { "v1": "127.0.0.1:3333", "v1_tls": null, "v2": "0.0.0.0:3336" },
"authority_pubkey": "<32-byte x-only key, hex>",
"job_declaration": false,
"connections": 2,
"channels": 3,
"current_job": {
"height": 912345,
"job_id": "1a",
"prev_hash": "<hex>",
"template_txs": 3210,
"template_fees": 12345678
},
"shares": { "accepted": 412, "rejected": 3, "stale": 1 },
"blocks_found": 0,
"last_block": null,
"hashrate": 1210000000000.0,
"miners": [
{
"protocol": "v1",
"peer": "192.0.2.10:51234",
"channel_id": null,
"address": "bc1q…",
"worker": "rig1",
"device": "<user agent the firmware sent>",
"difficulty": 10000,
"connected_time": 1760000000,
"shares": { "accepted": 208, "rejected": 1, "stale": 0 },
"best_share_difficulty": 4812337.6,
"last_share_time": 1760006280,
"hashrate": 1210000000000.0
}
]
}
listenersare the bound addresses, with the real port when a bind used:0.authority_pubkeyis null without a Stratum V2 listener.channelscounts authorized Stratum V1 connections and open Stratum V2 channels.current_jobis the work being handed out, or null while none is (during initial block download).job_ididentifies the work within this server; the job ids a miner sees are per connection.sharescount since startup.staleis a share for a job that is no longer current;rejectedis every other refusal.blocks_foundcounts blocks found through the server that joined the active chain;last_blockis{ "height", "hash", "time" }for the most recent, or null.minerslists every miner connected now, oldest first: an authorized Stratum V1 connection, or a Stratum V2 channel (channel_idset). A miner leaves the list when it disconnects.addressis the payout address, or null when--stratumaddresspays;workeris the part of the username after the first., reduced to printable ASCII and at most 64 characters.deviceis the Stratum V1 user agent, or the Stratum V2 vendor, hardware version and firmware, reduced to printable ASCII; null if the miner sent none.difficultyis the share difficulty the miner is set to now.connected_timeandlast_share_time(null before the first accepted share) are Unix times.sharescount this miner's shares, as the node-widesharesdo.best_share_difficultyis the highest difficulty an accepted share's header achieved.hashrateis an estimate in hashes per second, from the difficulty of the shares accepted over the last ten minutes (see Verifying a miner).
- The top-level
hashrateis the sum overminers.
PSBT version 2 (BIP 370) and BIP 375
Every PSBT method — decodepsbt, analyzepsbt, combinepsbt,
joinpsbts, utxoupdatepsbt, finalizepsbt — accepts a version 2 PSBT
and answers with a version 2 PSBT. Bitcoin Core does not: it rejects any
PSBT whose PSBT_GLOBAL_VERSION is above zero. Version 0 behaviour is
unchanged, so nothing you already do moves.
Version 2 matters because BIP 375, which is how a wallet sends to a silent payment address through a PSBT, is version 2 only. Its fields are carried losslessly: anything satd does not understand comes back with the same bytes in the same place, which is what lets a PSBT pass through this node on its way between two wallets that do understand it.
BIP 375 is a draft. satd is refereed against the vectors the BIP publishes, and its field set has not changed since 0.1.0, but a draft can still change.
What decodepsbt returns
A version 2 PSBT decodes to a different shape from a version 0 one. Field names follow Bitcoin Core's own version 2 pull request wherever it has one.
{
"psbt_version": 2,
"tx_version": 2,
"fallback_locktime": 0,
"locktime": 0,
"input_count": 2,
"output_count": 2,
"inputs_modifiable": false,
"outputs_modifiable": false,
"has_sighash_single": false,
"silent_payments": { // only when a BIP 375 global field is present
"global_shares": [
{ "scan_key": "02…", "ecdh_share": "03…", "dleq_proof": "…" }
]
},
"unknown": { }, // global pairs satd does not recognise
"inputs": [
{
"previous_txid": "…",
"previous_vout": 0,
"sequence": 4294967293,
"witness_utxo": { "amount": 0.001, "scriptPubKey": { "hex": "0014…" } },
"has_utxo": true,
"is_final": false,
"sp_shares": [
{ "scan_key": "02…", "ecdh_share": "03…", "dleq_proof": "…" }
],
"unknown": { }
}
],
"outputs": [
{
"amount": 0.0009,
"script": { "hex": "5120…" }, // absent while it has not been computed
"silent_payment": { "scan_key": "02…", "spend_key": "03…", "label": 3 },
"unknown": { }
}
],
"tx": { }, "tx_hex": "…", // only when every output has a script
"unique_id": "…", // BIP 370's identifier for this PSBT
"fee": 0.0001 // as in version 0: null unless every input's UTXO is known
}
An output's script is absent, not empty, while a silent payment
script is still being computed. The two mean different things, and a
PSBT library that renders the second as the first describes an output
paying nobody. For the same reason tx and tx_hex appear only once
every output has a script.
amount honours the per-request amounts=sats|btc switch, as elsewhere.
A share whose DLEQ proof is missing keeps its row with a null
dleq_proof rather than disappearing: a missing proof is the thing a
reader most needs to see.
Creating one: createpsbt psbt_version=2
createpsbt takes a trailing optional psbt_version. It defaults to 0, so
every existing call is unaffected, and it sits after Core's own
replaceable and version arguments so a positional call written against
Core keeps meaning what it meant.
sat-cli createpsbt \
'[{"txid":"…","vout":0}]' \
'[{"tsp1q…":0.25},{"bcrt1q…":0.2499}]' \
0 null null 2
An sp1… (or tsp1…) output key is only accepted with psbt_version=2.
Without it the node refuses by name — silent payment recipients need a version 2 PSBT; pass psbt_version=2 — rather than building a PSBT that
cannot carry the recipient, so a caller whose signer speaks only version 0
learns at creation instead of at signing. createrawtransaction refuses an
sp1… key outright and says why: a raw transaction has nowhere to put the
information a Signer needs.
Two details worth knowing.
- Use the array form of
outputswhen order matters. A JSON object's key order is the serialiser's business, and the output order decides both the transaction and each silent payment output'sk. - A repeated
sp1…key is legal, unlike a repeated ordinary address. Two payments to one recipient getk = 0andk = 1, and each keeps its own amount.
The PSBT comes back with PSBT_GLOBAL_TX_MODIFIABLE saying both inputs and
outputs may still be added, and the silent payment output carrying its two
keys and no script. Computing that script is the Signer's job, because
computing it is what freezes the transaction.
decodepsbt and analyzepsbt spell the recipient back as the sp1… address
it came from. The PSBT itself carries only the two keys, so this is the only
way an operator can check the recipient against what they were given.
Where satd is stricter than Core's version 0 rules
combinepsbtrefuses a conflict. Where two PSBTs give the same key different values, satd refuses and names the field. Core keeps whichever it saw first. For a BIP 375 ECDH share that value decides where the money goes.joinpsbtsrefuses a committed silent payment PSBT. Joining adds inputs, and every silent payment output script derives from the input set. A PSBT whose scripts are already computed, whose global ECDH share is set, or which does not allow inputs and outputs to be added is refused by name.finalizepsbtis the Transaction Extractor. It recomputes every silent payment output script and refuses unless each one verifies and matches. There is no override flag, andextract=falseis gated too.
analyzepsbt reports a silent_payments object with a per-output
verdict; see Sending in the Silent
Payments chapter for the statuses and what satd checks to reach them. A
verified: false object carries a reason and means satd could not run
the checks — it never means they passed.
Client-side PSBT signing (no signing RPC)
There is no signing method: satd never handles private keys. Signing is a
client-side sat-cli command.
sat-cli signpsbtwithkey reads a WIF private key or a BIP-32 xpriv from
stdin, prompting without echo when stdin is a terminal. It signs the PSBT
entirely locally, using only the prevout data already carried in the PSBT.
It covers the common single-sig script types (Legacy, SegWit v0, nested
SegWit, and Taproot key-path) and writes partial_sigs / tap_key_sig for
the node's finalizepsbt to assemble, rather than finalizing itself. An
xpriv is expanded over the standard BIP 44/49/84/86 paths, so it can sign
PSBTs that carry no derivation metadata, including satd's own createpsbt
output. The key never crosses the JSON-RPC boundary, so satd stays strictly
keyless.
A version 2 PSBT is signed too, and signing one means doing BIP 375's Signer work first. See Sending in the Silent Payments chapter.
sat-cli signpsbtwithsigner passes a version 2 PSBT to an external
signer untouched, because for a silent payment the only party that can
compute an ECDH share is the one holding the input's private key — a
device is exactly who should be asked, and sat-cli cannot know which
devices have learned BIP 375. What comes back is checked with the same
code the node runs: a reply describing a different transaction, one
whose silent payment script is not what its own shares derive to, or one
carrying a signature while an output still has no script, is refused
rather than emitted. A signer that simply does not understand the PSBT
has its own message relayed, with one line saying the device must
support BIP 375.
Node Attestation (NAP)
The Node Attestation Protocol lets Bitcoin holders publish signed, non-binding statements weighted by the coins they control: a view on a soft fork's activation method, on mempool policy, or on anything else. A holder proves control of a coin by signing the statement with the key or script that locks it. Nothing is locked up or spent, and the statement counts for as long as the coin stays unspent.
An attestation is one key-value pair for one outpoint. What NAP measures is statements authorised by the scripts controlling the coins that took part. It does not establish who owns those coins, and it says nothing about holders who did not take part. No node behaves differently because of an attestation.
satd is a publisher. satd has no wallet, so the holder signs in their own
wallet and hands the signature to their node, which checks it, stores it and
judges it again at every new block, and serves the valid ones to peers that
ask. satd is also an aggregator: with napaggregator=1 it collects the
attestations publishers serve and keeps the ones that count (see
Aggregating) and reports on them (see Reports).
sat-tui shows both halves on key 5.
Enabling
satd -nap=1
or nap=1 in the config file. Changing it needs a restart. Without it, the
four NAP RPCs answer -1 "NAP is disabled".
The attestations are kept in nap/attestations.dat in the network's data
directory (~/.bitcoin/signet/nap/attestations.dat on signet, for example),
with attestations.journal beside it. Each change is one record appended to
the journal and synced before the RPC returns, so a change costs the same
however many attestations are stored. Once the journal is larger than the
file (and at least 1 MiB), the store is written to the file whole, through a
temporary file and a rename, and the journal starts again. A last journal
record cut short by a crash is dropped: its RPC had not returned. If the
store cannot be read back at startup, satd refuses to start rather than
start with none of them: it holds signatures that may be hard to make again.
Move both files aside to start without them, or set napstorerecover=1, and
satd does that itself: it renames them to attestations.dat.bad-<unix time>
and attestations.journal.bad-<unix time>, logs a warning, and starts with
an empty store. Either way the damaged files' signatures are still on disk.
A clean shutdown also writes the store to the file whole and empties the
journal, so after one the file alone holds every attestation, as an older
satd, which does not read the journal, would load it. After a crash the
newest attestations may be only in the journal: copy both files to back
the store up, and before going back to an older satd, start and stop this
one cleanly.
What gets signed
The holder signs this text exactly, with LF line endings and no newline at the end:
NAP attestation v1
outpoint: <txid>:<vout>
key: <key>
value: <value>
anchor: <height> <block hash>
- Key and value. Printable ASCII (0x20–0x7E). Keys are 1–128 bytes,
values 0–128. There is no namespace; a BIP may name a key for itself, such
as
bip_xyz.activation_method. An empty value withdraws the holder's earlier value for the key. - Anchor. A recent block on the active chain.
createnapattestationanchors 6 blocks below the tip.
Copy the message as createnapattestation returns it. A wallet that adds a
trailing newline or turns line endings into CRLF signs different text, and
the signature does not verify.
When an attestation counts
An attestation is valid at a tip when:
| Rule | Status when it fails | Meaning |
|---|---|---|
| The anchor is the active chain's block at that height | anchor-not-on-chain | A reorg replaced the anchor block. Sign again. |
tip - anchor < 26,208 (about six months) | stale | Sign again with a new anchor. |
| The coin is unspent | coin-not-found | A spent coin's attestation is dropped. |
| The coin holds at least 10,000 sats, on every network | coin-too-small | A smaller coin cannot attest. Weight follows value, so a dust coin could not sway a count, but the floor keeps dust from filling an aggregator's set. |
| The coin confirmed at least 4,032 blocks (about four weeks) before the anchor; 6 blocks on testnet, testnet4, signet and regtest | coin-too-young | A coin moved recently cannot attest yet. A coinbase coin also waits for coinbase maturity, 100 blocks. |
| The signature verifies against the coin's scriptPubKey | signature-invalid, signature-inconclusive, wrong-format | |
| A timelocked proof's locktime and relative lock are met at the next block | not-yet-valid | See below. |
For each outpoint and key, the attestation with the higher anchor counts.
Signing again with a newer anchor replaces or withdraws a statement. A node
stores one attestation per outpoint and key, and at most 32 keys per
outpoint. There is no limit on the number of outpoints unless the operator
sets one: napmaxattestations=<n> refuses a submission that would take the
store past n attestations, while still taking replacements of stored
ones.
Publishing
-
Ask the node for the message:
sat-cli createnapattestation "<txid>:<vout>" "bip_xyz.support" "yes" { "message": "NAP attestation v1\noutpoint: …\nkey: bip_xyz.support\nvalue: yes\nanchor: 912340 00000000…", "outpoint": "<txid>:<vout>", "key": "bip_xyz.support", "value": "yes", "anchor_height": 912340, "anchor_hash": "00000000…", "amount": 0.25000000, "address": "bc1q…", "formats": ["simple", "full"], "psbt": "cHNidP8BAD0AAAAAAf…", "warnings": [] }It checks that the coin exists, holds at least 10,000 sats, and is old enough at the anchor.
formatslists the signature formats the coin's script takes, andaddressis the address to sign with.psbtis the same request as a PSBT, for signers that sign BIP 322 through one; an optional fourth argument, the coin's descriptor, fills in what they need (see Signing through a PSBT).warningsnames anything that will get in the way, such as a message too long for a Coldcard (330 characters) or an outpoint that already has 32 keys on this node. -
Sign the message in the wallet that holds the coin (see the next section).
-
Submit the message and the signature, the signed PSBT, or the signed transaction in hex:
sat-cli submitnapattestation "$MESSAGE" "smpAkcwRAIg…" { "id": "5c1e…", "outpoint": "<txid>:<vout>", "key": "bip_xyz.support", "value": "yes", "anchor_height": 912340, "format": "simple", "signature": "smpAkcwRAIg…", "status": "valid", "amount": 0.25000000, "replaced": null }The node parses the message, decodes the signature for the coin's script, and checks every rule above. An attestation that is not valid is refused with
-26and the rule it failed, as inrejected (coin-too-young): ….replacedis the id of the attestation for the same key that this one replaced. Submitting the same attestation twice changes nothing; an older one is refused while a newer valid one is stored.
The id is the SHA256 of the attestation's serialized form, the same
whichever node holds it.
Signing
Signatures are base64. satd takes BIP 322's prefixed forms, smp… (simple:
the witness stack) and ful… (full: the signed to_sign transaction), and
an unprefixed string, which it reads as simple for a native segwit coin and
as Core's legacy format for a P2PKH coin, because most wallets made before
BIP 322 was finalized emit unprefixed signatures. Proof-of-funds (pof…)
signatures are refused: sign without extra inputs. The RPCs return
signatures in the prefixed form.
submitnapattestation also takes the signed to_sign transaction as raw
hex, with no prefix, and reads it as a full proof. This is what a wallet
exports when it finalizes the PSBT itself (see
Signing through a PSBT). A string of hex digits
that decodes as a transaction is read as one; verifybip322 takes BIP 322's
own forms only.
| Coin | Format | Signers (as surveyed in 2026) |
|---|---|---|
| P2WPKH, P2TR key path | simple | BIP 322 message signing in Sparrow (smp), Unisat, Xverse and Leather (unprefixed) |
| P2PKH | legacy | Bitcoin Core's signmessage, Electrum, Trezor, Jade, or signmessagewithprivkey |
| P2WSH and P2TR script paths, multisig | simple or full | The PSBT, signed by each cosigner, or a BIP 322 tool that builds the witness for the script, such as the Rust bip322 crate or btcd |
| P2SH-wrapped segwit, P2SH, bare scripts | full | As above. BIP 322's simple format covers native segwit only |
| Timelocked scripts | full | As above, with to_sign's locktime and sequence set |
Wallet support changes quickly. Check your wallet's documentation for BIP 322 before relying on a row above. Hardware signers and multisig coordinators mostly sign BIP 322 through a PSBT, described next.
Signing through a PSBT
createnapattestation returns psbt: BIP 322's to_sign transaction as a
version 0 PSBT. Its input spends the virtual to_spend transaction, which
the PSBT carries as the input's UTXO, and the message is in BIP 322's global
field 0x09. A signer signs it the way it signs a transaction; one that
knows the field shows it as a message to sign rather than a payment. Submit
the signed PSBT in place of a signature:
sat-cli submitnapattestation "$MESSAGE" "cHNidP8BAD0AAAAAAf…"
A hardware signer finds the key to sign with through the PSBT's BIP 32 key
origins, and a script coin also needs its script in the PSBT. satd holds no
wallet and knows neither, so pass the coin's public output descriptor as
createnapattestation's fourth argument and the node adds them:
sat-cli createnapattestation "<txid>:<vout>" "bip_xyz.support" "yes" \
"wsh(sortedmulti(2,[a1b2c3d4/48h/0h/0h/2h]xpub6E…/<0;1>/*,[e5f6a7b8/48h/0h/0h/2h]xpub6F…/<0;1>/*))"
The descriptor is the one your wallet exports, with key origins. It must
hold public keys only; one with private keys is refused, as is one with a
hardened step after an xpub, which a public key cannot derive. A ranged
descriptor is searched at indices 0 to 999, each branch of a <0;1>
multipath one in turn, for the coin's address. A descriptor that does not
parse is refused with -5, and one that does not derive the coin with
-8. Without a descriptor the PSBT carries neither, and the wallet or
coordinator that holds the coin's descriptor has to add them before the
signer signs.
The node finalizes the signed PSBT, building the input's witness from the signatures, and
stores the proof: simple for a native segwit coin, full for P2SH-wrapped,
P2PKH and bare scripts, or when the signer changed to_sign's version,
locktime or sequence. The PSBT must carry the message the node was given,
for the outpoint's own script.
-
Single key (P2WPKH, P2TR key path, P2SH-P2WPKH, P2PKH): the signer's signature is enough.
-
Multisig and other scripts: the PSBT must also carry the script: the witness script for P2WSH, the redeem script for P2SH, or the leaf and its control block for a Taproot script path, as the descriptor adds them. Each cosigner signs; merge their PSBTs with
combinepsbt, or submit the coordinator's combined PSBT. The node finalizes any script that miniscript can satisfy with the signatures given, which coversmulti,sortedmultiandmulti_a. For a script outside miniscript, or a timelocked one, build afulsignature with your own tools. -
Coldcard signs these PSBTs (from firmware 5.5.1, and 1.4.1 on the Q), shows them as messages, and returns the signed PSBT without finalizing it, which is what the node expects.
-
Sparrow signs the PSBT with a connected hardware wallet (a Ledger on the Bitcoin app 2.5.1, for one), finalizes it and exports the signed transaction in hex. Submit that hex as the signature, without a prefix:
sat-cli submitnapattestation "$MESSAGE" "00000000000101c144…"On the device the request looks like a transaction with one input, a zero-value
OP_RETURNoutput and no fee. It spends nothing on chain; do not broadcast it.
A PSBT is refused with -8 when it lacks the message field, has more than
one input (proof of funds), has any output but the zero-value OP_RETURN,
signs another message or another coin, or is not fully signed; the message
says which.
Coldcard signs messages of up to 330 characters. Everything in a NAP
message but the key and value comes to about 190 characters (more for a
large output index), so a key and value that add up to more than about 140
characters do not fit. createnapattestation warns when a message is longer
than 330.
Timelocks. A full proof for a timelocked script sets to_sign's
locktime or input sequence. satd judges them as if to_sign were in the
block after the tip, as consensus would, against that block's height and the
tip's median time past, and the coin's age. Until they are met the proof is
not-yet-valid, and the node refuses it; submit it once the lock has
passed.
Some scripts can be satisfied without a signature: anyone-can-spend, or a hash lock with a known preimage. Proofs for those coins are valid, but anyone can make them, and anyone can sweep the coin.
Following the chain
The node judges every stored attestation again at each new block:
- An attestation whose coin was spent is dropped from the store.
- One that goes stale, or whose anchor block a reorg replaced, stays stored but is not served. A lost anchor is logged as a warning naming the outpoint and key: sign again to keep publishing it. An anchor 6 blocks deep makes this rare.
- A missing coin counts as spent only on a chain that holds the anchor. A node that is behind its stored anchors, during a reindex for example, or on a fork without them, keeps everything until it is back on the anchor's chain.
- At startup, an attestation that lost its anchor while the node was stopped is reported the same way, once the node has reached the anchor's height.
During initial block download the attestations are judged once, when it ends.
sat-cli listnapattestations
[
{
"id": "5c1e…",
"outpoint": "<txid>:<vout>",
"key": "bip_xyz.support",
"value": "yes",
"anchor_height": 912340,
"anchor_hash": "00000000…",
"format": "simple",
"signature": "smpAkcwRAIg…",
"message": "NAP attestation v1\n…",
"status": "valid",
"amount": 0.25000000
}
]
status is valid or the name of the rule the attestation fails at the
current tip, with a reason. It is unknown for an attestation the node has
not been able to judge since it started, because the tip kept changing; ask
again. amount, the coin's value, is the
attestation's weight while it is valid.
removenapattestation "<txid>:<vout>" "<key>" stops publishing an
attestation and returns its id. That is not a withdrawal: copies already
collected stay valid until they go stale or the coin is spent. To withdraw,
sign an empty value.
Serving to peers
With nap=1 the node advertises the NODE_NAP service bit, bit 30 from the
range Bitcoin Core leaves for experiments (getnetworkinfo lists it as
NAP), and answers the getnapatt P2P message with the attestations valid
at its tip, in napatt pages of up to 1,000,000 bytes ordered by outpoint.
It answers any peer that asks, but never sends a NAP message unasked, so a
Bitcoin Core peer, which never asks, never receives one. There is no gossip:
a node serves only what was submitted to it.
Serving is limited so that a peer cannot make the node serve without bound:
| Limit | Per connection | Per network group |
|---|---|---|
| New pulls (a request without a continuation cursor) | 1 per 10 minutes | 8 per 10 minutes |
| Bytes served | 200 MB per hour | 1.6 GB per hour |
A network group is an IPv4 /16, an IPv6 /32, or the AS under -asmap. A
connection's limits end with it; a group's do not, so reconnecting does not
reset them. Requests over a limit are ignored, not punished. Peers that
reach the node over Tor all arrive from the loopback address and share one
group, so many Tor connections from one source can use up that group's
limits. A malformed getnapatt counts as misbehaviour.
Aggregating
With napaggregator=1 the node collects attestations from publishers over
P2P and keeps, for each coin and key, the one that counts at its tip. It does
not need nap=1, and does not advertise NODE_NAP unless it also publishes.
- Where it looks. Every full-relay, manual or inbound peer the node is
connected to that advertises
NODE_NAPis asked as soon as the connection opens, and again every hour while it stays up; block-relay, address-fetch and feeler connections are never asked. At most eight pulls run at once, and at most four of them over inbound connections. Publishers the address book knows but the node is not connected to are reached over shortnap-pullconnections, at most two at a time and each address at most once an hour. Anap-pullconnection (getpeerinfolists it with thatconnection_type) takes no outbound slot and carries the pull and nothing else: no blocks, transactions or addresses. It closes when the pull ends. - What it checks. Cheapest first: whether it already holds the attestation, then the anchor, freshness, the coin and its age, and only then the signature. Signature results are cached, so an attestation served by many publishers is verified once. At most 5,000 signatures are verified per pull and 1,000,000 an hour (at most five or six minutes of one core); the rest wait, and pulling pauses while 16 MB of them are waiting. Nothing received is dropped for lack of budget.
- Misbehaviour. A malformed
napatt, or an attestation whose signature fails or does not fit its coin, is misbehaviour, and the peer is disconnected and punished as for any other (a manually added peer is not). An attestation that fails only on chain state, such as a spent coin or an anchor the node does not have, is not: the publisher's tip may differ. - What it keeps. For each coin and key the attestations that verified,
newest first. The newest valid one counts. One that is superseded is kept
until the attestation that superseded it is valid and its anchor 144
blocks deep, so that if a reorg takes the newer anchor, the older one
counts again. At most 8 are kept per coin and key, and at most 256 keys
per coin, the first seen; a key new to a full coin is refused. The whole
set holds at most
napaggregatormaxattestations (default 1,000,000); when full, pairs new to it are refused, while pairs already held still take newer attestations. At every new block the set is judged again: a spent coin drops out at once, and a stale one, or one whose anchor is lost for good, goes too. - On disk.
<datadir>/nap/aggregate/aggregate.dat, written every 30 seconds when it changed and at shutdown. Every signature is verified again when it is loaded; an attestation the chain cannot judge yet (during initial block download, say) waits for it. A file that does not read back is renamed toaggregate.dat.bad-<unix time>with a warning, and the node starts with an empty set: collection rebuilds it.
listnapaggregate lists the attestations that count, each with its coin's
value (amount) and the peer it was first collected from (source), and the
collection's counters:
sat-cli listnapaggregate
{
"height": 870000,
"held": 3,
"counting": 2,
"deferred": 0,
"pulls": { "active": 0, "completed": 4, "timed_out": 0, "pages": 4 },
"misbehaving": 0,
"unsolicited": 0,
"key_limit": 0,
"full": 0,
"attestations": [
{ "id": "…", "outpoint": "…:0", "key": "bip_xyz.support", "value": "yes",
"anchor_height": 869994, "anchor_hash": "…", "amount": 0.5, "source": "203.0.113.5:8333" }
]
}
held counts the superseded attestations kept for reorgs too; counting
is one per coin and key.
Reports
getnapreport judges every attestation held at the tip and reports, for
each key, the weight behind each value. Weight is the coin's value, so one
1 BTC coin weighs what a hundred 0.01 BTC coins do. Every number is an
integer: weights in satoshis (weight_sat), and shares and coverage in parts
per million, rounded down (250000 is 25%). The report is computed once per
tip and set and served again until either changes, so a dashboard can poll
it. While collection keeps changing the set, a report on the same tip is
served again for up to 30 seconds; its digest names the set it covers. A
new block is always reported on afresh.
sat-cli getnapreport
{
"height": 870000,
"hash": "…",
"utxo_total_sat": 1980000000000000,
"digest": "…",
"attestations": 3,
"invalid": 0,
"counting": 2,
"keys": [
{ "key": "bip_xyz.support", "weight_sat": 50000000, "outpoints": 1, "coverage_ppm": 0,
"withdrawn": { "weight_sat": 0, "outpoints": 0 },
"values": [
{ "value": "yes", "weight_sat": 50000000, "outpoints": 1,
"share_ppm": 1000000, "coverage_ppm": 0,
"value_tiers": [ { "tier": "<0.01", "weight_sat": 0, "outpoints": 0 }, … ],
"anchor_age": [ { "blocks": "<2016", "weight_sat": 50000000, "outpoints": 1 }, … ],
"coin_age": [ … ],
"script_types": [ … ],
"concentration": { "top10_ppm": 1000000, "top100_ppm": 1000000 } }
] }
]
}
- The key.
weight_satandoutpointsare the coins stating a value.coverage_ppmis that weight's share of the whole UTXO set's value at the tip (utxo_total_sat), in which lost coins count. Coins that withdrew (an empty value) are inwithdrawn, and in no share. - Each value, heaviest first: its weight, its
share_ppmof the key's weight and itscoverage_ppm, then breakdowns. Each breakdown lists every bucket, including empty ones.value_tiers, by coin value:<0.01,0.01-1,1-100and>=100BTC.anchor_age, in blocks below the tip:<2016(about two weeks),<13104(three months) and<26208(the six-month freshness window).coin_age, in blocks since the coin confirmed:<13104,<52416(a year),<104832(two years),<262080(five years) and>=262080.script_types, by the coin's script:p2pkh,p2sh,p2wpkh,p2wsh,p2trandother.concentration: the share of the value's weight in its 10 and 100 largest coins.
- Coins, not holders. Tiers and concentration describe coins. One holder can spread across tiers, and several can share a custodian's coin.
Keys and values are shown exactly as signed, ordered by bytes, with no grouping of variant spellings.
Snapshots
The report's digest names the set it was computed from. It is SHA256 over
each attestation's serialized form, prefixed with its length as a compact
size, in ascending id order. exportnapsnapshot "path" writes that set,
including the superseded attestations kept for reorgs, to a file:
sat-cli exportnapsnapshot nap-snapshot.dat
{ "path": "/home/me/.satd/nap-snapshot.dat", "height": 870000, "hash": "…",
"attestations": 3, "digest": "…" }
A relative path is resolved against the datadir, as dumptxoutset does, and
an existing file is not overwritten. replaynapsnapshot "path" reports on a
snapshot instead of the node's own set. It verifies every signature, and
judges the set at this node's tip. Any node can do this, without
napaggregator. At the same tip it gives the same bytes as the aggregator
that exported the snapshot, so anyone can check a published report. The
replay does not add the snapshot to the node's set. invalid counts
attestations whose signatures fail. An attestation whose coin is gone is
not verified, and does not count.
A report, an export and a replay split their work between up to eight threads once the set holds a few thousand attestations: the replay's signatures, the chain rules for each attestation, and each entry's bytes. What depends on order, the digest and the choice of the attestation that counts, is computed in order, so the result is the same however the work was split.
The file is "NAPS", a version (1), the height and hash it was exported
at, a count, the length-prefixed attestations, and a SHA256 checksum. A
replay reads at most napaggregatormax attestations, or 1,000,000 on a
node that does not aggregate.
Access
createnapattestation, listnapattestations and listnapaggregate change
nothing and need rpc:read; they are allowed on the read-only listener. submitnapattestation
and removenapattestation decide what the node publishes and need
rpc:write. getnapreport needs rpc:read and is allowed on the
read-only listener: it is computed once per tip and set, however often it
is asked. replaynapsnapshot reads a file and verifies every signature in
it, and exportnapsnapshot writes a file. Both need rpc:write and are
refused on the read-only listener, as scantxoutset and dumptxoutset
are. See Authentication & Authorization.
| Error | Meaning |
|---|---|
-26 | The attestation is not valid, or is superseded, or the outpoint has 32 keys (key-limit), or the store is at napmaxattestations (store-full). The message names the rule. |
-8 | The message, signature, PSBT, key, value or outpoint is malformed, a PSBT is not fully signed, removenapattestation found nothing to remove, a snapshot file does not read or is malformed (replaynapsnapshot), or exportnapsnapshot's file exists. |
-1 | NAP is disabled (nap=1 for the publisher RPCs, napaggregator=1 for listnapaggregate, getnapreport and exportnapsnapshot), the chain moved during the call (try again), or a snapshot could not be written. |
Privacy and key exposure
An attestation reveals its outpoint, and with it the coin's amount and history. Expect a published attestation to link your statement to that coin permanently.
For P2PKH, P2WPKH and script outputs that have never been spent, signing also publishes the public key or script. That is a security cost as well as a privacy one: it removes the hash protecting a coin that may otherwise sit unspent for years, which matters if elliptic-curve keys are ever broken. Taproot key-path outputs already show their key on chain.
- Sign with a coin you are willing to have identified with the statement.
- For long-term holdings, consider the key exposure before signing with a coin whose key is not yet public.
- Publish through a node reachable over Tor if you do not want the outpoint linked to your IP address.
Custody
Exchanges and custodians control the largest coins and can sign with coins their customers own. NAP cannot tell owned coins from custodied ones, and publishing an outpoint does not identify the custodian behind it. Weight caps and concentration statistics are both defeated by splitting coins, so NAP has neither; interpreting custodial weight is left to whoever reads the results.
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
read-only. The TUI cannot change node state. Bitcoin Core ships no
equivalent surface.
This document is the reference for what the TUI shows. It is not a walkthrough. For guidance on what to look at first, see Observability & Metrics.
Running
sat-tui is built as part of the workspace and ships in every release
tarball alongside satd and sat-cli.
sat-tui # mainnet, default RPC port (8332)
sat-tui --regtest # regtest, port 18443
sat-tui --testnet # testnet, port 18332
sat-tui --signet # signet, port 38332
Authentication
Same precedence as sat-cli:
--rpcuser+--rpcpasswordif both provided.- Cookie file at
--rpccookiefileif provided. - Auto-detected cookie under
--datadir(default~/.bitcoin) for the active network.
If the cookie rotates while the TUI is running, for example because satd restarted, the RPC client re-reads the cookie file and retries once. If the retry also fails, the TUI falls back to the "Connecting to satd…" splash.
Other CLI flags
| Flag | Default | Meaning |
|---|---|---|
--rpcconnect <host> | 127.0.0.1 | RPC host. |
--rpcport <port> | per-network default | Override the auto-detected port. |
--datadir <path> | ~/.bitcoin | Used to locate the cookie file. |
--rpcuser <user> | (none) | Userpass auth (with --rpcpassword). |
--rpcpassword <pass> | (none) | Userpass auth (with --rpcuser). |
--rpccookiefile <path> | auto | Override cookie path. |
The TUI exits cleanly on q or Ctrl-C and restores the terminal mode.
Connection states
Before any view is shown, sat-tui is in one of three connection states:
- "Connecting to satd…" (yellow, centered): RPC is unreachable, or has returned only failures so far. Common during cold start, satd restart, or misconfigured auth.
- Startup splash: satd is up but still starting (header scan,
reindex, address-index backfill). The splash is sourced from satd's
getstartupinfoRPC; see Startup splash below. - Active view:
getblockchaininfosucceeded and one of the four main views is rendered.
A red ✕ stale indicator in the title bar means the last successful
poll is more than about 3 seconds old. It means RPC is degraded right
now. The TUI recovers on its own when polling resumes.
Views
There are four main views, a fifth while the node has NAP on, plus a startup splash and three modal overlays.
The active view is auto-selected from chain state (is_ibd): the IBD
view during initial block download, the Steady view once synced. Press
1 / 2 / 3 / 4 (and 5, see NAP view) to force a
view. Press the same key again to return to auto-detect.
Startup splash
Shown while satd is in startup (header scan, reindex, address-index backfill). The splash is one panel:
| Field | Meaning |
|---|---|
| Phase | Current startup phase (e.g. reindex_scan, reindex_connect, headers, verify). |
| Status | Free-form human-readable description from satd. |
| Gauge | Progress through the current phase, 0–100%. |
| Elapsed | Wall-clock time since this phase began. |
| Rate | Items per second: blocks, headers, or whatever the phase iterates over. |
| ETA | Estimated remaining time for this phase only, not whole startup. |
ETAs cover one phase at a time because per-item costs differ sharply between phases. During a reindex, the header scan and the block replay proceed at unrelated rates, so a whole-startup estimate would mislead until the replay dominates.
IBD view (1)
Shown while the node is in initial block download. Five panels stacked vertically:
Title bar
Chain name, satd version, is_ibd indicator.
Progress block
- Blocks / target: connected blocks vs. the highest block any peer has advertised.
- blk/s: blocks connected per second, EMA-smoothed.
- hdr/s: headers received per second.
- ETA: server-side estimate from satd's
getibdprogressRPC. Per-block validation cost varies about 50× across history, from early empty blocks to modern weight-bound blocks. A naive "blocks remaining ÷ blk/s" calculation is wildly wrong, especially in the first few hundred thousand blocks. - Peers: connected peer count.
Block map
A bitmap of download state per block group (one cell ≈ many blocks):
| Glyph | Color | Meaning |
|---|---|---|
█ | green | Connected: validated and in the chain. |
░ | cyan | Downloaded: on disk, waiting for sequential connection. |
▓ | yellow | In flight: requested from a peer, not yet received. |
· | dim | Pending: queued for download, not yet requested. |
A healthy IBD shows a leading wave of █ followed by ░, with a
narrow ▓ band at the frontier. Long stretches of ▓ mean a peer is
slow or unresponsive. Long stretches of · mean the node is
bandwidth-bound or short on peers.
Sync rate + stats
Two sparklines (about 90 seconds of history):
- blk/s connected (yellow): rate of blocks fully validated.
- blk/s downloaded (cyan): rate of blocks pulled from peers, all peers summed.
Plus a stats panel: Headers, Connected, Stored, In-Flight, Remaining.
Peers table
| Column | Meaning |
|---|---|
| Addr | Peer IP and port. |
| Agent | Subversion string (/Satoshi:25.1.0/, /satd:0.1.0/, …). |
| Recv | Blocks received from this peer this session. |
| Assigned | Blocks currently assigned to this peer for download. |
| Rate | Per-peer blk/s, EMA-smoothed. Shows — below 0.1 blk/s. |
Up / Down highlights a row.
Steady view (2)
Default view once is_ibd=false. Six stacked panels.
Title bar
Health dot, uptime.
| Symbol | Meaning |
|---|---|
● ready (green) | Polling is fresh, node is at tip. |
○ syncing (yellow) | Polling is fresh, node is catching up a small lag. |
✕ stale (red) | Last poll is older than about 3 s. 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. More than an 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. While a transaction-filtering policy
is loaded, the quarantined count follows (
12,345 +87 quarantined): the first number, like Size, covers the transactions the node relays and mines, and leaves the quarantined ones out. - Size: total bytes.
- Min Rate: current mempool minimum fee.
0.0is the default, the min-relay floor of about 1 sat/vB. A non-zero value means the mempool is full and is evicting low-fee txs. New txs need at least this rate to enter. - Tx Rate: recent tx-entry rate from
getchaintxstats(tx/s). - Size distribution: bar chart of transactions per vbyte bucket (0, 100, 250, 500, 1k, 5k, 10k, 50k+), each bar labelled with its lower edge and its count. The bar heights are on a log scale, so a bucket of a few transactions stays visible next to one of tens of thousands; read the counts, not the heights, to compare buckets.
Fees (right half), fee tier estimates from estimatefees
(mempool.space convention):
- High: next-block target (1-block confirmation).
- Medium: about 30 minutes (3-block target).
- Low: about 1 hour (6-block target).
- None: economy / min-relay floor.
- Mode: the estimator's data source:
historical,mempool, orblend. - Confidence:
high(green) /medium(yellow) /low(red), for the High tier specifically.
UTXO + Network (split)
UTXO (left half):
- UTXOs: total unspent outputs.
- Total: sum of UTXO values (BTC).
- Supply: fraction of the 21M cap. Asymptotic; never reaches 100%.
- Age distribution: bar chart of UTXOs by age, from
gettxoutsetinfo: <1h, 1h–1d, 1d–1w, 1w–1mo, 1mo–6mo, 6mo–1y, 1y–3y, 3y+. Log-scaled heights with the count on each bar, as above. The four youngest buckets are exact counts. The four older ones are estimated from 1000-block chunks, so they can be off by up to a chunk's worth of coins at each edge. After upgrading an existing datadir the node builds its exact counts with a one-time background scan; until it finishes, the heading reads Age Distribution: (est.) and the four youngest bars are yellow.
Network (right half):
- Peers: inbound and outbound counts.
Peers table
Same controls as IBD's table; columns differ:
| Column | Meaning |
|---|---|
| Addr | Peer IP:port. |
| Agent | Subversion. |
| Height | Peer's best-known block height. |
| Recv | Total bytes transferred with this peer. |
Services row
A single line summarizing satd's wallet-server surfaces, sourced from
getserverstatus and getsatdindexinfo:
addr-idx <state> [sp-idx <state>] esplora <state> electrum <state>
The sp-idx column appears only when the silent-payment index is
enabled, so nodes not using it keep the three-column row.
Every column's dot follows one rule: green when done or serving, yellow while work is pending (a sync, backfill, scan or restore, or a pause or wait in one), red when it failed, dim when off or unknown.
addr-idx states:
| Display | Meaning |
|---|---|
⬤ synced (green) | Address-history index is at tip. Esplora and Electrum are safe to serve history. |
⬤ syncing (yellow) | Backfill in progress. Address queries may return partial history. |
⬤ backfill pass N/2 XX% (C/S) ETA … (yellow) | Active backfill with progress. C/S is cursor / snapshot height. The ETA is omitted for the first few seconds after a start, resume, or daemon restart — it is measured over the current working span, so it has nothing to report until that span has run (see Disk footprint). |
⬤ backfill paused … (yellow) | Backfill paused. Resume with sat-cli resumeindex address. |
⬭ backfill FAILED — <err> (red) | Backfill errored. Check journalctl or the satd logs. |
⬭ off (gray) | Address index disabled (-addressindex=0). |
⬭ - (dim) | Status unknown: older satd, or a transient RPC error. |
sp-idx states (BIP 352 tweak index, served via
getsilentpaymentblockdata
and the streaming tweaks category):
| Display | Meaning |
|---|---|
| (column absent) | Index disabled (-silentpaymentindex=0, the default), or a satd too old to report it. Exception: on an older satd with a backfill mid-flight, the backfill itself is proof the index is on, so the column shows (without a percentage — see below). |
⬤ synced (green) | Tweak index is at tip. getsilentpaymentblockdata and the streaming tweaks category return data. |
⬤ syncing (yellow) | Enabled but not caught up: fresh sync, or a backfill is still owed. Tweak-serving surfaces do not return data yet. |
⬤ backfill XX% (C/S) ETA … (yellow) | Active backfill with progress. C/S is cursor / snapshot height, and the percentage is the daemon-reported ratio over the taproot-era walk — not C/S, which would measure from genesis and overstate progress. Unlike the address index this is a single pass, so there is no pass counter. On a satd too old to report the ratio, the counts show with no percentage. |
⬤ backfill paused … (yellow) | Backfill paused. Resume with sat-cli resumeindex silentpayment. |
⬭ backfill FAILED — <err> (red) | Backfill errored. Check journalctl or the satd logs. |
The silent-payment backfill is CPU-bound and can run for hours on mainnet; see Disk footprint for measured timings.
op-prune and op-restore states (OP_RETURN data
pruning, from
getblockchaininfo.opreturn_prune):
| Display | Meaning |
|---|---|
| (column absent) | No block is stripped and pruning is off: an ordinary node, or one whose restore has finished. |
⬤ op-prune scanning XX% (E/T), N stripped (yellow) | pruneopreturn is on and the worker is still working through the chain: every block below height E examined, of the T past the strip depth. N blocks stripped so far. |
⬤ op-prune N blocks stripped (green) | The worker has caught up (within a few blocks of T) and keeps up with the tip; N blocks are stripped. |
⬤ op-prune N blocks stripped, paused (…) (yellow) | The worker waits for IBD, a reindex or an AssumeUTXO background validation to finish. |
⬭ op-prune N blocks stripped, off (dim) | Pruning is off but N stripped blocks remain; restoreopreturns downloads them again. |
⬤ op-restore XX% (D/T) P peers ETA … (yellow) | restoreopreturns is downloading: D of the pass's T stripped blocks restored, from P full peers. The percentage and ETA are the daemon's. |
⬤ op-restore waiting for … (D/T) (yellow) | The restore waits for a full peer (NODE_NETWORK), for disk space, or for the tip to catch up. |
esplora and electrum states:
| Display | Meaning |
|---|---|
⬭ <bind:port> (green) | Bound and serving. |
(tls <bind:port>) (cyan) | Electrum TLS bind, additional column. |
⬭ off (gray) | Disabled in config, or auto-disabled (e.g. address index off). |
⬭ - (dim) | Unknown. |
Footer
Keybindings hint, plus an unclean-shutdown indicator
(⚠ unclean shutdown) if last_shutdown from getsysteminfo is dirty.
Mempool view (3)
Drill-down on unconfirmed transactions. Five panels, and a sixth, the quarantine panel, while a transaction-filtering policy is loaded.
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 and removed in the last polling window.
Quarantine (only with a filtering policy)
Appears under the summary strip while a ruleset is loaded (see
Transaction filtering policy). The rest of the view counts
the acting class only, as getmempoolinfo does; this panel is where the
transactions the policy holds back show up.
- Held: quarantined transactions and bytes, against the
quarantinemempoolbudget. - Confirmed anyway: quarantined transactions later seen in a block, since satd started.
- Foregone fees: the fees, above the template floor, of transactions withheld from block templates. Relay-only quarantine is still mined, so it is not counted.
- fuel exhausted (red, only when non-zero): evaluations that ran out of fuel and were quarantined fail-safe.
- ⚠ rules withhold Lightning enforcement relay (only when non-zero):
see
allowdangerousfiltersin the policy chapter.
| Column | Meaning |
|---|---|
rule | The rule holding the transactions. (infectious: quarantined ancestor) holds descendants of quarantined transactions that matched no rule themselves. |
held | Transactions held under the rule. |
bytes | Their serialized size. |
sat/vB | Their fee-rate range. |
matched | Times the rule matched since the ruleset loaded; - for a name that is not a rule in the loaded ruleset. |
Rows are ordered by bytes held. Every quarantine rule gets a row, a dimmed one when it holds nothing; past five rows, the last line counts the rest.
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 colored by fee tier, matching the Steady view's Fees panel.
Trend + Top-N (split)
Trend (left): sparklines for Bytes, Txs, MinFee over about 40 minutes.
Top-N (right): scrollable table of the top 50 unconfirmed txs by ancestor feerate.
| Column | Meaning |
|---|---|
# | Rank within top 50. |
vsize | Virtual size (vbytes). |
anc sat/vB | Ancestor-adjusted effective feerate. Accounts for CPFP: a low-fee child gets pulled in by a high-fee parent. |
A/D | Ancestor count / descendant count (chain depth in either direction). |
age | Time since the tx entered the mempool. |
Up / Down scroll.
Footer
Keybindings.
Chain view (4)
Long-horizon information that does not 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-minute 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 the 10-minute 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). A positive value means blocks arrive faster than the 10-minute target, so difficulty will rise.
- Progress bar through the current 2,016-block epoch.
Supply | Chain Security
Supply:
- Issued: BTC currently in the UTXO set.
- % issued: fraction of the 21M cap.
- Remaining:
21M − issued. - Inflation: realized / forward: annualized 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 the RPC's 256-bitchainworkhex string without materializing 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.
NAP view (5)
The Node Attestation Protocol as this node runs it. The view is
offered only while the node has nap=1, napaggregator=1, or both: on
any other node 5 does nothing, and neither the help screen nor the
footer lists it. If the node restarts with both off, the TUI goes back to
the automatic view. The TUI learns this from the replies to the two RPCs
below, which it asks once the node is synced.
It is the only report renderer satd ships. It shows what the node says and checks nothing itself.
NAP panel
- Publisher:
onwith how many attestations the node serves and how many of them are valid at the tip, oroff. - Aggregator:
onwith the tip the report was computed at, oroff. - Held / counting / invalid: attestations the aggregator holds, the ones that count at the tip, and those whose signatures failed.
- UTXO total: the value of every coin at the tip, which coverage is a part of; and the digest of the held set.
Report
From getnapreport. Each key in bold with its participating weight (in
BTC), coverage and the weight of coins that withdrew from it; under it,
its values, heaviest first, with each value's share of the key,
weight, coverage and number of coins. Shares and coverage are the node's
parts per million shown as percentages rounded down; weights are the
node's satoshis shown as BTC with all eight decimals, so nothing is
rounded through floating point.
Breakdown
For the value selected with Up / Down: its weight by coin value tier,
anchor age, coin age and script type, each bucket as a share of the value
with its coin count, and the share held by its 10 and 100 heaviest coins.
Published
From listnapattestations: the first six attestations the node serves,
with outpoint, key, value, anchor height, the coin's value and valid or
the rule the attestation fails.
Keys and values are chosen by holders. satd accepts only printable ASCII
in them, but the TUI does not count on the node it talks to: it draws
anything else as ?, so no escape sequence reaches the terminal.
Modal overlays
Modals are drawn over the active view. Esc or the toggle key closes
them.
Help (h / ?)
Context-sensitive keybindings for the active view.
Reorg history (r)
Last 7 days of reorg events from getreorghistory. Up to 40 entries.
| Column | Meaning |
|---|---|
| depth | Blocks displaced. Colored: 1 = yellow, 2–3 = light red, 4+ = red. |
| fork height | Height at which the old and new chains diverged. |
| old tip / new tip | Block hashes, truncated. |
| −N +M blocks | Disconnected vs. reconnected counts. |
| age | Time since the reorg. |
A persistent copy lives at $datadir/<network>/reorg.log, in the
network-specific datadir subdirectory. On mainnet the file sits
directly under $datadir. satd writes this log whether or not the TUI
is running; the modal is a viewer of it.
Warnings
A centered overlay (80% × 70%) that appears automatically when
getwarnings reports visible warnings. The border is red if any
warning has Error severity, otherwise yellow.
| Field | Meaning |
|---|---|
[ERROR] / [WARN] | Severity. |
| ID | Warning identifier (cyan). |
first seen Ns ago · ×count | Age and recurrence. |
| message | Human-readable description. |
Press a to acknowledge and dismiss every currently visible warning
for this session. Press w to re-show everything previously dismissed.
Dismissal is per-session. If satd clears a warning ID and re-emits it, the modal reappears.
Keybindings
| Key | Effect |
|---|---|
q | Quit. Closes Help / Reorg modal first if open. |
Ctrl-C | Quit. |
h or ? | Toggle Help overlay. |
r | Toggle Reorg history. |
1 | IBD view (or back to auto). |
2 | Steady view (or back to auto). |
3 | Mempool view (or back to auto). |
4 | Chain view (or back to auto). |
5 | NAP view (or back to auto). Only while the node has NAP on. |
a | Acknowledge all visible warnings. |
w | Re-show dismissed warnings. |
Esc | Close Help or Reorg modal. |
Up / Down | Scroll peers (IBD / Steady / Chain) or top-N (Mempool), or select a value (NAP). |
Polling and refresh
The TUI does not push commands to satd; it polls. The render loop runs every 250 ms regardless of polling state, so the UI stays responsive even when RPC is slow.
| Cadence | RPC calls |
|---|---|
| 1.5 s | getblockchaininfo, getpeerinfo, getmempoolinfo, getconnectioncount, getsysteminfo, getwarnings. |
| 3 s | getibdprogress. During IBD only; the reply is heavy (full bitmap and per-peer breakdown). |
| ~5 s | getsatdindexinfo, getserverstatus, getnetworkinfo, plus the steady-state batch (estimatefees, getmininginfo, getchaintxstats, uptime, getblockstats, getmempoolsummary, gettxoutsetinfo, getreorghistory, getmempoolhistory, getpolicyinfo), and getquarantineinfo while getpolicyinfo reports a loaded ruleset; and listnapattestations and getnapreport, which also tell the TUI whether NAP is on. |
| per epoch | getblockhash + getblockheader to anchor the current 2,016-block epoch's start time. Refreshed only when the epoch floor advances. |
If a steady-state RPC has not returned within about 3 s, the title bar
shows stale. The view continues to render; the indicator shows that
the data on screen is older than the polling cadence implies.
The health dot tracks the connection. A single RPC can fail while the
connection is healthy — an unknown method against an older node, or one whose
reply the node refuses to build — and that failure has its own indicator:
rpc failing: <method> in the title bar, naming each method whose last poll
errored. The panel fed by that method keeps its last value rather than
blanking, so the indicator is the only thing distinguishing stale data from
live data.
Failure modes
| What you see | What it means |
|---|---|
Connecting to satd… | RPC unreachable, returning errors, or only getstartupinfo is responding. |
rpc failing: <method> in the title bar | That method's last poll returned an error while the connection stayed up. The panels it feeds are showing their last good value. |
Auth retry, then Connecting… | The cookie rotated and the retry also failed, which is common during a satd restart. Recovers on its own. |
Stale indicator (✕ stale) | Polling is alive but a recent call has not returned. Investigate if persistent. |
Empty / dashed fields (—, -) | The RPC backing that field has not returned yet, or returned an error. |
| Warnings modal won't dismiss | The warning is still active in satd. Dismissal is per-session; resolve at the source. |
The TUI does not panic on RPC errors. It shows them and keeps polling.
See also
- Observability & Metrics and Configuration, Tuning & Reload: the broader operator surfaces (CLI, RPC, observability, tuning).
CORE_DIFFERENCES.md: what satd does differently from Bitcoin Core.- Esplora REST API: Esplora REST endpoint reference.
sat-cli help: every JSON-RPC method exposed by satd, including the ones the TUI uses.
satd Esplora REST API
satd ships a native Esplora-compatible REST server, enabled by default and
listening on 127.0.0.1:3000. Wire shapes match upstream
blockstream/esplora and
mempool.space byte-for-byte within the
endpoint set listed below.
Like the Electrum server, the Esplora server is a query layer over satd's own
chainstate and shared address-history index. There is no separate indexer
process (electrs, esplora-electrs, Fulcrum) beside the node with its own copy
of the data. One RocksDB store backs the node and every API surface, updated
atomically inside the node's connect_block and disconnect_block path. A
read can never observe an index out of sync with the tip. The combined index is
larger on disk than a standalone electrs or Fulcrum index: the trade is disk
for consistency and single-process operation. See
Disk Footprint & Indices for the accounting.
This chapter covers what is 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.
Note. The Esplora surface defaults to unauthenticated loopback. For Basic auth (
--esploraauth) or capability-scoped bearer tokens (--esploraauthbearer,esplora:read, plusrpc:submitto broadcast), see Authentication & Authorization.
Configuration
| Flag | Default | Notes |
|---|---|---|
--esplora=<bool> | 1 | Disable with --esplora=0. Disabling stops the listener; address-index data is still maintained for RPC consumers. |
--esplorabind=<addr:port> | 127.0.0.1:3000 | Bind address. Read the Auth section before binding to a non-loopback address such as 0.0.0.0:3000. |
--esploraprefix=<path> | / | Mount under a path (for example /api) for blockstream.info-style deployments. Must start with /. |
--esploraauth=<scheme> | none | One of none, cookie, userpass. none runs the listener unauthenticated. cookie reuses the JSON-RPC cookie file. userpass requires --esplorauserpass=user:pass. |
--esplorauserpass=<user:pass> | (none) | Static credentials, used only when --esploraauth=userpass. |
--esploracookiefile=<path> | (auto) | Override the cookie-file path when --esploraauth=cookie. The default is the same .cookie file the JSON-RPC server uses. |
--esploracors=<origin> | (none) | Repeat for multiple origins. Use * for any origin. |
--esplorarequesttimeout=<seconds> | 30 | Per-request timeout, answered with 503; also the TLS handshake budget and how long a keep-alive connection may sit idle between requests before it is closed. |
--esploramaxconns=<n> | 256 | Cap on concurrent in-flight requests. 0 disables the cap. The address and scripthash routes do their reads on a blocking thread; a read still running after its request timed out keeps counting against this cap until it ends. Does not bound long-lived SSE streams; see Live updates. |
--esplorasseconns=<n> | same as --esploramaxconns | Hard cap on simultaneously open SSE streams (/blocks/sse, /address/:addr/sse, /scripthash/:hash/sse). Each open stream holds a permit until the client disconnects; over-cap connections receive 503. 0 disables the cap. |
--esploramaxsockets=<n> | 1024 | Hard cap on open sockets across both Esplora listeners, counted at accept, idle keep-alive connections included. Over the cap a socket is closed at accept. --esploramaxconns bounds requests in flight and never sees a socket that sends nothing; this does. 0 disables. A keep-alive connection idle for longer than --esplorarequesttimeout is closed. |
POST /tx has a fixed 1 MiB body limit at the route layer. A witness-heavy
400 KB raw transaction hex-encodes to about 800 KB, so 1 MiB leaves margin and
stays well under any consensus block limit. There is no flag to change this.
Esplora requires --addressindex=1 (auto-enabled if not set; see the
address-index docs) and --txindex=1 (auto-enabled by the reconciliation in
satd/src/config.rs). Both flags are on by default.
Endpoints
Chain
| Method | URL | Returns |
|---|---|---|
| GET | /blocks/tip/hash | text/plain: current best-chain tip hash (display hex, 64 chars). |
| GET | /blocks/tip/height | text/plain: current tip height. |
| GET | /blocks | JSON array of up to 10 most-recent block summaries, descending. |
| GET | /blocks/:start_height | JSON array of up to 10 summaries ending at start_height inclusive, descending. |
| GET | /block-height/:height | text/plain: block hash at the active-chain height, or 404. |
Block
| Method | URL | Returns |
|---|---|---|
| GET | /block/:hash | JSON: {id, height, version, timestamp, mediantime, tx_count, size, weight, merkle_root, previousblockhash, nonce, bits, difficulty}. |
| GET | /block/:hash/header | text/plain: 80-byte serialized header, hex-encoded. |
| GET | /block/:hash/raw | application/octet-stream: raw block bytes. |
| GET | /block/:hash/status | JSON: {in_best_chain, height?, next_best?}. |
| GET | /block/:hash/txs | JSON: first 25 txs in full Esplora shape ({txid, version, locktime, vin, vout, size, weight, fee, status}). |
| GET | /block/:hash/txs/:start_index | JSON: 25 txs starting at start_index, which must be a multiple of 25 (400 otherwise). At or past the end of the block: 404 start index out of range. |
| GET | /block/:hash/txid/:index | text/plain: txid at the given block-tx index. |
| GET | /block/:hash/txids | JSON: array of every txid in the block. |
Transaction
| Method | URL | Returns |
|---|---|---|
| GET | /tx/:txid | JSON: full tx (vin/vout/status/fee). 404 if unknown. |
| GET | /tx/:txid/status | JSON: {confirmed, block_height?, block_hash?, block_time?}. |
| GET | /tx/:txid/hex | text/plain: hex-encoded serialized tx. |
| GET | /tx/:txid/raw | application/octet-stream: raw tx bytes. |
| POST | /tx | Body: hex-encoded tx. Returns the txid as plain text on accept. Bad hex or a mempool reject returns 400. With bearer auth, the token must hold rpc:submit (or rpc:write) as well as esplora:read; a read-only token gets 403. |
| GET | /tx/:txid/outspend/:vout | JSON: {spent, txid?, vin?, status?}. |
| GET | /tx/:txid/outspends | JSON: array of outspends, one per output, vout-ordered. |
| GET | /tx/:txid/merkle-proof | JSON: {block_height, merkle: [hex...], pos}. |
| GET | /tx/:txid/merkleblock-proof | text/plain: hex-encoded P2P MerkleBlock for the given tx. |
Address & Scripthash
The address-string and scripthash endpoint families share handlers; only the parser differs. A scripthash is the 32-byte sha256 of the scriptPubKey, hex-encoded in natural byte order. It is not reversed; Esplora's scripthash format differs from Electrum's.
| Method | URL | Returns |
|---|---|---|
| GET | /address/:address /scripthash/:hash | JSON: {address, chain_stats, mempool_stats}. Each *_stats block: {tx_count, funded_txo_count, funded_txo_sum, spent_txo_count, spent_txo_sum}. |
| GET | /address/:address/txs /scripthash/:hash/txs | JSON: up to 50 mempool txs followed by first 25 confirmed (newest first). With ?after_txid=<txid>, the history after that tx: the next 25 confirmed when it is confirmed, or the mempool txs after it followed by the first 25 confirmed when it is unconfirmed. 422 after_txid not found when it is in neither. |
| 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 returns an empty array, not 404. |
| GET | /address/:address/txs/mempool /scripthash/:hash/txs/mempool | JSON: up to 50 mempool txs, in the order this node admitted them. No paging. |
| GET | /address/:address/utxo /scripthash/:hash/utxo | JSON: live UTXOs (confirmed + mempool funding) with {txid, vout, value, status}. |
Wrong-network addresses return 400, as do malformed addresses and bad
scripthash hex (non-hex characters or wrong length). The stats, utxo and
sse routes also return 400 Too many history entries for a script with
more than 200,000 confirmed transactions; see
High-history scripts.
Mempool & Fee
| Method | URL | Returns |
|---|---|---|
| GET | /mempool | JSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate. |
| GET | /mempool/txids | JSON: array of every mempool txid. |
| GET | /mempool/recent | JSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}. |
| GET | /fee-estimates | JSON: object mapping confirmation target (string) to feerate (sat/vB, float). Standard targets: 1..25, 144, 504, 1008. A target the node has no estimate for is left out, and the object is {} when there is none (see below). Never below the minimum relay feerate. |
Root
| Method | URL | Returns |
|---|---|---|
| GET | / | JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings. |
Live updates (Server-Sent Events)
| Method | URL | Stream |
|---|---|---|
| GET | /blocks/sse | One block event per BlockConnected. Body: {hash, height}. |
| GET | /address/:addr/sse | One status event per status-hash change for the address. Body: {address, status_hash}. |
| GET | /scripthash/:hash/sse | Parallel scripthash variant. The address field carries the scripthash hex. |
Connections receive a :keep-alive heartbeat every 25 seconds, so idle streams
survive intermediate proxy timeouts (Caddy defaults to 30 s, nginx to 60 s).
Per-scripthash subscriptions consume from the registry capped by
--addrindexsubscriptions=N (default 50000). Over-cap subscribe attempts
return 503.
Total open SSE streams across all three endpoints are capped by
--esplorasseconns=N, which defaults to the --esploramaxconns value. Each
stream holds a permit until the client disconnects. This cap is distinct from
the request-handling cap, which does not 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 never
panics but can drop intermediate events. On reconnect, refresh state through
the standard endpoints (/address/:addr or /blocks/tip/{hash,height}).
Wire-shape gotchas
- Hex byte order. Block hashes, txids, and merkle siblings are hex-encoded
in display (reversed) byte order, the same as Bitcoin Core's
getblockhashandgetrawtransaction. Scripthash hex is the natural byte order ofsha256(scriptPubKey), not reversed; this differs from Electrum's wire format. - Pagination cursors.
/address/:addr/txs/chain/:last_seen_txidstarts the next page strictly after the cursor in the descending list. An unknown cursor returns an empty array, so clients with stale state get[]rather than 404, as blockstream.info does. - Combined
/txsand?after_txid=. Returns up to 50 mempool transactions followed by the first 25 confirmed, in that order. Mempool entries are in the order this node admitted them (oldest first, then by txid), as upstream lists them; the confirmed ones are newest first.?after_txid=<txid>is mempool.space's paging cursor ("load more"): it continues after a transaction from an earlier page. A confirmed cursor gives the next 25 confirmed transactions, the same page as/txs/chain/:last_seen_txid; a mempool cursor gives the mempool transactions after it and then the first 25 confirmed. A cursor in neither list is 422after_txid not found, a malformed one 400, and an empty one is the first page. mempool.space also takesmax_txs; satd does not. - Block pages.
/block/:hash/txs/:start_indexanswers 404start index out of rangeat or past the end of the block, and 400start index must be a multiple of 25for an unaligned start inside it, as mempool.space does. blockstream.info answers 400 for both. witnessonvin. Present only when the input has a witness. A legacy (non-segwit) input carries nowitnesskey, as upstream./fee-estimateswithout a fee signal. A target is left out when the node has no estimate for it: no mempool signal at that depth and no confirmed-block fee history, as during IBD or right after startup. With no estimate for any target the answer is{}. Upstream Esplora leaves out the targets bitcoind cannot estimate in the same way; clients such as BDK pick the nearest listed target at or below the one they want.feefield on tx JSON.nullwhen at least one prevout cannot be resolved (for example, txindex disabled or the previous tx pruned).Some(0)for coinbase. Otherwisesum_inputs - sum_outputs.- Mempool UTXOs in
/utxo. Outputs created by mempool transactions appear withstatus.confirmed: falseand no block fields. Outputs spent in the mempool are excluded. - Confirmation status on outspends. Confirmed spends carry a full
statuswithblock_height,block_hash, andblock_time. Mempool spends carrystatus: { confirmed: false }.
OP_RETURN-pruned blocks
On a node running with pruneopreturn
(OP_RETURN data pruning):
- A stripped transaction (in
/tx/:txid,/block/:hash/txsand the address histories) carries"opreturn_stripped": true. Each stripped output reads as the bareOP_RETURNit keeps:scriptpubkey"6a",scriptpubkey_asm"OP_RETURN",scriptpubkey_type"op_return", plus"scriptpubkey_stripped_size", the original length. Thetxid, sizes, values and fee are the original's. - Raw bytes are refused with 404:
/block/:hash/rawwithblock data not available (OP_RETURN data stripped), and/tx/:txid/hexand/tx/:txid/rawwithtransaction data not available (OP_RETURN data stripped). - Block summaries, txids, merkle proofs (both kinds), outspends and statuses are unchanged.
Chain-tip headers
Every route that reads the chain answers with two extra headers:
| Header | Value |
|---|---|
X-Bitcoin-Chain-Tip | The best-chain tip's block hash (display hex) the answer was read at. |
X-Bitcoin-Chain-Tip-Height | That tip's height. |
They let a client that makes several requests check that the answers describe one chain: if the hash differs between two responses, a block connected or a reorg happened in between, and the client should fetch the batch again. Compare the hash, not the height; a block replaced at the same height changes only the hash. The headers follow the proposal in mempool/mempool#6584, and rbitcoin's Esplora serves the same two.
Each request answers for the tip it started at, and the headers name that tip. Nothing above it counts as confirmed, however many blocks connect while the request runs:
- A transaction that confirms in a later block is served as unconfirmed:
status.confirmed: falsefrom/tx/:txidand/tx/:txid/status, in the unconfirmed part of/address/:address/txs, inmempool_stats, and as a mempool spend on the outspend routes. Its outputs are unconfirmed UTXOs. It has no merkle proof yet (404). - Confirmed history, chain stats and confirmed UTXOs stop at the tip's
height.
/blocks/tip/hash,/blocks/tip/height,/blocks,/block-height/:heightand/report the tip, and/block/:hash/statusreports a later block as not in the best chain. /block/:hash/...routes still serve any block the node holds by hash, as before.
A disconnect while a request runs (a reorg, or invalidateblock) can change
the blocks the answer covers, so the request is run again at the new tip. One
that a disconnect interrupts eight times in a row answers 503
chain view moved with no tip headers.
The mempool part of an answer (unconfirmed transactions, mempool stats and UTXOs, mempool spends) is read as it is when the request reads it.
As of a block: ?asof=<blockhash>
Some routes answer as of an earlier block on the best chain, given its hash
(display hex, as /block/:hash takes it):
curl "http://127.0.0.1:3000/address/bc1q.../utxo?asof=00000000000000000002a7c4..."
| Route | As of the block |
|---|---|
/address/:address /scripthash/:hash | chain_stats over blocks up to and including it; mempool_stats all zero. |
.../txs | The confirmed transactions only, newest first; ?after_txid= pages them. |
.../txs/chain[/:last_seen_txid] | The confirmed transactions up to it. |
.../utxo | Outputs funded up to it and not spent up to it, all confirmed. |
/tx/:txid/status | confirmed: true if it confirmed up to it, confirmed: false if later; 404 if only in the mempool. |
/tx/:txid/outspend/:vout /tx/:txid/outspends | Spent only by a spend confirmed up to it; a later or mempool spend leaves the output unspent. 404 for a transaction only in the mempool. |
The mempool is never part of the answer. The chain-tip headers name the asof block and its height.
A hash that is not a block on the best chain is 404, and so is one that leaves
the best chain while the request runs: satd never answers from another block
at that height. A malformed or empty asof, or asof on any route not in
the table, is 404 too. /utxo?asof= is worked out from the address's
history, not the live coin set, so it is refused above the same history cap
as the stats. rbitcoin's Esplora takes the same parameter with the same
rules.
Every route carries the headers except POST /tx, /mempool,
/mempool/txids, /mempool/recent, /fee-estimates, the
/txs/mempool lists and the SSE streams, which do not read the chain.
Error answers from a chain route (a 404 for an unknown transaction, say)
carry them too. With CORS enabled both are listed in
Access-Control-Expose-Headers, so browser clients can read them.
Auth
Warning. The default auth mode is
none. Loopback-only deployments (--esplorabind=127.0.0.1:3000) are usually fine. Set an auth mode explicitly before binding to a non-loopback address such as0.0.0.0:3000.POST /txis a broadcast endpoint, and an unauthenticated public listener accepts any transaction submission.
Three auth modes are available via --esploraauth=<mode>:
-
none(default): no authentication. The listener accepts every request. -
cookie: reuses the same.cookiefile the JSON-RPC server creates. Clients pass it via HTTP Basic Auth as__cookie__:<token>, the form Bitcoin Core-compatible tooling generates. Use--esploracookiefile=<path>to override the cookie path.satd --esplora=1 --esploraauth=cookie -
userpass: static credentials supplied via--esplorauserpass=<user>:<pass>. The comparison is constant-time, and the HTTP scheme is case-insensitive.satd --esplora=1 --esploraauth=userpass --esplorauserpass=admin:hunter2
In cookie and userpass modes, satd refuses to start if the auth source
cannot be established (unreadable cookie file, or --esplorauserpass missing).
CORS
--esploracors=<origin> enables CORS for the listed origins; * allows any
origin. Allowed methods: GET and POST. Allowed headers: Content-Type and
Authorization. Exposed headers: the two
chain-tip headers. CORS does not bypass authentication; it only admits
cross-origin browser requests.
Bench harness
scripts/run-esplora-bench.sh starts a regtest node, mines warmup blocks, then
drives ESPLORA_BENCH_REQS requests (default 200) against each implemented
endpoint. It reports p50, p90, and p99 latency per endpoint. See the script
header for the environment variables. It is not a CI gate; use it as a local
regression check.
Compatibility statement
The implemented endpoints aim for byte-for-byte parity with upstream blockstream.info and mempool.space within these constraints:
- Standard scripts only.
scriptpubkey_typestrings coverp2pk,p2pkh,p2sh,v0_p2wpkh,v0_p2wsh,v1_p2tr,op_return,multisig, andunknown, matching upstream. Non-standard scripts serialize withscriptpubkey_address: null. - Mempool ordering.
/address/:addr/txs/mempoolreturns entries in the order this node admitted them (oldest first, then by txid), the order upstream Esplora keeps them in. Upstream's contract is "up to 50". - 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.
- History order is unchanged by the ordinal-keyed chainstate. The
address and spend indexes store rows keyed on a dense transaction
ordinal rather than a height and txid (see
Disk Footprint), but the store resolves and orders
them before they leave it.
/address/:addr,/address/:addr/txs/chain,/address/:addr/utxo,/tx/:txid/outspend/:voutand/tx/:txid/outspendsreturn exactly the objects they did before,txidandstatusfields included. Nothing on the Esplora wire carries an ordinal. - WebSocket subscriptions are not implemented; SSE is satd's
live-updates transport. BDK's Esplora client polls over HTTP and needs
neither. Clients built on the mempool.space WebSocket (
/api/v1/ws), such as the mempool.space JS client's live updates, need adapting to SSE: it is not a drop-in replacement for them. - High-history scripts.
/address/:addr/txs/chain[/:cursor]and the confirmed part of/address/:addr/txs(with or without?after_txid=) read the history from the cursor down and stop once the page is complete, so a page costs about the same however long the history is./address/:addr(the stats) and/address/:addr/utxoread the script's whole history, so they are capped: a script whose confirmed history holds more than 200,000 transactions gets 400Too many history entries (more than 200000 transactions), as mempool.space's electrs answers above its history limit. Opening/address/:addr/sseor/scripthash/:hash/ssefor such a script gets the same 400: its status is not recomputed above the cap, so the stream would carry no events, and a stream already open for a script that grows past the cap stops receiving them. The refusal counts the history's transactions and stops at the first one past the cap, so it costs a bounded read however long the history is; below the cap the answers are unchanged. Set the cap with-addrindexmaxhistory=<n>(shared with Electrum;0for none, which serves every script again at the cost of reading its whole history), or page such a script's history through/txs/chain. Under the cap, a high-activity script (exchange hot wallet, mining pool, popular donation address) can still cost multi-MB allocations and sub-second latency per request. Every address and scripthash route runs its reads on a blocking thread rather than on the API runtime's workers, so a slow read does not hold up other requests and--esplorarequesttimeoutanswers 503 on time; reads still running after a timeout count against--esploramaxconnsuntil they end. For a public deployment that serves such scripts, put the listener behind a per-IP rate limiter at the reverse proxy. - Address prefix search (
/address-prefix/:prefix) is not implemented; it 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 or 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 for the rationale behind the
native, shared-chainstate design.
The server is off by default. Enable it with --electrum=1. It needs
the address index for scripthash history (on by default) and
--txindex=1 for the confirmed-transaction and merkle-proof methods
(off by default). Startup fails if either index is disabled.
- Protocol version:
1.4, advertised as bothprotocol_minandprotocol_max. satd serves a single protocol version. - Transport: line-delimited JSON-RPC over plain TCP (default
127.0.0.1:50001) and/or TLS (default port 50002). Expose the server over Tor /.onionrather than directly on the LAN.
Note. Electrum is loopback by default. It supports native TLS and mutual TLS (
--electrumtlsbind+--electrummtls…). The unified bearer-token layer does not gate Electrum; client-certificate principals are planned but not yet implemented. See Authentication & Authorization.
Configuration
| Flag | Default | Notes |
|---|---|---|
--electrum=<0|1> | 0 | Enable the Electrum server. Requires --addressindex=1 and --txindex=1. |
--electrumbind=<addr:port> | 127.0.0.1:50001 | Plain-TCP listener bind. |
--electrumtlsbind=<addr:port> | none | TLS listener bind (standard port 50002). Requires cert + key. |
--electrumtlscert=<path> | none | PEM TLS certificate. |
--electrumtlskey=<path> | none | PEM TLS private key. |
--electrummtls=<0|1> | 0 | Require mutual TLS on the TLS listener. |
--electrummtlsclientca=<path> | none | PEM CA bundle to verify client certs when --electrummtls=1. |
--electrummtlsclientallow=<subj> | any CA-signed | Allowlist of accepted client-cert CN / DNS-SAN values. |
--electrummaxconns=<n> | 64 | Hard cap on simultaneously-open connections. |
--electrummaxsubsperconn=<n> | 10000 | Per-connection scripthash subscription cap. See Subscription limits for how it relates to the server-wide --addrindexsubscriptions. |
--electrumrequesttimeout=<secs> | 30 | Per-request handler timeout. A request still running after this long is answered with a request timed out error and its connection is closed. The handler's work cannot be interrupted, so the connection keeps its --electrummaxconns slot until that work ends. |
--electrummaxbatchrequests=<n> | 100 | Max requests per JSON-RPC batch line. Wallets such as Sparrow batch their whole gap-limit window of scripthash.subscribe calls at scan time, so a low cap fails the scan. |
--electrummaxbroadcastpackagetxs=<n> | 25 | Max txs per blockchain.transaction.broadcast_package. |
--electrumfeehistogramttl=<secs> | 10 | TTL for the mempool.get_fee_histogram cache. |
--electrumbanner=<text> | powered by satd <version> | Override for server.banner. |
--electrumservername=<text> | satd-electrs-compatible/<version> | Name reported by server.version / server.features.server_version. Does not affect the P2P user agent. See The server name. |
The server runs on satd's isolated API runtime
(--api-threads), so Electrum load cannot starve block connection. Each
request's handler runs on that runtime's blocking pool rather than on one of
its workers: a request that reads a long history does not hold up other
connections, Esplora, events gRPC or /metrics, and the request timeout
answers on time.
History cap
A scripthash whose history holds more than 200,000 transactions is refused
rather than served. blockchain.scripthash.get_history, get_balance,
listunspent and subscribe answer with code 2, history too large (cap = 200000 entries), as ElectrumX ("history too large") and Fulcrum ("exceeds
max history") refuse the same methods above their max_history. For
get_history and subscribe the cap counts confirmed plus mempool
transactions. The refusal counts the history and stops at the first
transaction past the cap, so it costs a bounded read however long the
history is; a refused subscribe is not kept. A subscribed scripthash whose
history grows past the cap gets no further status notifications.
get_first_use and get_mempool are not capped: they read only the first
block and the mempool. -addrindexmaxhistory=<n> sets the cap, for Electrum
and Esplora together; 0 removes it, and a request for a busy scripthash
then reads its whole history again.
Supported methods
A scripthash is the SHA-256 of an output scriptPubKey, reversed (hex),
exactly as in the Electrum protocol.
Server / session
| Method | Description |
|---|---|
server.version | Negotiate client/server software + protocol version. Also accepts rbitcoin's 1.4.2-asof; see Chain-tip stamps. |
server.ping | Keepalive; returns null. |
server.banner | Server banner text (configurable via --electrumbanner). |
server.donation_address | Configured donation address (empty if unset). |
server.features | Feature/identity dict: genesis hash, protocol_min/protocol_max (both 1.4), hosts, tweaks (whether blockchain.tweaks.subscribe can be served), chain_tip (true: answers name the tip they were read at), asof / asof_protocol (As of a block), etc. |
server.peers.subscribe | Peer-server discovery list (satd returns an empty set; no peer gossip). |
Headers & blocks
| Method | Description |
|---|---|
blockchain.headers.subscribe | Subscribe to new-tip notifications; returns the current tip header and pushes on each new block. |
blockchain.headers.get | Fetch a header by height. |
blockchain.block.header | A block header (with an optional merkle proof to a checkpoint). |
blockchain.block.headers | A contiguous range of headers (with optional checkpoint proof). |
Scripthash (address) queries
| Method | Description |
|---|---|
blockchain.scripthash.get_history | Confirmed + mempool history for a scripthash. |
blockchain.scripthash.get_balance | Confirmed + unconfirmed balance. |
blockchain.scripthash.listunspent | Unspent outputs for a scripthash. |
blockchain.scripthash.get_mempool | Mempool-only history for a scripthash. |
blockchain.scripthash.get_first_use | First block/tx that paid the scripthash (electrs-style extension). |
blockchain.scripthash.subscribe | Subscribe to a scripthash; pushes a new status hash whenever its history changes. |
blockchain.scripthash.unsubscribe | Cancel a scripthash subscription. |
Transactions
| Method | Description |
|---|---|
blockchain.transaction.get | Raw transaction by txid (verbose decode optional). Needs --txindex. |
blockchain.transaction.get_merkle | Merkle inclusion proof for a confirmed tx. Needs --txindex. |
blockchain.transaction.id_from_pos | Txid at a (height, position), optionally with a merkle proof. Needs --txindex. |
blockchain.transaction.broadcast | Submit a raw transaction to the network. |
blockchain.transaction.broadcast_package | Submit a package of transactions (bounded by --electrummaxbroadcastpackagetxs). |
Fees
| Method | Description |
|---|---|
blockchain.estimatefee | Estimated fee rate (BTC/kB) for a confirmation target. |
blockchain.relayfee | The node's minimum relay fee rate. |
mempool.get_fee_histogram | Mempool fee-rate histogram (cached; TTL --electrumfeehistogramttl). |
Silent payments
| Method | Description |
|---|---|
blockchain.tweaks.subscribe | BIP 352 tweak stream for client-side scanning. Requires -silentpaymentindex=1; see below. |
Chain-tip stamps
The methods that read the chain at one block answer for the tip the
request started at, and name it beside result:
{"jsonrpc": "2.0", "id": 7, "result": {"confirmed": 5000, "unconfirmed": 0},
"chain_tip": "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
"chain_tip_height": 3}
They are blockchain.scripthash.get_history, get_balance and
listunspent, and blockchain.transaction.get, get_merkle and
id_from_pos. The members and methods are rbitcoin's (satd adds
id_from_pos), and as on rbitcoin every connection gets them. A wallet
that syncs with several requests compares chain_tip across them: if it
changed, a block connected or a reorg happened in between, and the answers
do not describe one chain.
- Nothing above the pinned tip counts as confirmed. A transaction that
confirms while the request runs is listed as the unconfirmed transaction
it was:
get_historyandlistunspentgive it height0(-1when it spends another transaction that was unconfirmed then) andget_historyits fee when its inputs can be read;get_balancecounts it inunconfirmed;transaction.getgives it no block, andget_merkleandid_from_posno proof or position. - A mempool transaction whose parent confirmed above the pinned tip spent
an unconfirmed parent as of it, and has height
-1too. transaction.get'sconfirmationscounts to the pinned tip.- A disconnect while a request runs (a reorg, or
invalidateblock) runs it again at the new tip. One that a disconnect interrupts eight times in a row is answered with the errorchain view moved. - The mempool part of an answer is read as it is when the request reads it.
- Errors, and every other method, carry no stamp.
server.version also accepts rbitcoin's protocol string 1.4.2-asof, alone
or at either end of a [min, max] range, and answers with it, so a client
written for rbitcoin negotiates as it does there. The protocol spoken is
still 1.4, and the stamps do not depend on it.
As of a block
The same six methods take one more argument, after their own (optional
ones included): asof:<blockhash>, rbitcoin's. The answer is then as of
that block, which must be on the best chain, and leaves out the mempool:
{"id": 1, "method": "blockchain.scripthash.listunspent",
"params": ["<scripthash>", "asof:<blockhash>"]}
{"id": 2, "method": "blockchain.transaction.get",
"params": ["<txid>", true, "asof:<blockhash>"]}
- Blocks above the asof block do not count: a transaction confirmed later
is left out of
get_history, an output spent later is still inlistunspentandget_balance, andtransaction.get,get_merkleandid_from_posfind nothing confirmed later. chain_tip/chain_tip_heightname the asof block.- A hash that is not a block on the best chain is the error
asof not on chain, and so is one that leaves the best chain while the request runs. The answer never comes from another block at that height. - A malformed hash, or
asof:on any other method, is an error, never ignored. listunspentandget_balanceas of a block are worked out from the scripthash's history, under the same history cap.
rbitcoin takes asof: only after server.version negotiated 1.4.2-asof;
satd takes it on any connection, so sending it is the opt-in.
server.features reports asof: true and asof_protocol: "1.4.2-asof".
Check server.features before sending asof:. A server without the
feature does not always refuse it: transaction.get, get_merkle and
id_from_pos already take an optional last argument, and an older satd (or
another server) reads asof:… there as verbose = false or
merkle = false, or ignores it, and answers live, with no error. Check the
answer too: its chain_tip names the asof block only when the asof was
applied.
Subscriptions
Two long-lived push subscriptions are supported. Only
blockchain.scripthash.subscribe is counted against
--electrummaxsubsperconn; blockchain.headers.subscribe is one per
connection whatever the cap:
blockchain.headers.subscribe: ablockchain.headers.subscribenotification on every new tip.blockchain.scripthash.subscribe: ablockchain.scripthash.subscribenotification carrying the new status hash whenever a watched scripthash's history changes, in the mempool or confirmed. The index is updated inside the sameconnect_block/disconnect_blockbatch as the chainstate, so a subscriber can never observe a status out of sync with the tip. The status is the protocol's sha256 overtx_hash:height:for each history row, in the orderblockchain.scripthash.get_historyreturns them: confirmed transactions by height and position in the block, then mempool transactions, height0(all inputs confirmed) before-1(spends an unconfirmed parent), then by txid as displayed. A client can therefore check an announced status by hashing theget_historyanswer as received.
Status notifications are computed after every block for every subscribed scripthash, and after every mempool change for the subscribed scripthashes it touches, by reading each history from the index. That work runs on the blocking pool, never on a runtime worker, and block events that arrive while one pass is running share the next pass.
Subscription limits
Two caps apply to blockchain.scripthash.subscribe:
--electrummaxsubsperconn(default10000) bounds one connection. A desktop wallet subscribes every address it has used plus its gap-limit windows, for every wallet it has open, on one connection; 10,000 covers a large one.--addrindexsubscriptions(default50000) bounds the distinct scripthashes subscribed across the whole server. Esplora's/address/:addr/sseand/scripthash/:hash/ssestreams count against it too. Once it is full, every further subscribe of a new scripthash is refused withsubscribe: subscription cap reached (N scripthashes), whichever connection asks.
With the defaults, five connections at their own cap fill the server-wide
registry while 59 of the 64 connection slots are still free, and satd logs
how many connections fill it at startup. For the per-connection cap to bind
first, set --addrindexsubscriptions to at least
--electrummaxconns × --electrummaxsubsperconn (640,000 with the
defaults), plus room for the Esplora SSE streams you serve. Each subscribed
scripthash costs about 4.6 KB of memory (about 230 MB for a full default
registry) and a history read after every block (tens of microseconds for a
short history on a warm index, more for a long one), so size it to the
wallets you serve. --addrindexsubscriptions can be
changed with SIGHUP; lowering it does not drop existing subscriptions.
blockchain.tweaks.subscribe also pushes notifications, but it is a bounded
chunk rather than a standing subscription — it ends itself with
{"message":"done"} — so it does not count against the per-connection cap.
A chunk serves at most 1000 heights, whatever count asks for; both known
clients request the entire remaining chain in one call and resubscribe when the
chunk ends, and the de-facto reference server clamps the same way. The cap is
what makes the end marker unambiguous: clients disagree about what done means
— Cake reads it as "this chunk ended, ask again from the last height key",
kiss-bdk as "the range I requested was served in full" — and those readings only
agree if the server finishes every range it accepts. satd therefore bounds the
range up front rather than truncating an accepted one. If a chunk does stop
early anyway (a height it cannot read, or the 60-second budget), it ends with
{"message":"incomplete: …; resume from height <h>"} instead of done, so a
client that reads the sentinel as completion is not told that unserved heights
were scanned and empty.
Only one runs per connection at a time: a second subscribe while one is still
producing is refused rather than replacing it, because notifications the
superseded stream had already queued cannot be recalled and would arrive after
the new stream's first height.
Serving silent-payment tweaks
blockchain.tweaks.subscribe serves the BIP 352 per-transaction tweaks a wallet
needs to scan for silent payments on the device — the node never sees a scan
key. It requires the tweak index (-silentpaymentindex=1), and refuses in-band
without it, or while the index is still backfilling: a partial index would answer
the heights it has not reached with silence, and a scanning client cannot tell
that from "no payments here". server.features.tweaks reports whether this node
can serve it right now — index present and complete, the same test the
subscribe itself makes — so a client can check before starting a scan rather
than discovering hours of backfill the hard way.
It is a stream, not a call. The JSON-RPC result carries the first
height only; every further height arrives as an unsolicited
blockchain.tweaks.subscribe notification, and {"message":"done"} ends the
chunk. A client that treats it as an ordinary request/response reads one block
and believes it finished a scan. Params are
[start_height, count, historical_mode], and one height looks like:
{"850000": {"<txid>": {"tweak": "<33-byte hex>",
"output_pubkeys": {"<vout>": ["<x-only hex>", 100000]}}}}
Carrying each transaction's taproot outputs alongside the tweak is what lets a client confirm a match without fetching the block — the difference between a scan that is CPU-bound and one that waits on network round-trips.
historical_mode is the same trade the streaming API's tweak_unspent_only
makes, in reverse polarity: false (what Cake Wallet sends) cuts through
coins that are already spent, true keeps them. A balance scan wants false; a
wallet reconstructing transaction history must pass true, because a payment
received and later spent is omitted entirely under cut-through. See
Silent Payments for the full contract.
Heights below taproot activation are streamed as one notification carrying up to
1024 empty height keys, so a wallet restoring from an old height still sees its
progress marker advance without the server writing ~700k lines. A chunk ends
after 60 seconds of wall clock at a height boundary; clients resubscribe from the
next unscanned height, which is what done is for.
The server name, and why it says electrs
satd reports satd-electrs-compatible/<version> from server.version and
server.features.server_version.
Cake Wallet feature-detects by matching on that string rather than on
server.features, and will not probe
blockchain.tweaks.subscribe at all unless it contains the substring electrs
— note electrs, not electrum; the two read the same to a person and only one
matches. Carrying the token is what makes silent-payment support work out of the
box for those clients.
The name leads with satd's own identity and states a claim about the protocol,
in the same way every browser still sends Mozilla at the front of its
user-agent long after that stopped saying anything about who wrote the browser.
It is scoped to this surface: peers on the P2P network see satd's own user agent
(/satd:<version>/, reported by getnetworkinfo as subversion), which this
setting cannot change.
Override it if you would rather not advertise the token, or need a different one for another client:
electrumservername=satd/0.6.0
Keep an override from beginning with electrs. Several clients test that
prefix rather than searching the whole string: BlueWallet uses it to decide a
server needs request batching disabled, and a name starting with electrs
would leave batching off permanently. Leading with satd- — identity first,
compatibility token second — is what keeps the default matching Cake's
substring test while matching nobody's prefix test. An empty value
(electrumservername=) is ignored with a warning rather than advertising a
nameless server.
Other tweak clients (for example
kiss-bdk) do not need the substring — they
identify the chain from server.features.genesis_hash. Nothing else about the
server depends on the name.
Sparrow's silent-payment path is a different method
(blockchain.silentpayments.subscribe), which uploads a scan key to the server.
satd does not implement it. Sparrow still uses satd as an ordinary Electrum
backend.
Notes & differences
--txindexis required forblockchain.transaction.get,get_merkle, andid_from_pos.--addressindex(on by default) backs everyscripthash.*method.- History order is unchanged by the ordinal-keyed chainstate. The
address index stores rows keyed on a dense transaction ordinal rather
than a height and txid (see Disk Footprint), but
the store resolves and orders them before they leave it, so
get_history,listunspentandget_balancereturn exactly the rows, in exactly the order, that they did before. Nothing on the Electrum wire carries an ordinal. - Histories over the cap are refused, not truncated: see History cap.
- satd advertises a single protocol version (
protocol_min == protocol_max == 1.4); it does not negotiate a range. The one other string it accepts is rbitcoin's1.4.2-asof(Chain-tip stamps). server.peers.subscribereturns an empty list: satd does not participate in Electrum peer gossip.- OP_RETURN-pruned blocks (
pruneopreturn, see OP_RETURN data pruning):blockchain.transaction.getwithoutverboserefuses a stripped transaction withtransaction data not available (OP_RETURN data stripped). Withverboseit reads as satd'sgetrawtransactiondoes: nohex,"opreturn_stripped": true, and each stripped output'sscriptPubKeyreduced to the bareOP_RETURNplusstripped_size.get_merkle,id_from_posandblockchain.tweaks.subscribeare unchanged. - The protocol layer is vendored from
romanz/electrs(MIT; attribution inelectrum-proto/vendor/electrs.MIT) and adapted to satd'sAddressIndextrait over the shared RocksDB.
Stratum Mining Server
satd ships a Stratum V1 and Stratum V2 solo-mining server built into the node. A miner — a BitAxe, an NerdQAxe, any ASIC or firmware that speaks Stratum — connects to the node directly, receives work built from satd's own block template, and has any block it finds accepted, taken out of the mempool and relayed by the same process. There is no pool in between and no separate proxy to run.
It is a solo server, not a pool. The username a miner presents is the
payout address: the coinbase of every block that miner finds pays that
address the full subsidy plus fees. There is no share accounting, no payout
splitting and no share database; shares exist only so the miner (and its
operator) can see that it is hashing. This is the same model as
ckpool -B solo mode.
The server is off by default. Enable it with --stratum=1; add
--stratumv2bind for a Stratum V2 listener beside the V1 one.
Quick start
satd --stratum=1
Point the miner at stratum+tcp://<node-address>:3333, with the username
<your-address>.<worker-name> and any password. The worker name after the
first . is optional and appears only in the node's log.
The default listener is loopback only (127.0.0.1:3333), so the command
above serves a miner on the same host. A miner on the network needs either the
TLS listener (recommended, below) or an explicit
plaintext bind:
satd --stratum=1 --stratumbind=0.0.0.0:3333 --stratumallowplaintextremote=1
Exposure posture
A Stratum V1 session is cleartext JSON. The payout address travels in the
clear on every mining.authorize, and a device on the path between the miner
and the node can rewrite it: the miner keeps hashing, and every block it finds
pays someone else. Nothing on the miner shows that anything is wrong.
So satd refuses to start when --stratumbind is not a loopback address and no
--stratumtlsbind is configured:
Error: --stratumbind=0.0.0.0:3333 is not a loopback address and no
--stratumtlsbind is configured. Miners on the network would receive work
and submit shares in cleartext. Set --stratumtlsbind (recommended) or
--stratumallowplaintextremote=1 to accept this.
--stratumallowplaintextremote=1 accepts the risk, and is reasonable on a
network segment you control end to end. The TLS listener runs beside the
plaintext one, not instead of it: loopback clients can keep using port 3333.
This is stricter than the Electrum server's defaults. The difference is what is at stake — an Electrum session leaks privacy, a Stratum session can be robbed.
TLS with real miners
Set --stratumtlsbind (the conventional port is 4333) with a certificate and
key, and point the miner at stratum+tls://<node-address>:4333. Add
--stratummtls=1 with --stratummtlsclientca to require a client certificate,
and --stratummtlsclientallow to accept only listed certificate names.
A home node has no public certificate, so the miner has to trust a CA you create. Two things decide whether that works.
The certificate must name the address the miner dials. Miner firmware
verifies the server certificate against the host in the pool URL. If the
miner is pointed at stratum+tls://192.168.1.50:4333, the server certificate
needs 192.168.1.50 as an IP subject alternative name; a hostname SAN alone
fails.
The CA certificate must be small. ESP-Miner-based firmware (AxeOS, used by
the BitAxe family) accepts a custom CA certificate in its pool settings, but
copies it into a 512-byte buffer — at most 511 bytes of PEM. A longer
certificate is truncated without an error message, and the TLS connection then
never verifies. An RSA-2048 CA is about 1,100 bytes. Even an EC P-256 CA
exceeds the limit once it carries the usual key-identifier extensions: the CA
that contrib/stack/tls/mkca.sh issues for the other TLS surfaces is about 700
bytes and does not fit.
A P-256 CA with a short name and only the two extensions a CA needs fits, at 497 bytes:
openssl ecparam -name prime256v1 -genkey -noout -out ca.key
openssl req -x509 -new -config /dev/null -key ca.key -sha256 -days 3650 \
-subj "/CN=satd" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign" \
-addext "subjectKeyIdentifier=none" \
-out ca.crt
openssl ecparam -name prime256v1 -genkey -noout -out stratum.key
openssl req -new -key stratum.key -subj "/CN=satd-stratum" -out stratum.csr
printf 'subjectAltName=IP:192.168.1.50\nextendedKeyUsage=serverAuth\n' > stratum.ext
openssl x509 -req -in stratum.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 825 -sha256 -extfile stratum.ext -out stratum.crt
cat stratum.crt ca.crt > stratum-fullchain.crt
wc -c ca.crt # must be 511 or less
Then run:
satd --stratum=1 \
--stratumtlsbind=0.0.0.0:4333 \
--stratumtlscert=stratum-fullchain.crt \
--stratumtlskey=stratum.key
and paste the contents of ca.crt — not the full chain, not the server
certificate — into the miner's custom-CA field.
At startup satd warns when the last certificate in --stratumtlscert (the CA,
in a full-chain file) is larger than 511 bytes of PEM. It is a warning, not an
error: firmware that uses a system CA bundle, or keeps a larger buffer, is not
affected.
Stratum V2
Stratum V2 runs the mining protocol over a Noise-encrypted connection
(Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256), authenticated by the
server's authority key. Enable it with --stratumv2bind:
satd --stratum=1 --stratumv2bind=0.0.0.0:3336
Because the connection is encrypted and the server authenticated, the V2 listener may bind a network address without TLS; the plaintext refusal above applies only to the V1 listener. ESP-Miner-based firmware (AxeOS 2.14 and later) speaks Stratum V2 natively, using an extended channel by default.
The authority key. On first start satd creates the key at
<datadir>/stratum_v2.key (override with --stratumv2key), readable only by
its owner, and logs the public key in both forms the ecosystem uses:
Stratum V2 authority key ... created=true authority_pubkey=<64 hex characters> authority_pubkey_base58=<base58check>
A miner either trusts the key it sees on first connection or is configured with it in advance; either way it refuses a server that later presents a different one. Back the key file up with the rest of the datadir. Losing it means reconfiguring every miner that pinned the old key. A key file that exists but cannot be read, or does not hold a valid key, stops the node rather than being replaced. The base58check form — a little-endian key version of 1 followed by the 32-byte x-only key — is what AxeOS and most Stratum V2 tooling accept as the pool's authority public key.
Channels. Both channel types are served. An extended channel receives the
coinbase split around an extranonce hole, the merkle path and an extranonce
range, and rolls its own extranonce; this is what AxeOS uses. A standard
channel receives a finished merkle root and rolls only the nonce, timestamp
and version bits. --stratumv2maxchannels caps channels per connection. The
user_identity a channel is opened with is the payout address, resolved
exactly as a V1 username is; without a usable address the channel is refused
with unknown-user.
Jobs. Each channel's first job on a tip is sent as a future job followed by
the SetNewPrevHash that activates it; a new tip repeats that for every
channel, and the 30-second refresh sends a job that is active at once. Vardiff
changes are sent as SetTarget. Shares are judged exactly as V1 shares are;
rejections carry the Stratum V2 codes stale-share, difficulty-too-low,
duplicate-share, invalid-share, invalid-timestamp and
invalid-channel-id.
A connection must complete the handshake within 10 seconds. After that it is held to the same limits on silence as a Stratum V1 connection, counting a frame on any of its channels; see Idle connections.
Job Declaration
With --stratumv2jd=1 the Stratum V2 listener also serves the Job
Declaration Protocol, for a miner that wants to choose the transactions in its
blocks. The miner runs a Job Declarator Client, which:
- opens a Job Declaration connection to the same port and sends
AllocateMiningJobTokenwith its payout address as the user identifier (the answer names the payout output its coinbase must include); - declares a coinbase and a list of transactions by wtxid
(
DeclareMiningJob); - on a mining connection opened with
REQUIRES_WORK_SELECTION, sendsSetCustomMiningJobon an extended channel with the token the declaration returned, and mines the job id it gets back. A block found on it is submitted with ordinary shares, or pushed withPushSolution.
This is solo-mining Job Declaration, so the checks are strict and nothing is fetched:
- Every declared transaction must already be in this node's mempool and
eligible for a block template. A declaration naming anything else is refused
with
invalid-job-param-value-wtxid_list; the server never asks for missing transactions. - A transaction that spends an unconfirmed parent must be listed after it, and the set must fit in a block: its weight, and its signature-operation cost together with the coinbase's, at most 80,000. When the extranonce sits outside any push in the coinbase scriptSig, its bytes are opcodes the miner chooses later, so the scriptSig is counted at 20 per byte, the most any opcode costs; putting the extranonce inside a push, as the reference client does, makes the count exact.
- The coinbase must commit to the next height on the current tip, pay the address the token was issued for, claim no more than the subsidy plus the declared fees, and carry a witness commitment that matches the declared transactions.
- The custom job must name the current tip and difficulty, a merkle path that matches the declaration, a coinbase prefix of at most eight bytes starting with the height, and outputs that satisfy the same payout and value rules and keep the block within the sigop limit.
Refusals use the Stratum V2 codes invalid-mining-job-token and
invalid-job-param-value-<field>, with a human-readable reason in
DeclareMiningJobError's details. A token is good for one declaration and
expires after ten minutes. Without --stratumv2jd, a Job Declaration
connection is refused with unsupported-protocol, and a mining connection
asking for work selection with unsupported-feature-flags.
Monitoring
getstratuminfo reports the listeners, the authority key, open connections
and channels, share counters, blocks found, the current job, and every
connected miner with its device, difficulty, share counts, best share and
estimated hashrate. See JSON-RPC Extensions.
The same counters are Prometheus metrics; see
Observability.
Verifying a miner
For a quick check, sat-cli getstratuminfo lists each connected miner with
its share counts, the time of its last accepted share and its estimated
hashrate. The log has the history. Every miner gets these lines with no extra
flags:
| Line | Level | Says |
|---|---|---|
Stratum miner authorized (V1), Stratum V2 channel opened | info | The payout address and worker, the user agent (V1) or device (V2), and the starting difficulty. |
Stratum share rejected | warn | Why: low difficulty, stale or unknown job, duplicate, ntime out of range, or a malformed submit (V2 uses its protocol's error codes). A submit from a connection that has not authorized is logged at debug instead, so a peer that is not a miner cannot fill the log. Where known it also names the job, the difficulty it was issued at, and share_difficulty, the difficulty the header actually achieved. |
Stratum miner disconnected (V1), Stratum V2 channel closed | info | Why it ended (for example end of stream when the miner hung up, idle, write failed when it stopped reading, protocol violation, node shutting down), how long it was connected, accepted, rejected and stale share counts, the best share, and the estimated hashrate. |
-debug=stratum adds the detail for a device that is not behaving. Like any
-debug category it can go in the config file (debug=stratum), be switched
on at runtime with sat-cli logging '["stratum"]' and off with
sat-cli logging '[]' '["stratum"]', and a SIGHUP puts it back to what the
config file says. -debugexclude=stratum keeps it out of -debug=all. With it
on:
Stratum miner subscribednames the user agent the firmware sent, andStratum V2 SetupConnectionthe vendor, hardware version, firmware and device id.Stratum version rolling negotiatedshows the mask the miner asked for and the mask it was granted, andStratum miner suggested a difficultyshows amining.suggest_difficultyand the difficulty adopted.Stratum share acceptedfor every share, with the worker, job, difficulty andshare_difficulty. A share that is also a block is logged as a found block instead.- A
vardiff retargetline for every difficulty change. Stratum miner statusevery five minutes for each miner: shares accepted, rejected and stale since the last status line, the current difficulty, the estimated hashrate, and the seconds since the last accepted share.
-loglevel=stratum:trace also logs every job sent to a miner.
The hashrate is estimated from the shares the node accepted: a share at
difficulty d takes d × 2^32 hashes on average, so the estimate is the sum of
the accepted shares' difficulties over the last ten minutes (or since the miner
connected, if that is shorter), times 2^32, divided by that span. At
vardiff's one share every 30 seconds, ten minutes is about twenty shares, so
expect the estimate to wander about a quarter either side of the device's rated
hashrate. A status line early in a connection covers only a few shares.
What the lines point to:
- No
authorizedline. The miner is not reaching the listener, or its username is refused; the refusal is logged at warn. - Authorized, then no shares for a long time. A device far slower than the starting difficulty needs several minutes for vardiff to lower it, and a sub-MH/s device over an hour for its first share at difficulty 1 (see Slow miners). If a fast device shows no shares after several minutes, check that the node is issuing work: during initial block download it withholds work and says so once.
- Disconnected with
reason="binary data on the Stratum V1 port (a Stratum V2 client?)". The miner is set to Stratum V2 but pointed at the V1 port. Point it at the--stratumv2bindport, or switch it to Stratum V1. - Every share
low difficulty, withshare_difficultyfar belowdifficulty. The miner is hashing a different header from the one the node rebuilds, so its shares are effectively random. Compare the granted version-rolling mask with what the firmware rolls. Real bad luck putsshare_difficultyneardifficulty, not orders of magnitude below it. - Mostly
stale or unknown job. The miner is slow to switch to new work, or its connection is lagging. - A hashrate well below the device's rating over several status lines. The device is hashing slower than it should (thermal throttling, a failing hashboard), or losing work to rejects.
- Repeated disconnect lines. The device or its network is dropping the
connection.
connected_secssays how long each one lasted.
Configuration
Every Stratum key is restart-only.
| Flag | Default | Notes |
|---|---|---|
--stratum=<0|1> | 0 | Enable the server. Refused on signet (see Networks). |
--stratumbind=<addr:port> | 127.0.0.1:3333 | Plaintext listener. A non-loopback address needs --stratumtlsbind or --stratumallowplaintextremote=1. |
--stratumtlsbind=<addr:port> | none | TLS listener (conventional port 4333). Requires cert + key. |
--stratumtlscert=<path> | none | PEM certificate or full chain. |
--stratumtlskey=<path> | none | PEM private key. |
--stratummtls=<0|1> | 0 | Require a client certificate on the TLS listener. Requires --stratummtlsclientca. |
--stratummtlsclientca=<path> | none | PEM CA bundle for client certificates. |
--stratummtlsclientallow=<name> | any CA-signed | Accepted client-certificate CN / DNS-SAN names. Repeatable or comma-separated. Requires --stratummtls=1. |
--stratumaddress=<address> | none | Payout address for a miner whose username is not a valid address for this network. |
--stratumdifficulty=<n> | 10000 mainnet, 1000 testnet3/testnet4, 1 regtest | Initial share difficulty. |
--stratummaxconns=<n> | 64 | Connection cap across both listeners. |
--stratumallowplaintextremote=<0|1> | 0 | Accept a non-loopback --stratumbind with no TLS listener. |
--stratumv2bind=<addr:port> | none | Stratum V2 listener. Noise-encrypted; may bind a network address without TLS. Requires --stratum=1. |
--stratumv2key=<path> | <datadir>/stratum_v2.key | Authority key file, created if absent. Back it up: miners pin the key. |
--stratumv2maxchannels=<n> | 16 | Channels one Stratum V2 connection may open. |
--stratumv2jd=<0|1> | 0 | Serve Stratum V2 Job Declaration on the V2 listener. Requires --stratumv2bind. |
The server runs on satd's isolated API runtime, and block
submission runs on a blocking thread, so a found block does not stall the
other API listeners. getserverstatus reports the bound stratum, stratum_tls
and stratum_v2 listeners, including the real port when a bind used :0.
Payout address
The address is resolved on mining.authorize, in order:
- The username, up to the first
., if it is a valid address for this network. - Otherwise
--stratumaddress. - Otherwise authorize fails with
[24, "Unauthorized worker: username is not a valid address for this network and no --stratumaddress is set", null].
"For this network" means mainnet versus the test networks. A mainnet address is refused on testnet and regtest, and a test address on mainnet; but testnet3, testnet4 and signet share an address encoding, so one test network's address is accepted on another. The log line for each authorize shows the address and script that will be paid.
Difficulty and vardiff
A connection starts at --stratumdifficulty (or the per-network default) and
vardiff steers it toward one share every 30 seconds.
Shares arrive at random, so a few minutes of them say little about a miner's speed: at the target rate, seven shares in 90 seconds where three were expected happens about 3% of the time. Vardiff therefore changes the difficulty only when the shares since the last change are clearly off target:
- when a miner exactly on target would produce a count that far off with probability below 1 in 100,000 (checked every 10 seconds, so that luck alone rarely gets through); or
- once at least 40 shares were expected since the last change, when that probability is below 1 in 1,000, so that a moderate error is still corrected, on enough shares to land close to the target; or
- for a miner that has not had a share accepted since it connected, when that probability is below 1 in 100. Its difficulty is only a starting guess, and a slow device can be lowered from the default to difficulty 1 in about twelve minutes instead of half an hour. A fast miner lowered too far by this floods shares and is raised again within a minute.
Even then, a rate within 15% of the target is left alone. A change moves the
difficulty to where the observed rate says it should be, by at most a factor
of eight, never below the mining.suggest_difficulty floor and never above
the network difficulty. Shares are counted for the difficulty they were
judged at, so a share on an older job still counts for its work.
In practice, measured over 300 simulated six-hour runs of miners from 100 GH/s to 100 TH/s:
- A miner whose difficulty is ten times too low (shares far too fast) is within a factor of two of its best difficulty within a minute and a half.
- A miner whose difficulty is ten times too high is within a factor of two of its best difficulty in about two and a half minutes, at worst about forty.
- Once settled, the difficulty of 95% of miners stays within a factor of two, changing at most four times in five hours, with the mean share interval within 20% of 30 seconds. The check runs every ten seconds, so now and then luck passes even a 1-in-100,000 test; such a miner makes one excursion of no more than one step and is corrected within minutes.
- A miner that has submitted before and goes quiet has its difficulty lowered after about eleven expected share intervals without a share.
Slow miners. Difficulty is a whole number, so no miner gets shares easier
than difficulty 1, which takes 2^32 hashes (about 4.3 billion) on average. A
device's share interval at difficulty 1 is 2^32 / hashrate seconds: about 72
minutes at 1 MH/s and about a day at 50 kH/s. Simulated from the mainnet
default difficulty of 10,000, without mining.suggest_difficulty:
| Device | First share (median) | Shares a day | Idle disconnects a day |
|---|---|---|---|
| 1.2 TH/s | 22 s | ~2,900 | 0 |
| 100 GH/s | 2.6 min | ~2,900 | 0 |
| 1 GH/s | 7.5 min | ~2,800 | 0 |
| 7 MH/s | 19 min | ~140 | 0 |
| 1 MH/s | 76 min | ~19 | 0 |
| 50 kH/s | 16 h | ~1 | about one |
A device below about 1 MH/s cannot find twenty difficulty-1 shares a day, so it now and then outlasts the one-day idle limit (below) and reconnects. That costs it nothing but the reconnect: its chance of finding a block does not depend on its shares, and every connection gets current work.
A difficulty change is sent as mining.set_difficulty followed by a new job
(Stratum V1) or SetTarget (Stratum V2); shares for the previous job are
still judged at the difficulty that job was issued with.
mining.suggest_difficulty sets the connection's difficulty and makes it the
floor vardiff will not go below. It is clamped to [1, 2^48].
A ~1.2 TH/s BitAxe-class device at the mainnet default of 10,000 finds a share about every 35 seconds, so it starts close to the target rate.
Stratum V2 channels start at the same difficulty and are steered the same
way; a channel's target also never exceeds the max_target the miner opened
it with.
A share is checked against the easier of the share target and the block target. On regtest, where the block target is far easier than difficulty 1, that is what lets a block-winning header through.
Stratum V1 protocol
Methods served: mining.configure (BIP 310 version rolling, mask
1fffe000), mining.subscribe, mining.authorize,
mining.suggest_difficulty, mining.submit, mining.extranonce.subscribe
(accepted, no-op) and mining.ping. The server sends mining.notify and
mining.set_difficulty.
mining.subscribereturns a 4-byte extranonce1 unique to the connection and an extranonce2 size of 4 bytes.- A new job is sent on every chain tip change (with
clean_jobsset: earlier jobs are stale) and every 30 seconds otherwise, so new mempool transactions reach the miner. - The last eight jobs per connection are kept. A share for any other job is
[21, "Job not found", null]. Other rejections are[22, "Duplicate share", null],[23, "Low difficulty share", null], and[20, "Invalid ntime", null]for a timestamp below the median time past or more than two hours ahead of the node's clock. - On testnet3 and testnet4, a block timestamped more than 20 minutes after its parent may use the minimum difficulty, so a job's difficulty holds only on one side of that moment. A share timestamped on the other side from the job is stale; the next refresh issues a job with the right difficulty.
- Lines are limited to 8 KiB. A connection that sends nothing for too long is closed; see Idle connections.
Idle connections
A miner sends nothing but shares, so how long it can go quiet depends on how often it should find one. A connection that has not authorized (Stratum V1) or opened a channel (Stratum V2) is closed after 120 seconds of silence. After that:
- Once the miner has four accepted shares, it may go quiet for twenty expected share intervals at its current difficulty, but never less than 120 seconds and never more than a day. The expected interval comes from the slower of two hashrate estimates: the ten minutes before its last accepted share, and the whole connection up to that share. Both stop at the last share, so the silence being timed does not stretch its own limit. At vardiff's one share every 30 seconds the limit is ten minutes.
- Before that, it may go quiet for a day. A new miner's silence says nothing yet: a slow device at the default difficulty can take over an hour to find its first share, most of it after vardiff has lowered it as far as it goes.
The limit is recomputed as the difficulty changes. Shares arrive at random, so a gap of twenty expected intervals has a probability of about 2 in a billion. A Stratum V2 connection counts the share rates of all its channels.
The idle limit is a backstop, not how a miner that has gone away is noticed. The node sends every miner a job at least every 30 seconds, so a peer that is switched off or unplugged stops acknowledging them, the operating system's TCP retransmission gives up on the connection (after about fifteen minutes with Linux defaults), and the session ends with a read or write error.
The one-day ceiling matters for a miner that expects fewer than one share
every 72 minutes: a device slower than about 1 MH/s even at difficulty 1 (see
Slow miners), or one whose
mining.suggest_difficulty floor is above what it can reach. Such a miner is
now and then dropped while it is hashing and reconnects; lower the floor if
you set one.
When vardiff lowers the difficulty of a miner that has gone quiet, the idle limit is still judged at the difficulty of the miner's last share, so the lowering itself cannot make a silence already endured exceed the limit.
Template checks
Every block template is checked before a miner gets it, as Bitcoin Core checks
the templates it builds (TestBlockValidity). A template that would make a
block the node rejects is a node bug — the mempool admitted a transaction
consensus refuses — and the check exists so that a miner who finds a block
has not found it on a template that was never valid.
The check has two tiers:
- Structural, on every template, before it is issued: every rule block connection applies except running scripts — size and weight, sigops, the BIP 34 height, lock times and sequence locks, the coinbase value, the witness commitment, and inputs that are missing or already spent.
- Full, which adds the scripts, run through the same verifier the node
connects blocks with (under
-consensus=cpp-shadow, the shadow comparison included). It runs in the background, never delaying work: for each new tip, and at most every two minutes while the tip stands still. It costs about as much as connecting a block, since satd has no script-execution cache: a few CPU-seconds for a full mainnet template.
A template that fails either check is replaced by a coinbase-only job on
the same tip, which is always valid, and every miner switches to it at once.
getstratuminfo shows "coinbase_only": true on the current job. While that
lasts, each new template must pass the full check before it is issued; the
first one that does brings transactions back. The failure is logged at error
with Core's reject reason and the offending transaction, counted in
satd_template_checks_total, and raised as the template_invalid alert (see
Observability). The transaction is not
evicted from the mempool: Core does not do that either, and the bug that let it
in should stay visible.
getblocktemplate runs the structural check too and answers an invalid
template with TestBlockValidity failed: <reason>, as Core does; the full
check for its template follows in the background once per tip. The generate
RPCs refuse such a block the same way before solving it.
Stratum jobs come from the same block assembler as getblocktemplate, so
-blockmaxweight and -blockmintxfee shape them too.
When work is withheld
During initial block download the server accepts connections and answers
subscribe, configure and authorize, but sends no work: a block built on
a tip days behind the network is worthless. It logs
stratum: not issuing work during initial block download once and sends work
as soon as the node catches up. Regtest is exempt, because a fresh regtest
chain's genesis block is from 2011.
The server does not wait for peers. Outside regtest it logs a warning the first time it issues work with no peer connected, since a block found then cannot be relayed until one connects.
When a block is found
A share whose header meets the block target is assembled into a full block
and submitted exactly as submitblock would: proof of work, then block
acceptance. If the block joins the active chain its transactions leave the
mempool and it is announced to peers; every miner receives a clean job for the
new tip. The node logs:
Stratum miner found a block height=... hash=... address=... worker=...
The miner's submit is answered true whatever the outcome — the miner did its
part. A block that is valid but does not join the active chain, or that is
rejected, is logged at warn.
The saved copy
Before a found block is submitted, it is written to
<datadir>/stratum/found/<height>-<hash>.hex
(under the network subdirectory on a test network, like the rest of the
datadir). The file is the serialized block as a single line of hex, exactly
what submitblock takes. It is written to a temporary file, synced and renamed
into place, so it either holds the whole block or is absent. The node logs
Stratum found block saved; submitting it height=... hash=... path=...
and the warn for a refused block names the file again in saved=.
The copy is there for the case the node itself gets wrong. If this node refuses a block — a bug, a disk error part way through connecting it — the block is still worth broadcasting, and any other node will take it. A full block is megabytes of hex, more than a single command-line argument can carry, so pass it on standard input:
sat-cli -stdin submitblock < <datadir>/stratum/found/968181-<hash>.hex
bitcoin-cli -stdin submitblock < <datadir>/stratum/found/968181-<hash>.hex
A block that was refused as invalid (bad-cb-amount, bad-txns-...) will be
refused everywhere; one refused for a reason that is this node's own is not.
Submit it promptly: a block is only worth anything until the network builds
another one at its height.
Files are kept after a successful submission too, as a record of the blocks found. There is one per block, so the directory does not grow in practice. Only a header that meets its block target is ever saved. If the directory cannot be written, the node logs an error and submits the block anyway.
Networks
- mainnet, testnet3, testnet4, regtest: supported.
- signet: refused at startup with
--stratum is not available on signet: signet blocks require the network's signing key. Signet blocks carry a signature from the network's signing key (BIP 325), which no block template here provides, so every block a miner found would be invalid.
Not supported
- Pool operation: multiple payout addresses per share stream, PPLNS or any other reward splitting, share accounting.
- The Stratum V2 Template Distribution Protocol (the
TemplateProviderrole), group channels, and fetching declared transactions this node does not already have (ProvideMissingTransactions).
Getting Started: Consuming Events
This chapter takes you from nothing to a durable, reconnect-surviving consumer of satd's Streaming Consumption API, one runnable step at a time. Each step names the concept, then links to the two reference chapters that own the detail:
- the Streaming Consumption API chapter: the wire protocol, transports, watch-sets, cursors, quotas, and operator limits;
- the Rust SDK (
satd-events-client) chapter: every type and method, in isolation.
Read those when you need a full signature or an edge case.
The tutorial uses the Rust SDK throughout. Nothing here is Rust-specific at the
protocol level: the Go SDK (satdevents) is a full-parity sibling
and every step below maps onto it one-for-one (that chapter has the translation
table), and WebSocket and SSE consumers follow the same sequence over the JSON
rendering (see the Transports section).
Prerequisites
You need a running node with the events gRPC listener enabled
(eventsgrpcbind = 127.0.0.1:50051) and a project that depends on
satd-events-client. A loopback node needs no token. A remote node needs
bearer auth or mTLS; Step 8 covers that.
Step 1: Choose a transport
The API has one schema and three transports: gRPC (the primary programmatic
surface), JSON over WebSocket (GET /ws, with a control channel), and SSE
(GET /sse, a read-only firehose for browsers and curl).
If you are writing a service, use gRPC. It is the only transport with the full bidirectional watch-set control channel. The transport details and the port model are in the Transports section. The rest of this tutorial uses gRPC.
Step 2: Connect
use satd_events_client::{StreamClient, SubscribeOptions, Categories, Event};
let mut client = StreamClient::builder("http://127.0.0.1:50051")
.keepalive_default()
.connect()
.await?;
This opens a plaintext loopback connection, which is fine for a node on the same host. TLS, mTLS, and bearer tokens are one builder call each; see Connecting and Step 8.
Step 3: Tail the firehose
Before you watch anything specific, prove the pipe works. Tail the raw event firehose, every block and mempool transition the node sees:
let mut events = client.subscribe(SubscribeOptions {
categories: Categories::MEMPOOL | Categories::CHAIN,
from_cursor: None, // forward-only for now; Step 5 makes it durable
since_seq: None,
}).await?;
while let Some(event) = events.message().await? {
match event {
Event::BlockConnected { height, .. } => println!("block {height}"),
Event::MempoolEnter { txid, .. } => println!("mempool {txid}"),
_ => {}
}
}
Event is a flat enum, so you match on it instead of unwrapping nested
options. The full firehose semantics (categories, the captured Cursor, lag
notices) are under the subscribe reference.
Step 4: Watch something and react
The firehose is the wrong tool for tracking your own scripts; that is a
watch-set. Open the bidirectional watch stream, register interest, and react
to matches:
let (watch, mut events) = client.watch().await?;
// A direct script watch, with an optional per-script value floor (sat).
watch.add_scripts([(scripthash, Some(100_000))]).await?;
// Or a whole wallet from its exported descriptor: the server expands the
// gap-limit window and derives the scripts for you (keyless: public-key-only).
watch.add_descriptor(descriptor, /*gap*/ 20, /*start*/ 0).await?;
while let Some(event) = events.message().await? {
if let Event::ScriptMatched { txid, descriptors, .. } = event {
// `descriptors` maps a descriptor-derived hit back to its descriptor
// and exact (branch, derivation_index); it is empty for a direct
// watch. A multi-wallet consumer routes the hit with no reverse index.
println!("hit {txid} ({} descriptor attributions)", descriptors.len());
}
}
Outpoint, txid-lifecycle, and confirmation-depth watches all take the same
shape. Every watch kind, and the edge cases the typed helpers handle, are under
the watch reference.
Step 5: Make it survive a reconnect
The watch stream above loses its watch-set and its place in the stream the
moment the connection drops. resilient_watch fixes both. It re-registers the
watch-set on every reconnect and resumes from a persisted cursor, so a network
blip or a process restart is invisible to your logic.
use satd_events_client::{ResilientWatchConfig, FileCursorStore, Event, AutoClose};
use std::sync::Arc;
let config = ResilientWatchConfig::new()
.cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")));
let mut watch = client.resilient_watch(config);
// Registered once; replayed automatically across every reconnect.
watch.add_scripts([(scripthash, None)]).await?;
watch.add_tx_lifecycle([txid], AutoClose::AtDepth(6)).await?;
loop {
match watch.next().await? {
Event::ScriptMatched { txid, .. } => { /* your logic */ }
_ => {}
}
}
Kill the node's listener and bring it back: the wrapper reconnects with
backoff, re-registers the set, and re-anchors the cursor. Re-anchoring is
deterministic, driven by the in-band CursorAccepted/CursorRejected result.
Use this wrapper as the default for any long-lived consumer. See
the resilient_watch reference.
Step 6: Bind the watch-set to your source of truth
The mirror resilient_watch keeps is authoritative only if you build the set
once and never change it. Real consumers have a durable source of truth, such
as a database table of watched addresses, and it changes while the process
runs.
Give the wrapper a watch_set_loader. The wrapper then rebuilds the canonical
set from that truth on every reconnect, and on a fresh start it rehydrates from
truth, not from an empty mirror:
let config = ResilientWatchConfig::new()
.cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")))
.watch_set_loader({
let db = db.clone();
move |builder| {
let db = db.clone();
async move {
for row in db.load_watched_scripts().await? {
builder.add_scripts([(row.scripthash, row.min_value)]);
}
Ok(())
}
}
});
When the truth changes while the stream is up, after a bulk import for
example, call watch.reload().await?. It re-runs the loader and pushes the
whole desired set as a single atomic SetWatchSet. The server reconciles the
set by effective coverage under its lock and answers deterministically:
WatchSetReplaced, or WatchSetRejected { reason, .. } with QuotaExceeded,
CapExceeded, or Malformed. No client-computed delta can strand coverage.
The full semantics are in the loader and reload() subsections of
the resilient_watch reference.
Step 7: Watch privately with a script prefix
Every step so far tells the node exactly which scripts you care about. For a custodian, an exchange, or a privacy-sensitive wallet, that interest set is itself sensitive: the node operator learns exactly whom you watch. A prefix watch breaks that link.
You register only a coarse bits-bit prefix of sha256(scriptPubKey). The
server delivers every transaction that falls in that 2^-bits bucket, so it
learns only the bucket, never your exact script. You filter the decoys out
locally. PrefixWatcher (behind the bitcoin feature) computes the buckets to
register and does the local filtering:
use satd_events_client::{PrefixWatcher, Event};
let mut watcher = PrefixWatcher::new();
watcher.watch_script(&my_script_pubkey); // add each real script locally
let (watch, mut events) = client.watch().await?;
watch.add_script_prefixes(watcher.prefixes(16)).await?; // register 16-bit buckets
while let Some(event) = events.message().await? {
if let Event::PrefixMatched(m) = event {
let hits = watcher.filter(&m)?; // recomputes sha256(spk), drops decoys
for f in &hits.funding { /* a genuine funding match */ }
for s in &hits.spending { /* a genuine spend match */ }
if hits.has_unresolved() {
// A spend-side prevout the server did not retain (mempool below
// the `full` tier). Resolve the outpoint yourself before you
// conclude non-match; do not treat "absent" as "not mine".
}
}
}
bits sets the privacy/bandwidth trade-off. Fewer bits means a larger bucket,
more decoy traffic, and a weaker link between you and any one script. filter
never issues a precise follow-up fetch, because that would re-leak the interest
the bucket exists to hide. The streamprevoutmeta option governs spend-side
retention; the retention tiers and the full mechanism are in
Prefix watches and the Streaming API chapter.
Step 8: Go remote safely
Every step above assumed a loopback node. A remote bind must be encrypted and
authenticated: over plaintext http://, the bearer token and the entire event
stream travel in the clear. Add TLS (a public CA or a pinned self-signed CA)
and a token, or mutual TLS, with one builder call each:
let mut client = StreamClient::builder("https://node.example:50051")
.tls() // or .tls_ca_pem(std::fs::read("node-ca.pem")?)
.bearer_token(token)
.keepalive_default()
.connect()
.await?;
The node-side options (eventsgrpctlscert, eventsgrpcmtls,
eventsgrpcallowremote) are in the Transport encryption
section. The client-side builder options, including the mTLS client identity,
are under TLS / mTLS.
Where to next
The tutorial covered the full sequence: connect, tail the firehose, register a watch-set, make it durable, bind it to your source of truth, watch privately, and go remote. The reference chapters cover what this tutorial deferred:
- Quotas and error handling. The watch quota, the rate limits, and which
StreamErrors are retryable: Errors and the Authentication & quotas section. - Cursors and replay. Exact confirmed-side replay, best-effort mempool
replay, and the replay-truncation
ReplayGap: Cursors & replay. - Runnable examples.
firehose_tail,resilient_tail,watch_outpoints,descriptor_wallet,lifecycle_alarms,prefix_privacy,tls_tail, andmtls_tail, insatd-events-client/examples/.
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 of blocks and mempool transitions with live, cursor-resumable watch subscriptions keyed on outpoints, scripts, descriptors, and transaction ids.
The incumbent ways to consume a node all leave the same gaps: descriptor lifecycle, outpoint-level subscriptions, and cursor-based event replay. Consumers end up rebuilding each of these themselves. satd serves all three natively and in-process, as consensus ground truth, with no reconstruction from a ZMQ side channel.
This chapter is the integrator guide. The authoritative wire-level protocol
specification (the satd.events.v1 protobuf, frame formats, and cursor
semantics) is
docs/api/streaming.md.
For a step-by-step onramp before this reference, start with
Getting Started: Consuming Events.
The base primitive: outpoint subscription
Outpoint subscription is the base primitive. Lightning channel-close detection, watchtower triggers, exchange deposit confirmation, and theft monitoring all reduce to one request: report when this outpoint is spent. Address watching is outpoint watching with a derivation rule on top. The API builds down to outpoints, and layers script, descriptor, and transaction-id watches over the same matcher.
Transports
One schema serves three transports. The satd.events.v1 protobuf definition is
the source of truth.
- gRPC (
satd.events.v1, tonic). The primary transport for programmatic consumers. It offers a server-streamingSubscribe(the firehose) and a bidirectionalWatch(the firehose plus a managed watch-set). - JSON over WebSocket (
GET /ws). A hand-mapped JSON rendering of the same tagged unions, with a client-to-server control channel that mirrorsWatch. - Server-Sent Events (
GET /sse). A read-only JSON firehose with no control channel, for browser andcurlconsumers.
A satd-native ZMQ PUB sink (-eventszmqbind) carries the firehose bodies
only, not per-subscriber watch matches. It is not Bitcoin Core's ZMQ: for
software written against Core's -zmqpub* topics, see
Bitcoin Core ZMQ compatibility below.
WebSocket and SSE bind a dedicated --streamws port; they do not upgrade on
the Core-compatible JSON-RPC port. The stream stays 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 on the core block-connecting runtime. A flood of streaming clients
therefore cannot 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) and a bidirectional Watch. The client-to-server Watch
messages are a tagged union: SetCursor, SetCategories, Add/Remove for
scripts, outpoints, transactions, script prefixes, and descriptors, and
SetWatchSet. SetWatchSet is an atomic whole-set replace: the client sends
the complete desired watch-set in one message, and the server reconciles it
under its lock by effective coverage, replying with a deterministic
WatchSetResult. An incremental Add* the server does not register (over the
quota or the rate limit, over a per-connection cap, without stream:watch, or
malformed) is answered with one WatchAddRejected event that names the refused
items; an add that registers gets no event. New subscription kinds can be added
without protocol breakage.
Match events delivered on the per-subscriber Watch channel include:
OutpointSpent. An outpoint was spent, in the mempool or in a connected block.ScriptMatched. A script was funded or spent (both sides). For a descriptor-derived script it carriesdescriptor_matches: which descriptor matched, and the exact(branch, derivation_index)the script was derived at. The field is empty for a directly watched script. A multi-descriptor consumer can route a hit without keeping its own reverse index. The event also carries the matched value (amount/has_amount, at parity withSpentPrevout), so an exact-script consumer can skip the per-matchgetrawtransactionenrichment call. WithSetWatchOptions{include_raw_tx}set (per connection, off by default) it also carries the full consensus-serialized matching transaction inraw_tx.TxidMatched/TxidReplaced/TxidEvicted/TxidUnconfirmed/TxidDepthReached/TxidFinalized. Transaction lifecycle and confirmation-depth alarms.PrefixMatched. A privacy-preserving script-prefix match.SilentPaymentMatched. A BIP 352 silent payment paid one of your registered scan keys. See the scan-key watch below.
The matcher is decoupled from the consensus path. A dedicated task subscribes
to the existing chain and mempool broadcasts, re-reads blocks and accepted
transactions the node already holds, scans the watch-set, and delivers matches.
A node with no subscribers pays nothing; a lock-free has_watchers() gate
skips the work. A slow client's matches are dropped with notice. The matcher
never stalls and never blocks consensus.
Descriptor convenience layer
AddDescriptor takes a public-key-only descriptor that rust-miniscript can
parse, plus a gap_limit window. The server expands the descriptor over
[start, start + gap_limit), derives the watch scripts, and registers them
with the matcher.
A BIP-389 multipath descriptor (.../<0;1>/*, the canonical export form of
Core, Sparrow, and BDK wallets) is split into its branches, and each branch is
expanded over the same window. The descriptor therefore yields up to
branches × gap_limit scripts and costs that many watch units. The branch
count is capped at 2; more branches are rejected.
Expansion is bounded per branch: MAX_DESCRIPTOR_WINDOW = 1000, so a 2-branch
descriptor yields at most 2000 scripts. Any secret-bearing descriptor is
rejected at the type level. No signing material can be submitted, and the node
stays keyless.
The server retains the descriptor-to-scripthash membership for the connection,
so a window can be slid or dropped cleanly. Re-sending AddDescriptor with an
advanced start reconciles the slid window server-side: scripts that leave the
window are released, and scripts that enter it are added. RemoveDescriptor
drops the whole window. A scripthash shared with a direct add or with another
descriptor is held until its last owner is removed.
A connection may retain up to 256 distinct descriptors
(MAX_DESCRIPTORS_PER_CONNECTION). At the cap, drop a descriptor with
RemoveDescriptor before adding a new one. Re-asserting an existing descriptor
to slide its window is always allowed.
Gap-limit advancement stays a client concern. The server manages the window it
is told to manage; it does not track derivation progress and does not prompt
the client to extend. The client decides when to advance start or send
RemoveDescriptor.
Silent-payment scan-key watch (BIP 352, Tier 2)
This section is the Tier 2 watch reference. The dedicated Silent Payments (BIP 352) chapter compares all three consumption tiers and walks through an integration in each mode.
For clients that would rather not run a per-block scan themselves, Watch accepts
BIP 352 scan-key targets. AddSilentPayments (or an atomic
SetWatchSet.silent_payments replace) registers up to 16 targets per connection
(MAX_SP_TARGETS_PER_CONNECTION); each is a (scan_secret b_scan, spend_pubkey B_spend) pair plus optional label integers. The node then matches every
silent-payment output that pays a registered target and emits a
SilentPaymentMatched carrying the output key and value, the transaction's public
tweak T, and the output counter k — enough for the wallet to re-derive the
full output key, and therefore its spending key, offline from its own
b_scan. Targets are removed by their identity b_scan·G (RemoveSilentPayments),
which the client derives locally; each costs one watch-quota unit. Matching
recomputes from the block and its undo data with the same kernel the index uses,
so it needs no silentpaymentindex and does zero extra work on a block when
no target is registered.
A fresh wallet cold-syncs its history by registering its scan key and then
issuing a bounded RescanBlocks over the taproot-activation-to-tip window. That
rescan produces exactly the confirmed matches the live path would; when
silentpaymentindex is enabled and fully synced it also runs faster, reading
each block's tweaks from the index instead of recomputing them (verified per
block against the stored row's block hash, falling back to recompute on any
mismatch). The index only changes rescan speed, never which payments are found.
A match fires in two phases, like the ScriptMatched watch. When a paying
transaction is accepted into the mempool the node emits the match with
confirmed = false; when it later lands in a block it re-emits the same match
with confirmed = true and a resume cursor. The unconfirmed phase is
best-effort: a scan key registered after a transaction was already admitted
matches it only once it confirms, and a replaced or evicted transaction never
reaches the confirmed re-emit. Mempool matching needs each spent prevout's script
to classify inputs, which the default event path does not retain — so while any
scan key is registered the node keeps those scripts on each mempool entry (paid
for by that watch), and drops back to retaining nothing the moment the last scan
key goes away.
Operator-trust trade. A scan key lets the node run the ECDH match, so the operator — and anyone who compromises the node — learns which outputs are yours. It is not a spending key:
B_spend's private half never leaves the client, so no party but you can ever spend them. The node treats the secret accordingly: scan secrets live in memory for the connection's lifetime only, are wrapped in a zeroize-on-drop buffer, and are never written to disk, a cursor, a status RPC, or a log line. A routable events bind still requires auth or mTLS, the same as every other watch kind. The zero-custody alternative is Tier 1 client-side scanning (see the streaming API reference), where the scan key never leaves the device.
Cursors & replay
Reconnect-with-cursor is the one replay mechanism for every subscription type. It subsumes Electrum's subscribe-then-get-history sequence and Esplora's per-address pagination.
- Confirmed-side replay is exact. The cursor is
(height, tx_index), and replay reads straight from the block index with no extra log. - Mempool-side replay is best-effort within a bounded in-memory window, because
the mempool is not durable. Only the high-water
seqis persisted. - A process restart is detected through
Cursor.instance_id. The per-publisherseqresets on restart; it is the mempool-side watermark only, never a durable confirmed-side cursor.
Reorgs are not a separate event type. ChainEvent carries a Reorg marker,
followed by the per-block disconnect and connect sequence.
Authentication & quotas
The streaming API adds no new authentication surface. It reuses the unified
auth layer; see Authentication & Authorization for the
details. With no token store configured, the transports are open under
loopback trust, matching the existing events-gRPC behavior. A remote bind
requires a token store (-streamwsauth / -eventsgrpcauth, backed by
-authfile).
| Action | Capability | Quota |
|---|---|---|
| Open a stream; receive the firehose | stream:subscribe | none |
AddScripts / AddOutpoints / AddTransactions / AddDescriptor | stream:watch | per-token watch quota plus per-add rate limit |
Remove* | none | releases each item's unit immediately |
The quota unit is one watched item; N items cost N units. Each item holds an
RAII WatchLease, so Remove* returns its unit immediately, and a long-lived
client can rotate a sliding watch-set without exhausting quota. An over-quota
add is refused with a WatchAddRejected event (QUOTA_EXCEEDED, with the units
required, held and allowed) without tearing down the subscription, and so is an
add the per-add rate limit throttles (RATE_LIMITED, with a retry-after hint).
RESOURCE_EXHAUSTED (gRPC) and 429 (WebSocket) are returned only when a stream
is opened.
Transport encryption (events gRPC TLS / mTLS)
Bearer auth controls who may subscribe. Over a plaintext http:// bind, the
token and the event stream still travel in the clear. The events gRPC listener
can terminate TLS in-process, sharing the same certificate and mTLS plumbing as
the RPC, Electrum, and Esplora surfaces.
Set a certificate and key to upgrade the existing eventsgrpcbind listener to
TLS. There is no separate plaintext-plus-TLS bind:
eventsgrpcbind = 0.0.0.0:50051
eventsgrpctlscert = /etc/satd/events-cert.pem
eventsgrpctlskey = /etc/satd/events-key.pem
With mutual TLS, every client must present a certificate signed by a CA you control. Add the CA bundle and, optionally, an allowlist of accepted certificate subjects (CN or DNS-SAN). An empty allowlist accepts any certificate the CA signed:
eventsgrpcmtls = 1
eventsgrpcmtlsclientca = /etc/satd/clients-ca.pem
eventsgrpcmtlsclientallow = alice,bob # optional
A remote bind must be authenticated: eventsgrpcallowremote requires either
bearer auth (eventsgrpcauth) or mTLS (eventsgrpcmtls). mTLS satisfies the
requirement on its own, since every client must present a CA-signed
certificate. satd checks the certificate, key, and CA at startup, so a
misconfiguration fails startup immediately instead of failing per-connection.
The handshake timeout (eventsgrpctlshandshaketimeout, default 30s) bounds
slow or probing clients. Certificates hot-reload from the same paths on
SIGUSR1, like the other TLS surfaces. TLS uses the workspace ring provider
exclusively.
A remote bind must also be encrypted when bearer auth is what authenticates
it: eventsgrpcallowremote together with eventsgrpcauth requires
eventsgrpctlscert and eventsgrpctlskey. A bearer token is sent on every RPC,
and the stream can carry BIP 352 scan keys, so a routable plaintext listener
puts both on the wire for every host on the path. mTLS needs no separate flag
here — it already requires the certificate and key.
If a TLS-terminating reverse proxy fronts the node, keep the loopback bind and leave these options unset.
Operator limits
Every remote-facing streaming surface is bounded, so it cannot be driven to
file-descriptor, memory, or task exhaustion. All of these options are
restart-classified; 0 means unlimited.
| Key | Default | Bounds |
|---|---|---|
streamwsmaxconns | 256 | concurrent /ws + /sse connections |
streamwsmaxsockets | 1024 | open sockets on the listener, counted at accept and held for the socket's life, an open WebSocket included (0 disables) |
streamwsmaxsubscriptions | 256 | watch-set size per WS connection; an add past it is refused with watch_add_rejected |
streamwsmaxmessagebytes | 262144 | a single inbound WS control frame |
eventsgrpcmaxconns | 64 | concurrent gRPC connections |
eventsgrpcmaxsubscriptions | 256 | concurrent Subscribe + Watch streams, across all gRPC connections |
streammaxresyncblocks | 10000 | blocks the matcher will rescan after a lag, bounding catch-up |
Admission shedding runs before authentication and request-body buffering. A connection flood, authenticated or not, is bounded before it does any work.
Bitcoin Core ZMQ compatibility
satd also publishes Bitcoin Core's ZMQ notifications, for software written
against Core: LND's bitcoind backend, Umbrel's Bitcoin apps, pool software
that waits for hashblock, and anything else that subscribes to Core's
topics. It is a compatibility surface. New integrations should use the
streaming API above, which has cursors, replay, explicit removal reasons and
reorg markers that ZMQ cannot carry.
Configure it exactly as on Core, on the command line or in bitcoin.conf:
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
zmqpubhashblock=tcp://127.0.0.1:28334
zmqpubsequence=tcp://127.0.0.1:28335
zmqpubhashtx=tcp://127.0.0.1:28336
Each option may be given more than once, and each value is a notifier. Topics
that name the same address share one socket. unix:<path> binds a Unix
socket, as on Core, and satd also accepts libzmq's own ipc://<path>
spelling. -zmqpub<topic>hwm=<n> sets a topic's high-water mark (default
1000; 0 is unlimited). getzmqnotifications lists the notifiers, in Core's
shape.
What matches Core. Every message is byte-for-byte what Core sends: the topic, the body, and the per-notifier sequence number in the third frame, starting at 0. Each event produces Core's messages in Core's order:
| Event | Messages |
|---|---|
| block connected | hashtx and rawtx per transaction, coinbase first; sequence C; then hashblock and rawblock if the block is a new tip (see below) and the node is not in initial block download |
| block disconnected | hashtx and rawtx per transaction; sequence D |
| mempool accept | hashtx, rawtx, sequence A |
| mempool removal, except by a block | sequence R |
sequence A and R carry the mempool sequence number the change took, the
counter getrawmempool(false, true) reports as mempool_sequence, so a
consumer can line a snapshot up with the stream. A reorg announces on
hashblock and rawblock none of the blocks it connects until its chain has
more work than the old tip, then each block from there to its new tip. A
reorg to a chain one block longer than the old one therefore announces only
its new tip. invalidateblock announces nothing there. Both are Core's
behaviour.
Ordering. satd guarantees:
- Block-derived messages (hashblock, rawblock, block transactions on hashtx/rawtx, sequence C/D) are published in chain order. A reorg publishes D for each disconnected block newest first, then C for each connected block oldest first, each block with more work than the old tip followed by its hashblock/rawblock.
- Mempool-derived messages (mempool transactions on hashtx/rawtx, sequence A/R) are published in the order satd's mempool emitted them. That is the mempool-sequence order except under concurrent mutation, where adjacent events may swap.
- No ordering is promised between the two groups, on a shared socket or
within
hashtx/rawtx/sequence. A/Rsequence values are exact, so a consumer can reconcile againstgetrawmempool(false, true).
Core puts the two groups in one total order. No consumer that subscribes to
one topic per socket, as Umbrel's apps and LND do, can tell the difference:
two sockets have no ordering between them on Core either. A consumer that
rebuilds a mempool purely from the sequence stream, without checking it
against a snapshot, can drift around a reorg, whose returned transactions
may be announced before its blocks, and in the narrow window where a
transaction is admitted and mined within about a millisecond; it should use
the streaming API instead.
Where it differs. Messages are published from the moment blocks connect
at the tip: blocks connected during initial block download or -reindex
produce none (Core publishes their transactions and sequence C). A
subscriber that cannot keep up loses messages past its high-water mark, and
also past 256 MiB of queued data whatever the mark, since a thousand queued
blocks would be gigabytes. If the publisher itself falls behind the node's
event bus, every notifier skips a sequence number, so subscribers see the
gap. A block that cannot be read from disk loses its transaction and
rawblock messages (counted in satd_zmq_block_read_failures_total); Core
instead stops that notifier for good. CORE_DIFFERENCES.md has the full list.
Failure. As on Core, an address that cannot be bound is not fatal: the
node logs the error and runs with ZMQ off, and getzmqnotifications returns
[]. An address with one colon and an invalid port stops the node at
startup with Core's Invalid port specified in -zmqpub<topic> error.
Monitoring. /metrics exports satd_zmq_messages_total{topic},
satd_zmq_subscribers, satd_zmq_subscriber_drops_total,
satd_zmq_events_lagged_total, satd_zmq_rawtx_unavailable_total and
satd_zmq_block_read_failures_total while the publisher runs.
-eventszmqbind is a separate, satd-native ZMQ socket that carries the event
envelope (nodeevent) and satd's own topics. Its hashtx and hashblock
share Core's wire format but not Core's per-event content; use -zmqpub* for
Core semantics.
Consensus-safety invariants
These guarantees are structural, not policy:
- The event bus is publish-only out of
connect_blockandaccept_tx. The matcher only reads data the node already holds. It adds no code to the consensus path and takes no lock on it. - A slow client never backpressures the publisher. Degradation is
drop-with-notice: the
broadcastsend is non-blocking and lossy, and per-subscriber delivery uses a non-blockingtry_send. - Streaming listeners run on the API runtime only, never on the core block-connecting runtime.
Silent Payments (BIP 352)
A BIP 352
silent-payment address (sp1…) is a reusable, static address that produces a
unique, unlinkable taproot output on chain for every payment it receives.
Nothing on chain connects two payments to the same address, and nothing
identifies an output as a silent payment at all. The cost of that privacy falls
on the receiver: finding your own payments means running an ECDH computation
against candidate transactions, because there is no address string to look up.
satd implements the receive side: a tweak index, a streaming tweak firehose with cursor replay, mempool-time detection, and an optional server-side scan-key matcher, with typed support in both SDKs. The matching kernel is tested for parity against the BIP 352 reference vectors. Everything is opt-in; a node that enables none of it behaves exactly as before.
It also carries and checks the send side, through BIP 375 PSBTs — see Sending below. satd holds no keys and signs nothing; what it adds is the verification a sender otherwise has no way to run.
This chapter is the integrator guide: what each consumption mode gives you, how to pick one, and how to operate the index behind them. The wire-level contract lives in the streaming API specification (§7.7).
In the node, not beside it
Silent-payment support follows the same one-process, one-store model as satd's
Electrum and Esplora surfaces: the tweak index is written inside block
connection, atomically with the chainstate, and the serving and matching layers
read it in-process from the same RocksDB store the node validates against.
There is no companion indexer to keep in sync and no window where an external
index's view lags the node across a reorg — rows are removed in the same batch
that disconnects the block, and tweak events carry the block's own hash so a
client re-anchors from the (block_hash, height) it already holds.
For context: Bitcoin Core has no silent-payment support in any released version as of this writing (August 2026), so receiving against a stock node means running a separate tweak-indexing daemon and serving layer beside it, each with its own sync state and reorg handling. satd's index produces the same per-block public tweak data such stacks do, served over the streaming API and a JSON-RPC method instead of a sidecar's own protocol.
The trade-off, as with every satd index, is local disk — measured in Disk Footprint & Indices.
Choosing a tier
Three consumption modes ride on the streaming surface. They differ in who runs the ECDH scan, and therefore in who ever sees your scan key.
| Tier 1 — client-side scan | Tier 1.5 — mempool tweaks | Tier 2 — scan-key watch | |
|---|---|---|---|
| Who computes | your wallet | your wallet | the node |
| Scan key leaves the device | never | never | disclosed to the node |
Requires silentpaymentindex=1 | yes | yes | no (accelerates rescan only) |
| Detection latency | block | mempool admission | mempool admission |
| History / cold-sync | unclamped cursor replay | none (best-effort, live only) | RescanBlocks |
| Transport | gRPC Subscribe | gRPC Subscribe | gRPC Watch (mirrored on WebSocket) |
| Typical consumer | wallets, batch scanners | payment-notification clients | thin clients, phones |
Tier 1 is the recommended, zero-custody mode. The node streams each block's
public tweak data (BlockTweaks, category bit 8 — explicitly requested, never
part of the categories = 0 default); the wallet runs one ECDH per tweak
locally. The scan key never leaves the device, and the node learns nothing
about which outputs are yours. Because every stored row embeds the hash of the
block it describes, tweaks-only replay is exempt from the usual
MAX_REPLAY_BLOCKS clamp: a fresh wallet cold-syncs the entire taproot era in
one from_cursor subscription, paged and backpressured server-side.
Tier 1.5 is Tier 1 at mempool latency. Setting mempool_tweaks = true
alongside bit 8 additionally delivers a MempoolTweak at each eligible
transaction's admission — the same 33-byte tweak its later BlockTweaks entry
will carry, plus the transaction's taproot outputs so a match is confirmed
in-band without a getrawtransaction race. It is best-effort like the mempool
itself: no durable cursor, no replay, no retraction on RBF (dedup by txid;
the confirmed record at connect stays authoritative). A payment missed while
offline is simply caught at confirmation.
Tier 2 moves the scan to the node. Register up to 16
(scan_secret, spend_pubkey) targets per connection and the node emits a
SilentPaymentMatched for every output paying you — at mempool admission with
confirmed = false, then again at confirmation with confirmed = true and a
resume cursor. Each match carries the transaction's public tweak T and output
counter k, which is exactly enough for the wallet to re-derive the output's
full spending key offline from its own b_scan and b_spend. This mode works
on any satd node: matching recomputes from the block and its undo data with the
same kernel the index uses, so it needs no silentpaymentindex and costs the
node nothing while no target is registered.
The trust trade is explicit: a scan key lets the node — and anyone who
compromises it — learn which outputs are yours. It is not a spending key;
b_spend's private half never leaves the client, so no one else can ever spend
them. The node treats the secret accordingly: scan secrets live in memory for
the connection's lifetime only, wrapped in a zeroize-on-drop buffer, never
written to disk, a cursor, a status RPC, or a log line. Both SDKs refuse to
send one over a plaintext transport that carries a bearer token, and a routable
events bind requires auth or mTLS like every other watch kind. Pointing a thin
client at your own node keeps the disclosure inside your trust boundary;
pointing it at someone else's node is a choice to extend that boundary to them.
The tweak index
Tier 1 and 1.5 serve from the sp_tweaks index: one row per block from taproot
activation upward (height 709,632 on mainnet — earlier blocks cannot carry
silent payments), holding the public tweak T = input_hash · A for every
eligible transaction. Rows are written inside block connection, removed on
disconnect, and rebuilt by -reindex-chainstate. A row is present even for a
block with no eligible transactions, so row presence distinguishes "indexed,
none" from "not indexed", and every row embeds its block's hash, so readers
authenticate it without trusting the height-to-hash index.
Enable it with:
# bitcoin.conf — default off, restart to change
silentpaymentindex=1
A node that syncs from genesis with the flag set builds the index inline. To add it to an existing datadir, run the deferred backfill:
sat-cli backfillindex silentpayment
The backfill walks from taproot activation to the snapshot height pinned at
start, resumes across daemon restarts, and answers to the generic index
controls (pauseindex / resumeindex / cancelindex silentpayment). It
refuses to start with less than 6 GiB of free disk. Progress is visible three
ways, all reporting the same walk-relative ratio:
getsatdindexinfo→ thesilentpaymentssection:enabled,synced, and abackfillobject withstate,cursor_height,snapshot_height,progress_ratio, andestimated_remaining_seconds. Use the reportedprogress_ratio, notcursor_height / snapshot_height— the latter measures from genesis and overstates a mainnet backfill from its first block.sat-tui→ the services row'ssp-idxcolumn.- Prometheus → the
satd_spindex_*family; see Observability & Metrics.
Until the backfill completes, the tweak-serving surfaces refuse rather than
return a partial result: a from_cursor tweak replay is rejected in-band so a
light client can never silently miss payments below the backfill frontier.
What it costs, measured on a synced mainnet node (August 2026): ~13 GB for the full taproot era, growing ~1 GB/year at the recent eligible-transaction rate, with a mainnet backfill taking 6 h 46 m. The full accounting, including the estimator's stint semantics and the row format, is in Disk Footprint & Indices.
Serving tweaks (Tier 1 on the wire)
With the index enabled and synced, a gRPC Subscribe with category bit 8
streams one BlockTweaks per connected block, shaped by five per-subscription
knobs:
tweak_dust_limit— drop entries whose largest eligible output is below the floor (in sats). At 546 sat this trims roughly 10% of mainnet entries.tweaks_only— striptxidandmax_value, leaving the 33-byte tweak alone: the leanest form for bulk cold-sync.mempool_tweaks— additionally streamMempoolTweakat admission (Tier 1.5).tweak_outputs— include each entry's taproot outputs, re-derived at serve time, so matches confirm in-band. Off by default because it makes replay read each block;MempoolTweakalways carries its outputs regardless.tweak_unspent_only— cut-through: drop entries whose taproot outputs are all already spent. Entries that survive carry their full output set. The biggest single saving on a cold sync, and the one knob with a correctness caveat, below.
Cut-through is a balance scan, not a restore. tweak_unspent_only asks the
node "is this coin still there?", answered against the UTXO set at the moment the
event is served — not against the chain as of that height. A payment received at
height H and spent at H+100 is therefore absent from a scan of H that runs
today. A wallet that wants a current balance loses nothing and skips the ECDH for
every coin that no longer exists; a wallet reconstructing its transaction history
must leave the flag off, or the history will omit everything it has already
spent. Entries dropped this way set the block's filtered flag, so an empty
block is never mistaken for one with no eligible transactions.
Spentness decides only whether an entry survives — never which outputs a
surviving entry carries. Scanning walks k = 0, 1, 2, … and stops at the first
k with no match among the outputs it was given, so an entry trimmed to just its
unspent outputs would cut the walk short and hide a live coin at a higher k.
An entry with one spent and one live output therefore arrives carrying both. Like
tweak_outputs, it re-derives outputs from the block, so it needs a block source
and reads one block per event. It never applies to MempoolTweak — an
unconfirmed output is in no confirmed UTXO set.
The firehose serves on gRPC only — the WebSocket/SSE transports do not carry the
tweaks category. For scripts and integrators not on an SDK,
getsilentpaymentblockdata "blockhash" ( verbosity dust_limit ) returns the
same per-block bytes over JSON-RPC; see
JSON-RPC Extensions.
Serving tweaks to existing wallets (Electrum)
The streaming API is the better protocol — durable cursors, mempool-time tweaks,
reorg anchors — but no third-party wallet speaks it yet. The wallets that do
scan silent payments today speak blockchain.tweaks.subscribe on the Electrum
port, so satd serves that method too, from the same index and with the same
tweaks:
electrum=1
silentpaymentindex=1
It is a stream rather than a call (the JSON-RPC result is the first height, the
rest arrive as notifications, {"message":"done"} ends the chunk), and its
historical_mode parameter is the cut-through trade described above with the
polarity flipped: false cuts spent coins, true keeps them for a restore.
The Electrum chapter has the wire
shape and the per-network behaviour. Nothing here needs an operator decision:
Cake Wallet probes the method only when the advertised server name contains
electrs, and satd's default name does, so enabling the index above is enough.
Walkthrough: a zero-custody light wallet (Tier 1)
The shipped SDK examples are the reference implementations —
sp_light_scan.rs
(Rust) and
sp_light_scan
(Go) — each a complete scanner in one file: subscribe, ECDH, label handling,
in-band output confirmation, and a restart-durable resume cursor. The shape, in
Rust:
let opts = SubscribeOptions {
categories: Categories::TWEAKS, // bit 8 — never implied by "all"
mempool_tweaks: true, // Tier 1.5: detect at admission
tweak_outputs: true, // confirm matches in-band
// Cold-start anchor, used only when the cursor file is empty. A cursor
// names the last height already done, so `activation - 1` scans the
// activation block itself.
from_cursor: Some(Cursor { height: 709_631, ..Default::default() }),
..Default::default()
};
let mut sub = client.resilient_subscribe(
opts,
ResilientConfig::new().cursor_store(Arc::new(FileCursorStore::new(path))),
);
loop {
// Propagate, never `while let Ok(..)`: `next()` returns `Err` on every
// PERMANENT failure — a corrupt cursor file, a rejected subscribe (an index
// still backfilling answers `FAILED_PRECONDITION`), retries exhausted.
// Swallowing that exits the loop silently and the wallet reports a zero
// balance it never actually scanned for.
let event = sub.next().await?;
// scan, then poll again — the next poll commits this event's cursor
}
For each TweakEntry, the wallet computes locally, per BIP 352:
ecdh = b_scan · T // one point multiply per entry
t_k = hash("BIP0352/SharedSecret", ecdh ‖ k) // k = 0, 1, … per candidate output
P_k = B_spend + t_k · G // expected output key
and compares P_k's x-only form against the transaction's taproot outputs —
carried in the event itself under tweak_outputs, so no follow-up RPC is
needed. A payment to a labeled address (BIP 352 §5) shifts P_k by the label
tweak; scan with each of your labels, and include label 0 even if you issue
none, because label 0 is how your own change comes back. On a match, the
spending key is b_spend + t_k (plus the label tweak if any) — derived
entirely on the device.
Cold-sync is the same subscription with a from_cursor at taproot activation;
the replay is unclamped, index-backed, and ends in-band on any storage error
rather than skipping a height.
The resume anchor to persist is the cursor of the last event you have finished
scanning, not the last one delivered — a cursor written ahead of the work it
stands for turns a crash into a silently skipped block, and for a scanner a
skipped block is a missed payment. Both SDKs get this right for you: a
ResilientSubscription with a CursorStore commits on poll, writing an
event's cursor only when you come back for the next one, so an interrupted scan
replays its last block instead of stepping over it. Use that rather than
hand-rolling persistence around the raw stream; both reference examples do
(sp_light_scan.rs,
sp_light_scan).
The mirror-image slip — persisting the previous event's cursor — costs only a
repeated scan, and has shipped in a production wallet
(cake_wallet#3574).
Walkthrough: a thin client with a registered scan key (Tier 2)
The reference implementations are
sp_wallet.rs
and
sp_wallet.
The shape, in Go:
target := satdevents.SilentPaymentTarget{
ScanSecret: bScan, // disclosed to the node: a watch credential, not a spend key
SpendPubkey: spendPubkey, // public half only; b_spend never leaves the client
Labels: []uint32{0}, // label 0 catches your own change
}
handle.AddSilentPayments(ctx, []satdevents.SilentPaymentTarget{target})
From here the node does the scanning. Each SilentPaymentMatched arrives twice
— once at mempool admission (confirmed = false, best-effort) and once at
confirmation (confirmed = true, with a resume cursor) — and carries the
output key and value plus the public tweak T and counter k, from which the
client re-derives the full spending key offline exactly as in Tier 1. Targets
are removed by their identity b_scan · G, which the client derives locally;
each target costs one watch-quota unit.
A fresh wallet cold-syncs by registering its targets and issuing a
RescanBlocks over the taproot-activation-to-tip window. The rescan produces
exactly the matches the live path would have; on a node whose tweak index is
enabled and complete it also runs faster, reading each block's tweaks from the
index (verified per block against the stored row's embedded hash) instead of
recomputing them. The index changes rescan speed, never results.
Both SDKs' ResilientWatch re-registers scan-key targets automatically on
reconnect, so a dropped connection never silently stops the watch; see the
Rust SDK and Go SDK chapters for the
reconnect-and-resume contract and the TLS posture around scan secrets.
Sending (BIP 375)
Paying a silent payment address is not something a wallet can do alone with a raw transaction. The output script is derived from a shared secret between the sender's input keys and the recipient's scan key, so whoever assembles the transaction has to be told what that secret produced — and has no way to check the answer. BIP 375 is how that conversation happens inside a PSBT, and satd is the party that checks it.
BIP 375 is a draft. Its field set has not changed since 0.1.0 and satd is refereed against the vectors it publishes, but a draft can still change.
What satd does
BIP 375 PSBTs are PSBT version 2 only, and satd accepts version 2 on every PSBT method (see PSBT version 2). On top of carrying the fields, satd verifies them:
- Which inputs may contribute. BIP 352 admits P2TR, P2WPKH, P2PKH and P2SH-P2WPKH, excludes a taproot input committed to the nothing-up-my-sleeve internal key, and forbids a segwit version 2 or later input anywhere in the transaction. Every signature must be SIGHASH_ALL, or the outputs the secret was computed from could still change.
- Whose key each input really is. A taproot input's key is in its script.
For the others, satd takes the key from the PSBT's derivation data,
signatures or witness and keeps only one whose
hash160matches the previous output's script. This is stricter than BIP 375's reference validator, which takes the first declared key and never checks it. Without the check, a PSBT can name any key, prove an ECDH share against it, derive an output from it, and pass — while the transaction spends something else and the recipient scans for an output that was never created. - Every ECDH share has a valid proof. BIP 374 DLEQ proofs are what make a share checkable without the private key behind it. satd verifies the global proof against the sum of the contributing keys, and each per-input proof against that input's own key.
- The output scripts are what the shares derive to. satd recomputes each silent payment output script from BIP 352 and compares.
- The previous outputs are the real ones. A
witness_utxois whatever the PSBT's author wrote, and for a taproot input it is the key the share is supposed to belong to. satd looks each one up in its own UTXO set. This is the one check a node can do and a hardware wallet cannot.
Reading the verdict
analyzepsbt reports a silent_payments object for any PSBT with silent
payment outputs. Each output gets a status:
| Status | Meaning |
|---|---|
ready | Shares complete, proofs valid, keys bound, and the script either absent or equal to the derived one. |
missing_shares | An eligible input still owes an ECDH share for this scan key. Normal while the PSBT is going round; a fault once the script has been computed. |
invalid_proof | A DLEQ proof does not verify. invalid_inputs names which input and why. |
invalid_script | PSBT_OUT_SCRIPT is present and is not what the shares derive to. |
invalid_inputs | The transaction cannot carry a silent payment: a segwit v2+ input, a sighash that is not SIGHASH_ALL, or a degenerate key sum. |
invalid_prevout | A previous output disagrees with the UTXO set. |
unverifiable | An eligible input's public key is not pinned down by the PSBT, so its share cannot be checked. |
The object also carries derived_script whenever satd could compute one, which
is what lets a coordinator see where the money is actually going before anyone
signs. A verified: false object with a reason means satd could not run the
checks at all — a malformed field, or a PSBT asking for more work than any
relayable transaction needs. It never means "checked and fine".
finalizepsbt is BIP 375's Transaction Extractor, so it refuses unless every
silent payment output is ready with a matching script. There is no override
flag, and extract=false is gated too: a finalised PSBT is one
sendrawtransaction away from the chain, and a silent payment paid to the
wrong script cannot be recovered.
The flow
satd has no wallet and no coin selection, so choosing which coins to spend is still the caller's. Everything else is a node call.
# 1. Build a version 2 PSBT naming the recipient. The `sp1…` key needs
# psbt_version=2; without it the node refuses and says so.
sat-cli createpsbt \
'[{"txid":"…","vout":0}]' \
'[{"sp1q…":0.25},{"bc1q…":0.2499}]' \
0 null null 2
# 2. The node fills in the previous outputs it knows about.
sat-cli utxoupdatepsbt "$PSBT"
# 3. A Signer — the wallet, or a hardware device that speaks BIP 375 —
# computes the ECDH shares, proves them, derives the output scripts,
# clears PSBT_GLOBAL_TX_MODIFIABLE, and only then signs.
# 4. The node checks all of it before anyone commits.
sat-cli analyzepsbt "$SIGNED" # silent_payments.outputs[].status == "ready"
# 5. Extract and broadcast.
sat-cli finalizepsbt "$SIGNED"
sat-cli sendrawtransaction "$HEX"
Step 3 needs an input's private key, which never enters this node — so it
happens in the client. sat-cli signpsbtwithkey does it.
Signing with sat-cli
sat-cli signpsbtwithkey is a BIP 375 Signer. Given a version 2 PSBT and a key
on stdin it does the Signer's duties in the order BIP 375 states them, because
the order is the safety property:
- Refuse a transaction that cannot carry a silent payment at all — a segwit version 2 or later input, or a sighash that is not SIGHASH_ALL.
- Record the public key of every input it holds, so that anyone else can
check whose key a share belongs to. satd's
createpsbthas no wallet and cannot write these, which makes it the Signer's job. - Compute an ECDH share for each of those inputs, with a BIP 374 proof, and check every share it did not produce.
- Once every eligible input is covered, compute each output's script and
clear
PSBT_GLOBAL_TX_MODIFIABLE. - Only then sign. It never signs while an output has no script: a signature commits to the outputs, so signing first would commit to a transaction that is still going to change.
A signer holding only some of the inputs is the normal case, not an error. It writes its shares, names the inputs that still owe one, emits the PSBT, and exits 2 — the same exit code a partially signed version 0 PSBT gets. The next signer continues. A signer that holds every eligible input writes one global share per scan key instead of one per input; a signer that does not must not, because a global share claims to cover inputs it does not hold.
The key is read from stdin, never over RPC, and the auxiliary randomness for each BIP 374 proof is 32 fresh bytes from the operating system. Reusing it across two proofs for the same key can leak the secret, which is why two runs over the same PSBT produce different proofs of the same share.
Signing with a device
sat-cli signpsbtwithsigner passes a version 2 PSBT to an external signer
untouched. For a silent payment the only party that can compute an ECDH share
is the one holding the input's private key, so a device is exactly the right
party to ask — and sat-cli cannot know which devices have learned BIP 375.
No shipping device writes BIP 375 fields today. What does work now is the
second half: BIP 375 says a PSBT is plain PSBTv2 once every output has a
script, so a device that has never heard of BIP 375 can sign one another signer
has already completed. In that case the device shows a taproot address, not the
sp1… one, so the user's assurance is the node's verdict — run analyzepsbt
and check for ready/matches before the PSBT goes to the device.
Either way sat-cli checks the reply, with the same code the node runs. A
signer adds signature data; it does not choose the transaction. A reply
describing a different transaction, one whose silent payment script is not what
its own shares derive to, or one carrying a signature while an output still has
no script, is refused rather than emitted.
The reply is also checked against the document that was sent, input by input.
A DLEQ proof says a share was computed from the key bound to an input, and
which key that is comes from the previous output the PSBT says the input
spends — which the device hands back along with everything else. A device that
substitutes a previous output paying a key of its own can then do the rest of
the BIP 375 work honestly against it: every proof verifies, and the script the
outputs get is derived from a key no input has, so the recipient's scan never
finds the payment. sat-cli refuses a reply that restates what an input
spends, or the key bound to it. The node refuses the same thing at
finalizepsbt, where the UTXO set has the last word.
What satd will not do
- It will not choose which coins to spend. There is no wallet here.
- It will not sign. Signing is
sat-cli, in the client, with the key on stdin. - It will not hold a scan or spend key, or a label.
- It will not compute
PSBT_OUT_SCRIPTfor you. BIP 375 gives that to the Signer, along with clearing the modifiable flags, because computing it is what freezes the transaction. - It will not verify
PSBT_OUT_SP_V0_LABEL: checking a label needs the recipient's scan secret.
Rust SDK (satd-events-client)
satd-events-client is the async Rust client for the Streaming Consumption
API. The gRPC contract is fully specified and a generated tonic
client exists, but the generated client is raw. Every consumer otherwise
hand-writes the same channel wiring, authorization metadata injection, cursor
capture and persistence, lag recovery, reconnect with backoff, and, for prefix
watches, local re-filtering. The SDK absorbs all of that behind a small typed
surface. A consumer can watch outpoints in ten lines instead of a hundred.
It is the recommended way to consume the streaming API from Rust. Go consumers
have a full-parity sibling in the Go SDK (satdevents); every
other language uses the gRPC/WebSocket surface directly against the
.proto
contract.
Note. Getting Started: Consuming Events walks the whole sequence, from connect through firehose, durable watch, and prefix privacy, one runnable step at a time. This chapter is the per-method reference it links back to.
Crate layout
The wire types are generated once in satd-events-proto, a thin tonic/prost
crate shared by the node's server and this client. The SDK therefore pulls in
no server glue: no node crate, no RocksDB. On top of the proto crate,
satd-events-client depends on tonic, prost, tokio, tokio-stream,
thiserror, tracing, and an optional bitcoin.
[dependencies]
satd-events-client = "0.6"
Note. The crate is not yet on crates.io. Until the published release lands, depend on it via git and read its API docs locally:
satd-events-client = { git = "https://github.com/epochbtc/satd", branch = "master" }cargo doc -p satd-events-client --no-deps --all-features --open
The default build includes the bitcoin feature, which provides the
prefix-watch re-filter and the scripthash helpers. For a minimal dependency
tree that hands you raw bytes to filter yourself:
satd-events-client = { version = "0.6", default-features = false }
Note that this also drops the default-on tls feature, which is not merely a
smaller dependency tree — it is a plaintext-only client. Keep tls unless the
node is genuinely reachable over loopback only:
satd-events-client = { version = "0.6", default-features = false, features = ["tls"] }
Connecting
use satd_events_client::{StreamClient, SubscribeOptions, Categories, Event};
let mut client = StreamClient::builder("https://node:50051")
.tls()
.bearer_token(token) // sent as `authorization: Bearer …` on every call
.keepalive_default() // http2 keepalive matching the server (30s/20s)
.connect()
.await?;
The bearer token is honored only when the server enforces auth
(-eventsgrpcauth). The client's Debug impl redacts the token and never
prints TLS key material.
A token requires an encrypted endpoint
connect() returns StreamError::InsecureCredential for a bearer token
combined with a non-https:// endpoint, rather than putting the credential on
the wire in the clear. Anyone who captures the token can subscribe to the
firehose and register watches; on a Tier 2 scan-key watch the same stream also
carries BIP 352 scan secrets, which disclose which outputs belong to the
receiver.
tonic selects TLS from the URI scheme alone, so https:// is the thing that
decides — a scheme-less node:50051 is plaintext even with .tls() called (and
is rejected for that separately).
For loopback and test harnesses, insecure_bearer_token(token) is the same
thing with the risk accepted explicitly. It is a separate method rather than a
flag so the unsafe choice has to be named at the call site, and so that
switching back to bearer_token cannot silently leave the waiver behind.
TLS / mTLS
The default tls feature encrypts the transport, so neither the token nor the
event stream crosses the network in the clear. The node terminates TLS
natively (eventsgrpctlscert/eventsgrpctlskey; see the
Streaming chapter).
// Public-CA server: trust the bundled Mozilla roots.
let client = StreamClient::builder("https://node.example:50051")
.tls()
.bearer_token(token)
.connect()
.await?;
// satd node with its own (self-signed) CA: pin it.
let ca = std::fs::read("node-ca.pem")?;
let client = StreamClient::builder("https://10.0.0.5:50051")
.tls_ca_pem(ca)
.tls_domain("node.example") // when connecting by IP / through a proxy
.bearer_token(token)
.connect()
.await?;
// Mutual TLS (server set with `eventsgrpcmtls=1`): present a client certificate.
let client = StreamClient::builder("https://node.example:50051")
.tls_ca_pem(std::fs::read("node-ca.pem")?)
.tls_client_identity(std::fs::read("client-cert.pem")?, std::fs::read("client-key.pem")?)
.connect()
.await?;
tls_ca_pem pins exactly that authority; the bundled public roots are then
not used. Plain tls() uses the public roots. TLS uses the ring rustls
provider. For a plaintext-only minimal build, depend with
default-features = false.
In a build without the tls feature, no endpoint is encrypted — including an
https:// one. tonic gates its own https handling behind its tls feature, so
without it an https:// URI opens a plain TCP connection and speaks cleartext
h2c rather than failing. bearer_token() therefore refuses every endpoint in
such a build; if you need a token, keep the tls feature.
Firehose: subscribe
let mut events = client.subscribe(SubscribeOptions {
categories: Categories::MEMPOOL | Categories::CHAIN,
from_cursor: persisted_cursor, // durable replay anchor; None = forward-only
since_seq: None, // forward-only dedup within the broadcast window
}).await?;
while let Some(event) = events.message().await? {
match event {
Event::BlockConnected { height, .. } => println!("block {height}"),
Event::MempoolEnter { txid, fee, vsize, .. } => { /* … */ }
Event::Lagged { resume_cursor, .. } => { /* reconnect from resume_cursor */ }
_ => {}
}
}
Event is a flat enum mirroring the proto oneof body, so you match
instead of unwrapping nested Options. As confirmed events flow, the stream
captures their durable Cursor, and events.cursor() returns the latest.
Persist it and present it again as from_cursor to resume exactly where you
left off — persist the cursor of the last event you have finished
processing, since anything written ahead of the work it stands for is skipped
outright after a crash. resilient_subscribe (below) does that sequencing for
you.
Durable firehose: resilient_subscribe
For a long-lived consumer, resilient_subscribe wraps the firehose in a
ResilientSubscription that handles the failure modes:
use std::sync::Arc;
use satd_events_client::{ResilientConfig, FileCursorStore, Event};
let config = ResilientConfig::new()
.cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/satd.cursor")));
let mut sub = client.resilient_subscribe(opts, config);
loop {
match sub.next().await? {
Event::ReplayGap { resume_height, first_height } => {
// replay was clamped: blocks (resume_height, first_height) were
// skipped; full-resync them from another source
}
event => handle(event),
}
}
What it absorbs:
- Reconnect with backoff. Transport errors and clean server closes trigger
an exponential-backoff reconnect (
Backoff, capped, optionally bounded bymax_retries).next()returnsErronly on a permanent failure or exhausted retries. - Cursor persistence, committed on poll. Confirmed cursors are written to a
CursorStore. The default isNoopCursorStore; useFileCursorStorefor restart-durable resume, or your own impl over a database. A reconnect and a process restart both resume from the stored anchor. A delivered event's cursor is persisted only when you callnext()again — an implicit ack — so the store never advances past an event you have not finished handling, and a crash mid-processing replays it. That makes delivery at-least-once, not at-most-once: dedup on your side if you need exactly-once.commit()writes the pending anchor before a clean shutdown, so the last event handled is not replayed on the next start. - Lag recovery. Under the default
LagPolicy::AutoResume, aLaggednotice becomes a reconnect from itsresume_cursor.LagPolicy::Surfacehands the notice to you instead. - Replay-truncation detection. The server clamps a far-behind cursor's
replay to the most recent
MAX_REPLAY_BLOCKS(10,000) blocks. When that happens, the SDK emits a syntheticEvent::ReplayGapbefore the first replayed block, naming the skipped range, so you can full-resync it rather than silently receiving a gap. instance_idhandling. The full cursor replays verbatim. On a restart mismatch the server discards a stalemempool_seq; confirmed (height) replay is unaffected.
Watches: watch
watch opens the bidirectional stream and returns a WatchHandle plus the
event stream. The handle has a typed helper for every watch kind. Empty inputs
are no-ops, and dropping the handle tears the stream down.
let (watch, mut events) = client.watch().await?;
watch.add_scripts([(scripthash, Some(100_000))]).await?; // per-script min_value floor (sat)
watch.add_outpoints([(txid, vout)]).await?;
watch.add_tx_lifecycle([txid], AutoClose::AtDepth(6)).await?;
watch.add_depth_alarms([txid], [1, 3]).await?; // cross product txids × depths
watch.add_descriptor(descriptor, /*gap*/ 20, /*start*/ 0).await?; // multipath <0;1> ⇒ 2×gap scripts
watch.add_script_prefixes([(prefix_bytes, 16)]).await?; // privacy-preserving prefix
watch.set_categories(mask).await?;
watch.set_cursor(cursor).await?; // mid-stream re-anchor (best-effort)
watch.remove_scripts([scripthash]).await?; // releases quota immediately
The helpers absorb some sharp edges of the wire protocol:
- Depth alarms versus lifecycle.
add_tx_lifecyclesends an emptymin_depths, which the server reads as a lifecycle add.add_depth_alarmssends the depths and filters outdepth < 1client-side, so an all-invalid call is a true no-op rather than an accidental lifecycle add. min_valuefloors. The floors run parallel to the scripthashes. ANonefloor delivers everything, a floor of 0 also delivers everything, and a non-zero floor suppresses matches below it server-side, symmetric across funding and spend sides.set_cursorreports its outcome in-band.Ok(())means the re-anchor was sent, not that it ran. The server answers on the event stream with exactly oneEvent::CursorAccepted { clamped, earliest_replayed, .. }(admitted and replaying;clampedflags an authoritative replay-window gap) orEvent::CursorRejected { reason, .. }with reasonRateLimited,ConcurrentReanchor,EmptyCursor, orNoSource. Drive your catch-up off those events rather than treatingOk(())as success, or useresilient_watch(below), which does this for you.
Durable watch: resilient_watch
watch gives you the raw bidirectional stream; resilient_watch wraps it the
way resilient_subscribe wraps the firehose, plus the extra work the Watch
stream needs. The watch-set is per-connection: when the stream drops, the
server discards your watch-set and quota leases, so a bare reconnect comes
back blind.
ResilientWatch closes that gap:
- Watch-set mirror. It records every
add_*/remove_*/set_categoriesyou make and re-registers the whole set on each reconnect. You keep calling the same typed helpers, now onResilientWatch. - Re-anchor off the deterministic result. After re-registering, it
set_cursors to the persisted high-water mark and drives catch-up off the in-band ack. A transientCursorRejected(RateLimited/ConcurrentReanchor) is backed off and retried in place. ACursorAccepted { clamped: true, .. }or a terminal reject (NoSource) is surfaced so you can resnapshot; that path is the exception, not the everyday fallback. - Cursor persistence and backoff. It reuses the same
CursorStoreandBackoffasresilient_subscribe, committing confirmed cursors on poll.
use satd_events_client::{ResilientWatchConfig, FileCursorStore, Event, AutoClose};
use std::sync::Arc;
let config = ResilientWatchConfig::new()
.cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")));
let mut watch = client.resilient_watch(config);
// Register interest once; it is replayed automatically across reconnects.
watch.add_scripts([(scripthash, None)]).await?;
watch.add_tx_lifecycle([txid], AutoClose::AtDepth(6)).await?;
loop {
match watch.next().await? {
// `descriptors` attributes a descriptor-derived hit back to its
// descriptor + (branch, derivation_index) (empty for a direct watch).
Event::ScriptMatched { txid, descriptors, .. } => { let _ = descriptors; }
Event::CursorAccepted { clamped: true, earliest_replayed, .. } => {
// Authoritative gap: full-resync confirmed history below
// `earliest_replayed` from another source.
}
Event::CursorRejected { reason, .. } => { /* escalate to a resnapshot */ }
_ => {}
}
}
It is single-task, like ResilientSubscription: interleave watch-set edits
with next() calls from one task, reacting to a match and then adjusting the
watch-set. A descriptor replays from its latest (gap_limit, start), so
advance start to slide the window across reconnects; the server reconciles
the slid window. remove_descriptor(descriptor) drops the descriptor and
releases every scripthash whose last owner it was. A script shared with a
direct add or another descriptor stays.
Watch-set loader
The mirror above is authoritative only when you build the watch-set once at startup and never change it during the process lifetime. Often the watch-set has a durable source of truth outside the wrapper: a database table, a config file, an upstream service. The mirror is then a cache of that truth, and two gaps open. A process restart starts with an empty mirror, so there is nothing to replay. And a change to the truth while the stream is down (an entity added, removed, or rekeyed through your own API) leaves the mirror stale until the next in-process edit happens to touch it.
watch_set_loader closes both gaps. It runs once after every (re)connect,
before the event stream resumes, and rebuilds the canonical set from your
truth into a fresh WatchSetBuilder. The first events after a reconnect land
on a fully populated subscription, and a restart rehydrates from truth instead
of from an empty mirror:
use satd_events_client::{ResilientWatchConfig, FileCursorStore, WatchSetBuilder};
use std::sync::Arc;
let db = Arc::new(my_watch_db());
let config = ResilientWatchConfig::new()
.cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")))
.watch_set_loader({
let db = db.clone();
move |builder: WatchSetBuilder| {
let db = db.clone();
async move {
// Query the source-of-truth and declare the canonical set.
for row in db.load_watched_scripts().await? {
builder.add_scripts([(row.scripthash, row.min_value)]);
}
Ok(())
}
}
});
let mut watch = client.resilient_watch(config);
Semantics:
- Canonical on every connect. The loaded set replaces the mirror. You can
still call
add_*/remove_*for live edits within the current connection, but the next reconnect re-derives the set from the loader. Your truth, not the accumulated in-process edits, is the record across reconnects. Persist a hot-add to your truth and the loader picks it up on the next connect. - The cursor is independent. Resume still comes from the
CursorStore/from_cursor. The re-anchor runs after the loaded set is registered, exactly as without a loader. - Loader errors are transient. A failure maps to
StreamError::WatchSetLoaderand is backed off and retried on the next connect. A momentary outage of your source of truth must not crash an at-least-once consumer.
WatchSetBuilder exposes the declarative add_* / set_categories surface.
There is no remove_*, because you are building a complete set into an empty
mirror. Omit the loader and behavior is exactly the mirror replay described
above.
Reloading mid-stream: reload()
The loader fires on every reconnect. Sometimes the durable truth changes while
the stream is up: a bulk import writes rows outside your hot-add path, an
admin rotates keys, or an operator wants the wire to match truth now.
reload() re-runs the loader and pushes the freshly loaded set as a single
atomic SetWatchSet:
let summary = watch.reload().await?; // ReloadSummary { added, removed, unchanged, applied }
tracing::info!(?summary, "watch-set realigned with truth");
- One atomic replace, server-reconciled.
reload()sends the whole desired set in oneSetWatchSetmessage. The server reconciles it under its watch-set lock, by effective scripthash coverage (descriptors expanded). The client never sends a computedAdd*/Remove*delta, so no message ordering can strand coverage or over-charge at quota. An item watched in both the old and new set is kept without a re-registration, even if its mechanism changes (a direct script becoming descriptor-covered, or the reverse), so the matcher sees no gap. Quota is all-or-nothing on the whole target. - Deterministic result. The outcome arrives in-band on
next()asEvent::WatchSetReplaced { added, removed, unchanged }with the server's authoritative counts, orEvent::WatchSetRejected { reason, required, quota }.reasonisQuotaExceeded(the target does not fit quota; shed and retry),CapExceeded(more entries than the per-connection cap, which applies even with no quota; shed and retry), orMalformed(the server could not parse an element of the snapshot; this is a client bug, and retrying the same set will not help). In every case the live set is left unchanged. TheReloadSummaryreturned byreload()carries advisory client-side counts; theEventis the source of truth. - Atomic with respect to your task.
&mut selfserializesreload()against youradd_*/next()on the single task. - Disconnected defers, never errors. With the stream down there is nothing
to apply now. The mirror is still updated, and the next reconnect's loader
re-registers it.
ReloadSummary::appliedtells you which happened. - Returns
ReloadError::NoLoaderif no loader is configured, orReloadError::Loaderif the loader itself fails. A loader failure is surfaced, not retried; you decide whether to call again.
reload() reuses the wrapper's backoff, cursor re-anchor, and loader
plumbing, so there is no need to drop and rebuild the wrapper to force a full
re-push.
Prefix watches (privacy-preserving)
A prefix watch registers a coarse bits-bit prefix of sha256(scriptPubKey).
The server delivers every transaction in that 2^-bits bucket, so it learns
only the bucket, never your exact script. You filter the decoys out locally.
PrefixWatcher (the bitcoin feature) is that filter:
use satd_events_client::{PrefixWatcher, Event};
let mut watcher = PrefixWatcher::new();
watcher.watch_script(&my_script_pubkey);
let (watch, mut events) = client.watch().await?;
watch.add_script_prefixes(watcher.prefixes(16)).await?; // dedup'd bucket set
while let Some(event) = events.message().await? {
if let Event::PrefixMatched(m) = event {
let hits = watcher.filter(&m)?; // decodes raw_tx, recomputes sha256(spk)
for f in &hits.funding { /* true output match */ }
for s in &hits.spending { /* true spend match */ }
if hits.has_unresolved() {
// spend-side prevout the server didn't retain (mempool below the
// `full` tier): resolve the outpoint yourself before concluding
// non-match; never treat absent as zero
}
}
}
prefixes(bits) derives the deduplicated bucket set to register; scripts
sharing a bucket collapse to one. filter returns only genuine matches plus
the outpoints it could not resolve locally. It never issues a precise
follow-up fetch, which would re-leak the interest the bucket exists to hide.
See the Streaming API chapter for the streamprevoutmeta
retention tiers, which govern what the spend side carries.
Errors
StreamError classifies the conditions that stop forward progress. The
Lagged notice is not among them; it is a normal, recoverable Event. Use
StreamError::is_retryable() to decide whether to back off and retry
(Connect, transient transport codes, QuotaExhausted) or give up
(PermissionDenied, a bad URL or token, client-side argument errors).
Unauthenticated is reported non-retryable: re-auth and reconnect rather than
blind-retrying the same token. QuotaExhausted is treated as retryable
because both of its causes, the subscription cap and the per-principal rate
limit when a stream is opened, are transient.
A watch add the server refuses is not an error. It arrives on next() as
Event::WatchAddRejected, which names the kind, the reason (QuotaExceeded,
RateLimited, CapExceeded, PermissionDenied or Malformed), the numbers
behind it and the refused items; none of those items is watched. An add that
registers produces no event.
ResilientWatch treats a RateLimited refusal as transient: it keeps the items
in its mirror, re-sends them after retry_after_secs (or the backoff delay, if
longer), and absorbs the event. Only when an item has used up the backoff
budget (Backoff::max_retries) is the event handed on. Then, and for every
other reason, the refused items leave the mirror before the event reaches you,
so a reconnect does not re-register them (a refused descriptor slide falls back
to the window the server kept). Re-add them yourself if you want another try.
Stability & versioning
The crate's version is the satd version it was released with (it inherits the
workspace version), and it follows semver. Pick an SDK
whose minor version is at or below your node's: satd-events-client 0.6 is
built for satd 0.6 and works unchanged against 0.7, 0.8 and later nodes, which
only add fields and event kinds within schema_version 1.
Each time subscribe or watch opens a stream, the SDK reads the version
the node advertises in its satd-version response header and compares it with
its own:
| Node | Result |
|---|---|
| Same or newer | Opens silently. |
| One minor version behind | Opens and logs a tracing warning (target satd_events_client::compat), once per client per node version. |
| Two or more minor versions, or a major version, behind | Refused with StreamError::NodeTooOld. |
| Different event schema | Refused with StreamError::SchemaMismatch. |
A node older than 0.6.0 sends no header and counts as 0.5. The warning means
"upgrade the node": features added after the node's release are unavailable,
and request fields it does not recognise are ignored. For a rolling upgrade
where clients go first, StreamClient::builder(..).allow_old_node() turns
NodeTooOld into the same warning. Nothing bypasses a schema mismatch. Both
errors are non-retryable, so the resilient layers surface them instead of
reconnecting. StreamClient::node_version() returns what the node last
advertised. The full rule is in
STABILITY_POLICY.md.
The generated wire types are
re-exported under proto, so you can pin to the schema directly when a typed
helper does not yet cover your case. The minimum supported Rust version
(MSRV) is 1.93; an MSRV bump is treated as a minor-version change. The
underlying gRPC contract is the streaming spec.
Examples
Runnable examples live in
satd-events-client/examples/:
firehose_tail, resilient_tail, resilient_watch, watch_outpoints,
descriptor_wallet, lifecycle_alarms, prefix_privacy, health_watch, plus
tls_tail and mtls_tail over an encrypted transport.
health_watch is the alerting shape: subscribe with Categories::STATUS,
track raise/clear pairs to hold "what is wrong right now", and route by
severity so a condition your build predates still reaches the right place.
cargo run -p satd-events-client --example resilient_tail -- http://127.0.0.1:50051 /tmp/satd.cursor
cargo run -p satd-events-client --example resilient_watch -- http://127.0.0.1:50051 /tmp/satd-watch.cursor
cargo run -p satd-events-client --example tls_tail -- https://node.example:50051 ./node-ca.pem
cargo run -p satd-events-client --example health_watch -- http://127.0.0.1:50051
Go SDK (satdevents)
satdevents is the Go client for the Streaming Consumption
API. It is a full-parity sibling of the Rust
SDK — same surface, same guarantees, expressed in Go idiom rather
than transliterated from Rust.
It lives in the satd repository at clients/go/, as an independently versioned
Go module:
go get github.com/epochbtc/satd/clients/go
import satdevents "github.com/epochbtc/satd/clients/go"
Only Go is needed — no Rust toolchain, no protoc. The protobuf bindings are
committed, and CI regenerates them on every PR and fails on a diff, so they
cannot drift from the .proto.
Note. Getting Started: Consuming Events walks the whole sequence — connect, firehose, durable watch, prefix privacy — one step at a time. It is written against the Rust SDK, but the sequence and the concepts are identical here; the mapping table below is the translation key.
Module layout & dependencies
The published SDK's dependency graph is gRPC and protobuf, and nothing else. That is a deliberate constraint, not an accident of being small: a client library that drags a Bitcoin stack or a secp256k1 implementation into every consumer forces version bumps on applications that already have their own. So:
- Script and txid parameters are
[]byteand[32]bytewith helpers, never library-specific types. Bring whatever Bitcoin library you already use, or none. - Silent-payment scan-key validation is an in-tree on-curve check rather than a curve dependency.
- Code generators, linters, the E2E suite, and the examples each live in their own nested module, so what they need never reaches a consumer.
| Path | What |
|---|---|
clients/go/ | the satdevents package |
clients/go/eventspb/ | generated satd.events.v1 bindings, exported for cases a typed helper does not cover |
clients/go/examples/ | thirteen runnable programs (own module) |
clients/go/e2e/ | live-node end-to-end tests, build tag e2e (own module) |
clients/go/tools/ | pinned generators and linters (own module) |
The go directive tracks one release behind the latest stable Go, covering
Go's two-release support window.
Rust → Go mapping
Where the two languages' idioms diverge, the Go shape wins. Nothing is dropped; this table is the translation key.
| Rust SDK | Go SDK | Why |
|---|---|---|
StreamClient::builder(..).bearer_token(..).connect() | Dial(ctx, target, WithBearerToken(..)) | functional options are Go's builder |
enum Event | sealed Event interface + type switch | a sealed interface is Go's closed union; the marker method is unexported, so the implementation set is fixed |
Event::Unknown | *UnknownEvent | same forward-compatibility contract |
#[non_exhaustive] enums | Known() bool on each enum type | Go has no exhaustiveness check, so the "is this value from a newer node?" question is a method |
Option<T> fields | pointer fields (*uint64) | absent stays distinguishable from zero |
| cancel-safe futures | ctx on every blocking call | see cancel safety |
AutoClose::{Never, AtDepth(n)} | AutoCloseNever, AutoCloseAtDepth(n) | a uint32 newtype, zero meaning never |
StreamError::is_retryable() | Retryable(err), errors.Is(err, ErrX) | Go error idiom, one sentinel per class |
Connecting
client, err := satdevents.Dial(ctx, "node:50051",
satdevents.WithTLSCAPem(caPEM),
satdevents.WithBearerToken(token), // sent as `authorization: Bearer …`
)
defer client.Close()
Dial accepts host:port, optionally with an http:// or https:// scheme
(stripped, for symmetry with the Rust client and the -eventsgrpcbind
documentation). It does not block on the connection coming up — gRPC connects
lazily, and the first Subscribe or Watch surfaces a connection failure.
Keepalive matching the server (30s/20s) is on by default; WithoutKeepalive
and WithKeepalive override it.
The bearer token is honored only when the server enforces auth
(-eventsgrpcauth).
A token requires an encrypted connection
Dial returns a KindInsecureCredential error (errors.Is(err, ErrInsecureCredential)) for a bearer token combined with an insecure
transport, rather than putting the credential on the wire in the clear. Anyone
who captures the token can subscribe to the firehose and register watches; on a
Tier 2 scan-key watch the same stream also carries BIP 352 scan secrets, which
disclose which outputs belong to the receiver.
gRPC-Go's own safety net does not cover this. It refuses to attach a
PerRPCCredentials whose RequireTransportSecurity() reports true to an
insecure channel, but this SDK attaches the token with
metadata.AppendToOutgoingContext, which that check never sees.
Any WithTLS* option satisfies the requirement, as does an https:// target.
For loopback and test harnesses, WithInsecureBearerToken(token) is the same
thing with the risk accepted explicitly. It is a separate option rather than a
flag so the unsafe choice has to be named at the call site, and so that
switching back to WithBearerToken cannot silently leave the waiver behind.
TLS / mTLS
// Pin a satd node's own (self-signed) CA — the usual case.
client, err := satdevents.Dial(ctx, "node.example:50051",
satdevents.WithTLSCAPem(caPEM),
satdevents.WithBearerToken(token),
)
// Publicly trusted certificate: the system roots.
client, err := satdevents.Dial(ctx, "node.example:50051", satdevents.WithTLS())
// Mutual TLS, against a node with `eventsgrpcmtls=1`.
client, err := satdevents.Dial(ctx, "node.example:50051",
satdevents.WithTLSCAPem(caPEM),
satdevents.WithMTLS(certPEM, keyPEM),
)
WithTLSServerName overrides the verified name, for dialing by IP or through a
tunnel.
Requesting TLS against an explicit http:// target is refused, not silently
downgraded. That combination can only be a mistake, and connecting in
cleartext would leak the token and the whole event stream while the caller
believed the link was encrypted.
Firehose: Subscribe
stream, err := client.Subscribe(ctx, satdevents.SubscribeOptions{
Categories: satdevents.CategoryMempool | satdevents.CategoryChain,
})
for {
ev, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) { break }
return err
}
switch e := ev.(type) {
case *satdevents.BlockConnected:
log.Printf("block %d %s", e.Height, satdevents.DisplayHex(e.Hash))
case *satdevents.MempoolEnter:
log.Printf("tx %s fee=%d", satdevents.DisplayHex(e.Txid), e.Fee)
}
}
CategoryAll (zero, the default) means every category except
CategoryTweaks and CategoryStatus, which are explicit-request only. That
exclusion is what keeps a client written against an older node from suddenly
receiving a body it has no parser for after the node is upgraded — so if you
want status or tweak events, ask for them by name.
stream.Cursor() returns the latest durable resume position. Persist it to
resume later.
Durable firehose: ResilientSubscribe
sub := client.ResilientSubscribe(ctx,
satdevents.SubscribeOptions{Categories: satdevents.CategoryChain},
satdevents.ResilientConfig{
CursorStore: satdevents.NewFileCursorStore("/var/lib/app/satd.cursor"),
})
defer sub.Close()
for {
ev, err := sub.Next(ctx) // reconnects and replays underneath
if err != nil { return err }
// ...
}
Next returns an error only on a permanent failure (bad endpoint or token,
PERMISSION_DENIED, a failed cursor write), when retries are exhausted, or when
ctx is done. An ordinary disconnect is not an error — it is handled.
The zero ResilientConfig is valid: default backoff (500 ms doubling to a 30 s
ceiling, retrying forever), auto-resume on lag, and no persistence. Set a
CursorStore; without one, a restart resumes forward-only and silently skips
everything that happened while the process was down.
CursorStore is an interface — FileCursorStore is provided, and a database
or key-value implementation is a two-method type.
Watches: Watch
handle, stream, err := client.Watch(ctx)
defer handle.Close()
err = handle.AddScripts(ctx, []satdevents.ScriptWatch{
{Scripthash: satdevents.ScripthashOf(scriptPubKey)},
})
err = handle.AddOutpoints(ctx, []satdevents.OutpointRef{{Txid: txid, Vout: 0}})
err = handle.AddTxLifecycle(ctx, [][32]byte{txid}, satdevents.AutoCloseAtDepth(6))
err = handle.AddDepthAlarms(ctx, [][32]byte{txid}, []uint32{1, 3})
err = handle.AddDescriptor(ctx, descriptor, 20 /*gap*/, 0 /*start*/)
err = handle.AddSilentPayments(ctx, []satdevents.SilentPaymentTarget{target})
err = handle.AddScriptPrefixes(ctx, watcher.Prefixes(16))
The WatchHandle is safe for concurrent use — sends are serialized internally,
since a gRPC stream permits only one send at a time. Every Add has a matching
Remove, and SendControl is the escape hatch for anything the typed helpers
do not wrap yet.
The watch-set is per-connection. The server holds no principal-keyed state, so when the stream drops, the watch-set and its quota leases go with it and a fresh stream starts blank. That is why the durable variant below exists.
Some registrations are outcome-in-band rather than outcome-on-return:
SetCursor and SetWatchSet return nil once the request reaches the control
stream, and the actual outcome arrives on the event stream as exactly one
CursorAccepted/CursorRejected or WatchSetReplaced/WatchSetRejected.
Drive catch-up off those events, not off the return value.
The incremental Add* calls work the same way. One the node does not register
(over the quota or the rate limit, over a per-connection cap, without
stream:watch, or malformed) is answered with one *WatchAddRejected event
naming the kind, the reason, the numbers behind it and the refused items; none of
those items is watched. An add that registers produces no event.
ResilientWatch re-sends a rate-limited add itself after the node's retry hint,
within its backoff budget, and absorbs the event until that budget runs out.
Then, and for every other reason, it drops the refused items from its mirror
before handing the event on, so a reconnect does not re-register them.
Durable watch: ResilientWatch
watch := client.ResilientWatch(ctx, satdevents.ResilientWatchConfig{
CursorStore: satdevents.NewFileCursorStore("/var/lib/app/watch.cursor"),
WatchSetLoader: func(ctx context.Context, set *satdevents.WatchSet) error {
rows, err := db.WatchedScripts(ctx) // your durable truth
if err != nil { return err }
for _, r := range rows {
set.AddScripts(satdevents.ScriptWatch{Scripthash: r.Scripthash})
}
return nil
},
})
defer watch.Close()
for {
ev, err := watch.Next(ctx)
if err != nil { return err }
// ...
}
Without a loader, ResilientWatch re-registers its in-memory mirror of the
Add/Remove calls made through it. That is correct for a watch-set built once
at startup — but the mirror is empty after a process restart, and goes stale if
the truth changes while the stream is down.
With a loader, the mirror becomes a cache of your truth:
- the loader runs after every successful (re)connect, before any event is pumped, so the first events after a reconnect already land on a fully populated subscription;
- the loaded set replaces the mirror, so the next reconnect re-derives from the loader rather than from accumulated in-process edits;
- a loader error is transient — backed off and retried on the next connect,
not surfaced. A momentary outage of your database must not kill a consumer
whose contract is at-least-once. A permanently broken loader is
indistinguishable from a transient one and retries forever; set
Backoff.MaxRetriesif you need a terminal error instead.
Reload(ctx) realigns a live stream with the loader's truth on demand, and
returns a ReloadSummary (Added, Removed, Unchanged, Applied). Use it
after adding an address, so a later reconnect's loader agrees with what this
process registered.
Cancel safety
ResilientSubscription.Next and ResilientWatch.Next take a ctx, and
cancelling it never consumes an event. (Stream.Recv on the non-resilient
surfaces takes no ctx; it is unblocked by cancelling the context passed to
Subscribe/Watch, and that surfaces as a gRPC CANCELED status rather than
context.Canceled — check ctx.Err(), not errors.Is(err, context.Canceled).)
The reconnect state machine runs on its own goroutine and hands events over an
unbuffered channel, so it is never more than one event ahead of the caller.
Returning on ctx.Done() therefore cannot drop an event in flight: the handoff
only completes when the caller actually receives. Cancel Next freely — in a
select against a command channel, or under a per-call deadline — and call it
again.
This is the Go equivalent of the Rust SDK's explicit cancel-safe state machine.
The language does the work here, because a gRPC Recv cannot be abandoned
mid-flight without losing the message.
Prefix watches (privacy-preserving)
Register a coarse bits-wide bucket of sha256(scriptPubKey); the node learns
only the bucket and delivers everything in it, and the client filters locally.
watcher := satdevents.NewPrefixWatcherWithScripts(script1, script2)
err = handle.AddScriptPrefixes(ctx, watcher.Prefixes(16))
// on each *satdevents.PrefixMatched:
hits, err := watcher.Filter(m)
for _, f := range hits.Funding { /* f.Vout, f.Value */ }
for _, s := range hits.Spending { /* s.Outpoint */ }
if hits.HasUnresolved() {
// The server did not retain these prevout scripts. They are UNKNOWN, not
// misses — resolve the outpoints yourself before concluding otherwise.
}
Distinct scripts sharing a bucket collapse into a single registration, which is
the point: the node cannot tell how many of your scripts a bucket covers. Bits
below the bucket width are masked before deduplicating, so a coarse bucket
really does collapse — at 1 bit, any number of scripts registers at most two
buckets. MaxPrefixBits (32) is where the server's mask saturates; a wider
registration cannot be more selective and is rejected client-side rather than
silently dropped by the server.
A server may lower the ceiling further via streamprefixmaxbits. That bound is
not advertised over the wire, so an over-precise (but ≤ 32) prefix can still be
dropped server-side with no client-side signal.
Errors
Every SDK error is an *Error carrying a Kind, and each kind has a sentinel
that errors.Is matches:
if errors.Is(err, satdevents.ErrPermissionDenied) { /* fix the token's caps */ }
if satdevents.Retryable(err) { /* back off and retry */ }
var serr *satdevents.Error
if errors.As(err, &serr) && serr.Status != nil {
log.Print(serr.Status.Code(), serr.Status.Message()) // server detail survives
}
A Lagged notice is deliberately not an error: it is a normal, recoverable
event carrying a resume cursor. ErrUnauthenticated is reported non-retryable
on purpose — a blind retry with the same token will not help.
Delivery guarantees
Cursors commit on poll: a delivered event's cursor is persisted only once the caller comes back for the following event, which is an implicit ack. The store therefore never advances past an event you have not received, so a crash mid-processing replays that event. This is at-least-once, not at-most-once; dedup on your side, keyed by what you process, if you need exactly-once.
The exception is ReplayGap. It means the persisted cursor fell outside the
server's replay window: the blocks it names were never delivered and never will
be. Full-resync that range from another source — logging it and moving on loses
transactions.
Stability & versioning
The SDK is versioned with the node: clients/go/vX.Y.Z is cut at the node's
vX.Y.Z, even when no Go code changed, and satdevents.Version names it. A
unit test pins Version to the workspace version, so the two cannot drift. Pick
an SDK whose minor version is at or below your node's. Newer nodes only add:
an event kind this build predates decodes to *UnknownEvent, and an unknown
enum value is preserved with Known() == false, so a Status from a newer
node still routes correctly on Severity and Message.
Each time Subscribe or Watch opens a stream, the SDK reads the version the
node advertises in its satd-version response header and compares it with its
own:
| Node | Result |
|---|---|
| Same or newer | Opens silently. |
| One minor version behind | Opens and logs a warning through log/slog (WithLogger, default slog.Default()), once per client per node version, with attributes node_version and sdk_version. |
| Two or more minor versions, or a major version, behind | Refused with ErrNodeTooOld. |
| Different event schema | Refused with ErrSchemaMismatch. |
A node older than 0.6.0 sends no header and counts as 0.5. The warning means
"upgrade the node": features added after the node's release are unavailable,
and request fields it does not recognise are ignored. For a rolling upgrade
where clients go first, WithAllowOldNode() turns ErrNodeTooOld into the same
warning. Nothing bypasses a schema mismatch. Both errors are non-retryable, so
the resilient layers return them from Next instead of reconnecting.
Client.NodeVersion() returns what the node last advertised. The full rule is
in
STABILITY_POLICY.md.
Watch waits for the node's response headers before returning, as Subscribe
already did. satd sends them as soon as the stream is set up, so a quiet
watch-set does not hold it up.
The first tag under this scheme is clients/go/v0.6.0; the previous tag was
clients/go/v0.1.0, shipped with node 0.5.0. Because
there is no go.mod at the repository root, the node's own vX.Y.Z tags do not
collide, and the module proxy serves consumers a zip of the module subtree only
— importing this SDK does not pull the Rust tree.
The module stays within v0/v1: Go's semantic import versioning makes v2+ a
breaking import-path change (.../go/v2), so the bar for declaring v1 is "we
can live with this API".
How parity is verified
The Go SDK is not a best-effort port kept in sync by hand. Every satd PR runs:
- the Go unit tests, including a protobuf-reflection test that fails when a new
event variant is added to the
.protowithout a Go decoder; - a build of all thirteen examples, so a renamed method cannot rot in a file whose whole job is to be copied;
- the Go E2E suite against the same freshly built
satdbinary the Rust E2E suite uses; - a differential parity harness — one node, two clients, byte-identical
watch spec. The Go SDK and the Rust
satd-events-clienteach render every event they receive to canonical JSON, and the two dumps are diffed line by line. A field one SDK decodes differently, or a variant one of them cannot decode at all, fails the PR.
The harness normalizes exactly two things, and no more: it drops the publisher's
per-process instance_id (which differs per connection by definition), and it
sorts by cursor rather than arrival order (two connections are served by
independent tasks, so interleaving is server scheduling, not a parity property).
It therefore proves the two SDKs see the same events with the same field values
— not that they see them in the same order.
Examples
Thirteen runnable programs live in
clients/go/examples/,
one per usage shape:
cd clients/go/examples
go run ./firehose_tail -endpoint 127.0.0.1:50051
go run ./resilient_tail -endpoint 127.0.0.1:50051 -cursor /tmp/satd.cursor
go run ./health_watch -endpoint 127.0.0.1:50051
go run ./tls_tail -endpoint node.example:50051 -ca ./node-ca.pem
| Example | What it shows |
|---|---|
deposit_notify | the smallest useful integration: tell me when this address is paid |
firehose_tail | the minimum: connect, subscribe, print |
resilient_tail | reconnect + file-backed cursor; the shape to copy |
watch_outpoints | watch outpoints for their spend |
descriptor_wallet | watch by descriptor, advance the gap limit |
lifecycle_alarms | seen → confirmed → replaced, with depth alarms |
resilient_watch | watch-set rebuilt from a durable truth on every reconnect |
health_watch | node-health alerting, and why silence needs a deadline |
prefix_privacy | coarse buckets registered, real filtering done locally |
sp_wallet | BIP 352 scan-key watch (Tier 2) |
sp_light_scan | BIP 352 client-side scan (Tier 1), scan key never leaves the device; durable cold-sync cursor |
tls_tail | TLS with a pinned self-signed node CA |
mtls_tail | mutual TLS against eventsgrpcmtls=1 |
health_watch is the alerting shape worth reading in full: it subscribes to
heartbeats and enforces a deadline on them, because a wedged publisher on a
live connection produces silence, and silence otherwise reads as "nothing is
wrong". It also documents what a status stream structurally cannot tell you —
status events are not replayable, so getwarnings over JSON-RPC remains the
authoritative answer to "what is wrong right now".
MCP Server
satd ships a native Model Context Protocol server (the mcp crate, built on
rmcp). It exposes the node's query, ops, and transaction-construction
surfaces as MCP tools for AI agents and other MCP clients. An LLM-driven
client can inspect chain, mempool, and peer state, estimate fees, decode and
build transactions, and run operator actions through a typed tool interface
rather than raw JSON-RPC.
The server 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 a running node over the network.
| Option | Default | Notes |
|---|---|---|
--mcpport | (off) | Port to serve MCP on; enables the listener. |
--mcpbind | 127.0.0.1 | Bind address. A non-loopback bind requires auth and TLS. |
--mcpcert / --mcpkey | (none) | PEM certificate and key; enables HTTPS. Required for any non-loopback bind. |
--mcpmtls | false | Require client certificates (mTLS). Needs --mcpcert/--mcpkey and --mcpmtlsclientca. |
--mcpmtlsclientca | (none) | PEM CA bundle that client certificates must chain to. |
--mcpmtlsclientallow | (any) | Optional allowlist of client-certificate CN / DNS-SAN values. |
The listener runs on satd's core (consensus) tokio runtime, not the isolated API runtime, because MCP exposes block-connecting and broadcast tools. See Posture.
Transport security (TLS)
The MCP listener serves plaintext HTTP only when bound to loopback. Setting
--mcpcert and --mcpkey switches the listener to HTTPS. TLS 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, and Electrum
surfaces, and reloads on SIGUSR1.
For mutual TLS, add --mcpmtls --mcpmtlsclientca <ca.pem>. Clients without a
certificate that chains to the CA are rejected at the handshake. To narrow
further, use --mcpmtlsclientallow <CN> (repeatable or comma-separated). mTLS
is additive: the --mcpauth bearer layer still runs on top.
Authentication
MCP uses the unified auth system:
- Loopback default. With
--mcpauthoff, the server performs no per-request auth check. This mode is valid only for a loopback bind. - Bearer.
--mcpauth(which requires--authfile) demandsAuthorization: Bearer <token>resolving to a principal that holds themcp:*capability. Otherwise the server returns401withWWW-Authenticate: Bearer. The token's rate limit applies; a throttled request gets429withRetry-After. - Remote exposure is gated. A non-loopback
--mcpbindrequires--mcpallowremote(which in turn requires--mcpauthand--authfile) and TLS (--mcpcert/--mcpkey). satd refuses to start a routable MCP listener that lacks either auth or TLS. - The
Hostheader is validated. The transport accepts only the names in its allowlist, which is loopback (localhost,127.0.0.1,::1) plus whatever--mcpallowedhostadds; anything else gets403before auth runs. This is a DNS-rebinding defence, and TLS does not substitute for it: a browser induced to resolve an attacker's name to this address completes a perfectly valid handshake, andHostis what still names that domain. A listener reached by hostname needs--mcpallowedhostor every request is refused. The option is additive — loopback stays allowed, and no value empties the list.
A single capability, mcp:*, gates all of MCP. There is no read-only versus
mutating split, so any token with mcp:* can call every tool.
Posture: MCP is not read-only
MCP exposes state-changing tools: send_transaction broadcasts to the
network, generate_blocks mines and connects blocks on regtest, and
manage_peer disconnects, bans, unbans, or adds peers. The transaction
construction and signing tools also mutate what the client can do with funds.
Treat an mcp:* token as a privileged credential. Keep the listener
loopback-bound unless both auth and TLS are 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, and 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 \
--mcpallowedhost=NODE_HOST
The server is then reachable at https://NODE_HOST:18888/. Clients
authenticate with an Authorization: Bearer <token> header.
--mcpallowedhost must name every hostname clients put in the URL — the name
in the certificate is not consulted, and neither is the machine's own
hostname. Repeat the option, or give it a comma-separated list, for more than
one. An entry may be a bare host (any port) or a host:port authority (that
port only). Without it, every request to https://NODE_HOST:18888/ is
answered 403 Forbidden. See
Authentication and Transport security.
Claude Code
# Loopback node, no auth:
claude mcp add --transport http satd http://127.0.0.1:18888/
# Authenticated TLS node: 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 node: supply the bearer token.
http_headers = { Authorization = "Bearer YOUR_TOKEN" }
Note. If you terminate TLS with a self-signed certificate, configure the client to trust it, or front satd with a reverse proxy holding a CA-issued certificate. mTLS clients additionally present their own certificate and 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_opreturn_prune_info: OP_RETURN pruning status (getopreturnpruneinfo).get_config: effective post-merge config (secrets redacted).get_metrics_snapshot: current Prometheus metrics as text.get_health/get_readiness: liveness and readiness (mirror/healthzand/readyz).get_reorg_history: persisted reorg events. Param:since_secs(default 86400).
Blockchain / block
get_block: block by hash or height. Params:identifier,verbosity(summary/full/raw).get_block_header: header by hash or height. Params:identifier,raw.get_block_stats: fees, sizes, tx counts, UTXO/SegWit stats. Param:identifier.get_chain_info: tips, tx rate over a window, difficulty. Param:window(default 30).search_block_range: headers for a range (max 100). Params:start_height,end_height.
Transaction (query / decode)
get_transaction: lookup bytxid(chain and mempool); optionalblockhashhint.decode_raw_transaction: decode a hex tx to JSON. Param:hex_tx.decode_script: decode a hex script (opcodes, type, addresses). Param:hex_script.
Mempool
get_mempool_overview: size, byte usage, fee histogram, policy.list_mempool_transactions: list withsort_by(fee_rate/time/size),limit(up to 100),min_fee_rate.get_mempool_entry: one tx; optionalinclude_relatives(ancestors and descendants).get_mempool_entries_bulk: detail for manytxids(missing entries return null).get_mempool_history: windowed snapshots. Param:since_secs(default 3600).subscribe_mempool_snapshot: most recent mempool events. Param:limit(up to 50).
Fees
estimate_fee: rates for multipletargets(default[1,3,6,12,25]), in BTC/kvB and sat/vB.
Network / peers
get_peer_info: connected peers. Param:summary(default true).manage_peer: mutating;add/disconnect/ban/unban. Params:action,address.get_ban_list: banned peers with timestamps and reasons.
Transaction construction (mutating)
create_transaction: build an unsigned raw tx. Params:inputs,outputs,locktime.sign_transaction: sign with WIF keys client-side. Params:hex_tx,private_keys,prevtxs,sighash.send_transaction: broadcast a signed raw tx. Param:hex_tx.psbt_workflow: PSBTcreate/decode/analyze/combine/finalize/update/convert/join. Accepts PSBT version 0 and version 2 (BIP 370), the BIP 375 silent payment fields included. A version 2 PSBT in is a version 2 PSBT out, anddecodereturns the version 2 shape described under PSBT version 2.
Mining
get_mining_info: difficulty, network hashrate, height.generate_blocks: mine blocks (regtest only). Params:count,address.get_block_template: mining template.
UTXO / address
get_utxo: single UTXO bytxid/vout(null if spent).get_utxo_set_stats: total UTXOs, total value, best block.validate_address: parse and classify an address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR); returns script hex and witness info.
Core Functional-Test Conformance
satd runs Bitcoin Core's own functional test suite, unmodified, against itself. Every test file in the pinned Core release is accounted for: it either runs, or it carries a reason it does not. This page is the scoreboard.
It is deliberately a different kind of evidence from satd's other conformance
work. The ported fixture corpora check that satd agrees with Core on specific
inputs, and the live block-acceptance differential checks that satd and
bitcoind accept the same blocks from the real network. This suite checks
something neither of those can: that Core's own idea of how a Bitcoin node
behaves — written by Core's developers, in Core's terms, exercising Core's RPC
surface, P2P behaviour and startup semantics — holds when pointed at satd.
Scoreboard
The current numbers come from the harness itself:
contrib/core-functional/check_inventory.py --summary
The harness is in contrib/core-functional/; its README.md documents the
rules that keep the number honest, the most important being that a test flips
to run only in the pull request that makes it pass. The count moves when
behaviour changes, never because a batch of rows was re-labelled.
At the time this harness landed the run-set was deliberately tiny: two tests.
That number is not a measure of how Core-compatible satd is — it is a measure of
how much of Core's test framework satd can currently drive. The framework
leans on test-only facilities that satd had no reason to implement until the
harness needed them — setmocktime, syncwithvalidationinterfacequeue and a
periodic P2P ping, all of which have since landed. Clearing one rarely turns a
row green on its own: the blockers are layered, and a test that gets past the
framework's setup then stops on whatever its body needs next. Every skip records
what actually blocked it when it was last measured, so the queue of work is
explicit rather than a guess; contrib/core-functional/README.md ranks it.
How to read a skip
A skip is not an admission that satd fails a test. Most are statements about what satd deliberately is:
| Reason | What it means |
|---|---|
no-wallet | The test drives the legacy Core wallet. satd is walletless by design — see CORE_DIFFERENCES.md. |
no-tool | The test drives a Core-only binary (bitcoin-tx, bitcoin-util, bitcoin-wallet, bitcoin-chainstate, bench_bitcoin). |
zmq-ordering | The test asserts Core's ordering between block and mempool ZMQ messages. satd publishes Core's ZMQ messages byte for byte but promises no order between those two groups; see Bitcoin Core ZMQ compatibility. |
no-ipc, no-usdt, no-qt | Core interfaces satd does not ship. |
core-internal | The test asserts on Core implementation details satd does not share — LevelDB files, blk*.dat layout, settings.json. |
core-net-policy | The test asserts on Core-specific net artifacts: anchors.dat, asmap, banlist format. |
core-log | The test greps debug.log for a line satd has no honest equivalent of. |
rpc-missing, feature-missing | A genuine gap. These rows must name the follow-up work, so they cannot quietly become permanent. |
cache, harness, prev-release, flaky-quarantine | Blocked by the harness rather than by satd. |
needs-triage | Measured as failing, cause not yet attributed. The row carries the observed error. This is the one bucket expected to empty. |
The two buckets worth watching are rpc-missing and feature-missing: those
are the ones that represent work, and the harness refuses to accept such a row
unless it names what will retire it.
Where satd already matched Core, and where it did not
Standing the harness up was itself a conformance test, because Core's framework is an exacting client: it drives the node the way Core's own developers assume a node behaves. Several places where satd had drifted only became visible once that client was pointed at it — most of them affecting real Core-compatible software, not just tests:
Content-Type: application/json. satd answered RPC withapplication/json; charset=utf-8. The parameter is redundant (RFC 8259 fixes JSON's encoding), but Core-derived clients compare the header for equality rather than parsing it, so the suffix reads as a non-JSON response. Core's own test client rejected every satd reply without reading the body.-28 RPC in warmupduring startup. While coming up, Core answers every RPC with-28and a status line; that is how a client learns "alive, retry shortly". satd's startup listener answered-32601 Method not foundfor anything but its own progress method, which a Core-compatible client reads as a permanent failure.- Core options on the command line. satd skipped
recognized-but-unsupported Core options in
bitcoin.confwith a warning, but the same option passed as a flag aborted startup. Core treats the two as one namespace; satd now does too, while still rejecting genuine typos. - Single-dash spelling for satd's own flags. Core spells every option with
a single dash. satd's compatibility table had drifted from its parser, so 56
flags — including Core's own
-blockfilterindexand-peerblockfilters— were reachable only as--double-dash. The set is now derived from the parser, so a new flag cannot fall out of reach. -versionand unknown-argument errors. satd's-versionoutput did not contain the word "version", and a bad flag was not reported in Core's wording.- Core's fee-rate units. Core denominates
-minrelaytxfeeand-dustrelayfeein BTC/kvB (0.00001); satd documents them in sat/kvB (1000) — the same rate, written differently. satd now takes both. An unparseable value used to be silently discarded inbitcoin.conf, leaving the node relaying at a default the operator never chose; it is now an error. -blockfilterindexwith no value, which Core accepts asbasic.- A panic on an unparseable
-bind. satd joined-bindto-portand unwrapped, so a value it could not parse aborted on a stack trace rather than an error. Underneath that, IPv6 literals were never bracketed, so-bind=::1could not have worked at all.
None of these were consensus defects, and none would have shown up in satd's own test suite — they are exactly the class of difference that only an outside-in client finds.
Running it yourself
cd contrib/core-functional
./fetch-core.sh # fetch the pinned Core tree
cargo build --release --bin satd --bin sat-cli # from the repo root
./run.sh # run the inventory's run-set
./run.sh --candidate <test.py> # measure a still-skipped test after a fix
Nothing in the harness assumes a particular machine: the satd binaries, the
Core checkout location, the scratch directory and the job count are all
environment overrides, documented in contrib/core-functional/README.md.
In CI
The run set gates every pull request. The build is the only slow part of it,
and it is shared: the core-functional job downloads the same release binaries
the third-party canary fleet uses, so the suite adds about a minute and sits off
the critical path. A red run set blocks the merge.
A second, nightly run pays for its own build and is where the run set gets
widened and where a --candidate measurement runs unattended.
Both run on GitHub-hosted runners, as does every other job in this repository.
The split matters: for a while the suite ran only nightly, so a test could be
marked run in one pull request and quietly stop passing in the next, with the
published scoreboard still claiming it. That is not hypothetical -- it is how
the count came to read 30 while four of those thirty were failing.
Guided Code Tour
The guided code tour is a slide deck that walks the satd source
module by module. Each subsystem gets an introduction, verbatim source
snippets with file:line references, the design trade-offs, and a comparison
to the equivalent Bitcoin Core implementation.
The tour opens as a full-page deck outside the manual layout. Navigate with
the arrow keys. Press t for the table of contents. Use the manual link in
the footer to return here.
What it covers
Nine parts, 42 slides:
- Orientation: the compatibility thesis, the workspace crate map, and the architecture at a glance.
- Storage: the
Storetrait, the RocksDB column-family schema, the coin cache, flat block files, and undo data. - Chain and validation:
ChainState, the connect pipeline, reorg atomicity, the dual script engines, parallel IBD, and AssumeUTXO. - P2P networking: the peer-manager actor model, BIP 324 transport, the swarm IBD scheduler, addrman, compact blocks, and Tor.
- Mempool and mining: the two-class mempool, fee estimation, block templates, the policy language, and the Lightning danger gate.
- RPC and surfaces: the middleware stack, Core compatibility machinery, authentication, Esplora, and Electrum.
- Indexes: the shared write batch, the address index schema, BIP 158 filters, the BIP 352 tweak index, and deferred backfill.
- Streaming and ops: the event envelope, watch streams, the Rust and Go SDKs, alerts, and the operator tooling.
- satd vs Core: default differences, intentional exclusions, how parity is proven, and migration.
Snapshot provenance
The deck is a snapshot. Every snippet and file:line reference was taken
from master at commit 4874b537 (2026-08-19). The code moves; the commit
pins where each snippet came from. To read a referenced file at that exact
state, run:
git show 4874b537:node/src/chain/state.rs
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 catalog of
intentional deviations from Bitcoin Core is
CORE_DIFFERENCES.md.
Ecosystem and packaging work that has not shipped is tracked in
ROADMAP.md.
Document status
This is PACKAGING.md v1. It covers the surfaces shipped today: the
container image, the Type=notify systemd unit, the OpenRC and runit
equivalents, the on-disk layout, the operational surface, the release
pipeline, signing on all three surfaces, the reproducible build via
Nix, CycloneDX SBOMs per binary, and the cargo-deny supply-chain
gate.
Updated: 2026-05-07.
Binaries
satd ships two binaries:
| Binary | Purpose |
|---|---|
satd | The node. A long-running process that opens RocksDB and runs P2P, RPC, and the optional protocol surfaces. |
sat-cli | JSON-RPC command-line client. Takes Bitcoin Core-compatible flags (-rpcuser, -rpcpassword, -rpccookiefile, network selectors). |
A third binary, sat-tui, is a curses-style operator dashboard. It is
optional; packagers can omit it.
There are no separate sat-electrum or sat-esplora companion
binaries. Both protocols are subsystems of satd, enabled with the
--electrum=1 and --esplora=1 flags. satd runs as one process with
one RocksDB instance, one log stream, and one PID. Disk Footprint &
Indices covers the disk cost of the shared store.
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
│ ├── ...
│ └── xor.dat # 8-byte obfuscation key (Core v28+ compat; all-zero = plaintext)
├── 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)
├── bitcoin.conf # optional config file (Core-compat name)
└── satd.conf # alternative config name (also accepted)
Three paths hold state and must be backed up to preserve consensus
history: blocks/, chainstate/, and reorg.log.
The derived files are safe to delete. Everything inside chainstate/
(the RocksDB instance), mempool_history.log, and the *.complete
index marker files inside chainstate/ regenerate from blocks/ with
--reindex or --reindex-chainstate. There is no debug.log: satd
logs to stdout.
Difference from Bitcoin Core. satd does not keep separate 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_blockbatch.
Consequences of the single instance:
- Backup is one directory.
- An index update is never visible without the matching tip update.
The whole
WriteBatchcommits, or none of it does. --reindex-chainstaterebuilds everything in chainstate (UTXO set and indexes) and preserves the flat files.
Process model
- One process. The PID file is whatever the supervisor records; satd does not write its own PID file by default.
tokioasync runtime; many tasks on a fixed-size worker pool.rayonfor script verification (CPU-bound parallelism).- RocksDB keeps many SST files mmapped. Budget
LimitNOFILE=65536at minimum. The systemd unit and the Docker image both pre-set this.
Signals
| Signal | Behaviour |
|---|---|
SIGTERM | Clean shutdown. Flushes RocksDB, fsyncs undo files, drains the mempool snapshot, closes listeners. Can take up to 10 minutes under heavy IBD load; most shutdowns finish in under a second. |
SIGINT | Identical to SIGTERM. |
Both are handled from the moment satd starts, including the block replays of
-reindex, -reindex-chainstate and -upgradechainstate, which run inside
startup and can take days. A replay stops at once: satd abandons the block it
is connecting, flushes what it had connected, keeps the rebuild marker, and
exits 0. If that
takes longer than -maxshutdownsecs, satd exits without waiting, and the
replay loses what it connected since its last flush. Anywhere else in startup
a stop signal ends satd at once. This holds with satd as PID 1 of its PID
namespace (a container with no init), where the kernel would otherwise drop
the signal and leave the supervisor to kill satd at the end of its grace
period.
| SIGHUP | Live config reload. Re-reads bitcoin.conf and applies the hot-reloadable subset without dropping the P2P swarm or flushing chainstate. See Configuration, Tuning & Reload. |
| SIGUSR1 | Live TLS certificate reload. Re-reads the configured TLS leaf cert and key from disk and swaps them in atomically on every TLS surface, without a restart or dropped connections. |
| SIGKILL | Not clean. RocksDB recovers via WAL replay on the next start. Avoid it; have the supervisor send SIGTERM and wait. |
Difference from Bitcoin Core. Core reopens
debug.logonSIGHUP. satd logs to stdout and repurposesSIGHUPfor config reload.
Give the container supervisor a stop grace period of at least 10
minutes: --stop-timeout=600 for docker run,
terminationGracePeriodSeconds: 600 on Kubernetes. The systemd unit
ships TimeoutStopSec=10min for the same reason.
Network ports (defaults)
| Service | Mainnet | Testnet | Signet | Regtest |
|---|---|---|---|---|
| P2P | 8333 | 18333 | 38333 | 18444 |
| JSON-RPC | 8332 | 18332 | 38332 | 18443 |
Esplora REST (--esplora), Electrum (--electrum), and the metrics
and health endpoint (--metricsport) have no per-network default
port. Each is off by default on every network. Pick a port per
deployment, for example 3000 for Esplora, 50001 for Electrum, and
9332 for metrics.
The default RPC bind is loopback.
Health and readiness
When --metricsport=<port> is configured, satd exposes three
unauthenticated HTTP endpoints on that port (default bind 127.0.0.1):
| Endpoint | Meaning |
|---|---|
GET /healthz | The process is alive and the event loop responds. Cheap. |
GET /readyz | The tip is within six blocks of the best headers tip seen from peers, the block connector is making progress, and neither the tip_stall nor the peer_floor health detector is raised. 503 otherwise — for the whole of a sync, whenever the connector has persistently failed, when no block has connected for -alerttipstallseconds, and when the node has had fewer than -alertpeerfloor peers for the detector's window. Setting either threshold to 0 leaves /readyz out of it. |
GET /metrics | Prometheus exposition format. |
The two are not interchangeable, and the difference matters most where it
is easiest to get wrong. /readyz returns 503 until the tip is within six
blocks of the headers tip — on a fresh mainnet node, days — so it answers
"should clients be sent here yet", not "is this process healthy". Wire it to
a Kubernetes readiness probe or a load-balancer pool check, where 503
means "route elsewhere for now".
Do not wire /readyz to a Docker HEALTHCHECK, a Kubernetes liveness
probe, or anything that restarts or alarms on a failure: a syncing node will
sit unhealthy for the entire initial sync and be killed or reported as
broken while it is working correctly. Use /healthz, or the image's own
satd-healthcheck, which probes JSON-RPC liveness by default and is what
every deployment in contrib/ uses.
The listener answers from the moment the startup RPC does, including through
a -reindex-chainstate that runs for hours: /healthz is 200 and /readyz
503 (not ready: starting: <what the node is doing>) throughout, and
/metrics carries the satd_startup_* gauges. So /healthz says the process
is up, not that startup has finished.
The shipped Type=notify unit (see the systemd section) signals startup
with sd_notify(READY=1). Supervisors without notify support can poll the
JSON-RPC, which answers -28 (warming up) until the node is running.
Configuration
Two files are accepted, both in Bitcoin Core's key=value /
[network] syntax:
bitcoin.conf: the Core-compatible name. Same shape, same precedence.satd.conf: identical syntax. Preferred when running next to a Core install.
Resolution order: --conf=<path> if given, else
<datadir>/bitcoin.conf, else <datadir>/satd.conf. Command-line
flags always win over file values.
The full option matrix is in Configuration, Tuning &
Reload. The container ships a mainnet-loopback
default; every value can be overridden with -e SATD_* environment
variables. See the Container section.
Ready-made deployments
Before packaging satd yourself, note that the repository ships three
finished ones, described in Appliance & Reference
Stack: a docker-compose reference stack
(contrib/stack/), a bootable appliance image (contrib/appliance/),
and sources for Umbrel and StartOS packages (contrib/packaging/).
They share one node configuration and one certificate scheme, and the
container image below carries the first-run tooling all three use
(satd-init, satd-mkca), so a package built on that image gets the same
behaviour without reimplementing it.
For test networks, contrib/warnet/ builds a variant of the container
image that Warnet's stock
Bitcoin Core Helm chart runs unchanged, with example networks and keyless
scenarios. It is not an operator image; see contrib/warnet/README.md.
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. A non-1000 UID avoids a bind-mount clash with the usual host operator UID. - Binaries:
satd,sat-cliandsat-tui, sodocker exec -it satd sat-tuiworks against a running container. - First-run tooling:
satd-mkca(issues the install's CA and server certificate) andsatd-init(rendersbitcoin.conf, mints the MCP token), plusopenssl. These are in the image so a deployment that cannot mount repository files — an Umbrel app, a StartOS package — behaves identically tocontrib/stack. HEALTHCHECK:satd-healthcheck, which probes JSON-RPC liveness. PointSATD_HEALTH_URLat an HTTP endpoint to gate on that instead — but not at/readyz, for the reason in Health and readiness.- PID 1:
tini, so SIGTERM forwards to satd cleanly. satd handles SIGTERM as PID 1 as well, for an image run without an init. - Datadir:
/var/lib/satd, declared as aVOLUME. - Exposed ports:
8333(P2P) and8332(RPC). Map other ports with-pper deployment.
An example mainnet run with persistent state, RPC on loopback, and 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
Tag-triggered releases publish linux/amd64 and linux/arm64 images
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
The images are signed with cosign keyless OIDC and attested to the Rekor transparency log. The verifier command is 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 restrictive hardening: read-only root, private /tmp,
a syscall filter, and no new privileges. To relax any of these, for
example to write to a datadir outside /var/lib/satd, use 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 is bound: RPC, P2P, and the optional Esplora, Electrum, MCP,
and events surfaces. Units that depend on satd, such as a Tor onion
service pointing at the RPC port or a monitoring agent, start once the
listeners exist 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, notinfinity. That is long enough for the first heartbeat, at 30 s, to land and push the deadline out. It is short enough that a wedge before the first heartbeat is killed in bounded time.EXTEND_TIMEOUT_USEConly works against a finiteTimeoutStartSec; an infinite startup timeout would let a wedged process hang beforeREADY=1. - Every 30 s during the pre-bind phase, satd emits
sd_notify(EXTEND_TIMEOUT_USEC=120000000, STATUS=...).EXTEND_TIMEOUT_USECresets systemd's internal kill deadline. TheSTATUSline shows the live phase and progress insystemctl status satd. - The heartbeat doubles as the liveness check. If satd sends nothing for more than 120 s, systemd kills the unit and the on-failure restart loop takes over.
$ 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)
Bitcoin Core's bitcoind.service has behaved the same way since v22.
Running multiple networks side by side
There is no satd@.service template unit yet. To run signet,
regtest, and mainnet on the same host, copy the unit under different
names and add 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
Use the same pattern for --regtest. Give each instance its own
datadir and its own RPC port (--rpcport=<n> in the drop-in). Each
instance can have its own satd-<network> user or share the satd
user.
A native satd@.service template unit (systemctl start satd@signet)
is a candidate for v0.1.x if the drop-in pattern proves insufficient.
OpenRC
For Alpine, Gentoo with the openrc profile, Artix, and other
OpenRC distributions, 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 marks the service started once satd
backgrounds via start-stop-daemon, so reindex never contends with a
startup timeout. The service reads as running for the whole
reindex.
Set per-instance options in /etc/conf.d/satd:
# /etc/conf.d/satd
satd_args="--prune=550 --txindex=0"
runit
For 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, so satd never daemonizes. There is no readiness gate and no startup timeout; reindex runs as long as it needs to.
Resource budget
Mainnet, fresh IBD, no optional indexes:
| Resource | Pi 5 (8 GB) target | Server target |
|---|---|---|
| Disk (chainstate + blocks) | ~700 GB at 2026-05 tip | same |
| RAM peak during IBD | ~3 GB | unbounded by dbcache |
| RAM steady-state | ~1.5 GB | ~2 GB |
| CPU during IBD | 4 cores ≈ saturated | scales with cores |
| Network during IBD | 50–200 Mbps | network-bound |
Each optional index (--txindex, --addressindex,
--blockfilterindex) adds disk and a one-time backfill cost.
Enabling an index on a synced node runs an online backfill; there is
no stop-and-reindex step. The backfill cursors are in
node/src/index/<index>/backfill.rs.
Pruning
--prune=<MiB> has the same shape as in Bitcoin Core. The minimum is
550 MiB, and the node prunes to that budget on its own.
--prune=1 is Core's spelling for manual pruning: prune mode is on, but
nothing is deleted until pruneblockchain <height> asks for it. The RPC
deletes block data at or below that height and returns the height of the
last block pruned; the height may be given as a Unix timestamp instead, and a
request that reaches into the most recent 288 blocks is clamped to them. A
manual pruner reports automatic_pruning: false and no prune_target_size
in getblockchaininfo.
Indexes that scan historical blocks (--txindex, --addressindex,
--blockfilterindex) require unpruned blocks. satd refuses to start
with a conflicting combination, as Core does.
Reproducible build via Nix
The repository ships a Nix flake at flake.nix. It 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 at rust-toolchain.toml is authoritative. Both
rustup and the flake read it; there is no second place to update.
What "reproducible" means in v1
- Two
nix buildinvocations of the same commit on two hosts produce a byte-identicalresult/bin/satd. CI proves this on every PR that touchesflake.nix,flake.lock,rust-toolchain.toml, orCargo.lock, via.github/workflows/nix.yml: a two-runner pair build plus a compare job that asserts SHA256 equality. - Local reproduction is one command:
contrib/repro/diff-build.sh /path/to/clone-A /path/to/clone-B. It runsnix buildin each clone, hashes the outputs, and falls back todiffoscopewhen they diverge. - Out of scope for v1: matching the rustup-stable tarball binary (the
one
.github/workflows/release.ymlships) byte for byte. That requires aligning linker, debug-info, and build-id behaviour across two different build drivers. It is tractable, but it is a separate PR.
Determinism hazards addressed
| Hazard | How the flake handles it |
|---|---|
rocksdb-sys bindgen output | rustPlatform.bindgenHook sets up libclang + the stdenv's system include paths so bindgen's translation-unit parse is reproducible. Output is deterministic for a fixed libclang version. |
| RocksDB native code | The flake links nixpkgs's pre-built rocksdb (via ROCKSDB_LIB_DIR / ROCKSDB_INCLUDE_DIR) instead of the C++ tree vendored by librocksdb-sys. nixpkgs builds rocksdb portably, without -march=native, so CPU variance across runners does not affect the output. The trade-off is a minor version mismatch between librocksdb-sys's pinned 10.4.2 and whatever nixpkgs ships; bindings are regenerated either way, and major API drift would surface as a compile error. |
cc-rs C/C++ compiles (secp256k1, zstd, lz4) | Compiler version pinned via nixpkgs; SOURCE_DATE_EPOCH respected by cc-rs for any timestamped output. |
libbitcoinkernel-sys cmake build of Bitcoin Core's kernel | Compiler pinned via nixpkgs, Boost headers from nixpkgs boost. The vendored Core tree is not a git checkout, so Core's build-info step embeds no commit, and its -fmacro-prefix-map maps the source path out of __FILE__. |
OUT_DIR paths in generated code | crane builds inside a content-addressed /build/source; paths are stable across hosts. |
| Linker build-id | RUSTFLAGS=-C link-arg=-Wl,--build-id=none drops the per-build random ID. |
Cargo --release profile | CARGO_PROFILE_RELEASE_STRIP=symbols strips deterministically inside the derivation. |
tonic_build / proto generation | events/proto/*.proto files included in the source filter; protoc is vendored via protoc-bin-vendored so no host protoc dep. |
Gating policy
The Nix workflow runs on tag pushes (v*), on workflow_dispatch,
and on PRs that change flake-specific files: the flake itself,
rust-toolchain.toml, the workflow, and the repro helper under
contrib/repro/. It does not trigger on Cargo.lock or Cargo.toml
edits. Every dependency bump touches those files, and the runs would
burn hosted-runner minutes for little signal.
The Nix and Release workflows fire in parallel at tag-cut time and do not gate each other. If the Nix side fails, the released tarball cannot claim Nix-rebuilt provenance for that tag, and the fix goes out forward.
Reconsider the trigger scope and a hard Release-gates-on-Nix dependency once the repo flips public and Actions minutes are free.
flake.lock
The first PR that lands the flake 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, to
flake-touching PRs, and to tag pushes. The first workflow_dispatch
run by a Nix-capable maintainer, or from a CI runner, generates the
lock. Commit the lock at that point and update the PR description.
Subsequent PRs run against the committed lock.
Renovate, or a manual cadence, bumps the lock weekly. A bump that
changes flake.lock re-triggers the repro check. If reproducibility
breaks under a new input revision, revert the bump and investigate
the hazard.
What's intentionally not in this flake
- macOS reproducibility (
aarch64-darwin): deferred. The release workflow ships an Apple Silicon tarball, but the flake does not yet verify it reproducibly. - musl reproducibility: deferred for
rocksdb-sysand musl cross-toolchain reasons. The release workflow ships both musl tarballs; the flake covers only the glibc Linux targets. - A NixOS module or Home Manager output: packagers write their own service definitions, with the contract in this document as the input.
- A maintainer-owned binary cache (Cachix): it adds a key-custody
surface not yet taken on. CI uses the ephemeral
magic-nix-cacheaction for speed only.
Bitcoin Core uses Guix. satd targets Nix as the primary reproducible build because the workspace is pure Cargo and a Nix integration is much shorter to specify. A Guix manifest may follow if a downstream packager needs it.
Release artifacts
Tag-triggered (v*) releases run .github/workflows/release.yml on
hosted GitHub runners and produce, per tag:
-
satd-<version>-<target>.tar.zstfor the targets currently shipped:x86_64-unknown-linux-gnuaarch64-unknown-linux-gnux86_64-unknown-linux-musl(statically-linked musl)aarch64-unknown-linux-musl(statically-linked musl)aarch64-apple-darwin(macOS Apple Silicon)
x86_64-apple-darwinis not built in the standard release matrix. GitHub is deprecating macos-13 runners, and Apple Silicon is the targeted macOS surface. To get an x86_64 darwin build, cross-compile from an arm64 darwin host (cargo build --release --target=x86_64-apple-darwin).Each tarball contains stripped
satdandsat-clibinaries, the authoritative reference docs (README.md,PACKAGING.md,CORE_DIFFERENCES.md,STABILITY_POLICY.md), and aMANIFESTfile pinning the build commit, target triple, Rust toolchain version, and build timestamp. -
A per-tarball
*.sha256file alongside each artifact, plus an aggregateSHA256SUMScovering the tarballs and the SBOMs. -
A multi-arch container at
ghcr.io/epochbtc/satd:<version>coveringlinux/amd64andlinux/arm64. -
CycloneDX 1.5 JSON SBOMs for each shipped binary:
satd-v<version>.cdx.jsonsat-cli-v<version>.cdx.json
Each ships with a
*.sha256next to it (already inSHA256SUMS) and a*.minisigproduced by the same maintainer-sidecontrib/release/sign-tarballs.shflow that signs the tarballs.
The release workflow triggers on tag pushes (v*) and on
workflow_dispatch, and builds the binary, container, and SBOM
artifacts in parallel.
Signed releases
satd signs three independent surfaces. Verifier commands and key
custody details live in
SECURITY.md.
-
Tarballs (minisign Ed25519). Each
.tar.zstships with a detached.minisig. The public keys, primary and cold spare, are inSECURITY.md. The maintainer signs offline; the passphrases sit behind 1Password with YubiKey 2FA, and the signing key is never present in CI. The maintainer runbook iscontrib/release/sign-tarballs.sh <tag>. -
Container image (cosign keyless OIDC). No signing key in custody. The merge-manifest CI job mints a short-lived certificate 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 with the maintainer's SSH key. The 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 dependency 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.
musl and macOS targets can resolve different platform-specific
dependencies, for example libc shim crates or security-framework
on darwin. A release that adds them needs the workflow to emit a
per-target SBOM, and the artifact filenames 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 dependency in
the workspace fails CI by default. Exceptions are documented in
[advisories.ignore]with areasonfield naming the rationale. - Licenses. Permissive only: the MIT / Apache-2.0 / BSD / ISC / Unicode / CC0 / Zlib / Unlicense / MPL-2.0 family. GPL-* and AGPL-* are denied implicitly.
- Bans. Wildcard versions on crates.io dependencies are denied.
Workspace-internal
path = "../foo"dependencies are allowed viaallow-wildcard-paths, since every workspace crate ispublish = false. - Sources. Only
https://github.com/rust-lang/crates.io-index. Git dependencies require an explicit allowlist entry.
The policy runs as a hard gate in two places:
.github/workflows/deny.yml, on every PR that touchesCargo.toml,Cargo.lock,deny.toml, or the workflow itself.- The
supply-chain-gatejob inside.github/workflows/release.yml. Every release artifact (tarballs, SBOMs, container)needs:it, so a new RustSec advisory that lands during a quiet period between merges cannot ship in a release.
Known deferred items
cargo-auditable: embed the dependency manifest in the compiled binaries for better runtime supply-chain verification.
Stability contract
The shipped surfaces (RPC method shapes, CLI flag shape,
bitcoin.conf syntax, the file layout described above) are governed
by STABILITY_POLICY.md. Tier 1, the Core-compatible surface, is the
strongest: a breaking change requires a scoped proposal with a
demonstrated migration story for downstreams.
Packaging contacts
To request a contract change for an ecosystem package (Umbrel,
Start9, Debian, Nix, Homebrew, and so on), file an issue tagged
packaging against the epochbtc/satd repo. Packaging breakage is
treated as a P1.
Versioning
This document is versioned alongside satd. Changes that shift the contract (file layout, signals, default ports) are called out in the release notes for the version that ships them.
| Version | Notable changes |
|---|---|
| 0.1.0 (current) | Initial PACKAGING.md. Dockerfile + systemd unit shipped. Tag-triggered release workflow on hosted runners produces tarballs (gnu-linux + Apple Silicon) and a multi-arch GHCR image. Signing across all three surfaces (minisign tarballs, cosign keyless image, SSH-signed tags) shipped. Nix flake (flake.nix) shipped for reproducible builds with two-runner CI verification (x86_64-linux, aarch64-linux). CycloneDX 1.5 SBOMs per binary + cargo-deny supply-chain gate (PR-time on dep-graph PRs, hard gate at tag time) shipped. systemd unit upgraded to Type=notify with sd_notify heartbeats so --reindex-chainstate does not trip TimeoutStartSec; OpenRC and runit unit equivalents shipped. |
Appliance & Reference Stack
satd ships three ways to run it beyond a bare binary: a docker-compose reference stack, a downloadable appliance image, and packages for the Umbrel and StartOS app stores. They share one configuration and one certificate scheme, so what you learn from any of them applies to the others.
| What it is | Where it lives | Support | |
|---|---|---|---|
| Reference stack | compose: satd plus optional third-party overlays | contrib/stack/ | satd supported; overlays best-effort |
| Appliance image | a bootable VM with satd, wallets and Lightning | contrib/appliance/ | satd supported; bundled software best-effort |
| Store packages | satd, sat-cli, sat-tui and MCP only | contrib/packaging/ | StartOS supported; Umbrel supported on x86_64; see below |
The appliance image and the stack's overlays bundle third-party software (wallets, Lightning, ecash, and others) so you can try satd end to end. That software is included on a best-effort basis for evaluation and testing. It is not a production deployment: we do not track its security advisories in real time, and a critical fix in a bundled component may not appear in an appliance image until the next scheduled build. satd itself in this image is the same supported release as our tarballs and container image. For production, run satd from a release artifact or an app store package and operate the other components yourself.
The Umbrel and StartOS packages carry no such notice: they contain only satd.
Both store packages have been installed on a real server and driven through
every interface they export — see What is checked, and how below for what
that covered. Both on x86_64, and the StartOS package on aarch64 as well:
built and installed on an arm64 machine, with every interface answering as it
does on x86_64, and the binaries in the image genuinely aarch64 rather
than emulated. Nothing there failed for a reason to do with the architecture.
The Umbrel package has not been installed on aarch64, which is why the table
still qualifies that one. The arm64 half of the container image is exercised
by the reference stack's own test suite on an arm64 host, but umbrelOS ships
aarch64 only as a Raspberry Pi image, with no supported path to a VM.
Installing
Installing satd has the steps for each: adding the Umbrel community store, sideloading the StartOS package, and downloading, verifying and booting an appliance image. This chapter covers how they fit together and how they are built.
The reference stack
cd contrib/stack
cp .env.example .env
docker compose up -d
That runs satd on signet with JSON-RPC, Electrum, Esplora and the metrics endpoint all enabled, each TLS-terminated by a certificate the install issues for itself on first start.
Overlays add third-party software, combined with repeated -f:
docker compose -f compose.yml -f compose.lightning.yml -f compose.proxy.yml up -d
| Overlay | Contents |
|---|---|
compose.lightning.yml | LND in Neutrino mode, Ride The Lightning |
compose.cln.yml | Core Lightning, as an alternative to LND |
compose.cashu.yml | a Nutshell mint backed by that LND |
compose.btcpay.yml | Postgres, NBXplorer, BTCPay Server |
compose.proxy.yml | Caddy, terminating TLS for the web UIs and metrics |
Overlays that need a secret have no default and refuse to start without one, rather than shipping a value every deployment would share:
echo "RTL_PASSWORD=$(openssl rand -hex 24)" >> .env
echo "MINT_PRIVATE_KEY=$(openssl rand -hex 32)" >> .env
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
echo "ARK_POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env
RTL_PASSWORD is the login for Ride The Lightning, which fronts LND's admin
macaroon. Left unset, RTL generates a configuration whose password is the
literal string password, so this one is required rather than defaulted.
satd-appliance enable <overlay> generates each of these into
/var/lib/satd-appliance/overlay.env on first use, so the appliance needs
none of this by hand.
Which ports are published
Plain RPC, Electrum, Esplora and metrics listeners bind the compose network and are not published. They exist because the overlay containers cannot be taught to trust a private CA. What leaves the host is TLS only:
| Published | Surface |
|---|---|
| 8336 | JSON-RPC over TLS |
| 50002 | Electrum over TLS |
| 3001 | Esplora over TLS |
| 9336 | metrics, health and the status page over TLS (satd 0.6.0 on) |
| 8339 | MCP over TLS, when SATD_MCP=1 |
| 38333 (signet) | Bitcoin P2P |
| 443 / 8443 / 49393 / 9443 | RTL, Cashu mint, BTCPay and metrics, with compose.proxy.yml |
BTCPay's own HTTP port binds 127.0.0.1 and RTL and the mint are not
published at all, so the proxy is the only route to a web UI from another
machine. A docker-published port is also not filtered by the appliance's
inbound firewall chain, which is the second reason those bindings matter.
The internal RPC port is 8332 on every network so that overlays, the
proxy and the store packages address one fixed port. The cost is that
sat-cli inside the container needs -rpcport=8332 on any network but
mainnet, since it derives its default from the chain:
docker compose exec satd sat-cli -rpcport=8332 getblockchaininfo
docker compose exec -it satd sat-tui -rpcport=8332
No pruning, anywhere
Electrum and Esplora both require txindex, and satd rejects txindex
together with prune. So every deliverable here runs a fully indexed node.
On mainnet that is the whole chain plus the address, spend and transaction
indices — see Disk Footprint & Indices, and budget a
2 TB volume. Initial Block Download & Fast Sync covers loading an
AssumeUTXO snapshot so the node is usable in hours rather than days.
signet is the default everywhere for this reason: it is the only network on which the whole stack is a one-evening exercise.
TLS
contrib/stack/tls/mkca.sh is the one certificate script. The compose
stack's satd-init, the appliance's first boot, and both store packages run
it, so all four produce the same material and the client instructions are
identical everywhere.
It creates a CA for that install only, then issues one server certificate that every satd surface presents. That is why there are two certificates and not one self-signed: clients import the CA once, and every later reissue — after a hostname change, a new address, or a year — is signed by a CA they already trust, with nothing to accept again.
The certificate covers localhost, 127.0.0.1, ::1, the hostname,
<hostname>.local, and the machine's non-bridge addresses. Prefer the
mDNS name. A DHCP change invalidates an address in the SAN list; the name
survives it.
Reissue happens automatically when the certificate expires within 30 days or the machine's names or addresses have changed. The CA is never rotated automatically — that would invalidate trust every client has established. Rotating it is a deliberate act: delete the CA files and re-run.
Trusting it
# compose
docker compose exec satd cat /var/lib/satd/tls/ca.crt > satd-ca.crt
# appliance
satd-appliance tls export-ca > satd-ca.crt
Then:
| Client | How |
|---|---|
curl, python, Go, anything using the OS store | import satd-ca.crt into the system trust store |
sat-cli / sat-tui | --rpctls --rpccacert=satd-ca.crt --rpcport=8336 |
| Firefox | already policy-configured on the appliance desktop; elsewhere, import it |
| Sparrow, Electrum, Liana | these pin the server certificate on first use; accept it once |
-rpccacert wants the certificate that issued the one the server
presents, and it is then the only authority sat-cli and sat-tui trust:
no public CA can vouch for the node in its place. For a self-signed node certificate that is the certificate itself;
it is not the leaf of a chain, which cannot anchor its own path.
What TLS does not cover
Bearer tokens from an authfile still gate MCP,
streaming and Esplora writes; the plain loopback RPC listener is
cookie-authenticated. The local CA authenticates the appliance to clients,
not clients to the appliance — every surface supports mTLS if you turn it
on, but none requires it by default.
The metrics endpoint has native TLS from satd 0.6.0 on, on 9336 beside the
plain listener; see Observability. The streaming
WebSocket has none. It stays on loopback or the container network, and
compose.proxy.yml fronts it.
The appliance image
A bootable VM: core is headless, desktop adds XFCE with Sparrow,
Electrum and Liana already pointed at the node. On arm64 the desktop has
Sparrow alone, because Electrum and Liana publish no arm64 build. Each bundled wallet is
installed from its project's own release, with the download checked against
a signature from a pinned key; the build fails rather than installing
anything that does not verify.
Downloading a built image
Released images, from 0.6.0 on, are attached to the GitHub release and signed with the release minisign key. Installing satd has the download, verification and first-boot steps.
The satd inside a released image is the same signed tarball published on that release, verified against the key above during the build — not a rebuild. Images you build yourself install the binaries from your working tree instead, and say so on the console.
Building one yourself
contrib/appliance/build-in-docker.sh --flavor core --out out/
No root, no KVM and no Packer: the image is built with mmdebstrap and a
GRUB install onto a loop device, which runs in a container and on a hosted
CI runner in minutes. contrib/appliance/README.md has the details.
First boot creates everything that must be unique to an install — the disk size, the console password, the CA and certificate, the MCP token — because an image that shipped any of those would be an image where every download shared them. The build asserts none of them exist in the artifact and refuses to finish otherwise.
Day-to-day operation goes through one command:
satd-appliance status
satd-appliance tls export-ca
sudo satd-appliance set-network mainnet # refuses below 1.5 TB free
sudo satd-appliance enable lightning
satd-appliance logs satd
satd runs natively under systemd; the overlays run as containers from
/opt/satd/stack, which is contrib/stack's overlay files unmodified.
The status page is at https://satd.local:9336/status from another machine,
once the CA is imported, and http://127.0.0.1:9332/status on the appliance
itself.
The firewall is default-deny inbound, and sshd is off until
satd-appliance ssh enable.
Why LND runs in Neutrino mode
LND's bitcoind backend requires Bitcoin Core's raw ZMQ topics
(zmqpubrawblock / zmqpubrawtx). satd publishes them as of 0.7.0 (see
Bitcoin Core ZMQ compatibility),
but the appliance predates that and keeps LND on Neutrino, the configuration
it was built and tested with. Neutrino needs no ZMQ — it pulls BIP 157/158
filter headers and filters over P2P, which satd serves because every
deliverable here sets peerblockfilters=1.
Core Lightning is unaffected: its bcli plugin polls JSON-RPC, so it runs
as an ordinary full-node client.
Ark
compose.ark.yml runs an Ark server against satd. Experimental — Ark is
young, and every setting in that overlay was established by running the
binary rather than read from a specification, so expect it to need attention
on a version bump.
The chain is:
satd -> NBXplorer -> arkd-wallet -> arkd
arkd v0.9 splits the wallet into its own service, and that wallet's chain backend is NBXplorer — not Esplora, and not Core's ZMQ — and NBXplorer against satd is already a PR-gating canary in this repository, so the single link in that chain which touches satd is the link that is continuously tested.
First run is two steps, because arkd will not start without a signer key and its wallet must then be created and unlocked:
docker compose -f compose.yml -f compose.ark.yml run --rm ark-init # prints the key
# add ARKD_SIGNER_KEY=... to .env
docker compose -f compose.yml -f compose.ark.yml up -d
docker compose -f compose.yml -f compose.ark.yml run --rm ark-init # creates the wallet
Both the signer key and the wallet password are generated per install into the data volume. Neither is shipped.
What is checked, and how
Each bundled application is a compatibility claim, so each is exercised rather than asserted:
contrib/stack/tests/mkca-test.sh— the certificate script, including that it does not reissue a healthy certificate or rotate the CA.contrib/stack/tests/smoke.sh— the stack on regtest, with every TLS listener probed from outside the container against the generated CA, LND syncing to the node's tip over Neutrino, and RTL served through the proxy.contrib/appliance/tests/boot-test.sh— the built image booted under QEMU, checked through the guest agent and through forwarded ports.contrib/stack/tests/compose-test.sh— static invariants of the compose files, including that the Umbrel package's ports are its own and mapped 1:1, and that its backup exclusions match the StartOS package's.contrib/packaging/startos/test/— the StartOS package's type check and unit tests, run by the app-store packages CI job. Two of them exist because a typecheck cannot see the defects they guard: a store read that made the Network action a no-op, and a ready gate pointed at/readyz, which is 503 for the whole of a sync.
The store packages were additionally installed and driven by hand — on
StartOS 0.4.0.1 and on umbrelOS on x86_64, and on StartOS 0.4.0.1 again on
aarch64. That is where those two defects were found, along with an image
pin that named a tag predating satd-init: none of the three was visible to
any static check, and the /readyz gate had passed an earlier spot check only
because the node under it was minutes old. A fourth came out of the aarch64
install and was not about the architecture at all — MCP refused every request
that arrived by hostname, because satd had left the transport's Host
allowlist at its loopback-only default. See MCP. What was covered: satd-init producing this install's
CA, certificate, MCP token and config; both health checks; Esplora and
Electrum answering through the OS proxy against the server's root CA with
Verify return code: 0 (ok); MCP refusing an unauthenticated call and
completing an initialize with the token the package prints; switching a
running node between chains; and satd returning by itself after a reboot.
Not yet covered: aarch64 on Umbrel, and StartOS backup/restore.
Every probe that verifies a certificate is paired with the negative control that the same handshake without the CA must fail. A probe that would pass unverified proves nothing about the certificate.
Configuration Flag Reference
This chapter is the complete reference for every config 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 how live reload works, see
Configuration, Tuning & Reload. This chapter is the flat
per-key index. The auth keys (authfile, *authbearer/*auth,
*allowremote, cookie/rpcuser/rpcauth) are explained in context in
Authentication & Authorization. The sync, consensus, and
storage-tuning keys (assumevalid, consensus, shadow*, dbcache,
prefetchworkers, maxahead, storageprofile, the rocksdb* / compaction*
family, reindex) are covered in Initial Block Download & Fast Sync.
How satd reads configuration
The goal is that an existing Bitcoin Core bitcoin.conf drops in and works.
satd reads Core's configuration surface directly: the same
bitcoin.conf / satd.conf key=value and [network] section syntax, and
the same flag names (-datadir, -rpcport, …). Supported names and semantics
track Bitcoin Core v30.
- Resolution order.
-conf=<path>if given, else<datadir>/bitcoin.conf, else<datadir>/satd.conf. Flags override file values. - Comments. As in Core,
#starts a comment anywhere on a line:txindex=1 # keep itsetstxindexto1. What is left of the line is trimmed of spaces, tabs and line endings. Anrpcpasswordline that contains a#is refused at load (using # in rpcpassword can be ambiguous and should be avoided), as Core refuses it. - Key disposition. Each config-file key gets one of four treatments:
- Honored. satd implements it. This is the common case.
- Skipped with a warning. A recognized Core v30 option satd does not
implement but that is safe to skip. The node still starts, and a
WARNline names the ignored key and the satd equivalent, if any. This is what lets a realbitcoin.confboot unedited. - Rejected at load. A small set where skipping would mislead you about the node's security, exposure, or privacy posture (see Unsupported Core keys). satd fails closed with guidance.
- Rejected as a typo. A key that is neither a satd option nor a known
Core v30 option. Rejection stops a mistyped security option such as
rpcusser=from silently disabling auth.
- No key is silently ignored. Skipped keys always warn; nothing a config asks for is dropped without a log line.
-profile=<preset>seeds a hardware/role profile (archival,pruned-home,mining,regtest-dev,signet-watchtower). Explicit flags override the profile's values.
Note. Compatibility is pinned to Bitcoin Core v30, a frozen and verifiable surface. Keys Core adds in v31 or later are not recognized, and are rejected as typos, unless satd implements them (
limitclustercount,limitclustersizeandtxospenderindexare;privatebroadcastis not). Keys Core removed at or before v30 (for exampleupnp,maxorphantx) are likewise not honored. Abitcoin.confmigrated from a newer Core that contains a v31+ key stops satd at startup with an "unknown key" error. This is intentional.
Note. This reference is for operating the node. To write software that consumes node state (blocks, mempool, address activity, reorgs), use the Streaming Consumption API (gRPC, WebSocket, or ZMQ). It is reorg-safe, supports durable cursor replay, and is decoupled from consensus. The Core
*notifyshell hooks and RPC polling exist for compatibility and quick scripts only. They have no delivery guarantee, no replay, and no reorg awareness.
Legend
- Reload.
hot: applied live onSIGHUP(systemctl reload satd).restart: wired into long-lived state at startup; reported as "restart required" on reload, never silently ignored. TLS certificate contents reload viaSIGUSR1even where the key isrestart; see Live TLS Certificate Reload. - Compat.
core: same key name and substantially the same semantics as Bitcoin Core.satd: a satd-specific extension (no Core equivalent, or satd-only semantics). The classification is best-effort; a key modeled on Core behavior but without a Core flag of the same name issatd.
Note. Every key in the per-category tables below is honored: satd implements it. Recognized Core v30 keys satd does not honor are not in these tables. They are listed, with their warn-and-skip or fail-closed disposition, under Unsupported Core keys: skipped vs rejected. A key in neither place is rejected as a typo.
Network selection
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
regtest | off | restart | core | Use the regtest network. |
testnet | off | restart | core | Use the testnet network. |
testnet4 | off | restart | core | Use the testnet4 network. |
signet | off | restart | core | Use the signet network. |
chain | main | restart | core | Unified network selector: main|test|signet|regtest|testnet4. Alternative to the per-net flags. |
The bare selectors (signet=1, testnet4=1, …) and chain= are honored both
on the command line and in bitcoin.conf, as in Bitcoin Core. Command-line
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.
Filesystem
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
datadir | platform default | restart | core | Data directory. |
blocksdir | <datadir>/blocks | restart | core | Alternative location for blocks/ and flat-file undo data. |
blocksxor | unset | restart | core | Blocks-dir *.dat XOR obfuscation (Core v28+ blocks/xor.dat). Unset: honor an existing key (an obfuscated Core v28+ blocks/ dir reads with no config) and initialize fresh dirs plaintext. 1: also generate a random key on a brand-new blocks dir (Core's default). 0: demand plaintext; refuses a dir with a nonzero stored key. |
conf | bitcoin.conf in datadir | restart | core | Config file path. |
includeconf | none | restart | core | Additional config file to splice in; honored only inside a config file. |
pid | none | restart | core | Write PID to file. |
profile | none | restart | satd | Named preset: archival|pruned-home|mining|regtest-dev|signet-watchtower; CLI flags override it. |
Daemon control
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
daemon | off | restart | core | Run in background; accepted for compatibility (no-op; use systemd). |
server | on | restart | core | Accept RPC commands; accepted for compatibility (always on). |
logformat | text | restart | satd | Log output format: text or json. Only verbosity hot-reloads, not the format. |
logtimestamps | on | restart | core | Prepend a timestamp to each log line. Disable (-nologtimestamps) when journald / the container runtime already stamps lines. |
logthreadnames | off | restart | core | Prepend the originating thread name to each log line. |
logsourcelocations | off | restart | core | Prepend source file:line to each log line. |
debug | none | hot | core | Enable debug logging for a category (repeatable; bare/all/1 = everything). satd adds stratum, the Stratum server's per-miner lines (see Verifying a miner). |
debugexclude | none | hot | core | Disable debug logging for a category debug would otherwise enable. |
loglevel | info | hot | core | Global verbosity (trace/debug/info/warn/error) or a per-category override (net:debug). Maps onto satd's tracing filter: a bare level sets the default for targets without an override, and does not lower a more specific -debug/RUST_LOG directive (-debug=net -loglevel=error still logs net at debug). A category:level pair overrides that subsystem. The JSON-RPC and WebSocket libraries' TRACE output (jsonrpsee*, tungstenite* targets), which prints request headers and call parameters, is never logged, whatever the level; their DEBUG output is. |
allowignoredconf | off | restart | core | Suppress startup warnings about includeconf files satd had to ignore. |
maxshutdownsecs | 30 | hot | satd | Graceful-shutdown budget (seconds), shared by the shutdownnotify hook (at most half of it), the wait for running RPC calls to finish (at most a quarter of what is left), the wait for the block connector to stop (at most half of what is left after that) and the final flush. An RPC call or a connector still running at the end of its share costs the clean-shutdown marker but not the flush; a flush that overruns the budget force-exits without the marker. |
RPC server
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpcport | 8332 (network-dependent) | restart | core | RPC server port. Defaults: main 8332, test 18332, testnet4 48332, signet 38332, regtest 18443. |
rpcbind | 127.0.0.1:<rpcport> and [::1]:<rpcport> | restart | core | Bind plain-HTTP JSON-RPC to address (repeatable). Non-loopback requires rpcallowip. With no rpcbind, a default that cannot be bound (no IPv6) is skipped. |
rpcallowip | loopback only | restart | core | Per-connection source-IP allowlist for JSON-RPC (repeatable). IPv6 may be bracketed ([::1]). Applies to the plain-HTTP binds always, and to rpctlsbind only once at least one entry is set (an empty list leaves a TLS bind reachable from wherever it is bound). Loopback is always allowed. |
cjdnsreachable | false | restart | core | satd has no CJDNS transport; as in Core, an rpcallowip in fc00::/8 is refused while it is set. |
rpcuser | none | hot | core | RPC username. |
rpcpassword | none | hot | core | RPC password. |
rpcthreads | 16 | restart | core | Max concurrent in-flight RPC method calls. |
rpcworkqueue | 64 | restart | core | Max queued RPC requests beyond rpcthreads before HTTP 429 (Core returns 503; documented divergence). |
rpcservertimeout | 30 | restart | core | Seconds a client may take to deliver a complete request (head and body), or sit idle between keep-alive requests, before the connection is closed. 0 disables. An open connection holds one of the listener's 100 connection slots until it closes, so with 0 an idle client keeps its slot indefinitely; see Scaling the API surfaces. |
apithreads | max(2, cores/4) | restart | satd | Worker threads for the isolated API runtime (Esplora/Electrum/events gRPC/metrics). |
rpcreadonlybind | none | restart | satd | Bind an opt-in read-only JSON-RPC listener (reads + mempool submit) on the API runtime. |
rpcreadonlyport | 8330 | restart | satd | Default port for rpcreadonlybind entries without an explicit port. |
rpcreadonlyallowip | loopback only | restart | satd | Source-IP allowlist for the read-only listener. |
rpcreadonlythreads | = rpcthreads | restart | satd | Max in-flight calls on the read-only listener. |
rpcreadonlyworkqueue | = rpcworkqueue | restart | satd | Read-only listener work-queue depth before HTTP 429. |
rpcreadonlytlsbind | none | restart | satd | TLS bind for the read-only listener (requires cert+key). |
rpcreadonlytlscert | none | restart | satd | PEM certificate (chain) for the read-only TLS listener. |
rpcreadonlytlskey | none | restart | satd | PEM private key for the read-only TLS listener. |
rpcreadonlymtls | false | restart | satd | Require a client cert (mTLS) on the read-only TLS surface. |
rpcreadonlymtlsclientca | none | restart | satd | CA bundle client certs must chain to on the read-only TLS surface. |
rpcreadonlymtlsclientallow | any CA-signed | restart | satd | Allowlist of client-cert subjects on the read-only TLS surface. |
rpcauth | none | hot | core | HMAC-SHA256 RPC credential user:salt$hash (Core rpcauth format; repeatable). An empty or malformed entry stops startup; -norpcauth discards the entries before it, and the config file's unless an entry follows it. |
rpcwhitelist | none | restart | core | Limit an RPC user to a list of JSON-RPC methods: <user>:<m1>,<m2>,... (repeatable; commas or spaces separate methods). A user listed more than once, including on the command line and in the file, keeps the intersection. A refused call is HTTP 403 with an empty body. Not combinable with rpcdisableauth. See Authentication & Authorization. |
rpcwhitelistdefault | on if any rpcwhitelist is set, else off | restart | core | When on, an RPC user with no rpcwhitelist entry may call nothing. |
authfile | none | restart | satd | Path to unified-auth bearer-token file (TOML); enables the opt-in bearer-auth layer. Token contents reload live. |
rpcauthbearer | false | restart | satd | Honor Authorization: Bearer tokens on the JSON-RPC listeners (requires authfile). |
rpccookiefile | $DATADIR/.cookie | restart | core | Override the auto-generated cookie file path. -norpccookiefile writes no cookie. |
rpccookieperms | owner (0600) | restart | core | Cookie file permissions: owner(0600)|group(0640)|all(0644). |
deprecatedrpc | none | restart | core | Bring back a deprecated RPC form (repeatable). satd knows warnings: getblockchaininfo, getnetworkinfo and getmininginfo then report warnings as one string, the latest warning or "", instead of the array Core v28 and later return. Other names are warned about and ignored. |
rpcdefaultunits | btc | hot | satd | Default units for RPC amount fields: btc (Core-compatible) or sats. |
rpcdisableauth | false | restart | satd | Disable HTTP Basic auth on the JSON-RPC TLS surface; only valid with rpcmtls=1. |
rpcextendederrors | off | hot | satd | Emit structured error payloads (category/suggestion/debug) on RPC errors. |
RPC TLS
(satd-specific; Core's RPC is HTTP-only behind a TLS-terminating sidecar.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpctlsbind | none | restart | satd | Bind the JSON-RPC TLS listener (requires cert+key). |
rpctlscert | none | restart | satd | PEM TLS certificate for the JSON-RPC server. |
rpctlskey | none | restart | satd | PEM TLS private key for the JSON-RPC server. |
rpctlshandshaketimeout | 10 | restart | satd | Per-handshake timeout (seconds) for the JSON-RPC TLS surface. |
rpcmtls | false | restart | satd | Require mutual TLS on the JSON-RPC TLS listener. |
rpcmtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when rpcmtls=1. |
rpcmtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
P2P
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
listen | on (see note) | restart | core | Accept P2P connections. The default is soft, as in Core: a node given any connect (including connect=0 / -noconnect), or maxconnections ≤ 0, does not accept inbound either, since a node pinned to specific peers has not asked to be reachable. bind or whitebind raises it back, and an explicit listen — flag or config file — beats both. Because the value is derived, changing a hot key that feeds it (connect, maxconnections) over SIGHUP logs a listen restart-required notice; the running listener is not started or stopped until a restart. A listen that is off in turn soft-sets listenonion off, as in Core, so a pinned node does not publish a hidden service; an explicit listenonion beats that. A proxy also lowers listen, as in Core ("to protect privacy, do not listen by default if a default proxy server is specified"); set listen=1 to accept inbound with a proxy. |
networkactive | on | hot | core | Start with P2P networking enabled. =0 boots with networking paused (no inbound accepts, no outbound dials); change it at runtime with the setnetworkactive RPC. |
blocksonly | false | hot | core | Suppress P2P transaction relay; locally-submitted txs still relayed. |
v2transport | true | hot | core | Offer/accept BIP 324 v2 encrypted transport (Core default since v26). |
v2only | false | hot | satd | Refuse peers that do not speak BIP 324 v2 (privacy hardening). |
externalip | none | hot | core | External address to advertise to peers (repeatable): an IP, IP:port, or a v3 .onion address with or without a port. An onion entry is how a hidden service run from torrc tells the node its address, as in Core; satd advertises one onion address, so with listenonion the service satd creates is advertised while it is up, and an onion externalip while it is not. An onion entry takes effect at startup; a reload applies only the IP entries. Hostnames are refused. An address that is not routable (private, loopback, link-local, shared or documentation space) is accepted but never advertised, as Core's AddLocal refuses it. |
whitelist | none | hot | core | Grant net permissions to peers by source subnet (repeatable). [<perms>@]<subnet>. Inbound only unless the permission list carries out, as in Core; an out entry applies only to manual outbound connections (connect, addnode, and seednode, which satd keeps as a manual connection), never to automatic ones such as the peers the node finds through gossip, peers.dat or a DNS seed. @<subnet> with an empty list matches the range and grants nothing. A peer arriving over the Tor hidden service is never matched against this — it reaches the node on a loopback socket Tor forwards to, so matching would grant an anonymous remote peer whatever you granted your own machine. The addr permission exempts a peer from Core's address rate limit: otherwise the node takes one address from a peer's addr/addrv2 messages to start and one more every 10 seconds (a thousand more after it sends the peer getaddr), and getpeerinfo reports the rest as addr_rate_limited. |
whitelistrelay | on | hot | core | Grant relay to whitelisted peers with default permissions (relay their txes even under -blocksonly). Entries with an explicit perms@ prefix are unaffected. |
whitelistforcerelay | off | hot | core | Grant forcerelay to whitelisted peers with default permissions. Entries with an explicit perms@ prefix are unaffected. |
whitebind | none | restart | core | Bind an extra permissioned P2P listener (repeatable). |
asmap | none | restart | core | asmap file for ASN-based addrman bucketing (eclipse resistance). |
port | network default | restart | core | P2P listen port. |
bind | 0.0.0.0 | restart | core | Bind P2P to this address; repeatable. Accepts addr, addr:port, and addr[:port]=onion. An entry with a port uses it; a bare address takes port, and a bare =onion entry takes port + 1 (as in Core). IPv6 literals may be plain (::1) or bracketed. Any explicit bind replaces the default listener. Cannot be combined with listen=0. |
connect | none | hot | core | Connect only to specific peer(s) (repeatable). An entry with no port takes the network's default P2P port. Any connect stops the node dialling addresses it learns from gossip and soft-sets listen=0 (see listen); connect=0 — and the command-line negation -noconnect — is Core's spelling for "open no outbound connections at all"; a literal connect=0 mixed with a peer address is refused, since Core keeps the peer and satd will not dial the 0. Connect-only exclusivity is a startup decision (restart to change). |
addnode | none | hot | core | Add a node to connect to (does not disable DNS seeding, and does not affect listen). An entry with no port takes the network's default P2P port. As in Core, added nodes are counted apart from the automatic outbound peers and maxconnections: at most 8 are connected at a time, and one that is not connected is dialled again at most a minute later, however many automatic peers the node has. A ban does not stop the dial; it does stop automatic dials to that address. |
uacomment | none | restart | core | Append a comment to the advertised user agent (repeatable; command-line and config-file values accumulate, command line first). Renders as /satd:<version>(c1; c2)/. A comment may contain only alphanumerics and .,;-_?@ — the user agent's own delimiters /, :, ( and ) are refused — and the whole user agent may not exceed 256 bytes. Either violation is a startup error, as in Core. |
seednode | none | hot | core | One-shot seed peer connected at startup to bootstrap discovery. |
maxconnections | 125 | hot | core | Maximum total connections. Manual connections (connect, addnode, seednode, addnode onetry) do not count against the automatic outbound peers, as in Core. 0 (or any value ≤ 0) soft-sets listen=0, as in Core — see listen. That half is a startup decision: changing maxconnections over SIGHUP applies the new cap but does not start or stop the listener. |
maxinboundperip | 3 | hot | satd | Max simultaneous inbound peers from one source IP (Core-style flood guard; no Core flag). |
cmpctblockprefill | false | restart | satd | Announce a new block as a cmpctblock with the transactions this node lacked when it arrived prefilled, so a peer that lacks them too rebuilds the block without a getblocktxn round trip. Bitcoin Core's proposed design (#35558, not yet merged in Core); off until measurement says otherwise. |
cmpctblockprefillbytes | 8192 | restart | satd | Transaction bytes a cmpctblockprefill announcement may carry beyond the coinbase. Transactions nobody relayed to this node go first, then replaced or policy-refused ones; one that does not fit is skipped. 0 prefills nothing. |
blockreconstructionextratxn | 100 | restart | core | Recently seen transactions that are not in the mempool — replaced, or refused by policy — kept so a compact block that includes one still reconstructs without a round trip. 0 keeps none. |
maxuploadtarget | 0 (unlimited) | hot | core | Soft cap (bytes/24h) on historical block upload. |
dns | true | restart | core | Allow DNS lookups for -addnode/-seednode/-connect. With dns=0 those options accept only literal IP addresses and .onion targets; a hostname is refused. |
dnsseed | true | restart | core | Query DNS seeds for peer addresses (requires dns). With nothing reachable in the address book the seeds are queried at startup; otherwise the node tries the book first and queries them if it has fewer than two full-relay outbound peers after 11 seconds (5 minutes with 1,000 or more addresses), as Core does. Off by default when onlynet leaves out both IPv4 and IPv6; dnsseed=1 with such an onlynet is refused at startup, as in Core. |
forcednsseed | false | restart | core | Query DNS seeds at startup even with a populated address book. |
fixedseeds | true | restart | core | Allow the compiled-in fixed-seed fallback: added a minute after startup if the address book still has nothing reachable, or at startup when DNS seeding is off and no seednode or addnode is set, as in Core. |
bantime | 86400 | hot | core | Ban duration in seconds. |
timeout | 5000 ms | hot | core | P2P connection timeout in milliseconds (accepts 5s/5000ms). Through a SOCKS5 proxy it bounds the connect to the proxy; the SOCKS5 exchange that follows gets 20 seconds or this value, whichever is larger, as Core gives each SOCKS5 reply 20 seconds. |
onlynet | all | restart | core | Make automatic outbound connections only to these networks (repeatable): ipv4, ipv6, onion, cjdns, i2p. Gossiped addresses on other networks are not kept. Inbound and manual connections (connect, addnode, seednode) are not affected. Refused at startup as in Core: an unknown name; onion with neither proxy nor onion (Core also takes listenonion; satd cannot reach Tor through it), or with onion=0; cjdns without cjdnsreachable; i2p (satd has no I2P transport). |
signetseednode | built-in seeds | restart | core | Additional signet seed node (repeatable; signet only). |
signetchallenge | default signet | restart | core | Custom signet challenge script, hex (BIP 325; signet only). Without it satd runs the default signet and verifies every block against its challenge. |
Note. satd answers a peer's BIP35
mempoolmessage (a request to announce our entire mempool) only for peers granted themempoolnet permission:-whitelist=mempool@<subnet>,all@<subnet>, or a bare-whitelist=<subnet>entry, whose implicit permission set includesmempool, as in Core. The permission is not implied bynoban@. The response honors the requesting peer's fee filter, and dumps to one peer are rate-limited to at most one per 30 s. satd does not advertiseNODE_BLOOM(BIP37 bloom filters are unsupported).mempoolrequests from peers without the permission are ignored, which is softer than Bitcoin Core with bloom disabled: Core disconnects such peers unless they havenoban.
Proxy / Tor
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
proxy | none | restart | core | SOCKS5 proxy for all outbound connections; proxy=0 is none. Lowers listen by default (see listen). Nothing is looked up on the local resolver while this is set: that lookup would leak the peer names the proxy is there to hide. The DNS seeds, and a hostname in -seednode, are handed to the proxy by name instead, as short-lived addr-fetch connections that ask for addresses and close, as Core does. A hostname in -addnode/-connect is refused; use a literal IP or a .onion address. |
proxyrandomize | on | restart | core | Use fresh random SOCKS5 credentials per connection so Tor isolates each peer on its own circuit (IsolateSOCKSAuth). Relies on Tor's default SocksPort isolation; a no-op on a non-Tor SOCKS proxy (or one with IsolateSOCKSAuth disabled), where credentials are not negotiated. Set =0 to opt out. |
onion | = -proxy | restart | core | SOCKS5 proxy for .onion connections. onion=0 (-noonion) never reaches .onion, even through proxy. |
torcontrol | 127.0.0.1:9051 | restart | core | Tor control port for the hidden service. Auth is negotiated via PROTOCOLINFO: SAFECOOKIE (stock-Tor default) when no password is set, else password, else null. |
torpassword | none | restart | core | Tor control port password (for a HashedControlPassword setup). Leave unset to use SAFECOOKIE cookie auth. |
listenonion | off (on if torcontrol set) | restart | core | Create a Tor v3 hidden service via the control port. Soft-set off when listen is off, as in Core; state it explicitly to keep the service on a non-listening node. Gets its own P2P listener on 127.0.0.1:<port+1> unless a bind=<addr>:<port>=onion entry names one, matching Core's onion_binds; peers arriving there are exempt from whitelist matching. The service's private key is kept in onion_v3_private_key in the network's data directory (mode 0600), as Core keeps it, and reused, so the onion address stays the same across restarts; delete the file to get a new address. Anyone who can read the file can run the node's onion address. When Tor stops or restarts, the address is withdrawn and satd reconnects to torcontrol, waiting 1 s, then half as long again each time up to 10 minutes, and adds the service again, as Core does; it also retries a failed authentication or ADD_ONION that way. |
Consensus
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
assumevalid | per-network hash (none on a custom signet) | restart | core | Skip script verification for HASH's buried ancestors on the best-header chain (0=verify all, all=skip old blocks). |
assumevalidage | 86400 | restart | satd | With assumevalid=all, still verify scripts for blocks newer than SECS. |
checkpoints | on | restart | core | Enforce the built-in block checkpoints (mainnet and the default signet; a custom signet has none). A block at a checkpoint height must match it, and a new header forking below the highest checkpoint the block index holds is refused (bad-fork-prior-to-checkpoint). -checkpoints=0 disables both. |
stopatheight | none | restart | core | Stop once the active-chain tip reaches HEIGHT, during initial block download too, where the node stops at HEIGHT itself. 0 is off, as in Core. |
testactivationheight | none | restart | core | Regtest only (warned and ignored elsewhere): name@height buried-deployment override (bip34|dersig|cltv|csv|segwit), repeatable. Note Core's own asymmetry, which satd matches: this option takes dersig/cltv, while getdeploymentinfo reports the same deployments as bip66/bip65; command-line and config-file occurrences merge, last wins per name. |
vbparams | none | restart | core | Regtest only (warned and ignored elsewhere): deployment:start:end[:min_activation_height] BIP 9 window override. Only testdummy is accepted — satd activates taproot at a fixed height and counts no signalling, so an override for it would be reported and not honoured, and is refused by name. |
consensus | rust-shadow | restart | satd | Consensus engine: cpp|rust|rust-shadow|cpp-shadow. |
Indexing
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
txindex | off | restart | core | Maintain a full transaction index. |
addressindex | on | restart | satd | Maintain an address-history index (backs native Electrum/Esplora). |
addrindexmaxhistory | 200000 | restart | satd | Largest scripthash history (transactions) the Electrum and Esplora address queries serve; a busier script is refused. 0 = no cap, which restores whole-history reads. See the Electrum chapter's History cap. |
addrindexsubscriptions | 50000 | hot | satd | Max concurrent per-scripthash status subscriptions, server-wide: shared by Electrum connections and Esplora SSE streams. See the Electrum chapter for sizing it against electrummaxconns × electrummaxsubsperconn. |
blockfilterindex | off | restart | core | BIP 158 compact-block-filter index (basic/0/1, or no value for basic). |
peerblockfilters | off | hot | core | Advertise NODE_COMPACT_FILTERS and serve BIP 157 filters; implies blockfilterindex=basic. |
silentpaymentindex | off | restart | satd | BIP 352 silent-payment tweak index (sp_tweaks); backs the streaming tweaks firehose and scan-key-watch rescan. Backfill an existing datadir with backfillindex silentpayment. |
coinstatsindex | off | restart | core | Accepted so a Core bitcoin.conf drops in unchanged. satd implements no UTXO-set hash index: the key sets nothing, and getindexinfo reports the index as never synced rather than claiming a readiness it cannot deliver. |
txospenderindex | off | restart | core | gettxspendingprevout also finds spenders in blocks, as in Core. satd answers from the address index's spend rows, which back Esplora's outspend endpoints, so the key needs addressindex and adds no index of its own. Refused with prune, as in Core. |
Mempool / relay policy
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
mempoolfullrbf | on | hot | satd | Enable full replace-by-fee. Core removed this flag in v28 (full-RBF is now unconditional there); satd retains the flag. |
maxmempool | 300 MB | hot | core | Maximum mempool size in MB, measured by the serialized size of the transactions held. Bitcoin Core measures its memory use instead, so at the same setting satd holds more transactions than Core and uses more memory than the figure. At least 5, or 0, as in Core: a smaller value refuses to start. |
minrelaytxfee | 1000 sat/kvB | hot | core | Minimum relay fee rate. A bare integer is sat/kvB; a decimal is BTC/kvB, Bitcoin Core's spelling (0.00001 = 1000 sat/kvB). |
dustrelayfee | 3000 sat/kvB | hot | core | Dust relay fee rate. A bare integer is sat/kvB; a decimal is BTC/kvB, Bitcoin Core's spelling (0.00003 = 3000 sat/kvB). |
datacarrier | on | hot | core | Accept OP_RETURN outputs. |
datacarriersize | 83 bytes | hot | core | Maximum OP_RETURN size in bytes (0 = reject all). |
limitclustercount | 64 | hot | core | Do not accept a transaction whose cluster, the unconfirmed transactions connected to it through spends in either direction and itself included, would hold more than this many; it is rejected as too-large-cluster. 64 is both the default and the maximum, so the option can only lower it; a larger value is a startup error. |
limitclustersize | 101 kvB | hot | core | Do not accept a transaction whose cluster would come to more than this many kilobytes of sigop-adjusted virtual size; it is rejected as too-large-cluster. maxmempool must leave room for 40 such clusters. |
limitancestorcount | 25 | hot | core | Maximum unconfirmed ancestor count. Deprecated in Bitcoin Core v31 and superseded by limitclustercount; accepted for config compatibility but no longer gates admission. |
limitdescendantcount | 25 | hot | core | Maximum unconfirmed descendant count. Deprecated alongside limitancestorcount, and likewise no longer gates admission. |
mempoolexpiry | 336 h | hot | core | Mempool entry expiry in hours. |
maxtipage | 86400 s | restart | core | A tip older than this keeps the node in initial block download. |
minimumchainwork | per network | restart | core | The chain work, in hex (up to 64 digits, optional 0x), a best header must have before -assumevalid skips scripts and before the node asks peers for header announcements. |
persistmempool | on | hot | core | Persist the mempool to mempool.dat across restarts. |
rebroadcastinterval | 0 (auto) | hot | satd | Seconds between rebroadcasts of unconfirmed local transactions (those submitted here via sendrawtransaction, the MCP tool, Esplora POST /tx, or Electrum transaction.broadcast). 0 = auto: a randomized 10–15 min interval per pass, matching Bitcoin Core. A locally-submitted tx is queued again for every peer that has not yet been announced it, 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. |
txannouncedelay | 1 | hot | satd | 1: announce transactions to each peer on Bitcoin Core's random timers, every 2 s on average to an outbound peer and every 5 s to inbound peers (one shared timer per network and listener), so a peer cannot tell from the timing when a transaction arrived. 0: announce to every peer as soon as a transaction is queued, as Core announces to a noban peer; transactions spread faster but peers learn when each one arrived here, which helps locate where it was first broadcast. Either way each peer is announced each transaction once, parents first, at most 70 per message (more for a long queue). To skip the wait for chosen peers only, give them noban (whitelist/whitebind) instead. |
broadcastconfirmpeers | 1 | hot | satd | Distinct peer IPs that must take a locally-broadcast tx before it counts as propagated and rebroadcast stops. A peer takes a tx by fetching it via getdata (the primary signal) or announcing it back via inv. Counted per IP, not per connection, so a reconnecting host is one witness. Raising it demands wider observed propagation before retries stop. |
permitbaremultisig | on | hot | core | Allow bare multisig outputs. |
bytespersigop | 20 | restart | core | Vbytes charged per unit of sigop cost when the mempool and block template price a transaction: its size is the larger of its vsize and its sigop cost times this. |
acceptnonstdtxn | off | hot | core | Relay and accept non-standard transactions (bypass the standardness relay checks: oversize, dust, OP_RETURN/datacarrier, non-standard scripts). Consensus rules are never relaxed. Intended for test/dev networks. |
Esplora
(satd-specific; native Esplora REST server. See Esplora REST API.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
esplora | on | restart | satd | Run the native Esplora REST server (requires addressindex=1). |
esplorabind | 127.0.0.1:3000 | restart | satd | Bind the Esplora REST listener. |
esploratlsbind | none | restart | satd | Bind the Esplora TLS listener (requires cert+key). |
esploratlscert | none | restart | satd | PEM TLS certificate for the Esplora server. |
esploratlskey | none | restart | satd | PEM TLS private key for the Esplora server. |
esploramtls | false | restart | satd | Require mutual TLS on the Esplora TLS listener. |
esploramtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when esploramtls=1. |
esploramtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
esploraprefix | / | restart | satd | URL prefix to mount the API under (/api for blockstream-style). |
esploracors | none | restart | satd | Allowed CORS origin (repeatable). |
esplorarequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). |
esploramaxconns | 256 | restart | satd | Hard cap on concurrent in-flight Esplora requests. |
esplorasseconns | = esploramaxconns | restart | satd | Hard cap on simultaneously-open SSE streams (0 disables SSE). |
esploramaxsockets | 1024 | restart | satd | Hard cap on open sockets across both Esplora listeners, counted at accept with idle keep-alive connections included; over the cap a socket is dropped. esploramaxconns bounds requests in flight and never sees a socket that sends nothing. 0 disables. A keep-alive connection idle for longer than esplorarequesttimeout is closed. |
esploraauth | none | restart | satd | Esplora auth mode: none|cookie|userpass. |
esploraauthbearer | false | restart | satd | Honor bearer tokens (esplora:read) on the Esplora server (requires authfile). |
esploracookiefile | shared .cookie | restart | satd | Cookie file when esploraauth=cookie. |
esplorauserpass | none | restart | satd | Static user:pass when esploraauth=userpass. |
Electrum
(satd-specific; native Electrum protocol server.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
electrum | off | restart | satd | Run the native Electrum protocol server (requires addressindex=1 and txindex=1). |
electrumbind | 127.0.0.1:50001 | restart | satd | Bind the Electrum plain-TCP listener. |
electrumtlsbind | none (std port 50002) | restart | satd | Bind the Electrum TLS listener (requires cert+key). |
electrumtlscert | none | restart | satd | PEM TLS certificate for the Electrum server. |
electrumtlskey | none | restart | satd | PEM TLS private key for the Electrum server. |
electrummtls | false | restart | satd | Require mutual TLS on the Electrum TLS listener. |
electrummtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when electrummtls=1. |
electrummtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
electrummaxconns | 64 | restart | satd | Hard cap on simultaneously-open Electrum connections. |
electrummaxsubsperconn | 10000 | restart | satd | Per-connection scripthash subscription cap. |
electrumrequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). Past it the client gets a request timed out error and the connection is closed; the connection keeps its slot until the handler's work ends. |
electrummaxbatchrequests | 100 | restart | satd | Max requests per JSON-RPC batch line. Wallets (Sparrow) batch their whole gap-limit window of subscribes at scan time. |
electrummaxbroadcastpackagetxs | 25 | restart | satd | Max txs per blockchain.transaction.broadcast_package. |
electrumfeehistogramttl | 10 | restart | satd | TTL (seconds) for the mempool.get_fee_histogram cache. |
electrumbanner | powered by satd <ver> | restart | satd | Override for server.banner. |
electrumservername | satd-electrs-compatible/<ver> | restart | satd | Name reported by server.version and server.features.server_version. The default carries an electrs compatibility token because Electrum clients feature-detect by matching on this string (Cake Wallet probes silent-payment tweaks only when it contains electrs). Affects the Electrum surface only — the P2P user agent stays /satd:<ver>/. |
stratum | off | restart | satd | Run the Stratum V1 solo-mining server. Refused on signet. See Stratum Mining Server. |
stratumbind | 127.0.0.1:3333 | restart | satd | Bind the plaintext Stratum listener. A non-loopback address needs stratumtlsbind or stratumallowplaintextremote=1. |
stratumtlsbind | none (conventional port 4333) | restart | satd | Bind the Stratum TLS listener (requires cert+key). |
stratumtlscert | none | restart | satd | PEM TLS certificate (or full chain) for the Stratum server. |
stratumtlskey | none | restart | satd | PEM TLS private key for the Stratum server. |
stratummtls | false | restart | satd | Require mutual TLS on the Stratum TLS listener. |
stratummtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when stratummtls=1. |
stratummtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
stratumaddress | none | restart | satd | Payout address for a miner whose username is not a valid address for this network. |
stratumdifficulty | 10000 mainnet, 1000 testnet, 1 regtest | restart | satd | Initial Stratum share difficulty. |
stratummaxconns | 64 | restart | satd | Hard cap on simultaneous Stratum connections across both listeners. |
stratumallowplaintextremote | false | restart | satd | Accept a non-loopback stratumbind with no TLS listener. |
stratumv2bind | none | restart | satd | Bind the Stratum V2 listener (Noise-encrypted; requires stratum=1). |
stratumv2key | <datadir>/stratum_v2.key | restart | satd | Stratum V2 authority key file, created if absent. Back it up with the datadir: miners pin the key. |
stratumv2maxchannels | 16 | restart | satd | Channels one Stratum V2 connection may open. |
stratumv2jd | false | restart | satd | Serve Stratum V2 Job Declaration on the V2 listener (requires stratumv2bind). |
Storage / pruning / reindex
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
prune | 0 (no pruning) | restart | core | Prune block data to target size in MB. |
pruneopreturn | 0 (off) | restart | satd | Strip OP_RETURN payloads from stored blocks more than N blocks deep (minimum 288; 1/all = 288). Blocks are still fully validated; the node stops serving historical blocks (NODE_NETWORK_LIMITED). One-way: see OP_RETURN data pruning. |
pruneopreturnpeers | limited | restart | satd | What a stripping node serves to peers. limited: only the last 288 blocks, as a pruned Core node. intact: any block it still holds intact, at any depth; notfound for stripped ones. Both advertise only NODE_NETWORK_LIMITED. |
pruneopreturnsize | 4096 | restart | satd | Strip only OP_RETURN outputs whose scriptPubKey is at least this size. Bytes, or with a suffix: k/M (1000s), KiB/MiB (1024s). |
restoreopreturns | 0 (off) | restart | satd | Download the blocks OP_RETURN pruning stripped again, from peers that keep every block, until none is left; the node then serves the full chain (NODE_NETWORK) without a restart. Refused with any pruneopreturn* option. Does nothing on a node with no stripped block. See Restoring stripped blocks. |
reindex | off | restart | core | Rebuild block index and chain state from block files on disk. Set in the config file, it rebuilds on every start, as in Core: remove it once the rebuild is done. |
reindexchainstate | off | restart | core | Rebuild the UTXO set from existing block files (Core -reindex-chainstate). Same rule in the config file as reindex. |
upgradechainstate | off | restart | satd | Rebuild the UTXO set from the block files, unprompted, when the datadir's chainstate schema is older than this satd's or a previous rebuild did not finish; otherwise nothing. Safe to leave on. Pruned nodes are refused (they must resync), and a newer schema is never downgraded. The app-store packages set it. |
checkblockindex | off (on for regtest) | restart | core | Audit block-index / active-chain consistency at startup (Core -checkblockindex). |
dbcache | 450 MB (or auto) | restart | core | Total write-cache size in MB, or auto for adaptive sizing. |
storageprofile | ssd | restart | satd | Storage class for chainstate tuning: ssd or hdd. |
prefetchworkers | CPU cores | restart | satd | Number of IBD prefetch worker threads. |
maxahead | 50000 | restart | satd | Max blocks ahead during IBD: number, N%, or all. |
maxopenfiles | 2048 | restart | satd | RocksDB max_open_files cap; -1 = unlimited. |
rocksdbbackgroundjobs | from storageprofile | restart | satd | Override RocksDB max_background_jobs (advanced). |
rocksdbsubcompactions | from storageprofile | restart | satd | Override RocksDB max_subcompactions (advanced). |
rocksdbwalmb | from storageprofile | restart | satd | Override RocksDB max_total_wal_size in MB (advanced). |
compactiondiagintervalsecs | 60 (0 disables) | restart | satd | Per-CF pending-compaction diagnostic log interval. |
compactionintervalsecs | 1800 (0 disables) | restart | satd | Periodic forced-compaction interval in seconds. |
compactionl0at | 16 | restart | satd | Force chainstate compaction when L0 SST count ≥ N. |
ibdl0pauseat | 64 (0 disables) | restart | satd | Pause the IBD connector when chainstate L0 SST count ≥ N. |
stallwatchdogsecs | 300 (0 disables) | restart | satd | Stall-watchdog forensic-dump threshold (seconds without tip advance). |
stallabortsecs | 300 | restart | satd | Additional grace after the forensics dump before abort(). |
shadowqueuesize | 4194304 | restart | satd | Shadow-verification queue capacity. |
shadowqueuemb | 128 (0 disables) | restart | satd | Memory the shadow-verification queue may hold, in MiB; work past it is dropped. |
shadowworkers | 4 | restart | satd | Shadow-verification worker threads. |
Mining
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blockmaxweight | 4000000 | restart | core | Maximum block weight for templates, coinbase reserve included (Core's -blockmaxweight). Applies to getblocktemplate, generatetoaddress / generatetodescriptor and Stratum jobs (not to generateblock, whose caller lists the transactions, as in Core); a transaction package that would bring the template to the cap is left out, as in Core. Above 4,000,000 is refused at startup; below the 8,000 WU coinbase reserve is raised to it, which leaves room for no transactions. getblocktemplate's weightlimit still reports the consensus limit, as Core's does. |
blockmintxfee | 1 sat/kvB | restart | core | Minimum fee rate for a transaction (judged with its package) to enter the block template. A bare integer is sat/kvB; a decimal is BTC/kvB, Bitcoin Core's spelling (0.00001 = 1000 sat/kvB). |
par | unset | restart | core | Script-verification threads (Core name). When shadowworkers is unset, a positive value sets the shadow-verification worker count. It does not size the connect path. |
Node attestation (NAP)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
nap | off | restart | satd | Publish Node Attestation Protocol attestations: enables createnapattestation, submitnapattestation, listnapattestations and removenapattestation, keeps the attestations submitted in <datadir>/nap/attestations.dat and its journal attestations.journal, serves the valid ones to peers that ask, and advertises the experimental NODE_NAP service bit (bit 30). See Node Attestation (NAP). |
napaggregator | off | restart | satd | Collect Node Attestation Protocol attestations from publishers over P2P: pulls from connected peers that advertise NODE_NAP and opens short nap-pull connections to the ones the address book knows, verifies what they serve, keeps the set in <datadir>/nap/aggregate/aggregate.dat, and enables listnapaggregate, getnapreport and exportnapsnapshot. Independent of nap. See Node Attestation (NAP). |
napaggregatormax | 1000000 | restart | satd | With napaggregator, the most attestations the collected set holds; 0 is no limit. When full, coin-and-key pairs new to the set are refused and counted (listnapaggregate's full); pairs already held still take newer attestations. A set already larger, after the limit is lowered, keeps what fits. |
napmaxattestations | 0 (no limit) | restart | satd | With nap, the most attestations the store holds. A submission that would add one more is refused (-26, store-full); replacing a stored one is allowed. A store already larger, after the limit is lowered, is kept whole. |
napstorerecover | off | restart | satd | With nap, an attestation store that does not read back at startup is renamed, attestations.dat and attestations.journal each with a .bad-<unix time> suffix, with a warning, and the node starts with an empty one. Off: startup fails, naming the file. |
Events
(satd-specific event bus. The eventszmq* keys configure satd's own ZMQ
socket, which carries the event envelope; for Bitcoin Core's ZMQ topics, use
the zmqpub* keys in Bitcoin Core ZMQ below.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
eventsnodeid | auto (persisted to <datadir>/node_id) | restart | satd | Stable per-node identifier (32-char hex) stamped on events envelopes. |
eventsregion | none | restart | satd | Optional region tag (≤8 ASCII bytes) on events envelopes. |
eventsgrpcbind | off | restart | satd | host:port to bind the events gRPC streaming server. |
eventsgrpcallowremote | false | restart | satd | Permit eventsgrpcbind on a non-loopback address (requires eventsgrpcauth or eventsgrpcmtls; with eventsgrpcauth, also requires eventsgrpctlscert/eventsgrpctlskey so the bearer token is never sent in cleartext). |
eventsgrpcauth | false | restart | satd | Require bearer tokens (stream:subscribe) on events gRPC (requires authfile). |
eventsgrpcmaxconns | 64 (0 disables) | restart | satd | Hard cap on simultaneously-open events gRPC connections. |
eventsgrpcmaxsubscriptions | 256 (0 disables) | restart | satd | Hard cap on concurrent events gRPC Subscribe and Watch streams. |
eventsgrpctlscert | off | restart | satd | PEM TLS certificate. Set with eventsgrpctlskey to terminate TLS in-process on the eventsgrpcbind listener (no separate TLS bind). |
eventsgrpctlskey | off | restart | satd | PEM TLS private key (required with eventsgrpctlscert). |
eventsgrpcmtls | false | restart | satd | Require mutual TLS (client certificates). Requires eventsgrpctlscert/key and eventsgrpcmtlsclientca. |
eventsgrpcmtlsclientca | off | restart | satd | PEM CA bundle verifying client certs when eventsgrpcmtls=1. |
eventsgrpcmtlsclientallow | empty (any CA-signed cert) | restart | satd | Allowlist of accepted client-cert CN / DNS-SAN values (repeatable, comma-separated). Requires eventsgrpcmtls=1. |
eventsgrpctlshandshaketimeout | 30 | restart | satd | Per-handshake timeout (seconds) for the events gRPC TLS surface. |
streamws | off | restart | satd | host:port for the streaming JSON-over-WebSocket + SSE transport (/ws + /sse). |
streamwsallowremote | false | restart | satd | Permit streamws on a non-loopback address (requires streamwsauth). |
streamwsauth | false | restart | satd | Require bearer tokens (stream:subscribe) on streamws (requires authfile). |
streamwsmaxconns | 256 | restart | satd | Hard cap on simultaneously-open streamws connections. |
streamwsmaxsockets | 1024 | restart | satd | Hard cap on open sockets on the streamws listener, counted at accept before any request is parsed and held until the socket closes, so an open WebSocket keeps its slot; over the cap a socket is dropped. streamwsmaxconns is taken inside the handlers and never sees a socket that sends nothing. 0 disables. A keep-alive connection idle for 30 s between requests is closed (an open /ws or /sse stream is not idle). |
streamwsmaxsubscriptions | 256 | restart | satd | Hard cap on watch-set entries per streamws connection. |
streamwsmaxmessagebytes | 262144 | restart | satd | Cap on a single inbound WebSocket message/frame in bytes. |
streammaxresyncblocks | 10000 (0 disables) | restart | satd | Max blocks the watch matcher re-scans in one catch-up after lagging. |
streamprefixminbits | 8 | restart | satd | Minimum bit-length for a privacy-preserving script-prefix watch. |
streamprefixmaxbits | 32 | restart | satd | Maximum bit-length for a script-prefix watch (range [min, 32]). |
eventszmqbind | off | restart | satd | ZMQ endpoint for the events PUB sink. |
eventszmqhashtx | on when bound | restart | satd | Enable the Core wire-format hashtx topic. |
eventszmqhashblock | on when bound | restart | satd | Enable the Core wire-format hashblock topic. |
eventszmqmpevict | on when bound | restart | satd | Enable mpevict topic (mempool eviction w/ reason; JSON). |
eventszmqmpreplace | on when bound | restart | satd | Enable mpreplace topic (RBF replacement; JSON). |
eventszmqmpconfirm | on when bound | restart | satd | Enable mpconfirm topic (mempool tx confirmed; JSON). |
eventszmqnodeevent | on when bound | restart | satd | Enable nodeevent topic (full envelope JSON). |
Bitcoin Core ZMQ
Bitcoin Core's ZMQ notifications, with Core's messages byte for byte. Each
address key may be given more than once; every value is a notifier, and
notifiers on the same address share a socket. unix:<path> binds a Unix
socket (reported as ipc://<path>); ipc://<path> is also accepted. An
address that cannot be bound turns ZMQ off and the node keeps running, as in
Core; an address with one : and an invalid port stops startup. See
Bitcoin Core ZMQ compatibility
for what each topic publishes and the ordering guarantee.
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
zmqpubhashblock | off | restart | core | Publish each new tip's hash on hashblock at <address> (not during initial block download). |
zmqpubhashtx | off | restart | core | Publish the txid of each mempool admission and of each transaction in a connected or disconnected block on hashtx. |
zmqpubrawblock | off | restart | core | Publish each new tip's serialized block on rawblock (not during initial block download). |
zmqpubrawtx | off | restart | core | Publish each mempool admission and each transaction in a connected or disconnected block, serialized with its witness, on rawtx. |
zmqpubsequence | off | restart | core | Publish sequence records: C/D for a connected/disconnected block, A/R with the mempool sequence number for a mempool admission/removal. |
zmqpubhashblockhwm | 1000 | restart | core | Outbound high-water mark of the hashblock notifiers, in messages per subscriber; 0 is unlimited. A value that is not a number reads as 0 and a negative one keeps 1000, as in Core. |
zmqpubhashtxhwm | 1000 | restart | core | As zmqpubhashblockhwm, for hashtx. |
zmqpubrawblockhwm | 1000 | restart | core | As zmqpubhashblockhwm, for rawblock. |
zmqpubrawtxhwm | 1000 | restart | core | As zmqpubhashblockhwm, for rawtx. |
zmqpubsequencehwm | 1000 | restart | core | As zmqpubhashblockhwm, for sequence. |
A socket shared by several topics takes the first one's high-water mark, in the order the table lists them. On top of it, a subscriber with more than 256 MiB of unsent messages loses further ones until it catches up.
Webhooks / notifications
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blocknotify | none | restart | core | Shell command run on each new best block; %s is replaced by the block hash. Commands run serially on a dedicated subscriber task; a slow hook never stalls block connection, because notifications coalesce instead. The command body is not logged (it may embed credentials). |
alertnotify | none | restart | core | Shell command run on each new node warning; %s is replaced by the warning text. Deduped by warning id (a repeated condition fires once, not per repeat). One-shot events such as deep_reorg have no standing condition to dedupe, so they are rate-limited instead: one exec per minute per event id, reporting the worst occurrence in the window rather than the first. Runs serially like blocknotify. Live from the start of chain loading, so warnings raised during startup (a -reindex that halts or ends short) fire it too. See Observability → Node-health alerts. |
startupnotify | none | restart | core | Shell command run once after the node finishes starting up (no %s). Detached, so a slow hook does not delay startup. Prefer a systemd ExecStartPost=. |
shutdownnotify | none | restart | core | Shell command run once at the start of a graceful shutdown, before the final flush (no %s). Bounded by maxshutdownsecs so a hung hook can't wedge teardown. Prefer a systemd ExecStopPost=. |
reorgwebhook | none | hot | satd | HTTP(S) endpoint receiving a POST on reorg detection. |
reorgwebhooksecret | none | hot | satd | HMAC-SHA256 secret signing webhook bodies via X-Satd-Signature. |
Health alerts
Thresholds for the node-health detectors. Each raises a status event on the
Streaming Consumption API and an entry in getwarnings (which
also fires alertnotify) when its condition is entered, and retracts both when
it recovers. Every one is hot-reloadable — retuning an alert should not need a
restart, since you are usually retuning it because it is firing.
Set a threshold to 0 to disable that detector. See
Observability → Node-health alerts for
the taxonomy and the details each event carries.
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
alertfile | none | path restart, contents hot | satd | TOML file describing outbound alert webhooks. Must be mode 0600 — it holds signing secrets. The path is read once at startup; the file's contents are re-read on every SIGHUP, so a hook can be added, edited, or removed live. A parse error keeps the last-good hook set. See Observability → Alert webhooks. |
alerttipstallseconds | 3600 (0 on regtest) | hot | satd | Raise tip_stall after this many seconds with no connected block. Defaults to disabled on regtest only, where blocks exist just when a test mines them and an idle chain is normal; every other network — test networks included — keeps the hour, since going an hour without a block is not an ordinary property of thin hashrate the way a shallow reorg is. Not suppressed during initial block download — is_initial_block_download() compares the tip header's timestamp against the wall clock rather than tracking sync progress, so a node that was caught up and then wedged re-enters it precisely when you need paging. A node that is genuinely syncing moves its tip continuously and so never crosses the threshold: the detector watches the tip itself, not just block-connected notifications, because the block-download path connects without emitting any. Cleared the moment a block connects — or, if the tip moved without a notification, or you raise this value past the current tip age, on the next detector poll. |
alertdiskfreemb | 10240 | hot | satd | Raise disk_low below this many MiB free on the blocks directory (or the data directory when blocksdir is not split out). Clears at 1.5× the floor, or as soon as you lower the floor below the current reading. |
alertmempoolfullpct | 90 | hot | satd | Raise mempool_congested at this percentage of maxmempool. Clears below 75 % of the raise line, or as soon as you raise the threshold above the current occupancy. Values above 100 are clamped. |
alertpeerfloor | 3 (0 on regtest; capped by the -connect= count) | hot | satd | Raise peer_floor below this many connected peers (inbound + outbound). The count must hold for 60 s in either direction, so ordinary peer churn does not page you, and it does not raise until 90 s after startup or the first peer, whichever is sooner. Defaults to disabled on regtest only, where a node with no peers is normal; signet keeps the floor — set alertpeerfloor=0 explicitly on a deliberately isolated signet node. When connect= is set the default drops to that many peers (never above 3), since connect= suppresses DNS and fixed seeds and the node can never exceed the addresses you named — an explicit value here still overrides it. |
alertreorgdepth | 3 (10 on test networks, 0 on regtest) | hot | satd | Emit the one-shot deep_reorg event for a reorg that rolls back at least this many blocks. Depth 3 is an incident on mainnet and ordinary on a chain with thin, volatile hashrate, so signet/testnet/testnet4 default to 10 — above the 6-confirmation convention, so a reorg that invalidated something a wallet called final still reports. Regtest defaults off: its test suites reorg deliberately. |
Note. The
*notifyshell hooks (blocknotify,alertnotify,startupnotify,shutdownnotify) exist for drop-in Bitcoin Core compatibility and quick scripts. They are best-effort shell execs with no delivery guarantee, no replay, and no reorg awareness. To build on satd, use the Streaming Consumption API (gRPC, WebSocket, or ZMQ): it is reorg-safe, offers durable cursor replay, and is decoupled from consensus. For lifecycle actions, prefer your service manager (systemdExecStartPost=/ExecStopPost=). satd honors these four hooks. Onlywalletnotifyis unsupported: satd is keyless, so watch scripts via the streaming or Esplora API. A node started with any of these hooks logs this guidance at startup.
MCP
(satd-specific; Model Context Protocol server.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
mcp | off | restart | satd | Enable the MCP server. |
mcpport | none | restart | satd | Enable the MCP HTTP transport on this port. |
mcpbind | 127.0.0.1 | restart | satd | MCP HTTP bind address (non-loopback requires auth + TLS). |
mcpcert | none | restart | satd | PEM TLS certificate for the MCP server (enables HTTPS; requires mcpkey). Required for any non-loopback bind. |
mcpkey | none | restart | satd | PEM TLS private key for the MCP server (requires mcpcert). |
mcpmtls | false | restart | satd | Require mutual TLS on the MCP listener (requires mcpcert/mcpkey + mcpmtlsclientca). |
mcpmtlsclientca | none | restart | satd | PEM CA bundle that client certs must chain to when mcpmtls. |
mcpmtlsclientallow | any | restart | satd | Allowlist of accepted client-cert CN / DNS-SAN values. |
mcpauth | false | restart | satd | Require bearer tokens (mcp:*) on the MCP HTTP server (requires authfile). |
mcpallowremote | false | restart | satd | Permit a non-loopback MCP HTTP bind (requires mcpauth + TLS). |
mcpallowedhost | loopback only | restart | satd | Extra Host values the MCP listener accepts, as host or host:port (repeatable, comma-separated). Loopback names are always accepted. Required to reach MCP by hostname. |
Metrics / health
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
metricsport | none | restart | satd | Enable Prometheus /metrics + /healthz + /readyz on this port (unauthenticated). |
metricsbind | 127.0.0.1 | restart | satd | Metrics/health HTTP bind address. |
metricstlsbind | none | restart | satd | Also serve the metrics/health endpoints over TLS on this addr:port (requires metricsport, cert and key). |
metricstlscert | none | restart | satd | PEM certificate for the metrics TLS listener. |
metricstlskey | none | restart | satd | PEM private key for the metrics TLS listener. |
metricsmtls | false | restart | satd | Require a client certificate on the metrics TLS listener (requires metricsmtlsclientca). |
metricsmtlsclientca | none | restart | satd | PEM CA bundle client certs must chain to when metricsmtls=1. |
metricsmtlsclientallow | any | restart | satd | Allowlist of accepted client-cert CN / DNS-SAN values on the metrics TLS listener. |
statuspage | 0 | restart | satd | Serve the status page at /status, /status.json and /status.js on the metrics listener. Requires metricsport. |
statusadvertise | none | restart | satd | Repeatable, one per surface. <surface>=<url>, where surface is electrum, esplora, rpc or mcp: a connection string the status page shows, e.g. electrum=ssl://node.local:50002. |
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 long tail:
| Key(s) | Warning guidance |
|---|---|
rest | satd ships native Esplora REST instead of Core's /rest/; enable with -esplora (on by default). |
peerbloomfilters | BIP37 unsupported (privacy/DoS); use BIP157/158 (-blockfilterindex/-peerblockfilters). |
natpmp | satd doesn't implement PCP/NAT-PMP port mapping; configure port forwarding externally. (upnp was removed in Core v29 and is rejected as unknown, as in Core v30.) |
debuglogfile, shrinkdebugfile, printtoconsole, logratelimit | satd logs to stdout/journald; no debug.log. |
logtimemicros | satd's logger always emits sub-second timestamps; there is no seconds-only mode, so the option has no effect. Use -logtimestamps=0 to drop timestamps entirely. |
maxorphantx | Removed in Core v30 too. |
wallet, walletdir, walletnotify, … | satd is keyless (no wallet); use external wallets + PSBT, and watch scripts via the streaming/Esplora API. |
coinstatsindex, loadblock, checkblocks/checklevel, maxsigcachesize, blockversion, printpriority, txreconciliation, discover, persistmempoolv1, acceptstalefeeestimates, settings, daemonwait, 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, or privacy posture. Each rejects with an actionable message:
| Key(s) | Reason |
|---|---|
i2psam, i2pacceptincoming | I2P is 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). |
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 mistyped rpcusser= from silently
disabling authentication. The same rule 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 bumped.
Note. "Supported" means the commonly used Core v30 operator surface, with semantics pinned to Core v30 (not later releases). The long tail is skipped with a warning rather than honored. To consume node events from your own software, use the Streaming Consumption API instead of the
*notifyhooks or RPC polling.