📄 Canonical release notes:
docs/release-notes/0.4.0.md
satd 0.4.0
Two major additions: an opt-in transaction-filtering/quarantine policy
language (policyfile=), and a substantially matured Streaming Consumption
API — including a published Rust SDK (satd-events-client), TLS/mTLS on the
events gRPC listener, bounded historical rescan, resilient reconnect-and-replay
watches, and descriptor match attribution. Also: getrawmempool verbose is no
longer O(N²), profilable release binaries, and a P2P listener bind failure is
now fatal at startup instead of silently degrading. All new surfaces are
opt-in — defaults stay Bitcoin Core-compatible.
Highlights
Transaction-filtering policy (opt-in)
satd gains an optional, total, statically-cost-bounded transaction-filtering
policy language. Point the node at a file with policyfile=/path/to/policy.txt
and it will quarantine the transaction shapes you describe — withholding them
from relay, from block templates, or both — without ever changing what the
node accepts as valid. Consensus is untouched by construction, and filtering
cannot prevent confirmation: a shape with economic demand confirms via other
paths regardless. The observability surfaces below let you watch that happen
in your own data.
- Invisible by default. Every standard mempool surface (
getrawmempool,
getmempoolinfo,getmempoolentry, Electrum, Esplora, the standard MCP
mempool tools) presents the acting class only — byte-identical whether or
not anything is quarantined. To a Core-compatible client the node looks
exactly like one whose relay policy refused the transaction. The whole canary
fleet passes unchanged with a policy active, which is the proof of this. - Scopes. A rule withholds along
relay(don't gossip/serve) and/or
template(don't mine, but still relay — "relay neutrally, decline to mine").
First-match-wins, withallowexemptions and infectious-ancestor propagation. - Live reload.
SIGHUPre-reads and recompiles the file, re-places the
whole mempool losslessly, and re-announces promotions on a bounded drain.
A compile error keeps the last-good ruleset. Removingpolicyfile=reverts to
baseline and promotes everything back. - Observability is the point. A dedicated, opt-in surface —
getpolicyinfo,
getquarantineinfo(per-rule rollup, a foregone-fees estimate, and a
confirmed-anyway counter),listquarantine,getquarantineentry,
policytest(thetestmempoolacceptanalogue), matching MCP tools, and
satd_policy_*Prometheus metrics — is the only place the quarantine class
appears. Validate files offline withsat-cli policylint [--explain]. - Lightning-enforcement danger gate (strict by default). A policy whose rule
would withhold relay for Lightning enforcement traffic — BOLT-3
commitment/justice/HTLC scripts, or a rule broad enough to sweep taproot-channel
force-closes — is refused at load (fatal at startup, last-good kept on
reload;policylintexits 3). Opt out withallowdangerousfilters=1. An
on templatematch warns but loads. This narrows the most common way a relay
filter silently degrades L2 enforcement (E1); it is a floor, not a guarantee —
see the manual.
The new Transaction-Filtering Policy
Operator Manual chapter carries the language reference, a posture-balanced
cookbook, the node-local consequences, and the network-scale effects (E1–E3)
verbatim; a companion contributor
design doc
explains the architecture, invariants, and rationale. Defaults are unchanged:
with no policyfile=, the node behaves exactly as before.
Fee estimation — accurate, monotone, and unified across every surface
Fee estimation was reworked end to end so every surface returns the same,
correct numbers:
- Monotone tiers. Smart-fee tiers could previously invert — e.g. the
"next block" rate showing below the "~30 min" rate. Each tier is now the
cheapest feerate that confirms within N blocks (a running minimum across
the simulated blocks), with a final clamp guaranteeing
High ≥ Medium ≥ Low ≥ economy. - One estimator behind every surface.
estimatefees,estimatesmartfee,
the TUI fee panel, the MCPestimate_feetool, the Esplora/fee-estimates
endpoint, and Electrumblockchain.estimatefeenow all resolve through a
single shared estimator (blend of mempool simulation and historical
percentiles), so they agree with one another.estimatesmartfeekeeps its
Bitcoin Core-compatible historical default and response shape. - Corrected a 4× fee over-report. Since 0.3.0, several Esplora/Electrum
presentation paths divided fee rates by the wrong unit and reported ~4×
the actual feerate: Esplora/fee-estimatesand/mempooltransaction
fee rates, and Electrumblockchain.estimatefee,blockchain.relayfee, and
mempool.get_fee_histogram. All now report correct sat/vB (Esplora) and
BTC/kB (Electrum). The JSON-RPC and TUI surfaces were always correct. - Robust full-block floor. A single cheap transaction slipping into the
tail of an otherwise-full block no longer drags the next-block estimate down
to the min-relay floor; full blocks use a weight-weighted low-percentile
floor, while partial blocks (where the whole mempool confirms next block)
still report the genuine minimum. - Bounded cost on public endpoints. The Esplora and Electrum fee endpoints
are unauthenticated by default; the mempool simulation behind them is now
served from a short-TTL shared cache instead of cloning the mempool and
re-simulating on every request.
Profilable release binaries
Release binaries are now built with frame pointers (-Cforce-frame-pointers=yes)
and line-table debug info, making them cheaply profilable in production with
perf -g — no DWARF, no rebuild. The frame-pointer cost is ~1% runtime; in
exchange a stuck or hot node can be diagnosed against the exact binary it's
running.
The shipped binary is still stripped, so the download size is unchanged. The
debug info is published as a separate per-target sidecar asset,
satd-<version>-<target>-debuginfo.tar.zst (Linux targets). To symbolize a
running node, drop the matching satd.debug next to the binary (or under
/usr/lib/debug); perf, gdb, and addr2line then resolve function names
and line numbers automatically via the embedded .gnu_debuglink. Source paths
in the debug info are remapped to a fixed prefix so they don't leak build-host
paths.
The sidecar is signed and checksummed exactly like the binary tarballs — it is
listed in SHA256SUMS and ships a minisign .minisig. Verify it before
use (minisign -Vm <sidecar> -P <pubkey>): debug info loaded by gdb can
trigger auto-loaded scripts, so hold a .debug you're about to load to the
same trust bar as the binary itself.
Mempool — getrawmempool verbose is no longer quadratic
Building the verbose mempool views — getrawmempool true,
getmempooldescendants, and getmempoolentry — computed each transaction's
ancestor and descendant rollups by scanning the entire mempool once per hop
of the traversal, and recomputed each transaction's txid (a double-SHA256) at
every hop. The result was O(N²) in the number of mempool transactions: a client
that polls verbose mempool on a timer (for example the sat-tui mempool pane)
could drive a CPU core to saturation, getting worse as the mempool filled.
Descendant traversal now follows the mempool's existing spend index — visiting
only actual children (O(descendants), not O(N) per hop) — shares a single
collect_children primitive with getmempoolentry's direct-children lookup,
and drops the redundant per-hop txid recomputation. The Txid- and
OutPoint-keyed mempool maps also switch from SipHash to a fast hasher: the keys
are already uniformly-distributed double-SHA256 hashes, so SipHash's
flooding-resistance was pure overhead. Output is byte-for-byte identical — this
is purely an internal performance fix, with no configuration or wire changes.
A single get_descendants call is now linear in the size of that
transaction's descendant subgraph, and chain-shaped mempools are linear overall
(the ancestor limit transitively bounds chain depth). The aggregate
getrawmempool true dump — which computes rollups for every transaction — can
still be superlinear for a pathologically wide, deep cluster, because satd does
not yet enforce a per-transaction descendant limit (only the ancestor limit
is enforced today). Bounding that worst case is tracked as separate follow-up
work alongside the broader mempool ancestor/descendant accounting.
Streaming Consumption API — prefix mempool spend-side prevout carriage
A privacy-preserving prefix subscription (§7.5) delivers the full matching
transaction inline, plus — on the spend side — the matched prevout(s). Confirmed
spends always carry the prevout scriptPubKey (and value) from undo data;
mempool spends previously carried only the outpoint with an empty script,
forcing a chainstate-less client to resolve it against its own UTXO set.
Under streamprevoutmeta = full the mempool spend side now carries the real
prevout scriptPubKey, so a sovereign / light client with no UTXO set can confirm
"a coin of mine was spent" entirely from the event — without resolving the
outpoint, which would re-leak the exact interest the bucket exists to hide. Under
the default amount tier the value is carried but the script stays empty; under
hash neither is. SpentPrevout gains amount + has_amount (the latter
distinguishes a genuine 0-sat prevout from "value not retained"). All additive —
the schema stays v1.
Streaming Consumption API — per-script min_value filter
AddScripts (gRPC and WebSocket) now accepts an optional min_values list,
parallel to scripthashes: a per-script satoshi floor below which matches are
suppressed server-side, so a watcher that only cares about economically
significant activity isn't woken by dust. An empty list means no floors; a
non-empty list must match the scripthash count exactly (a mismatch rejects the
add rather than silently delivering unfiltered). Re-asserting a watched
scripthash updates its floor in place (not a new quota item).
The floor is symmetric with both sides of ScriptMatched: it gates funding
matches on the output value and spend matches on the spent prevout's value.
The output and confirmed-block-input sides cost nothing (both values are already
resolved); the mempool spend side reads the value retained per
streamprevoutmeta (≥ amount, the default). On a node set to
streamprevoutmeta = hash there is no mempool-input value to test, so a floored
script's unconfirmed-spend matches fail closed (suppressed) rather than being
delivered unfiltered — the floor is still honored on that script's funding and
confirmed-block-input sides. The matched value never appears in the wire event —
the floor is purely a delivery filter.
This release also corrects a stale ScriptMatched.is_output proto comment that
read "input-side is future work": both funding and spending matches have shipped
(block-confirmed via undo data, mempool via retained prevout scripthashes).
Streaming Consumption API — mempool spend-side prevout retention (streamprevoutmeta)
The streaming watch matcher's mempool spend-side path needs prevout data the
spending input does not carry (the prevout's value and scriptPubKey). Because
that data is resolved at admission and then dropped, it can only be kept then —
there is no off-hot-path way to recover it later. A new mempool-policy key,
streamprevoutmeta, tunes how much is retained per mempool input, trading a
small amount of memory for matcher capability:
hash— scripthash only (32 B/input), byte-identical to prior behavior.amount(default) — adds the prevout value (8 B/input), the foundation for a
mempool-inputmin_valuefloor on spend-side script watches.full— adds the full prevoutscriptPubKey(variable, one heap allocation),
letting a chainstate-less client confirm a mempool prefix-bucket spend without
resolving any outpoint.
Retention is paid for every mempool entry regardless of whether anyone is
subscribed, so the default is the cheapest tier that makes the min_value filter
work out of the box (~sub-1 % of mempool memory); full is opt-in. The key is
live-reloadable via SIGHUP: a change governs subsequent admissions while
already-pooled entries keep what they were admitted with (mempool matching is
best-effort). The confirmed-block spend side is unaffected — it always recovers
full script and value from undo data. Note that this metadata is held alongside
the mempool and is not counted against maxmempool; under full (variable,
attacker-influenceable per-input script bytes) budget for it on top of the
configured mempool cap. See docs/api/streaming.md §12.1.
Streaming Consumption API — mid-stream SetCursor re-anchor
SetCursor on a live bidirectional gRPC Watch now re-anchors the firehose
instead of being a no-op. It replays confirmed history (cursor.height, tip] in
height order, drained to completion ahead of the resuming live tail
(drain-replay-resume, not an interleave); live events that accumulate during the
drain follow and may duplicate the replay tail, which the client de-duplicates by
(height, hash) exactly as at the establishment seam.
The point is that the watch-set and its quota leases are preserved across the
re-anchor: a long-lived Watch with a large registered watch-set can re-anchor its
replay position without the full re-add + re-auth + quota rebuild that reconnecting
with from_cursor would force. Historical watch matches are not replayed (they
are per-subscriber and not durable); the client reconstructs them from the replayed
blocks and receives live matches going forward. Concurrent re-anchors are bounded
to one in flight per stream, and each re-anchor is charged against the connection's
per-principal rate limit (the same bucket as watch-set adds), so neither overlapping
nor back-to-back SetCursors can pile up replay work. WS/SSE clients (no mid-stream
control channel) continue to re-anchor by reconnecting with ?from_cursor=.
Deterministic outcome (#439). Every actionable SetCursor now produces
exactly one in-band SetCursorResult event, so a consumer can tell "accepted and
replaying" from "ignored" instead of inferring it from the event flow — the
behavior at-least-once delivery pipelines need. CursorAccepted{from, clamped, earliest_replayed} is emitted ahead of the replay batch (with clamped
flagging that the requested cursor predated the 10,000-block replay window, so the
gap below earliest_replayed must be resynced from another source);
CursorRejected{reason, current_head} reports a re-anchor that did not run —
rate-limited, a drain already in flight, an empty cursor, or no block source — with
the server's current position. These were previously silent drops / no-ops.
satd-events-client surfaces them as Event::CursorAccepted /
Event::CursorRejected. See docs/api/streaming.md §7.3.1.
Streaming Consumption API — bounded historical rescan (RescanBlocks)
A new pull primitive on the bidirectional gRPC Watch stream complements the
forward SetCursor replay. RescanBlocks{from_height, to_height} scans this
connection's current watch-set against a closed, span-capped active-chain range
and returns the confirmed matches (ScriptMatched, OutpointSpent, TxidMatched,
PrefixMatched) in height order — where SetCursor replay synthesizes
BlockConnected but does not replay watch matches (the client reconstructs
them). This closes the streaming-watch-set cold-sync / beyond-MAX_REPLAY_BLOCKS
recovery gap without the client walking blocks itself.
The exchange is deterministic and in-band (mirrors SetCursorResult): one
RescanResult{Accepted{from, to, clamped} | Rejected{reason, tip_height}} ahead of
the matches, then a terminal RescanComplete{from, to, matches}. The rescan runs
the live matcher against an isolated single-subscriber registry (never leaks to
other connections), off the consensus hot path; it is rate-limited per principal,
admits one rescan in flight at a time (CONCURRENT_RESCAN), span-capped at
MAX_RESCAN_BLOCKS (= MAX_REPLAY_BLOCKS), and is a side query that does not
advance the durable cursor. satd-events-client exposes it as
ResilientWatch::rescan() / WatchHandle::rescan(), with Event::RescanAccepted
/ Event::RescanRejected / Event::RescanComplete. See
docs/api/streaming.md §7.6.
Rust SDK for the Streaming Consumption API (satd-events-client)
The Streaming Consumption API has a well-specified gRPC contract and a generated
tonic client — but the generated client is raw. Every Rust consumer that wanted
a watch stream hand-wrote the same boilerplate: an mpsc + ReceiverStream for
the bidi control side, authorization: Bearer … metadata on every call, cursor
capture and persistence, Lagged parsing and resume, reconnect with backoff,
and — for prefix watches — local re-derivation of sha256(scriptPubKey) to
filter the coarse bucket. satd-events-client is a new published crate that
absorbs all of it behind a small typed surface.
- Typed events and watches.
Eventis a flat enum mirroring the proto
oneof body, so consumersmatchinstead of unwrapping nestedOptions.
WatchHandlehas a typed helper for every watch kind — scripts (with
per-scriptmin_valuefloors), outpoints, txid lifecycle, single-shot depth
alarms, descriptors, and privacy-preserving prefixes — plusset_categories
and aset_cursorre-anchor whose outcome arrives as a typed
Event::CursorAccepted/Event::CursorRejected. The helpers hide the sharp
edges (e.g. lifecycle vs depth-alarm dispatch onmin_depths). - Durable, resilient firehose.
resilient_subscribewrapsSubscribein a
loop that reconnects with exponential backoff, persists the resume cursor
through aCursorStore(a file-backed impl ships; bring your own for a
database) so a process restart resumes from the stored height, recovers from
Laggedautomatically (LagPolicy::AutoResume), and detects the server's
10,000-block replay clamp — emitting a syntheticEvent::ReplayGapthat names
the skipped range instead of silently delivering a gap. - Privacy-preserving prefix re-filter.
PrefixWatcher(behind the default-on
bitcoinfeature) holds the consumer's real scriptPubKeys, decodes a
PrefixMatched's inlineraw_tx, recomputessha256(scriptPubKey)over every
output and retained spent prevout, and yields only true matches — with no
precise follow-up fetch that would re-leak interest. An unretained mempool
prevout surfaces asunresolved(resolve it yourself) rather than being
dropped as a non-match. - TLS / mTLS (default-on
tlsfeature). Builder methods encrypt the gRPC
transport so the bearer token — and the event stream — are never sent in the
clear:tls()(bundled Mozilla roots),tls_ca_pem()(pin a private /
self-signed CA, the usual satd-node case),tls_client_identity()(mutual TLS
against aneventsgrpcmtlsserver), andtls_domain()(SNI override for
connect-by-IP / proxy). Pinned to theringrustls provider — no aws-lc-rs / C
toolchain. Opt out withdefault-features = falsefor a plaintext-only build. - No server-dependency leak. The wire types were extracted into a thin
satd-events-protocrate shared by the node's gRPC server and this client, so
cargo add satd-events-clientpulls only the lean ergonomic layer (tonic,
prost,tokio,tokio-stream,thiserror, optionalbitcoin, and rustls
for TLS) — never thenodecrate or RocksDB. The SDK versions on the additive
v1wire schema, not the node's release cadence. Credentials and TLS key
material are redacted fromDebug.
This is the recommended way to consume the streaming API from Rust; non-Rust
consumers use the gRPC/WebSocket surface directly. See the new
Rust SDK Operator Manual
chapter and the runnable examples under satd-events-client/examples/.
Rust SDK — resilient Watch wrapper (resilient_watch)
satd-events-client gains ResilientWatch, the bidirectional-Watch twin
of the existing ResilientSubscription. The two recovery stories differ because
the Watch watch-set is per-connection — the server holds no principal-keyed
state, so when a Watch stream drops, its watch-set and quota leases are torn
down with it and a bare reconnect comes back blind. ResilientWatch closes that
gap:
- Watch-set mirror. It records every
add_*/remove_*/set_categories
the caller makes and re-registers the entire set on each reconnect — the same
typed helpers, now driven through the wrapper. - Re-anchor off the deterministic ack. After re-registering, it
set_cursors
to the persisted high-water and drives catch-up off the mid-stream re-anchor
result (above): a transientCursorRejected(RateLimited,
ConcurrentReanchor) is backed off and retried in place, while a
CursorAccepted { clamped: true, .. }or a terminal reject (NoSource) is
surfaced so a full resnapshot becomes the exception rather than the everyday
fallback. - Persistence + backoff reuse. It shares the
CursorStoreandBackoff
fromresilient_subscribe, committing confirmed cursors on-poll. - Watch-set loader (optional). Integrators whose watch-set has a durable
source-of-truth outside the wrapper (a DB, a config file, an upstream service)
can install aResilientWatchConfig::watch_set_loader. It rebuilds the
canonical set from that truth into a freshWatchSetBuilderon every
(re)connect, before the event stream resumes — closing the two gaps the
in-memory mirror leaves: an empty mirror after a process restart, and a mirror
that goes stale when the truth changes while the stream is down. A loader
failure is transient (StreamError::WatchSetLoader, backed off and retried on
the next connect). Purely additive; omit it and the mirror-replay behavior is
unchanged. - On-demand
reload(), via atomicSetWatchSet. The companion to the
connect-time loader for truth that changes while the stream is up (a bulk
import, an admin rotation, an operator reconciliation).ResilientWatch::reload()
re-runs the loader and pushes the freshly-loaded set as a single atomic
SetWatchSet— a new control message that carries the whole desired watch-set
and asks the server to become it. The server reconciles under its watch-set
lock by effective scripthash coverage (descriptors expanded): items in both
the old and new set — even one whose mechanism changed (direct ↔ descriptor) —
keep their registration and lease untouched, so the matcher sees no gap. The
replace is all-or-nothing on the whole target: over-quota is rejected whole
(WatchSetRejected{QUOTA_EXCEEDED}); a target with more entries than the
per-connection cap is rejected whole (WatchSetRejected{CAP_EXCEEDED}, applied
even to a no-auth connection so one frame can't exceed the cap); and — because
the snapshot is a full replacement — any unparseable element rejects it whole
too (WatchSetRejected{MALFORMED}) rather than being silently dropped and
shrinking the live set. This replaces an earlier client-computedAdd*/Remove*delta
whose ordering could either strand coverage or over-charge at quota on a
same-size swap. The deterministic outcome arrives in-band asWatchSetResult
(Event::WatchSetReplaced/WatchSetRejected { reason, .. }, mirroring
SetCursorResult). Returns aReloadSummary { added, removed, unchanged, applied }(advisory; theEventcounts are authoritative); a
disconnected reload defers to the next reconnect's loader rather than erroring.
The defensive ResilientSubscription event handler now also matches
CursorAccepted / CursorRejected explicitly, so a future carrier could not
mistake those control acks for confirmed data. See the
Rust SDK chapter.
Rust SDK — ResilientWatch::next is now cancel-safe across a re-anchor retry
Cancelling next() (e.g. losing a tokio::select! race to another channel)
while it was inside the transient-reject retry path for a set_cursor
re-anchor could burn a retry off the backoff budget without the re-anchor
ever reaching the wire, and drop the reject event itself — so a transient
RateLimited / ConcurrentReanchor rejection the wrapper is supposed to
absorb silently could instead surface to the caller after max_retries
phantom attempts. The retry is now recorded as resumable state and driven by
a cancel-safe path: a cancel mid-backoff or mid-send leaves the retry intact
and the next next() call resumes the same attempt without re-charging the
budget. Reconnects and explicit set_cursor calls both correctly supersede
a pending retry. SDK-internal fix — no wire or server-visible behavior
change.
Streaming Consumption API — matched amount on ScriptMatched
ScriptMatched gains amount / has_amount (gRPC and WS), at wire parity
with SpentPrevout: the funded output value for a funding match
(is_output = true) and the spent-prevout value for a spending match
(is_output = false). The value was already computed at every match site to
enforce the AddScripts min_value floor — it is now threaded through
instead of being dropped. Funding and confirmed-spend matches always carry
it; a mempool spend carries it under streamprevoutmeta >= amount (the
default), else has_amount = false. An exact-script Watch consumer can now
read the value directly off the match and skip the per-match
getrawtransaction enrichment call — and the cross-node "not found" mempool
race that call can hit. Additive and backward-compatible; existing consumers
ignore both fields. satd-events-client surfaces it as
Event::ScriptMatched { amount: Option<u64>, .. }.
Streaming Consumption API — optional raw_tx on ScriptMatched
A new opt-in, per-connection SetWatchOptions { include_raw_tx } control
message (mirroring SetCategories) lets a Watch consumer request the full
consensus-serialized matching transaction inline on every ScriptMatched,
at parity with PrefixMatched.raw_tx. It applies immediately and is
per-connection; re-send to toggle. For consumers that need the whole
transaction (e.g. multi-input net-of-change accounting) this removes the
getrawtransaction enrichment call entirely — the in-band amount above
already covers the common single-coin case, so raw_tx stays off by
default (it is bandwidth-heavy). The matcher only pays the serialization
cost when at least one connection has opted in, and the serialized bytes are
cached and shared across matches on the same transaction. satd-events-client
exposes WatchControls::set_watch_options / ResilientWatch::set_watch_options;
the opt-in replays on reconnect and reconciles correctly (including turning
back off) across reload().
Streaming Consumption API — events gRPC TLS / mTLS
The events gRPC streaming listener can now terminate TLS — and mutual
TLS — in-process, closing the last surface where a bearer token (and the event
stream) travelled in cleartext over a plaintext bind. It reuses the same
certificate, hot-reload (SIGUSR1), and mTLS client-cert / CN-SAN-allowlist
plumbing as the RPC, Electrum, and Esplora TLS surfaces (the shared tls-config
crate, ring provider). Setting eventsgrpctlscert + eventsgrpctlskey upgrades
the existing eventsgrpcbind listener to TLS in place — there is no separate
plaintext + TLS bind. eventsgrpcmtls=1 (with eventsgrpcmtlsclientca, and an
optional eventsgrpcmtlsclientallow CN/DNS-SAN allowlist) requires client
certificates; a remote bind (eventsgrpcallowremote) is now satisfied by mTLS as
well as by bearer auth. Cert/key/CA are validated at startup and the handshake is
bounded by eventsgrpctlshandshaketimeout (default 30s). New flags:
eventsgrpctlscert, eventsgrpctlskey, eventsgrpcmtls, eventsgrpcmtlsclientca,
eventsgrpcmtlsclientallow, eventsgrpctlshandshaketimeout. See
the Streaming chapter.
Streaming Consumption API — retire the never-emitted DescriptorNeedsAddresses event
NodeEvent.body field 15 (descriptor_needs_addresses) is removed from the
wire schema and its tag reserved. It was a deprecated gap-limit side-channel
from an early design in which the server tracked each descriptor's derivation
progress and nudged the client to extend its address window. That design was
dropped — gap-limit advancement is a client concern (the client owns its
address-generation policy and slides the [start, start + gap_limit) window
itself), and the server stays stateless. No server ever emitted the event, so
its removal changes no observable behavior. In satd-events-client the dead
arm that mapped it to Event::Unknown is gone, and the example/comment that
incorrectly implied the server pushed a top-up nudge are corrected. A genuinely
unrecognized future event arm still decodes to Event::Unknown, unchanged.
Streaming Consumption API — RemoveDescriptor and clean window management
SubscribeControl gains a RemoveDescriptor control message (field 12), and
the server now retains the descriptor → derived-scripthashes membership for
the connection instead of flattening a descriptor to scripts and forgetting it.
That makes descriptor windows manageable without the client juggling individual
scripthashes:
RemoveDescriptordrops a descriptor's whole window in one message,
releasing the scripts it contributed (and their quota units).- Sliding reconciles server-side. Re-sending
AddDescriptorfor the same
descriptor string with an advancedstartnow releases the scripts that left
the window and adds the ones that entered — the client no longer has to
RemoveScriptsthe trailing scripthashes by hand (which, for a multipath
descriptor, meant removing every branch's script per slid index). - Owner-counted release. Each scripthash tracks how many sources watch it (a
directAddScriptsand/or one or more descriptors); its quota unit and matcher
registration are released only when its last owner goes. A script shared by
two descriptors, or by a descriptor and a direct add, is never dropped while any
source still wants it.
This does not walk back the client-owns-gap-limit principle (above): the
server manages the window it is told to, but tracks no derivation progress and
emits no nudge — the client still decides when to advance start or
RemoveDescriptor. satd-events-client exposes it as
WatchHandle::remove_descriptor / ResilientWatch::remove_descriptor (the
resilient wrapper drops it from its replay mirror too). See
docs/api/streaming.md §11.
Streaming Consumption API — descriptor match attribution
ScriptMatched gains a descriptor_matches field (gRPC and WS): a match on a
descriptor-derived script now reports the descriptor(s) whose window contains it,
each with the exact (branch, derivation_index) the server derived it at. A
multi-descriptor consumer (one descriptor per account/xpub) can route a deposit to
the right account directly, instead of re-expanding its descriptors and
maintaining its own reverse scripthash → descriptor index — the work it
offloaded to the server by sending a descriptor. Empty for a directly-watched
script; multiple entries on overlap. Built on the per-connection descriptor
membership (above) via a reverse index.
branch is the 0-based BIP-389 multipath branch (<0;1> → external = 0,
change = 1; 0 for a single-path descriptor) and derivation_index is the absolute
index — the coordinate the server actually derived, ready to use with no
gap_limit arithmetic and correct for fixed and multipath descriptors alike
(a positional offset was not). The server still tracks no derivation progress —
it reports only where a matched script sits (the client-owns-gap-limit principle
holds; attribution is reactive, never a nudge). satd-events-client surfaces it
as Event::ScriptMatched { descriptors, .. } (a Vec<DescriptorMatch> of
{ descriptor, branch, derivation_index }).
See docs/api/streaming.md §11.1.
P2P listener bind failure is fatal at startup
With inbound P2P enabled (-listen=1, the default) or -whitebind configured,
a failure to bind the P2P port is now a fatal startup error instead of a log
line on a detached background task.
Previously the bind ran inside the spawned accept loop, so if the port was
already in use — almost always a second satd started against the same
datadir/port, or another process holding the port — the daemon logged an error
but continued running with no inbound P2P listener at all. It looked healthy
(RPC up, getblockchaininfo fine) yet was silently unreachable for inbound peers
and would only ever connect outbound. The bind now happens synchronously before
the accept loop is spawned; on failure satd prints a clear message and exits
non-zero, the same as the RPC and Esplora listeners. This makes the
"second instance on the same port" mistake fail loudly at start rather than
degrading the node invisibly.
Upgrade notes
Default behavior remains Bitcoin Core-compatible; estimatesmartfee and
estimatefees wire shapes are unchanged.
- Esplora/Electrum fee values drop ~4×. If you compare against the prior
releases,/fee-estimates, Esplora/mempoolfee rates, and Electrum
estimatefee/relayfee/get_fee_histogramnow report correct rates —
roughly a quarter of what 0.3.0 emitted. This is the bug fix, not a
regression; update anything pinned to the old (4×-inflated) values. - Electrum fee histogram now buckets sub-1-sat/vB transactions at integer
rate0(matchingromanz/electrs), where the 4×-inflated math previously
showed1–3. - New mempool config key
streamprevoutmeta(defaultamount). Adds ~8 B
per mempool input over prior releases to retain spent-prevout values for the
streaming watch matcher. Setstreamprevoutmeta=hashto restore the exact
prior memory profile (at the cost of mempool-inputmin_valuefiltering). - A P2P port collision now stops startup. If you currently start a second
satdagainst an in-use P2P port and rely on it coming up anyway (RPC-only,
no inbound P2P), it will now exit non-zero with a bind error. Set-listen=0
for an intentionally inbound-less node, or-port/-bindto pick a free port.
About this release
satd is free software under the MIT License.
- Stability & compatibility — Tier 1 surfaces (Bitcoin Core-compatible
JSON-RPC wire shape, CLI flag names/defaults,bitcoin.confkeys, on-disk
layout, Electrum/Esplora surfaces) are governed by
STABILITY_POLICY.md. satd follows
semantic versioning; while in the
0.xpre-1.0 line, deprecations may be accelerated (see the policy). - Security & disclosure — report vulnerabilities per
SECURITY.md. Consensus-affecting bugs are treated as
P0 with same-day acknowledgement. - Verifying this release — tarballs are signed with minisign, container
images with cosign (keyless OIDC), and git tags with SSH signatures (no GPG).
The key matrix and verification steps are in
SECURITY.md. - Intentional differences from Bitcoin Core — catalogued in
CORE_DIFFERENCES.md. - Operating satd — observability, the full flag matrix, tuning, and the
downstream packaging contract live in the
Operator Manual.
What's Changed
- docs(readme): fix Electrum protocol version, typo; add signet quickstart by @bkeroack in #375
- chore(release): open 0.4.0 dev cycle — bump version to 0.4.0-pre by @bkeroack in #376
- fix(fee): monotone smart-fee tiers — running-min over targets + ladder clamp by @bkeroack in #377
- fix(fee): unify all fee surfaces on shared smart_fees resolver + fix 4× unit bug by @bkeroack in #378
- fix(fee): robust per-block floor — trim cheap tail of full blocks (P10) by @bkeroack in #379
- chore(satd): rename
phase-cfeature tocore-block-differentialby @bkeroack in #385 - ci(actions): bump actions/cache from 3 to 5 by @dependabot[bot] in #383
- ci(actions): bump actions/setup-python from 5 to 6 by @dependabot[bot] in #384
- ci(actions): bump docker/setup-buildx-action from 3 to 4 by @dependabot[bot] in #382
- ci(actions): bump softprops/action-gh-release from 2 to 3 by @dependabot[bot] in #380
- ci(actions): bump actions/checkout from 4 to 6 by @dependabot[bot] in #381
- build(release): frame pointers + split-debug for profilable production binaries by @bkeroack in #388
- fix(mempool): make getrawmempool verbose O(N) instead of O(N²) by @bkeroack in #392
- feat(satd-policy): PR 1 — transaction-filtering policy language core by @bkeroack in #386
- feat(satd-policy): PR 2 — policy-file ruleset layer + first-match engine by @bkeroack in #387
- satd-policy PR 3: policylint (offline cost report, L2 advisory, --explain) by @bkeroack in #389
- satd-policy PR 4a: thread TxSource through the admission path (no behavior change) by @bkeroack in #390
- satd-policy PR 4b: quarantine-class mempool mechanics (no engine yet) by @bkeroack in #391
- PR 4c — the policy eval point (deferred standardness, infectious descendants) by @bkeroack in #393
- PR 4d — local-submission refusal + quarantine events by @bkeroack in #394
- fix(mcp): get_metrics_snapshot reports real address-index state (backport of #395) by @bkeroack in #397
- docs(manual): tighten operator manual — JSON-RPC Extensions chapter, drop planning-doc noise by @bkeroack in #402
- feat(node): PR 5 — assist-path gating (three-consumer scope split, §2.4) by @bkeroack in #398
- feat(node): PR 6a — ruleset reload re-placement (topological, lossless §8/I9) by @bkeroack in #399
- feat(satd): PR 6b — live policyfile reload handler + bounded promotion queue (§8) by @bkeroack in #400
- PR 7a — standard-surface invisibility (acting-only reads, §6.1/§10) by @bkeroack in #401
- PR 7b — policy observability RPCs + counters (§10) by @bkeroack in #403
- PR 7c — policy Prometheus metrics + MCP quarantine tools (§10) by @bkeroack in #404
- PR 7d — policytest dry-run (§10) by @bkeroack in #405
- PR 8 — policy Operator Manual chapter + changelog/notes/roadmap (§10) by @bkeroack in #406
- docs(satd-policy): contributor design & rationale doc by @bkeroack in #407
- feat(satd-policy): semantic LN-enforcement danger analysis (gate-grade) by @bkeroack in #408
- feat(sat-cli): policylint danger gate (strict by default + --allow-dangerous-filters) by @bkeroack in #409
- feat(node,satd): strict-by-default LN danger gate at policy load (--allow-dangerous-filters) by @bkeroack in #410
- docs(satd-policy): document the LN-enforcement danger gate + allowdangerousfilters by @bkeroack in #411
- streaming: mempool spend-side prevout retention (streamprevoutmeta) [PR-0 of stream-watch-filters] by @bkeroack in #412
- streaming: per-script min_value filter on AddScripts [PR-1 of stream-watch-filters] by @bkeroack in #413
- streaming: mempool-input min_value floor (fail-closed) [PR-2 of stream-watch-filters] by @bkeroack in #414
- streaming: prefix mempool spend-side prevout carriage (full tier) [PR-4 of stream-watch-filters] by @bkeroack in #415
- streaming: mid-stream SetCursor re-anchor on gRPC Watch [PR-3 of stream-watch-filters] by @bkeroack in #416
- docs(changelog): restore 0.3.1 release entry clobbered by stream-watch stack by @bkeroack in #418
- feat(satd-policy): transaction-filtering / quarantine policy DSL + LN-enforcement danger gate by @bkeroack in #417
- test(streaming): e2e coverage for min_value, prefix prevout carriage, SetCursor re-anchor by @bkeroack in #419
- fix(net): make P2P listener bind failure fatal at startup by @bkeroack in #421
- test(policy): e2e regtest suite for the transaction-filtering / quarantine engine by @bkeroack in #420
- docs(streaming): correct SetCursor proto doc to describe shipped re-anchor by @bkeroack in #422
- refactor(events): extract satd-events-proto codegen crate by @bkeroack in #423
- feat(satd-events-client): client skeleton — subscribe firehose + typed events by @bkeroack in #424
- feat(satd-events-client): typed watch ergonomics for all watch kinds by @bkeroack in #425
- satd-events-client PR-5: resilience — reconnect, replay, lag recovery by @bkeroack in #426
- satd-events-client PR-6: prefix-watch local re-filter (bitcoin feature) by @bkeroack in #427
- satd-events-client PR-7: examples, Operator Manual chapter, changelog by @bkeroack in #428
- satd-events-client PR-8: e2e suite driving a real satd through the SDK by @bkeroack in #429
- feat(satd-events-client): native TLS / mTLS for the gRPC transport by @bkeroack in #434
- feat(events): TLS / mTLS termination for the events gRPC listener by @bkeroack in #433
- docs(satd-events-client): publish-readiness — README, docs.rs metadata, MSRV, stability, TLS examples by @bkeroack in #435
- ci(actions): bump docker/login-action from 3 to 4 by @dependabot[bot] in #431
- ci(actions): bump actions/checkout from 6 to 7 by @dependabot[bot] in #430
- ci(actions): bump docker/metadata-action from 5 to 6 by @dependabot[bot] in #432
- fix(consensus): median-time-past must walk parent pointers, not the active-chain height index by @bkeroack in #436
- feat(events): deterministic mid-stream SetCursor re-anchor ack/reject (#439) by @bkeroack in #441
- fix(events): retire the never-emitted DescriptorNeedsAddresses event (#440) by @bkeroack in #443
- feat(events-client): ResilientWatch — reconnect + watch-set replay + deterministic re-anchor (#442) by @bkeroack in #444
- fix(events): expand BIP-389 multipath descriptors in the watch API (#445) by @bkeroack in #446
- ci(actions): bump actions/cache from 5 to 6 by @dependabot[bot] in #438
- feat(events-client): watch-set loader hook for durable-truth integrators (#447) by @bkeroack in #448
- feat(events): ResilientWatch::reload() via atomic SetWatchSet replace by @bkeroack in #450
- feat(events): RemoveDescriptor + server-retained descriptor membership by @bkeroack in #449
- feat(events): descriptor match attribution on ScriptMatched by @bkeroack in #451
- docs(manual): "Getting Started: Consuming Events" tutorial chapter by @bkeroack in #452
- feat(events): bounded historical rescan-against-watchset (RescanBlocks) by @bkeroack in #454
- fix(events-client): make ResilientWatch::next cancel-safe in the transient-reject retry path (#453) by @bkeroack in #455
- feat(events): expose matched amount on ScriptMatched (#456) by @bkeroack in #457
- feat(events): optional raw_tx on ScriptMatched via SetWatchOptions (#456) by @bkeroack in #458
- chore(release): 0.4.0 release-prep — doc gaps, backport v0.3.2 docs, publish SDK crates by @bkeroack in #459
Full Changelog: v0.3.0...v0.4.0