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
- 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, 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. - 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.
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>.
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) 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.
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.
| Condition | Severity | Raises when | Clears when |
|---|---|---|---|
ibd_complete | info | initial block download finishes | one-shot |
tip_stall | critical | no block connected for alerttipstallseconds, outside IBD | the next block connects, 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 |
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.
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.
Push notifications
contrib/push-relay/ is a reference service that receives these webhooks and
forwards the ones worth waking someone for as APNs / FCM push notifications,
using your Apple and Google credentials. It runs as a separate process,
outside satd's workspace, deliberately: a Bitcoin node should not hold a
push-provider credential, nor the JWT/OAuth dependency stack that comes with
one.
cd contrib/push-relay
cargo build --release
cp relay.example.toml /etc/satd-push-relay/relay.toml # then edit
./target/release/satd-push-relay /etc/satd-push-relay/relay.toml
# in satd's alertfile
[[webhook]]
id = "push"
url = "http://127.0.0.1:9099/hook"
secret = "the same value as satd_secret in relay.toml"
categories = ["status", "chain"]
min_severity = "warning"
Status alerts and reorgs become notifications; blocks, mempool churn, and dropped deliveries do not — a relay that buzzed on every block gets uninstalled within a day. A condition and its later recovery share a collapse id, so the "recovered" notification replaces the alert on the lock screen rather than stacking beneath it.
It is reference-grade and meant to be forked: device registration, per-user routing, and your own retry policy are yours to add. See its README for what is worth copying verbatim (the receive path) and what is not.
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).
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. |
--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. |
--limitancestorcount=<N> | 25 | Maximum unconfirmed ancestor count. |
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 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, limitancestorcount, limitdescendantcount, mempoolexpiry, permitbaremultisig | Mempool and relay policy is swapped atomically and governs subsequent transaction admissions. Already-admitted entries are not re-evaluated. |
connect, addnode, seednode | Newly added peers are registered and dialed immediately; existing connections are untouched. Removing an entry does not disconnect that peer (use disconnectnode), matching Core's -addnode. 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 reindex- index enable/disable (
txindex/addressindex/blockfilterindex) - DNS-seed bootstrap (
dns/dnsseed/forcednsseed/fixedseeds/asmap) - Tor (
proxy/onion/torcontrol/listenonion) consensusassumevalidandstopatheight
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. |
| 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. - 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.
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 at or below that block. The hash must already be in the block index. 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 |
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: the C++
libbitcoinconsensus FFI 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++ libbitcoinconsensus | Rust (logs mismatches) |
cpp-shadow | Rust | C++ (logs mismatches) |
cpp | C++ libbitcoinconsensus | none (single engine) |
rust | Rust | none (single engine) |
The Rust engine passes Bitcoin Core's script test suite. Shadow verification
against libbitcoinconsensus across the whole mainnet chain, genesis to about
height 945,000, found zero divergence. The Rust engine is also usually faster
than the C++ FFI: it avoids per-call FFI marshaling and uses a process-global,
verification-only cached secp256k1 context. cpp-shadow (Rust primary, C++
shadow) is therefore the high-performance pairing.
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 libbitcoinconsensus 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. Two flags tune it:
-shadowworkers=<n>(default 4): background shadow-verification threads.-shadowqueuesize=<n>(default 4194304): shadow work-queue capacity. When the queue is full, shadow work is dropped, and an aggregated WARN is logged at most once per 5 s. The primary engine still verifies every script, so correctness is unaffected.
Difference from Bitcoin Core. Core has a single C++ engine and no shadow mode. satd's default runs both engines at once.
-consensus,-shadowworkers, and-shadowqueuesizeare 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.
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): 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. The figures below describe a fully-indexed mainnet node in mid-2026; your numbers will track the chain's growth.
| Column family | Role | Keyed by | Row size | Approx. on disk |
|---|---|---|---|---|
addr_funding_v2 | every output paying a script | scripthash[16] ‖ height ‖ txid ‖ vout | 64 B | ~200 GB |
tx_index | txid → containing block | txid[32] | 64 B | ~140 GB |
addr_spending_v2 | every input spending a script | scripthash[16] ‖ height ‖ txid ‖ vin | 92 B | ~140 GB |
outpoint_spend | UTXO → the input that spent it | prev_txid[32] ‖ vout | 76 B | ~100 GB |
block_filter / _header | BIP 158 compact filters | type ‖ height | ~30 KB / 37 B | ~30 GB |
sp_tweaks | BIP 352 tweaks, one row per block from taproot activation | height | 73 B/eligible tx | ~4 GB |
coins | the live UTXO set | txid[32] ‖ vout | ~28 B varint | ~tens of MB |
undo | per-block disconnect data | block_hash[32] | ~28 B / input | small (rolling) |
The three address/txid indices plus outpoint_spend are the bulk. The UTXO set
itself (coins) is small: it lives mostly in the in-memory coin cache and
serializes to a few tens of MB on disk.
Note. During a
-reindexor-reindex-chainstate, RocksDB compaction falls behind the write rate, sotx_indexin 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.
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_v2, keyed by script (scripthash ‖ height ‖ …). It answers "show me everything address A spent."outpoint_spend, keyed by outpoint (prev_txid ‖ 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 + addr_spending) already exceeds
that range. satd also carries a Core-style tx_index, an outpoint_spend
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 trades pointer compactness for self-containment
tx_index stores the full 32-byte block hash as its value, where Core's
txindex stores an on-disk position (CDiskTxPos) of about 12 bytes. That
costs about 20 extra bytes per transaction, roughly 24 GB across the chain, and
one extra indirection on read. In exchange, the index is independent of
block-file layout and survives block-file re-packing. satd's keys are also
fixed-width binary tuned for prefix seeks rather than byte-minimal, which costs
a little space and 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. Heights are big-endian, so range scans return rows in chain order with no secondary sort.
The size is row_count × ~70 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) (outpoint_spend) | 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_index |
| Electrum / Esplora address history | -addressindex=1 (implies -txindex=1 for Electrum) | addr_funding_v2, addr_spending_v2, outpoint_spend, tx_index |
| BIP 157/158 light-client service | -blockfilterindex=basic -peerblockfilters=1 | block_filter, block_filter_header |
| 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 (see
Streaming Consumption API).
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 tip and resumes across a
restart. getindexinfo reports a silentpayments section with the synced flag
and the backfill progress. Until a backfill completes, the tweak-serving
surfaces refuse a request rather than return a partial result.
Note. At roughly 4 GB on mainnet,
sp_tweaksis small next to the address indices. About 85% of tweaks describe dust outputs; a subscription can drop them with atweak_dust_limit.
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.
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, 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. Each option is clamped to a ceiling, 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 |
| Esplora | -esploramaxconns, -esplorasseconns | 256 / = maxconns | HTTP 429 |
| Electrum | -electrummaxconns, -electrummaxsubsperconn | 64 / 1000 | connection refused |
-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. It is all-or-nothing: a valid operator credential has full access to everything. - 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 | All-or-nothing: full operator access | 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.
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 a read-only token does not hold.
| Capability | String | Grants |
|---|---|---|
| RPC read | rpc:read | Read-only JSON-RPC methods (classified by the same table the read-only listener uses). |
| RPC write | rpc:write | Mutating, control, and mining JSON-RPC methods, plus any unclassified method (fail-closed). |
| Esplora read | esplora:read | The Esplora REST + SSE surface. |
| Stream subscribe | stream:subscribe | Open a streaming subscription (events gRPC, streamws). |
| Stream watch | stream:watch | Register outpoint/script/descriptor/txid watches, bounded by the token's watch quota. |
| MCP | mcp:* | The MCP server. One capability; there is no per-tool split. |
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
# Read-only integration: REST + Esplora reads, rate-capped.
[[token]]
id = "btcpay" # logging/accounting id, not the secret
hash = "sha256:<64-hex SHA-256 of the token>"
capabilities = ["rpc:read", "esplora:read"]
watch_quota = 50000 # optional; omit for unlimited
rate_limit = "200/s" # optional; omit for unlimited
# Watchtower: streaming subscribe + watch registration, expires end-2026.
[[token]]
id = "watchtower"
hash = "sha256:<64-hex>"
capabilities = ["stream:subscribe", "stream:watch"]
watch_quota = 10000
expires = 2026-12-31T00:00:00Z # unquoted RFC 3339 datetime, or unix seconds
# AI agent: full MCP tool access.
[[token]]
id = "agent"
hash = "sha256:<64-hex>"
capabilities = ["mcp:*"]
Rules:
version = 1is required. Each[[token]]needs a uniqueidand ahashof the formsha256:<64 hex>.capabilitiesdefaults to empty; 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.
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. - 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 rejected without tearing down the subscription.
Operator and loopback principals are unlimited.
Per-surface enablement
Bearer support is opt-in per surface: the surface flag turns it on, and it
requires -authfile. satd refuses to start if a surface flag is set without
an authfile.
| Surface | Enable flag | Capability gate | Default without the flag |
|---|---|---|---|
| JSON-RPC (read/write listeners) | -rpcauthbearer | rpc:read / rpc:write | Core Basic auth (cookie/userpass/rpcauth) |
| Esplora REST / SSE | -esploraauthbearer | esplora:read | -esploraauth Basic, loopback-unauth default |
| events gRPC | -eventsgrpcauth | stream:subscribe / stream:watch | loopback-trust |
streaming WS/SSE (streamws) | -streamwsauth | stream:subscribe / stream:watch | loopback-trust |
| MCP (HTTP) | -mcpauth | mcp:* | loopback-trust |
The read-only JSON-RPC listener (-rpcreadonlybind) does not honor bearer
tokens. 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
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.
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: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).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.
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, see the
Streaming Consumption API.
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.
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, 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 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.
- 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: sparkline of vbyte buckets (0, 100, 250, 500, 1k, 5k, 10k, 50k+).
Fees (right half), fee tier estimates from estimatefees
(mempool.space convention):
- High: next-block target (1-block confirmation).
- Medium: 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: sparkline by UTXO age: <1h, 1h–1d, 1d–1w, 1w–1m, 1m–3m, 3m–1y, 1y–3y, 3y+.
Network (right half):
- Peers: inbound and outbound counts.
Peers table
Same controls as IBD's table; columns differ:
| Column | Meaning |
|---|---|
| Addr | Peer IP:port. |
| Agent | Subversion. |
| Height | Peer's best-known block height. |
| Recv | Total bytes transferred with this peer. |
Services row
A single line summarizing satd's wallet-server surfaces, sourced from
getserverstatus and getindexinfo:
addr-idx <state> esplora <state> electrum <state>
addr-idx states:
| Display | Meaning |
|---|---|
⬤ synced (green) | Address-history index is at tip. Esplora and Electrum are safe to serve history. |
⬤ syncing (yellow) | Backfill in progress. Address queries may return partial history. |
⬤ backfill pass N/2 XX% (C/S) ETA … (green) | Active backfill with progress. C/S is cursor / snapshot height. |
⬤ backfill paused … (yellow) | Backfill paused. Resume with sat-cli resumeindex address. |
⬭ backfill FAILED — <err> (red) | Backfill errored. Check journalctl or the satd logs. |
⬭ off (gray) | Address index disabled (-addressindex=0). |
⬭ - (dim) | Status unknown: older satd, or a transient RPC error. |
esplora and electrum states:
| Display | Meaning |
|---|---|
⬭ <bind:port> (green) | Bound and serving. |
(tls <bind:port>) (cyan) | Electrum TLS bind, additional column. |
⬭ off (gray) | Disabled in config, or auto-disabled (e.g. address index off). |
⬭ - (dim) | Unknown. |
Footer
Keybindings hint, plus an unclean-shutdown indicator
(⚠ unclean shutdown) if last_shutdown from getsysteminfo is dirty.
Mempool view (3)
Drill-down on unconfirmed transactions. Five panels.
Title bar
Health dot, uptime.
Summary strip
- Txs: unconfirmed transaction count.
- Bytes: total mempool bytes.
- Min / Max fees: feerate range across mempool entries.
- Δ last Ns: entries added and removed in the last polling window.
Feerate histogram
Bars per feerate bucket (1–2, 2–5, 5–10, 10–20, 20–50,
50–100, 100–200, 200–500, 500+ sat/vB) with vbyte counts.
Bars are 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.
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). |
a | Acknowledge all visible warnings. |
w | Re-show dismissed warnings. |
Esc | Close Help or Reorg modal. |
Up / Down | Scroll peers (IBD / Steady / Chain) or top-N (Mempool). |
Polling and refresh
The TUI does not push commands to satd; it polls. The render loop runs every 250 ms regardless of polling state, so the UI stays responsive even when RPC is slow.
| Cadence | RPC calls |
|---|---|
| 1.5 s | getblockchaininfo, getpeerinfo, getmempoolinfo, getconnectioncount, getsysteminfo, getwarnings. |
| 3 s | getibdprogress. During IBD only; the reply is heavy (full bitmap and per-peer breakdown). |
| ~5 s | getindexinfo, getserverstatus, plus the steady-state batch (estimatefees, getmininginfo, getchaintxstats, uptime, getblockstats, getrawmempool (verbose), gettxoutsetinfo, getreorghistory, getmempoolhistory). |
| per epoch | getblockhash + getblockheader to anchor the current 2,016-block epoch's start time. Refreshed only when the epoch floor advances. |
If a steady-state RPC has not returned within 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.
Failure modes
| What you see | What it means |
|---|---|
Connecting to satd… | RPC unreachable, returning errors, or only getstartupinfo is responding. |
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), 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. |
--esploramaxconns=<n> | 256 | Cap on concurrent in-flight requests. 0 disables the cap. 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. |
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. Empty array past the end. |
| GET | /block/:hash/txid/:index | text/plain: txid at the given block-tx index. |
| GET | /block/:hash/txids | JSON: array of every txid in the block. |
Transaction
| Method | URL | Returns |
|---|---|---|
| GET | /tx/:txid | JSON: full tx (vin/vout/status/fee). 404 if unknown. |
| GET | /tx/:txid/status | JSON: {confirmed, block_height?, block_hash?, block_time?}. |
| GET | /tx/:txid/hex | text/plain: hex-encoded serialized tx. |
| GET | /tx/:txid/raw | application/octet-stream: raw tx bytes. |
| POST | /tx | Body: hex-encoded tx. Returns the txid as plain text on accept. Bad hex or a mempool reject returns 400. |
| GET | /tx/:txid/outspend/:vout | JSON: {spent, txid?, vin?, status?}. |
| GET | /tx/:txid/outspends | JSON: array of outspends, one per output, vout-ordered. |
| GET | /tx/:txid/merkle-proof | JSON: {block_height, merkle: [hex...], pos}. |
| GET | /tx/:txid/merkleblock-proof | text/plain: hex-encoded P2P MerkleBlock for the given tx. |
Address & Scripthash
The address-string and scripthash endpoint families share handlers; only the parser differs. 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). |
| 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. 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).
Mempool & Fee
| Method | URL | Returns |
|---|---|---|
| GET | /mempool | JSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate. |
| GET | /mempool/txids | JSON: array of every mempool txid. |
| GET | /mempool/recent | JSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}. |
| GET | /fee-estimates | JSON: object mapping confirmation target (string) to feerate (sat/vB, float). Standard targets: 1..25, 144, 504, 1008. Floor 1.0 sat/vB. |
Root
| Method | URL | Returns |
|---|---|---|
| GET | / | JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings. |
Live updates (Server-Sent Events)
| Method | URL | Stream |
|---|---|---|
| GET | /blocks/sse | One block event per BlockConnected. Body: {hash, height}. |
| GET | /address/:addr/sse | One status event per status-hash change for the address. Body: {address, status_hash}. |
| GET | /scripthash/:hash/sse | Parallel scripthash variant. The address field carries the scripthash hex. |
Connections receive a :keep-alive heartbeat every 25 seconds, so idle streams
survive intermediate proxy timeouts (Caddy defaults to 30 s, nginx to 60 s).
Per-scripthash subscriptions consume from the registry capped by
--addrindexsubscriptions=N (default 10000). Over-cap subscribe attempts
return 503.
Total open SSE streams across all three 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. - Combined
/txs. Returns up to 50 mempool transactions followed by the first 25 confirmed, in that order. Mempool entries appear in the index's HashSet iteration order, not strictly time-ordered. feefield on tx JSON.nullwhen at least one prevout cannot be resolved (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 }.
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. 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 HashSet iteration order, not strictly time-ordered. Upstream's contract is "up to 50" with no order specified. - Fee histogram bucketing uses fixed boundaries spanning realistic mainnet fee regimes: 1, 2, 3, 5, 8, 10, 15, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000 sat/vB.
- WebSocket subscriptions are not implemented; SSE is the supported live-updates transport. Most consumers (BDK, the mempool.space SDK) accept SSE as a drop-in replacement.
- High-history scripts. The address-history endpoints (
/address/:addr,/address/:addr/txs/chain[/:cursor],/address/:addr/utxo, and the scripthash variants) load the full confirmed-history row set for the scripthash on every request and sort it in memory. For typical wallet-sized scripts this takes under a millisecond. For high-activity scripts (exchange hot wallets, mining pools, popular donation addresses) a request can cost multi-MB allocations and sub-second latency.--esploramaxconnsand--esplorarequesttimeoutbound the damage. For a public deployment that serves such scripts, put the listener behind a per-IP rate limiter at the reverse proxy. Cursor-paginated index reads are tracked as future work. - 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> | 1000 | Per-connection scripthash subscription cap. |
--electrumrequesttimeout=<secs> | 30 | Per-request handler timeout. |
--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. |
The server runs on satd's isolated API runtime
(--api-threads), so Electrum load cannot starve block connection.
Supported methods
A scripthash is the SHA-256 of an output scriptPubKey, reversed (hex),
exactly as in the Electrum protocol.
Server / session
| Method | Description |
|---|---|
server.version | Negotiate client/server software + protocol version. |
server.ping | Keepalive; returns null. |
server.banner | Server banner text (configurable via --electrumbanner). |
server.donation_address | Configured donation address (empty if unset). |
server.features | Feature/identity dict: genesis hash, protocol_min/protocol_max (both 1.4), hosts, etc. |
server.peers.subscribe | Peer-server discovery list (satd returns an empty set; no peer gossip). |
Headers & blocks
| Method | Description |
|---|---|
blockchain.headers.subscribe | Subscribe to new-tip notifications; returns the current tip header and pushes on each new block. |
blockchain.headers.get | Fetch a header by height. |
blockchain.block.header | A block header (with an optional merkle proof to a checkpoint). |
blockchain.block.headers | A contiguous range of headers (with optional checkpoint proof). |
Scripthash (address) queries
| Method | Description |
|---|---|
blockchain.scripthash.get_history | Confirmed + mempool history for a scripthash. |
blockchain.scripthash.get_balance | Confirmed + unconfirmed balance. |
blockchain.scripthash.listunspent | Unspent outputs for a scripthash. |
blockchain.scripthash.get_mempool | Mempool-only history for a scripthash. |
blockchain.scripthash.get_first_use | First block/tx that paid the scripthash (electrs-style extension). |
blockchain.scripthash.subscribe | Subscribe to a scripthash; pushes a new status hash whenever its history changes. |
blockchain.scripthash.unsubscribe | Cancel a scripthash subscription. |
Transactions
| Method | Description |
|---|---|
blockchain.transaction.get | Raw transaction by txid (verbose decode optional). Needs --txindex. |
blockchain.transaction.get_merkle | Merkle inclusion proof for a confirmed tx. Needs --txindex. |
blockchain.transaction.id_from_pos | Txid at a (height, position), optionally with a merkle proof. Needs --txindex. |
blockchain.transaction.broadcast | Submit a raw transaction to the network. |
blockchain.transaction.broadcast_package | Submit a package of transactions (bounded by --electrummaxbroadcastpackagetxs). |
Fees
| Method | Description |
|---|---|
blockchain.estimatefee | Estimated fee rate (BTC/kB) for a confirmation target. |
blockchain.relayfee | The node's minimum relay fee rate. |
mempool.get_fee_histogram | Mempool fee-rate histogram (cached; TTL --electrumfeehistogramttl). |
Subscriptions
Two push subscriptions are supported, both counted against
--electrummaxsubsperconn:
blockchain.headers.subscribe: ablockchain.headers.subscribenotification on every new tip.blockchain.scripthash.subscribe: ablockchain.scripthash.subscribenotification carrying the new status hash whenever a watched scripthash's history changes, 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.
Notes & differences
--txindexis required forblockchain.transaction.get,get_merkle, andid_from_pos.--addressindex(on by default) backs everyscripthash.*method.- satd advertises a single protocol version (
protocol_min == protocol_max == 1.4); it does not negotiate a range. server.peers.subscribereturns an empty list: satd does not participate in Electrum peer gossip.- The protocol layer is vendored from
romanz/electrs(MIT; attribution inelectrum-proto/vendor/electrs.MIT) and adapted to satd'sAddressIndextrait over the shared RocksDB.
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: 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 Core-compatible ZMQ PUB sink remains for legacy parity. It carries the firehose bodies only, not per-subscriber watch matches, and uses Core's per-topic sequence numbers.
WebSocket and SSE bind a dedicated --streamws port; 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. 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)
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. Over-quota adds
are rejected (RESOURCE_EXHAUSTED on gRPC, 429 on WebSocket) without tearing
down the subscription.
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.
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 |
streamwsmaxsubscriptions | 256 | watch-set size per WS connection |
streamwsmaxmessagebytes | 262144 | a single inbound WS control frame |
eventsgrpcmaxconns | 64 | concurrent gRPC streams |
eventsgrpcmaxsubscriptions | 256 | watch-set size per gRPC stream |
streammaxresyncblocks | 10000 | blocks the matcher will rescan after a lag, bounding catch-up |
Admission shedding runs before authentication and request-body buffering. A connection flood, authenticated or not, is bounded before it does any work.
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.
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. Non-Rust
consumers use 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, and an optional bitcoin.
[dependencies]
satd-events-client = "0.4"
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.4", default-features = false }
Connecting
use satd_events_client::{StreamClient, SubscribeOptions, Categories, Event};
let mut client = StreamClient::builder("http://node:50051")
.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). Over a plaintext http:// connection the token travels
in cleartext. Enable TLS (below), restrict bearer auth to loopback, or front
the node with a TLS-terminating proxy. The client's Debug impl redacts the
token and never prints TLS key material.
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.
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.
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. 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. - 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 its common causes, the subscription cap and the per-principal rate
limit, are transient. A full watch quota is not transient, so inspect the
boxed status message before retrying a watch-add forever.
Stability & versioning
The SDK tracks the additive satd.events.v1 wire schema, not the node's
release cadence. New optional fields and event or watch kinds are added
without breaking existing consumers. The crate follows
semver independently of the satd node version; a node
and an SDK do not need matching versions. 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
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.
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
The server is then reachable at https://NODE_HOST:18888/. Clients
authenticate with an Authorization: Bearer <token> header. See
Authentication and Transport security.
Claude Code
# Loopback 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_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.
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.
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. |
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 | RocksDB is open, headers are syncing, and peer count is above zero. Returns 503 during IBD. |
GET /metrics | Prometheus exposition format. |
Wire these endpoints to a Docker HEALTHCHECK, Kubernetes liveness
and readiness probes, or a systemd ExecStartPost= poll. The shipped
Type=notify unit (see the systemd section) signals startup with
sd_notify(READY=1). Supervisors without notify support can poll
/readyz instead.
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.
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. - PID 1:
tini, so SIGTERM forwards to satd cleanly. - 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.
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, bitcoinconsensus) | Compiler version pinned via nixpkgs; SOURCE_DATE_EPOCH respected by cc-rs for any timestamped output. |
OUT_DIR paths in generated code | crane builds inside a content-addressed /build/source; paths are stable across hosts. |
| Linker build-id | RUSTFLAGS=-C link-arg=-Wl,--build-id=none drops the per-build random ID. |
Cargo --release profile | CARGO_PROFILE_RELEASE_STRIP=symbols strips deterministically inside the derivation. |
tonic_build / proto generation | events/proto/*.proto files included in the source filter; protoc is vendored via protoc-bin-vendored so no host protoc dep. |
Gating policy
The Nix workflow runs on tag pushes (v*), 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. |
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. - 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 (for example
limitclustercount,limitclustersize,privatebroadcast,txospenderindex) are not recognized and are rejected as typos until the pin is bumped. 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). |
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. |
allowignoredconf | off | restart | core | Suppress startup warnings about includeconf files satd had to ignore. |
maxshutdownsecs | 30 | hot | satd | Max graceful-shutdown flush duration (seconds) before force exit. |
RPC server
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpcport | 8332 (network-dependent) | restart | core | RPC server port. Defaults: main 8332, test 18332, testnet4 48332, signet 38332, regtest 18443. |
rpcbind | 127.0.0.1:<rpcport> | restart | core | Bind plain-HTTP JSON-RPC to address (repeatable). Non-loopback requires rpcallowip. |
rpcallowip | loopback only | restart | core | Per-request source-IP allowlist for JSON-RPC (repeatable). |
rpcuser | none | hot | core | RPC username. |
rpcpassword | none | hot | core | RPC password. |
rpcthreads | 16 | restart | core | Max concurrent in-flight RPC method calls. |
rpcworkqueue | 64 | restart | core | Max queued RPC requests beyond rpcthreads before HTTP 429 (Core returns 503; documented divergence). |
apithreads | max(2, cores/4) | restart | satd | Worker threads for the isolated API runtime (Esplora/Electrum/events gRPC/metrics). |
rpcreadonlybind | none | restart | satd | Bind an opt-in read-only JSON-RPC listener (reads + mempool submit) on the API runtime. |
rpcreadonlyport | 8330 | restart | satd | Default port for rpcreadonlybind entries without an explicit port. |
rpcreadonlyallowip | loopback only | restart | satd | Source-IP allowlist for the read-only listener. |
rpcreadonlythreads | = rpcthreads | restart | satd | Max in-flight calls on the read-only listener. |
rpcreadonlyworkqueue | = rpcworkqueue | restart | satd | Read-only listener work-queue depth before HTTP 429. |
rpcreadonlytlsbind | none | restart | satd | TLS bind for the read-only listener (requires cert+key). |
rpcreadonlytlscert | none | restart | satd | PEM certificate (chain) for the read-only TLS listener. |
rpcreadonlytlskey | none | restart | satd | PEM private key for the read-only TLS listener. |
rpcreadonlymtls | false | restart | satd | Require a client cert (mTLS) on the read-only TLS surface. |
rpcreadonlymtlsclientca | none | restart | satd | CA bundle client certs must chain to on the read-only TLS surface. |
rpcreadonlymtlsclientallow | any CA-signed | restart | satd | Allowlist of client-cert subjects on the read-only TLS surface. |
rpcauth | none | hot | core | HMAC-SHA256 RPC credential user:salt$hash (Core rpcauth format; repeatable). |
authfile | none | restart | satd | Path to unified-auth bearer-token file (TOML); enables the opt-in bearer-auth layer. Token contents reload live. |
rpcauthbearer | false | restart | satd | Honor Authorization: Bearer tokens on the JSON-RPC listeners (requires authfile). |
rpccookiefile | $DATADIR/.cookie | restart | core | Override the auto-generated cookie file path. |
rpccookieperms | owner (0600) | restart | core | Cookie file permissions: owner(0600)|group(0640)|all(0644). |
rpcdefaultunits | btc | hot | satd | Default units for RPC amount fields: btc (Core-compatible) or sats. |
rpcdisableauth | false | restart | satd | Disable HTTP Basic auth on the JSON-RPC TLS surface; only valid with rpcmtls=1. |
rpcextendederrors | off | hot | satd | Emit structured error payloads (category/suggestion/debug) on RPC errors. |
RPC TLS
(satd-specific; Core's RPC is HTTP-only behind a TLS-terminating sidecar.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
rpctlsbind | none | restart | satd | Bind the JSON-RPC TLS listener (requires cert+key). |
rpctlscert | none | restart | satd | PEM TLS certificate for the JSON-RPC server. |
rpctlskey | none | restart | satd | PEM TLS private key for the JSON-RPC server. |
rpctlshandshaketimeout | 10 | restart | satd | Per-handshake timeout (seconds) for the JSON-RPC TLS surface. |
rpcmtls | false | restart | satd | Require mutual TLS on the JSON-RPC TLS listener. |
rpcmtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when rpcmtls=1. |
rpcmtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
P2P
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
listen | on | restart | core | Accept P2P connections. |
networkactive | on | hot | core | Start with P2P networking enabled. =0 boots with networking paused (no inbound accepts, no outbound dials); 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). |
whitelist | none | hot | core | Grant net permissions to peers by source subnet (repeatable). |
whitelistrelay | on | hot | core | Grant relay to whitelisted peers with default permissions (relay their txes even under -blocksonly). Entries with an explicit perms@ prefix are unaffected. |
whitelistforcerelay | off | hot | core | Grant forcerelay to whitelisted peers with default permissions. Entries with an explicit perms@ prefix are unaffected. |
whitebind | none | restart | core | Bind an extra permissioned P2P listener (repeatable). |
asmap | none | restart | core | asmap file for ASN-based addrman bucketing (eclipse resistance). |
port | network default | restart | core | P2P listen port. |
bind | 0.0.0.0 | restart | core | Bind P2P to this address. |
connect | none | hot | core | Connect only to specific peer(s) (repeatable). Connect-only exclusivity is a startup decision (restart to change). |
addnode | none | hot | core | Add a node to connect to (does not disable DNS seeding). |
seednode | none | hot | core | One-shot seed peer connected at startup to bootstrap discovery. |
maxconnections | 125 | hot | core | Maximum total connections. |
maxinboundperip | 3 | hot | satd | Max simultaneous inbound peers from one source IP (Core-style flood guard; no Core flag). |
maxuploadtarget | 0 (unlimited) | hot | core | Soft cap (bytes/24h) on historical block upload. |
dns | true | restart | core | Allow DNS lookups for -addnode/-seednode/-connect. |
dnsseed | true | restart | core | Query DNS seeds for peer addresses (requires dns). |
forcednsseed | false | restart | core | Always query DNS seeds even with a populated address book. |
fixedseeds | true | restart | core | Allow the compiled-in fixed-seed fallback. |
bantime | 86400 | hot | core | Ban duration in seconds. |
timeout | 5000 ms | hot | core | P2P connection timeout in milliseconds (accepts 5s/5000ms). |
onlynet | all | restart | core | Restrict to network types: ipv4, ipv6, onion. |
signetseednode | built-in seeds | restart | core | Additional signet seed node (repeatable; signet only). |
signetchallenge | default signet | restart | core | Custom signet challenge script, hex (BIP 325; signet only). |
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. |
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. |
torcontrol | 127.0.0.1:9051 | restart | core | Tor control port for the hidden service. Auth is negotiated via PROTOCOLINFO: SAFECOOKIE (stock-Tor default) when no password is set, else password, else null. |
torpassword | none | restart | core | Tor control port password (for a HashedControlPassword setup). Leave unset to use SAFECOOKIE cookie auth. |
listenonion | off (on if torcontrol set) | restart | core | Create a Tor v3 hidden service via the control port. |
Consensus
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
assumevalid | per-network hash | restart | core | Skip script verification up to HASH (0=verify all, all=skip old blocks). |
assumevalidage | 86400 | restart | satd | With assumevalid=all, still verify scripts for blocks newer than SECS. |
checkpoints | on | restart | core | Enforce the built-in block checkpoints. -checkpoints=0 disables checkpoint validation. |
stopatheight | none | restart | core | Stop once the active-chain tip reaches HEIGHT. |
consensus | rust-shadow | restart | satd | Consensus engine: cpp|rust|rust-shadow|cpp-shadow. |
Indexing
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
txindex | off | restart | core | Maintain a full transaction index. |
addressindex | on | restart | satd | Maintain an address-history index (backs native Electrum/Esplora). |
addrindexsubscriptions | 10000 | hot | satd | Max concurrent per-scripthash status subscriptions. |
blockfilterindex | off | restart | core | BIP 158 compact-block-filter index (basic/0/1). |
peerblockfilters | off | hot | core | Advertise NODE_COMPACT_FILTERS and serve BIP 157 filters; implies blockfilterindex=basic. |
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. |
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. |
minrelaytxfee | 1000 sat/kvB | hot | core | Minimum relay fee rate. |
dustrelayfee | 3000 sat/kvB | hot | core | Dust relay fee rate. |
datacarrier | on | hot | core | Accept OP_RETURN outputs. |
datacarriersize | 83 bytes | hot | core | Maximum OP_RETURN size in bytes (0 = reject all). |
limitancestorcount | 25 | hot | core | Maximum unconfirmed ancestor count. |
limitdescendantcount | 25 | hot | core | Maximum unconfirmed descendant count. |
mempoolexpiry | 336 h | hot | core | Mempool entry expiry in hours. |
persistmempool | on | hot | core | Persist the mempool to mempool.dat across restarts. |
rebroadcastinterval | 0 (auto) | hot | satd | Seconds between rebroadcasts of unconfirmed local transactions (those submitted here via sendrawtransaction, the MCP tool, Esplora POST /tx, or Electrum transaction.broadcast). 0 = auto: a randomized 10–15 min interval per pass, matching Bitcoin Core. A locally-submitted tx is re-announced until enough peers take it (see broadcastconfirmpeers) or it leaves the mempool, so it still propagates if no peer was connected at submit time; the pending set is persisted in mempool.dat so it also survives restarts. A SIGHUP interval change applies after the in-flight sleep completes. |
broadcastconfirmpeers | 1 | hot | satd | Distinct peer IPs that must take a locally-broadcast tx 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. |
acceptnonstdtxn | off | hot | core | Relay and accept non-standard transactions (bypass the standardness relay checks: oversize, dust, OP_RETURN/datacarrier, non-standard scripts). Consensus rules are never relaxed. Intended for test/dev networks. |
Esplora
(satd-specific; native Esplora REST server. See Esplora REST API.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
esplora | on | restart | satd | Run the native Esplora REST server (requires addressindex=1). |
esplorabind | 127.0.0.1:3000 | restart | satd | Bind the Esplora REST listener. |
esploratlsbind | none | restart | satd | Bind the Esplora TLS listener (requires cert+key). |
esploratlscert | none | restart | satd | PEM TLS certificate for the Esplora server. |
esploratlskey | none | restart | satd | PEM TLS private key for the Esplora server. |
esploramtls | false | restart | satd | Require mutual TLS on the Esplora TLS listener. |
esploramtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when esploramtls=1. |
esploramtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
esploraprefix | / | restart | satd | URL prefix to mount the API under (/api for blockstream-style). |
esploracors | none | restart | satd | Allowed CORS origin (repeatable). |
esplorarequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). |
esploramaxconns | 256 | restart | satd | Hard cap on concurrent in-flight Esplora requests. |
esplorasseconns | = esploramaxconns | restart | satd | Hard cap on simultaneously-open SSE streams (0 disables SSE). |
esploraauth | none | restart | satd | Esplora auth mode: none|cookie|userpass. |
esploraauthbearer | false | restart | satd | Honor bearer tokens (esplora:read) on the Esplora server (requires authfile). |
esploracookiefile | shared .cookie | restart | satd | Cookie file when esploraauth=cookie. |
esplorauserpass | none | restart | satd | Static user:pass when esploraauth=userpass. |
Electrum
(satd-specific; native Electrum protocol server.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
electrum | off | restart | satd | Run the native Electrum protocol server (requires addressindex=1 and txindex=1). |
electrumbind | 127.0.0.1:50001 | restart | satd | Bind the Electrum plain-TCP listener. |
electrumtlsbind | none (std port 50002) | restart | satd | Bind the Electrum TLS listener (requires cert+key). |
electrumtlscert | none | restart | satd | PEM TLS certificate for the Electrum server. |
electrumtlskey | none | restart | satd | PEM TLS private key for the Electrum server. |
electrummtls | false | restart | satd | Require mutual TLS on the Electrum TLS listener. |
electrummtlsclientca | none | restart | satd | PEM CA bundle to verify client certs when electrummtls=1. |
electrummtlsclientallow | any CA-signed | restart | satd | Allowlist of accepted client-cert CN/DNS-SAN values. |
electrummaxconns | 64 | restart | satd | Hard cap on simultaneously-open Electrum connections. |
electrummaxsubsperconn | 1000 | restart | satd | Per-connection scripthash subscription cap. |
electrumrequesttimeout | 30 | restart | satd | Per-request handler timeout (seconds). |
electrummaxbatchrequests | 100 | restart | satd | Max requests per JSON-RPC batch line. Wallets (Sparrow) batch their whole gap-limit window of subscribes at scan time. |
electrummaxbroadcastpackagetxs | 25 | restart | satd | Max txs per blockchain.transaction.broadcast_package. |
electrumfeehistogramttl | 10 | restart | satd | TTL (seconds) for the mempool.get_fee_histogram cache. |
electrumbanner | powered by satd <ver> | restart | satd | Override for server.banner. |
Storage / pruning / reindex
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
prune | 0 (no pruning) | restart | core | Prune block data to target size in MB. |
reindex | off | restart | core | Rebuild block index and chain state from block files on disk. |
reindexchainstate | off | restart | core | Rebuild the UTXO set from existing block files (Core -reindex-chainstate). |
checkblockindex | off (on for regtest) | restart | core | Audit block-index / active-chain consistency at startup (Core -checkblockindex). |
dbcache | 450 MB (or auto) | restart | core | Total write-cache size in MB, or auto for adaptive sizing. |
storageprofile | ssd | restart | satd | Storage class for chainstate tuning: ssd or hdd. |
prefetchworkers | CPU cores | restart | satd | Number of IBD prefetch worker threads. |
maxahead | 50000 | restart | satd | Max blocks ahead during IBD: number, N%, or all. |
maxopenfiles | 2048 | restart | satd | RocksDB max_open_files cap; -1 = unlimited. |
rocksdbbackgroundjobs | from storageprofile | restart | satd | Override RocksDB max_background_jobs (advanced). |
rocksdbsubcompactions | from storageprofile | restart | satd | Override RocksDB max_subcompactions (advanced). |
rocksdbwalmb | from storageprofile | restart | satd | Override RocksDB max_total_wal_size in MB (advanced). |
compactiondiagintervalsecs | 60 (0 disables) | restart | satd | Per-CF pending-compaction diagnostic log interval. |
compactionintervalsecs | 1800 (0 disables) | restart | satd | Periodic forced-compaction interval in seconds. |
compactionl0at | 16 | restart | satd | Force chainstate compaction when L0 SST count ≥ N. |
ibdl0pauseat | 64 (0 disables) | restart | satd | Pause the IBD connector when chainstate L0 SST count ≥ N. |
stallwatchdogsecs | 300 (0 disables) | restart | satd | Stall-watchdog forensic-dump threshold (seconds without tip advance). |
stallabortsecs | 300 | restart | satd | Additional grace after the forensics dump before abort(). |
shadowqueuesize | 4194304 | restart | satd | Shadow-verification queue capacity. |
shadowworkers | 4 | restart | satd | Shadow-verification worker threads. |
Mining
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blockmaxweight | 4000000 | restart | core | Maximum block weight for templates. |
blockmintxfee | 1000 sat/kvB | restart | core | Minimum tx fee for the block template. |
par | 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. |
Events
(satd-specific event bus. The eventszmq* spelling is satd's; Core uses
per-topic -zmqpub*=<addr> flags. The hashtx/hashblock payloads are
Core ZMQ wire-format compatible.)
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
eventsnodeid | auto (persisted to <datadir>/node_id) | restart | satd | Stable per-node identifier (32-char hex) stamped on events envelopes. |
eventsregion | none | restart | satd | Optional region tag (≤8 ASCII bytes) on events envelopes. |
eventsgrpcbind | off | restart | satd | host:port to bind the events gRPC streaming server. |
eventsgrpcallowremote | false | restart | satd | Permit eventsgrpcbind on a non-loopback address (requires eventsgrpcauth or eventsgrpcmtls). |
eventsgrpcauth | false | restart | satd | Require bearer tokens (stream:subscribe) on events gRPC (requires authfile). |
eventsgrpcmaxconns | 64 (0 disables) | restart | satd | Hard cap on simultaneously-open events gRPC connections. |
eventsgrpcmaxsubscriptions | 256 (0 disables) | restart | satd | Hard cap on concurrent events gRPC Subscribe streams. |
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. |
streamwsmaxsubscriptions | 256 | restart | satd | Hard cap on watch-set entries per streamws connection. |
streamwsmaxmessagebytes | 262144 | restart | satd | Cap on a single inbound WebSocket message/frame in bytes. |
streammaxresyncblocks | 10000 (0 disables) | restart | satd | Max blocks the watch matcher re-scans in one catch-up after lagging. |
streamprefixminbits | 8 | restart | satd | Minimum bit-length for a privacy-preserving script-prefix watch. |
streamprefixmaxbits | 32 | restart | satd | Maximum bit-length for a script-prefix watch (range [min, 32]). |
eventszmqbind | off | restart | satd | ZMQ endpoint for the events PUB sink. |
eventszmqhashtx | on when bound | restart | satd | Enable the Core wire-format hashtx topic. |
eventszmqhashblock | on when bound | restart | satd | Enable the Core wire-format hashblock topic. |
eventszmqmpevict | on when bound | restart | satd | Enable mpevict topic (mempool eviction w/ reason; JSON). |
eventszmqmpreplace | on when bound | restart | satd | Enable mpreplace topic (RBF replacement; JSON). |
eventszmqmpconfirm | on when bound | restart | satd | Enable mpconfirm topic (mempool tx confirmed; JSON). |
eventszmqnodeevent | on when bound | restart | satd | Enable nodeevent topic (full envelope JSON). |
Webhooks / notifications
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
blocknotify | none | restart | core | Shell command run on each new best block; %s is replaced by the block hash. Commands run serially on a dedicated subscriber task; a slow hook never stalls block connection, 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). Runs serially like blocknotify. |
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 connects blocks continuously and so never crosses the threshold. Cleared the moment a block connects — or, if 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). |
Metrics / health
| Key | Default | Reload | Compat | Description |
|---|---|---|---|---|
metricsport | none | restart | satd | Enable Prometheus /metrics + /healthz + /readyz on this port (unauthenticated). |
metricsbind | 127.0.0.1 | restart | satd | Metrics/health HTTP bind address. |
Unsupported Core keys: skipped vs rejected
A Core v30 option satd doesn't honor is handled
one of two ways so that an existing bitcoin.conf still drops in.
Skipped with a warning (the node still starts)
Recognized Core v30 options satd doesn't implement, but that are safe to skip,
are ignored with a startup WARN line; the node boots without them. The
warning names the satd equivalent where one exists. This covers the long tail:
| Key(s) | Warning guidance |
|---|---|
rest | satd ships native Esplora REST instead of Core's /rest/; enable with -esplora (on by default). |
zmqpub* (hashtx/hashblock/rawtx/rawblock/sequence + *hwm) | Core's per-topic ZMQ is replaced by the events bus (-eventszmqbind + -eventszmqhashtx/-eventszmqhashblock, Core wire-format). |
peerbloomfilters | BIP37 unsupported (privacy/DoS); use BIP157/158 (-blockfilterindex/-peerblockfilters). |
natpmp | satd doesn't implement PCP/NAT-PMP port mapping; configure port forwarding externally. (upnp was removed in Core v29 and is rejected as unknown, 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, bytespersigop, maxsigcachesize, blockversion, printpriority, txreconciliation, discover, persistmempoolv1, acceptstalefeeestimates, settings, daemonwait, deprecatedrpc, rpcdoccheck, … | Recognized Core v30 options satd does not implement; skipped (generic warning). |
Rejected at load (fail-closed)
A small set stays fatal, because silently skipping them would mislead you about the node's security, exposure, 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). |
rpcwhitelist, rpcwhitelistdefault | satd uses capability-scoped bearer tokens (-authfile); skipping would leave RPC less restricted than your Core config intends. See Authentication & Authorization. |
Typos
A key that is neither a satd option nor a known Core v30 option is rejected at
load as a likely typo. This is what stops a 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.