SatBooth dependencies and supply chain
Goal: as little third-party code as possible, and every line of what remains reviewed, pinned and vendored. The language is Go and SatBooth is self-hosted. Everything below was measured on 2026-11-02 against Go 1.27.3, the module proxy (proxy.golang.org), the OSV database (api.osv.dev) and the upstream sources named. Re-check before relying on it.
1. Let the nodes do the hard parts
Section titled “1. Let the nodes do the hard parts”The leanest dependency is the one we never need. Each rail already runs software that does the cryptography, so SatBooth calls it over RPC and holds no Bitcoin, Lightning or Monero crypto code itself.
| Need | Done by | Source |
|---|---|---|
| Validate a descriptor and detect private keys | bitcoind getdescriptorinfo (checksum, isrange, hasprivatekeys) |
src/rpc/output_script.cpp, bitcoin/bitcoin master |
| Derive the address for invoice n | bitcoind deriveaddresses <desc> [n,n] |
same file |
| Watch payments without being able to spend | a bitcoind wallet created with disable_private_keys=true, then importdescriptors |
src/wallet/rpc/wallet.cpp |
| Monero subaddress per invoice, incoming transfers | monero-wallet-rpc (view-only wallet) |
design.md §2.3 |
| Create and look up Lightning invoices | Alby Hub over NWC | design.md §2.1 |
| Check a claimant’s BOLT11 for a payout | Alby Hub, see §4 | transactions/transactions_service.go |
So SatBooth needs no BOLT11 decoder, no BIP-32 derivation and no Monero crypto. The one piece of bech32 it has is
the BIP-173 checksum in the claimant invoice’s syntax check (lninvoice, §5), which decodes nothing.
For scale: the Go BOLT11 decoder the hub itself uses, github.com/nbd-wtf/ln-decodepay, requires all of
lnd and with it etcd, gRPC, OpenTelemetry, Docker client libraries and three SQL drivers (its go.mod
for v1.13.0 lists 5 direct and 99 indirect modules).
2. What is left, and what covers it
Section titled “2. What is left, and what covers it”| Capability | Covered by |
|---|---|
| HTTP server and client, JSON, timeouts, size caps | stdlib net/http, encoding/json |
| JSON-RPC to bitcoind (cookie or basic auth) | stdlib |
JSON-RPC to monero-wallet-rpc (--rpc-login is HTTP Digest, which stdlib lacks) |
own Digest client (about 80 lines, RFC 7616 vectors). Built (plan 8): xmrrpc/digest.go is 224 lines with its comments, about three times the estimate, because it parses the challenge strictly (one grammar, a seeded property test and a fuzz target) and keeps the nonce state; the client around it, xmrrpc/client.go, is 429 lines (see §5) |
| Webhook signatures (HMAC-SHA256 with timestamp) | stdlib crypto/hmac, crypto/sha256 |
| Payout approvals from the admin system | stdlib crypto/ed25519, independent of the Nostr keys. Built (plan 9): approval (see §5) |
| Rate limits, token buckets, global caps | own code (small) |
| Logging | stdlib log/slog |
| Nostr event id and BIP-340 Schnorr signature | third party: btcec/v2/schnorr (see §3) |
| NIP-44 v2 encryption | own code on stdlib crypto/hkdf, crypto/hmac, crypto/sha256 plus x/crypto/chacha20 (see §3) |
| WebSocket client to the relay | third party: coder/websocket (see §3) |
| Storage | SQLite. Driver: ncruces/go-sqlite3 (pure Go); see §6 |
| QR code on the checkout page | own copy of rsc.io/qr’s encoder, rendered to SVG on the server (see §6) |
What the Go 1.27 standard library does not have, checked with go list std: secp256k1 or Schnorr
(crypto/ecdh has only P-256, P-384, P-521 and X25519), a public ChaCha20 (it exists only as
vendor/golang.org/x/crypto/chacha20, which cannot be imported), a WebSocket client, and SQLite.
crypto/hkdf is public.
3. The proposed third-party set (measured)
Section titled “3. The proposed third-party set (measured)”A probe module that imports exactly btcec/v2/schnorr, coder/websocket and x/crypto/chacha20 builds
with CGO_ENABLED=0 -trimpath and pulls in 13 non-std packages from 6 modules, 16,720 vendored lines
of Go (non-test files of the vendored packages):
| Module | Version (date) | Its go.mod requires | OSV records | Non-test Go lines (whole module) |
|---|---|---|---|---|
github.com/btcsuite/btcd/btcec/v2 |
v2.5.1 (2026-10-21) | chainhash/v2, decred secp256k1/v4; test-only: testify, go-spew |
none | 4,128 (schnorr/: 619) |
github.com/decred/dcrd/dcrec/secp256k1/v4 |
v4.4.1 (2026-02-24) | decred/dcrd/crypto/blake256 only |
none | 7,509 |
github.com/btcsuite/btcd/chainhash/v2 |
v2.0.0 | |||
github.com/decred/dcrd/crypto/blake256 |
v1.1.0 | (compiled in because btcec’s schnorr imports decred’s schnorr package; unused by us) | ||
golang.org/x/crypto (only chacha20, internal/alias) |
v0.58.0 (2026-10-09) | x/sys (only on some CPU architectures for chacha20) | 58 records (GO ids plus their GHSA aliases), none in chacha20: they name ssh (and agent, knownhosts), openpgp/*, salsa20/salsa, cryptobyte, acme/autocert |
chacha20/: 534 |
golang.org/x/sys (only cpu) |
v0.49.0 (2026-10-06) | none | 2 advisories, 3 ids: GO-2022-0493 = GHSA-p782-xgp4-8hr8 (x/sys/unix, fixed 2022) and GO-2026-5024 (x/sys/windows, fixed v0.44.0); none in cpu; all fixed before v0.49.0 |
cpu/: 3,011; only built on s390x; no shipped target (linux/amd64, linux/arm64, darwin/arm64) builds it, so it is not on the allowlist |
github.com/ncruces/go-sqlite3 (only driver, vfs and the helper packages they import) |
v0.36.0 (2026-10-29) | go-sqlite3-wasm/v6, julianday, x/sys. The driver’s own go.mod also lists ncruces/sort and ncruces/wbt, but the packages we import (driver, vfs) do not need them, so they are not in our module graph. internal/testenv (wasm2go harness hooks, nil variables) is compiled in via sqlite3_wrap/wrap_sys.go; the rest are optional-extension or test-only modules |
none | 12,256 |
github.com/ncruces/go-sqlite3-wasm/v6 |
v6.4.35401 (2026-10-29) | ncruces/wasm2go v0.4.19 and golang.org/x/tools v0.52.0 as tool requirements (not built or vendored) |
none | 160,604 (SQLite 3.54.1 machine-translated by wasm2go, plus a 103 KB sqlite3.dat data file) |
github.com/ncruces/julianday |
v1.0.0 (2023-10-18) | none | none | 124 |
golang.org/x/sys (now also unix; memlock imports it directly since plan 5, for Mlockall and Prctl, so it is no longer only the driver’s) |
v0.49.0 (2026-10-06) | none | see the row above; none in unix at v0.49.0 |
unix/: 191,946 (one package, only the part for the target OS is compiled) |
github.com/coder/websocket |
v1.8.15 (2026-06-15) | none | none | 4,524 |
Added in plan 2 (vendored): ncruces/go-sqlite3 (driver, vfs and three helper packages), go-sqlite3-wasm/v6,
julianday and x/sys/unix, for the ledger (§6 item 1). OSV was queried for each module at its version
on 2026-11-02 (POST api.osv.dev/v1/query): no records. As with any SQLite, an empty result means little,
because SQLite’s own CVEs are filed in NVD; the pinned SQLite is 3.54.1. The SQL is ours and parameterised,
and the database file is never attacker-supplied.
Added in plan 3 (vendored): coder/websocket v1.8.15 (ISC-style license, LICENSE.txt), the WebSocket
transport of satbooth-relay, with its three internal packages (bpool, errd, util). Its go.mod requires nothing.
OSV (POST api.osv.dev/v1/query, 2026-11-02): no records for the module, with or without the version. The standard
library has no WebSocket server or client, and RFC 6455 framing, fragmentation, control frames and the close
handshake are a network-input parser we would rather not own. The relay uses Accept with default options (the
Origin check stays on; the hub sends no Origin header, and a request without one passes), Read, Write, Ping
and Close.
Added in plan 1 (vendored): btcec/v2 (with schnorr), decred secp256k1/v4, chainhash/v2 and
blake256, and x/crypto/chacha20 with internal/alias (NIP-44). On s390x, chacha20 also imports x/sys/cpu (vendored, not built on other architectures).
Why these and not the alternatives:
- Schnorr:
btcec/v2/schnorris the BIP-340 code used by btcd and lnd. Decred’s ownschnorrpackage is not BIP-340 (it implements Decred’s variant). The alternative is our own BIP-340 on top ofdecred secp256k1/v4primitives, checked against the official vectors (bitcoin/bips/bip-0340/test-vectors.csv). It would drop three packages but make SatBooth own signing code for a key that can spend the payout budget. SatBooth uses btcec. - WebSocket:
coder/websockethas no dependencies and no OSV records.gorilla/websocket(last release v1.5.3, 2024-06-14) has two records, the latest from 2026-08: a weak PRNG for the mask key. Writing our own RFC 6455 client is possible, but it is a network-input parser with fragmentation, control frames and close handshakes. Recommendation:coder/websocket. - NIP-44 versus NIP-04: the hub accepts both (
nip47/cipher/cipher.go:"nip44_v2 nip04"). NIP-04 would dropx/cryptoentirely (AES-CBC is stdlib), but it is deprecated and has no authentication at the cipher layer. NIP-44 v2 only, own code, tested against the official vectors (paulmillr/nip44/nip44.vectors.json). SatBooth contains no NIP-04 code. - Not a Nostr library.
github.com/nbd-wtf/go-nostrv0.52.3 requires 34 direct and 54 indirect modules (badger, lmdb, fasthttp, sonic, four SQLite drivers, markdown, etc.). SatBooth needs four NIP-01/NIP-47 message shapes, which we write ourselves.
4. Payout checks: what the hub enforces, and what that rests on
Section titled “4. Payout checks: what the hub enforces, and what that rests on”From getAlby/hub at commit 921003b (the v1.24.6 release), SendPaymentSync in
transactions/transactions_service.go:
-
If
pay_invoicecarriesamountand the invoice has an amount, they must be equal, or the hub refuses (“amount cannot be specified for an invoice that already has an amount”). If the invoice is amountless, the hub pays ouramount(SatBooth refuses amountless claimant invoices anyway: below). So SatBooth always sendsamount= the approved amount, and the hub pays exactly that or nothing. -
An expired invoice is refused.
-
A second payment of the same payment request is refused when one is settled (“this invoice has already been paid”) or pending (“there is already a payment pending for this invoice”). That answers the “check the pending case” note in design.md §2.1.
-
The routing fee is not settable over NWC: the hub caps it at max(1 %, 10 sats) (
CalculateFeeReserveMsat, passed to LDK asMaxTotalRoutingFeeMsat). The connection’s budget counts amount plus that reserve. -
Paying one of the hub’s own invoices is not refused: the hub intercepts and settles it internally (
interceptSelfPayment). Because the amount must match, this is an accounting anomaly (a refund “paid” with no money leaving, an order “paid” with no money arriving), not theft. SatBooth refuses it: first by comparing against every invoice string it has issued (lowercased), then withlookup_invoiceon theinvoiceconnection, where a result of type incoming means ours. “Found” alone isn’t enough: for a connection that isn’t isolated,LookupTransactionsearches every app’s rows, so a claimant’s invoice that we already paid is found too, as outgoing. The local string compare is the primary check because the hub’s own self-payment test matches the payment hash and the exact payment request (SendPaymentSync). -
lookup_invoicedecodes the given invoice only to get its payment hash and searches the hub’s own rows. For a claimant’s invoice it says “not found” until we pay it. After a timeout, SatBooth looks the payment up withlookup_invoice {invoice}on thepayoutconnection instead of paying again. -
A second payment is not refused by the hub once the first row is
failed, and afailedoutgoing row can settle later if LDK reports success (transactions_service.go:913-927, 967). Only LDK’s own duplicate check stands then (ldk-node HEADsrc/payment/bolt11.rs:102-108; its payment records are deleted after 24 h,lnclient/ldk/ldk.go:2161-2195). Plan 4 could not produce this path on regtest (15 stops and kills, allpending), so it is unverified.
The qualified hub. ghcr.io/getalby/hub:v1.24.6@sha256:4bb27770ff2b68ab8342f75909dfcb59db0ca8f903045967e44c908b7f6f85f7
(the multi-arch index digest). The tag v1.24.6 points at commit 921003b, so the source citations in this
section and the image are the same code. The qualification suite asserts the facts marked observed below and passed in
full against this image; the timings and counts quoted are from the earlier v1.24.4 run (qualify/REPORT.md).
| Fact the client relies on | Source (getAlby/hub 921003b) |
Observed (qualification) |
|---|---|---|
Payment errors are INTERNAL with text; the hub never sends PAYMENT_FAILED; unknown method is INTERNAL |
nip47/controllers/map_nip47_error.go:12-32, constants/constants.go:60-72, nip47/event_handler.go:335-346 |
the texts quoted above, and “received payment failed event: RecipientRejected” |
| NIP-44 notifications are kind 23197; a NIP-04 23196 copy is also sent | nip47/models/models.go:12-13, nip47/notifications/nip47_notifier.go:137-145, 190-197 |
not observed (the client received a notification 0.08 to 0.12 s after the payment started, but the kind was not checked here) |
Notifications go to every non-isolated app with the notifications scope |
nip47_notifier.go:108-116 |
in part: an invoice made through the hub’s own API produced payment_received on the invoice connection, none on the payout connection (no notifications scope) |
| A failed notification publish is logged and dropped, never retried; the queue is in memory | nip47_notifier.go:137-145, 224-231, nip47/nip47_service.go:69-86, notifications/nip47_notification_queue.go:20-33 |
no payment_received when the test looked, right after the hub’s first answered get_info (5.3 to 5.6 s after the relay came back) and again after one lookup_invoice; a longer wait was not measured, so “no retry” holds for those seconds only, and lookup_invoice said settled; the queue after a hub restart was not measured |
lookup_invoice and list_transactions search all apps’ rows for apps that are not isolated |
transactions_service.go:606-640, 682-684, controllers/lookup_invoice_controller.go:62, list_transactions_controller.go:54 |
in part: an invoice made through the hub’s own API was found by lookup_invoice on the invoice connection; list_transactions not tested |
list_transactions filters and orders by updated_at; default only SETTLED |
transactions_service.go:686-692, 730-745 |
not tested |
expired is synthesized for any unsettled row past expires_at, outgoing too |
nip47/models/transactions.go:28-31, transactions_service.go:399-403 |
incoming row read expired; an outgoing one was not produced |
| Budget 0 means no budget; usage is amount + fee + fee reserve of settled and pending rows | transactions_service.go:1196, db/queries/get_budget_usage.go:11-23, 44-45, get_budget_controller.go:47-54 |
reserve max(1%, 10 sat); over-budget is QUOTA_EXCEEDED; a cancelled hold invoice returns the budget |
pay_invoice waits for LDK and has no hub timeout |
lnclient/ldk/ldk.go:662-712 |
answers in 0.09 to 0.15 s on a direct channel; the 50 s LDK limit was not measured |
The hub ignores expiration; it drops pay_invoice and make_invoice requests older than 6 h silently |
event_handler.go:348-360 |
not tested |
| A request from a key with no app gets no response | event_handler.go:73-82 |
no response; the client timed out at 15 s |
| Request ids are deduplicated in the database; only the newest 1000 are kept | db/migrations/202407151352_autoincrement.go:37, event_handler.go:57-66, service/service.go:168-178, 297-326 |
a replay after a hub restart got no answer and the log line “Event already processed” |
get_info without the get_info scope omits network and block height |
nip47/controllers/get_info_controller.go:69-72 (the guard HasPermission(app, GET_INFO_SCOPE)); the node fields, network and block height among them, are filled only inside it (74-105). SatBooth depends on this: the start-up check of the network (plan 14) needs the scope on both of its connections, so both pairing links ask for it. The network is the hub’s own string, with only bitcoin mapped to mainnet (get_info_controller.go:91-95; LDK reports its NETWORK setting raw, so a hub set to testnet runs a signet node and states testnet). With the scope, get_info calls the node and can fail (get_info_controller.go:78-89), which refuses the start with the same reason as a timeout |
confirmed |
The wallet pubkey is per app; a supplied pubkey makes the hub return no secret; the hub refuses a supplied pubkey that is not 64 lowercase hex digits forming a valid x-only key |
service/keys/keys.go:102-103, 145-147, apps/apps_service.go:81-99, 148-158 |
not observed |
| The admin API shares one limiter, 1 request/s, burst 2 | http/http_service.go:100-114, 178 |
not observed |
This is hub behaviour beyond NIP-47, so it is a coupling. SatBooth is qualified against one pinned
hub version, and scripts/qualify.sh runs on every hub upgrade, before that upgrade goes into service.
What it asserts (qualify/REPORT.md): a pay_invoice whose amount differs from the invoice’s is refused
by the hub (“WalletError codes and messages as observed”), and a claim with such an invoice is refused by
SatBooth at claim with no hub call (TestRefundPay); an expired invoice and a second payment of a settled
invoice are refused (“WalletError codes”), and so is a second payment while one is pending (“Payouts in
flight”); an invoice the hub already knows ends the refund invoice_known (TestRefundHubKnown); SatBooth’s
own invoice is refused at claim (TestRefundOwnInvoice). Amountless invoices cannot be qualified: the
pinned hub cannot issue one (its MakeInvoice refuses an amount below 1 sat,
transactions/transactions_service.go:180-181 at 921003b, on both POST /api/invoices and NWC
make_invoice). So SatBooth refuses an amountless claimant invoice at claim (invalid_invoice, asserted by
refund.TestClaimInvoiceAmount), deny by default, although the hub source above would pay one our amount.
SatBooth itself checks the claimant string’s syntax: lowercase, lnbc (or the configured
network’s prefix), bech32 character set, a length cap, no lightning: prefix and no whitespace. The one
field it reads is the amount in the human-readable part: an invoice that carries no amount or an amount other
than the approved one is refused at claim (invalid_invoice), so it never reaches the hub’s refusal above. Both get
seeded property and fuzz tests.
No sub-wallet. The payout connection gets a lifetime budget (renewal
never) that a person raises in the hub when it runs low. That is the same hard cap as a refund float,
without a funding payment for each top-up. An isolated sub-wallet’s balance is what has been paid into
it minus what it has paid out (db/queries/get_isolated_balance.go).
5. Our own code, and how each piece is tested
Section titled “5. Our own code, and how each piece is tested”| Code | Why it is ours | Tests |
|---|---|---|
NIP-01 event serialisation ([0,pubkey,created_at,kind,tags,content]) and id |
Go’s encoding/json cannot produce it: even with SetEscapeHTML(false) it writes U+2028/U+2029 as
/
and replaces invalid UTF-8 with U+FFFD (tested on 1.27.3). Either changes the id. |
Seeded fuzz over every character class; ids and signatures checked against events the hub itself emitted |
| NIP-44 v2 | A few dozen lines over stdlib and chacha20 |
All official vectors, including the invalid ones; fuzz for padding and length bounds |
| NIP-47 request/response/notification types | Four or five message shapes | Fuzz the decoders; every response built from an allowlist of fields |
Descriptor intake (descriptor) |
Must refuse private keys before anything reaches bitcoind. Base58Check is about 50 lines over math/big and crypto/sha256, decode only, so no module is added |
A hand-written allowlist scanner (only wpkh(...) and tr(...) with one xpub/tpub key, an optional key origin and a /0/* path), not a blocklist and not a regexp; a private-material scan (xprv-family prefixes, base58 runs that decode as extended private keys or WIF) runs first over the raw bytes; then hasprivatekeys must be false; then the wallet is created with disable_private_keys. No capability beyond the standard library: the package has none in docs/capslock.json. Tests: BIP32 test vector 1 (xpub, xprv), BIP32 test vector 5 (invalid keys), SLIP-132 versions, WIFs and testnet keys derived from vector 1 and pinned by derivation tests, 5,000 seeded round trips, 5,000 mutants, 5,000 key-character mutations that must all fail, FuzzParse and FuzzDecodeCheck |
Refund approvals (approval) |
The approval and void layouts are SatBooth’s own (threat model §4), and stdlib crypto/ed25519 signs and verifies them, so no module is added. The configured keys are also checked against libsodium’s list of small-order Ed25519 public-key encodings (seven 32-byte values, re-typed as data from libsodium 1.0.18, ISC; ACKNOWLEDGEMENTS.md): with such a key configured, a forged signature verifies for any message |
The spec vectors (approval/testdata), the small-order list refused as keys, a forgery with a small-order key refused, a seeded property test (TestProperty) and FuzzVerify over every field and length; floats kept out (rates/nofloat_test.go) |
Claimant invoice syntax check (lninvoice) |
The hub checks the invoice itself (§4); SatBooth only refuses, before it stores it, a malformed string, another network’s, or one that does not carry exactly the approved amount: the human-readable part and a BIP-173 checksum (polymod constant 1, no 90-character limit) over the bech32 set, and the amount in the human-readable part (AmountMsat), about 180 lines on the standard library. Nothing in the data part is decoded |
BOLT 11’s example invoices as test vectors (lightning/bolts, CC-BY-4.0; ACKNOWLEDGEMENTS.md), with signet and regtest re-encodings; BIP-173’s checksum vectors; a seeded property test (TestProperty) and FuzzCheck over every boundary (length, case, the separator, the network codes, the checksum); TestAmountMsat, the seeded TestAmountMsatProperty against a math/big oracle and FuzzAmountMsat (each multiplier, a p amount that is not a whole msat, a leading zero, zero, 2^53 ± 1 msat, the longest digit run) |
bitcoind JSON-RPC client and result decoders (btcrpc) |
Core speaks JSON-RPC 1.0 over HTTP with named params and basic auth: net/http and encoding/json are enough, and a library would add a dependency to the money path. Own code gives a method allowlist (the rpcwhitelist set, refused before any request), an error type that keeps the code and drops bitcoind’s message (it can echo a key back), exact decimal amounts (ParseBTC, never float64) and per-entry dropping of odd listsinceblock entries. Capslock: NETWORK (the client) and FILES (the password file read, 0600, no symlink); the other classes (ARBITRARY_EXECUTION, REFLECT, RUNTIME, SYSTEM_CALLS, UNANALYZED, UNSAFE_POINTER) come from net/http, encoding/json and os, as in nwc and store |
httptest server for the request shape, errors, timeout and an 8 MiB + 1 body; every typed call against Core-shaped answers; seeded round-trip of 5,000 amounts, FuzzParseBTC, FuzzParseSignedBTC, FuzzDecode (envelope and every decoder), FuzzLoadPassword, FuzzValidators, and a mutation sweep over the canned answers |
Fiat pricing (rates) |
An exact decimal (an integer of units at scale 10^18 on math/big), the one conversion per base unit (Convert, a ceiling over big integers, with Sats for BTC and, since plan 8, Piconero for XMR), the median and spread rules, five small JSON parsers (Kraken, Coinbase, Yadio, the custom API and, since plan 8, Bitfinex) and a background fetcher with a cache, per asset (BTC, and XMR with the monero block). Money must never pass through floating point, and the client must follow the outbound rules of threat model §10 (HTTPS only, no redirect, no proxy, bounded body, a fixed User-Agent): math/big, net/http, crypto/tls and encoding/json from the standard library are enough, so no module is added. Capslock: NETWORK (the client), plus the set every package that logs through log/slog carries and RUNTIME from context.WithTimeout |
Seeded property tests and fuzz targets for every parser (FuzzParseDecimal, FuzzParsePercent, FuzzSats, FuzzConvert, FuzzParseKraken, FuzzParseCoinbase, FuzzParseYadio, FuzzParseCustom, FuzzParseBitfinex); recorded live answers as fixtures, checked against each other (TestFixturesAgree); the client against httptest servers and the fake rate server rates/ratestest (redirects, statuses, timeouts, a 256 KiB + 1 body, and a sentinel that must never reach an error); the median, spread and freshness at every boundary; the fetcher under -race with stalled, failing and lying providers; a source scan that keeps floats out of rates, api, store, checkout, rail, xmrrpc and monero; and a by-hand live test (build tag live) |
monero-wallet-rpc JSON-RPC client and HTTP Digest (xmrrpc) |
wallet-rpc speaks JSON-RPC 2.0 over HTTP with Digest authentication only (epee’s http_server_auth: MD5, qop=auth), and the standard library has no Digest client; crypto/md5, net/http and encoding/json are enough, so no module is added. Own code gives a method allowlist (the eight methods SatBooth calls, refused before any request), one request in flight (epee keeps the Digest nonce and counter per connection, http_protocol_handler.h:190 and :227 at v0.18.5.4, and the client keeps one state for its pooled connections, so one call at a time keeps its nc in step), one retry on a 401 and never more, a refusal of a wallet-rpc that asks for no login, an error type that keeps the code and drops wallet-rpc’s message, exact integer amounts (never float64), per-entry dropping of odd get_transfers entries, and the address shape rule (never decoded). It imports btcrpc for the URL and user-name rules only; the password is loaded by service with btcrpc.LoadPassword. Capslock: the same set as btcrpc and rates: NETWORK (the client) and the classes net/http and encoding/json bring; FILES there is capslock’s over-approximation (an io.Writer call inside encoding/json that it resolves to the SQLite driver’s writer), as in rates, not a file read |
RFC 2617 §3.5 and RFC 7616 §3.9.1 (MD5) vectors; wallet-rpc v0.18.5.4’s real 401 header lines, recorded into xmrrpc/testdata/challenge.txt, served by an httptest server that checks Digest as epee does, but with one nonce and counter for all connections, which is stricter than epee’s per-connection state (nc equal to its counter, stale on an old nonce); 50 concurrent calls with no overlap; the allowlist, one retry, a stale nonce, redirects, an 8 MiB + 1 body, timeouts, the proxy environment and every rendering of a Client (the password and the Digest state sit two pointers away; Config holds no secret), and a raw listener whose malformed answers must reach no error text; every typed call against wallet-rpc-shaped answers; a seeded property test of 10,000 challenges against a reference grammar, round trips of 20,000 amounts and addresses at every boundary; FuzzParseChallenge, FuzzTransfers, FuzzValidAddress, FuzzEnvelope; and a source scan that keeps floats out (rates/nofloat_test.go) |
The rules two watched rails share (rail) |
Plan 8 moved every rule the on-chain rail had that Monero needs too (tiers, the payment predicates and sums, the reversal plan, the final event, the paid events, the ledger records of a rail and the walk over a payment’s epochs, the rail’s view and the peer facts) out of onchain, so each money rule has one definition. Standard library and SatBooth’s store, ledger and lnpay only; no module. Capslock: none of its own; it carries onchain’s set through the lnpay import and the events callback |
onchain’s own tests, unchanged, as the proof that the move changed nothing; table tests of every predicate, sum, the final-event rule, the reversal plan and the peer facts at their boundaries; TestTiersProperty and FuzzTiers (moved from onchain); the paid events and their replay (TestPaidEvents, TestPaidEventsReplayLogsNothing); the crash tests of both rails, which run through it |
The Monero rail (monero, monero/monerotest) |
wallet-rpc does the Monero cryptography (§1); what is left is SatBooth’s own: the subaddress index protocol (record before showing, store after create_address, each index above every recorded one), the startup checks, the watcher over get_transfers and get_transfer_by_txid, and the settle over the rail rules with Monero’s ledger. Standard library and SatBooth packages only; no module. monero/monerotest is an in-memory wallet-rpc that answers as v0.18.5.4 does (heights, the pool, double_spend_seen, a locked payment, a pop, a forgotten subaddress), imported by tests only. Capslock: monero has onchain’s set, all of it the store, ledger, slog and context artefacts described in §7; it opens no file and no socket (the xmrrpc client is passed in as monero.RPC). monerotest has no entry |
The money paths against monerotest: replay (a payment found twice gives one row and one transfer), double pay (two payments, one transaction to two orders, both rails at once), timeouts and an outage of each method, restarts between discovery, mark and transfer, a crash at every hook point of the address and of the pass, reorgs before and after paid, a pool drop, 0-conf and its double spend, partial, over-, late and locked payments, the window’s end, a stale peer holding the final event, and the pause on a wrong wallet; FuzzTransferIDs and FuzzConfigValidate beside seeded property tests; the whole service on regtest (qualify/, “Monero end to end”) |
| Webhook signer, rate limiter | small | Property tests (timestamp window, constant-time compare, bucket bounds) |
6. Decisions
Section titled “6. Decisions”-
Storage: start with SQLite. If a large setup ever needs more, TigerBeetle is the choice, not Postgres: as good as Postgres is, it is not a financial-grade ledger. Driver:
ncruces/go-sqlite3v0.36.0, pure Go (SQLite 3.54.1 translated to Go by wasm2go): no cgo.mattn/go-sqlite3needs cgo, which would break plan 1’sCGO_ENABLED=0static, reproducible builds.mattn/go-sqlite3(cgo) stays the alternative. All driver specifics live inledger/open.go, so swapping drivers means editing that one file.SatBooth shapes its money tables after TigerBeetle’s model from day one, so a later move is a copy, not a redesign. Its reference docs (
docs/reference/transfer.mdandaccount.mdin tigerbeetle/tigerbeetle, read 2026-11-02) define immutable transfers between two accounts on one ledger, each with a unique non-zero 128-bit id and an unsigned 128-bit amount; corrections are new transfers, never edits or deletes; pending transfers resolve at most once. In SQLite that means:- integer amounts in base units (msat, sat, piconero), one ledger per asset;
- a unique id per transfer, so a retried write is refused instead of doubled;
- no
UPDATEorDELETEon money rows, enforced by triggers; - an order’s paid or refunded state derived from its transfers.
The options:
- SQLite via
mattn/go-sqlite3(v1.14.52, 2026-09-05, no Go dependencies, no OSV records). Caveats: it embeds about 286,000 lines of SQLite C that we won’t review line by line (SQLite’s own testing is the reason to trust it); it needs cgo, which costs theCGO_ENABLED=0static, bit-for-bit reproducible build unless we pin the C toolchain (a build container pinned by digest); and the empty OSV result means little, because SQLite’s CVEs are filed in NVD, not the Go database. All SQL is our own and parameterised, and the database file is never attacker-supplied, so most SQLite CVE classes are out of reach. - Our own append-only log (one file, a checksum per record, fsync per write, state rebuilt in memory at start). It is fully reviewable, and the log is the audit log. But we would own fsync semantics, torn-tail recovery, compaction, consistent backups and format migration, for money state. It would need crash-injection, corrupt-tail and replay-idempotence tests.
- Postgres via
jackc/pgx/v5(v5.12.0): a network database, 6 direct dependencies, and 10 OSV entries (about five distinct issues) including SQL injection via protocol message size overflow (2024), placeholder confusion with dollar-quoted strings (2026) and memory safety (2026). modernc.org/sqlite: 11 modules of transpiled C.- Result: SQLite via
ncruces/go-sqlite3(see the driver note above).
-
Schnorr:
btcec/v2/schnorr. See §3. -
Payout cap: a lifetime budget on the
payoutconnection, not a sub-wallet. See §4. -
monero-wallet-rpc auth: our own Digest client, because wallet-rpc may not share a host with SatBooth. Built (plan 8):
xmrrpc, no module added (§2 and §5). -
QR code: server-side SVG from our own copy of the
codingandgf256packages ofrsc.io/qr(Russ Cox, BSD-3, v0.2.0 from 2018, about 1,050 lines, stdlib only, no OSV records; its cgolibqrencodepackage is not copied). The QR standard is frozen, so the old release date isn’t a risk for code we own and review. The BSD-3 notice stays with the copied files. With no QR script, the checkout page can use a strict content security policy.- What is in
qr/(plan 5):coding/qr.go,gf256/gf256.goandgf256/gf256_test.gofromrsc.io/qrv0.2.0 (module hashh1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=, checked against the checksum database before copying) and itsLICENSE. Changed: thegf256import path and the two import comments; each file carries a header naming its source. Not copied: the module’slibqrencodepackage (cgo), its tests that use it,png.goand the top-levelqr.go.qr/svg.gois ours: it picks alphanumeric or byte mode, the smallest version at level M, mask 0 (asrsc.io/qrdoes, no penalty scoring) and draws one<path>; the input is at most 1,200 bytes. - Tests: three golden symbols from ZXing’s own unit test (Apache-2.0; see
qr/testdata/SOURCE.txt), compared module by module (alphanumeric, numeric and byte mode). The ISO/IEC 18004 Annex I symbol could not be transcribed reliably. Also a structure property test (finder and timing patterns, format and version BCH codes) over 500 seeded inputs, a round trip of the SVG path, andFuzzSVG. No scanner is run in CI; the vectors and the property test are the evidence that the symbols decode.
- What is in
7. Process controls (whatever the final set)
Section titled “7. Process controls (whatever the final set)”go.modpins thetoolchain. Builds use-trimpath, so the same toolchain and flags give the same binary; with cgo, the C toolchain must be pinned as well.go mod vendoris committed, so every dependency line is in the repo and every upgrade is a diff that gets reviewed like our own code. CI builds with-mod=vendorandGOPROXY=off, so nothing is fetched.go.sumis checked against the public checksum database (sum.golang.org) when a version is added.- A dependency allowlist gate (
scripts/check.sh): CI fails whengo list -deps -testshows a non-std package that isn’t on the list, for each shipped target (linux/amd64, linux/arm64, darwin/arm64). The seed list is the 13 packages in §3. govulncheck(golang.org/x/vuln, v1.9.0;scripts/tools.shinstalls it,scripts/vulncheck.shruns it) in CI, on each Go point release and on a schedule. It reports only vulnerable symbols that are reachable, which is why thex/crypto/sshrecords above don’t concern us.capslock(github.com/google/capslock, v0.3.4, 2026-10-14) reports what each dependency can do (network, files, exec, unsafe, reflection). Its report is committed asdocs/capslock.json;scripts/capslock.sh(also run byscripts/check.sh) fails when a capability is added,scripts/capslock.sh updaterewrites it, and an upgrade that adds a capability needs a reason in the PR. The baseline is generated for linux/amd64. TheCAPABILITY_ARBITRARY_EXECUTIONentries fornip44andchacha20come from chacha20’s assembly stubs (capslock treats assembly as arbitrary execution), not from exec; on linux/amd64 the same applies to the blake256 AVX assembly (cpuid,blocksAVX), and itsinitreads an environment variable (CAPABILITY_READ_SYSTEM_STATE). Adding the SQLite driver (plan 2) added these capabilities toledger:FILES(the database file, viaos),SYSTEM_CALLS(x/sys/unix),UNSAFE_POINTER(go-sqlite3-wasm/v6,sqlite3_wrap),ARBITRARY_EXECUTION(sqlite3_wrap: body-less//go:linknamedeclarations inwrap_vfs.go, which capslock cannot analyse; not assembly),RUNTIME,REFLECTandUNANALYZED(database/sql,util). Measured on the driver’s own packages:vfsadditionally showsREAD_SYSTEM_STATE, not reachable fromledger. NoNETWORKand noEXECanywhere in the driver or its dependencies, which is the check that matters here. Adding the relay (plan 3,relaywithcoder/websocket) addedNETWORKtorelayandcoder/websocket: the HTTP listeners and the WebSocket upgrade. That is the expected new capability. Where the other newrelaycapabilities come from, counted indocs/capslock.jsonby the last function of each path:RUNTIME:runtime.SetFinalizerincoder/websocket(24 of 33 paths: 23 end in(*Conn).close, which clears the finalizer,conn.go:158, and 1 innewConn, which sets it), andruntime.Goexitvia the standard library (the other 9).FILES:os.FileWriteandClose(theslogtext writer,net/http) and oneos.Open.READ_SYSTEM_STATE: oneos.Getenv, the existing blake256 initialiser.UNANALYZED:errors.Is,sync.Poolandio.ReadAll(19 of 43 paths go throughcoder/websocket).SYSTEM_CALLS,UNSAFE_POINTER,REFLECTand 25 of 27ARBITRARY_EXECUTIONpaths: call-graph artefacts. capslock resolves an interface call (anio.Writerorio.Closer) to every implementation in the program, including the SQLite driver’s, sorelayappears to reachx/sys/unix.Getpagesizeandgo-sqlite3-wasm.relaydoes not import the driver orledger. The other twoARBITRARY_EXECUTIONpaths are the blake256 assembly, as above. The binarycmd/satbooth-relay(packagemainin the baseline) has the capabilities ofrelayplusMODIFY_SYSTEM_STATE(os/signal.NotifyContext, for SIGINT and SIGTERM); itsFILESpaths are theos.Openof the configuration file, and itsARBITRARY_EXECUTIONpaths are the same artefacts and the same blake256 assembly. The new baseline also listsNETWORKforledger, for the same reason: capslock resolves interface calls indatabase/sql(context cancel) and the SQLite driver’sClosetonetandnet/httptypes, which are now present in the program. The imports ofledgerdid not change, and it imports no network package. Addingnwc(plan 4, wire format only:encoding/json,nostr,nip44) adds these capabilities tonwc, counted indocs/capslock.jsonby the first and last function of each path.nwcitself opens no file and no socket. The paths start at its entry points:BuildRequest,buildRequest,OpenResponse,openResponse,openEvent,openNotification,decodeResponse, the parsing helpers (checkJSON,parseObject,parseArray,parseTransaction,txFromFields,parseWalletError,rawString,reqString,optString,optStringStrict,optStrings) andnwc.init.NETWORK(2 paths,net/http.body.Close),FILES(2,os.File.Close),RUNTIME(2,runtime.Goexit),SYSTEM_CALLS(2,x/sys/unix.Getpagesize) andUNSAFE_POINTER(2,go-sqlite3-wasmload32): artefacts, all fromBuildRequestandbuildRequestthroughjson.Marshalof the request. It flushes through anio.Writer, and capslock resolves that interface call to every implementation in the program, among themnet/httpand the SQLite driver.nwcimports neither. (The client, added next, is covered by its own entry below.)UNANALYZED(18):errors.Is(15) andio.ReadAll(3), reached from every entry point above exceptnwc.init, which has none, throughDecoder.Token,json.Unmarshal,json.Marshal,nostr.VerifyandPublicKeyHex.REFLECT(15): none of it reflects overTransaction. 2 paths arejson.Marshalof the request (transformMarshalError), 7 areDecoder.Token(reflect.TypeFor[float64]), 6 arejson.Unmarshalinto a map or string (reflect.MakeMap).ARBITRARY_EXECUTION(7): 6 go throughnip44.ConversationKey,EncryptandDecrypt, wherecrypto/hkdfandhmaccall ahash.Hashthat capslock resolves to blake256’sHasher224.Write, henceblocksAVX(an interface-resolution artefact, as above;nip44uses SHA-256). The 7th isnwc.inittonostr.initto the schnorr initialiser, ending at thecpuidassembly stub.READ_SYSTEM_STATE(1):nwc.inittonostr.inittoos.Getenv, the blake256 initialiser reading an environment variable. A real read, run at start-up. Adding the client (plan 4,nwc/client.go: one WebSocket per relay throughcoder/websocket’sDial,Read,Write,PingandCloseNow) takesnwcfrom 51 paths to 242 indocs/capslock.json; no other package changed. The entry points are now alsoDial,dial, everyClientmethod,supervise,runSession,call,write,pinger,sendREQ,onEvent,onResponse,onNotification,drop,parseOKandframeEvent,frameAuth,frameWith. The client opens no file. Its only network operations are those WebSocket calls to the one configured relay URL.NETWORK(2 to 31): 15 end atnet.netFD.connect$1through context cancellation (context.WithCanceltoafterFuncCtx.cancel; capslock resolves the cancel callback to everyAfterFuncuser in the program, among themnet’s dial), 7 are the real ones,(*websocket.Conn).CloseNowandclosetonet/http.loggingConn.Close(capslock’s resolution of thenet.Conninterface: the close is real, the type is not), 5 are thenet/http.body.Closeartefact above and 4 arenet.IP.AppendTextthroughslog. capslock does not followDialitself, so the real network I/O is not named by any path.FILES(2 to 23): 18os.File.Writethrough thesloghandler, whichConfig.Logcan point at a file (it is the caller’s writer, not ours), and 5os.File.Close(the SQLite artefact).RUNTIME(2 to 31): 22runtime.SetFinalizer, all ending in(*websocket.Conn).close, which clears the finalizer (conn.go:158), and 9runtime.Goexit.UNANALYZED(18 to 45),REFLECT(15 to 37):errors.Is,io.ReadAll,sync.Pool,bufio.Writer.Flush,encoding/jsonmarshalling and decoding of the relay frames and of the requests.ARBITRARY_EXECUTION(7 to 28),SYSTEM_CALLS(2 to 23),UNSAFE_POINTER(2 to 23): the same artefacts as before, now reached from the new entry points: 24ARBITRARY_EXECUTIONpaths end in blake256’sblocksAVXorcpuid, 4 in the SQLite driver’svfsCheckReservedLock; allSYSTEM_CALLSend atx/sys/unix.Getpagesizeand allUNSAFE_POINTERatgo-sqlite3-wasmload32, via interface calls capslock resolves to the driver.nwcdoes not import the driver.READ_SYSTEM_STATE(1): unchanged. The baseline also records two newnostrFILESpaths (os.OpenFileandos.RemoveinWriteNewSecretKeyFile, added in the key generation commit before this one, whose baseline had not been updated).nostr.Signcounts the signed JSON length by hand rather than callingjson.Marshal, to keepnostr’s baseline free of theNETWORK,RUNTIME,SYSTEM_CALLSandUNSAFE_POINTERartefacts thatencoding/json’s encoder would add. Addingcmd/satbooth-pair(plan 4; packagemainin the baseline, ascmd/satbooth-relay; importsnostr,flag,net/url) adds 10 paths indocs/capslock.json, all frommain,run,loadOrCreateorinit. No other package changed. It has noNETWORK,EXECorMODIFY_SYSTEM_STATEpath: the link is printed, never fetched.FILES(3):os.LstatinloadOrCreate, which decides between loading and creating the key file. The reads and the create are real too (LoadSecretKeyFile,WriteNewSecretKeyFile) but capslock names them undernostr.UNANALYZED(3):errors.IsinloadOrCreate, which tests for a missing file.REFLECT(2):flag.FlagSet.ParsetoPrintDefaultstoreflect.New, the standard library’s usage printing.READ_SYSTEM_STATE(1) andARBITRARY_EXECUTION(1):main.inittonostr.initto the blake256 initialiser (os.Getenv, thecpuidassembly stub), the artefacts listed above fornwc.init.- Adding
setup(plan 12: the setup command’s logic,cmd/satbooth-setup; standard library and SatBooth packages only, no module) adds one package entry todocs/capslock.json. It listsNETWORK, likealert,ledgerandstore, and for the same reason: itsencoding/jsonencodes reach anio.Writercall that capslock resolves to every writer type in the program, the SQLite driver’s andnet/http’s among them.setupimports no network package and opens no socket. The command analysed alone (./cmd/satbooth-setup, where that resolution cannot arise) hasARBITRARY_EXECUTION,FILES,READ_SYSTEM_STATE,REFLECTandUNANALYZEDand noNETWORK;scripts/capslock.shruns that analysis for it and forcmd/satbooth-pair(both ship in the tools image) and fails ifNETWORKappears, if capslock fails or if it prints no capability at all. serviceandcmd/satboothgainMODIFY_SYSTEM_STATEunder the labelLOGGING(plan 13):service.Runsets the standard logger’s flags and output (log.SetFlags,log.SetOutput) once, to a writer that logs a fixed line. The standard logger is wherenet/httpwrites on its own (a client logs the first bytes a server sent on a connection with no request pending), and a webhook receiver could put its URL’s token into SatBooth’s standard error that way. No module is added and no other capability changes.- Adding
qr,qr/codingandqr/gf256(plan 5) adds three package entries todocs/capslock.jsonand no capability: all three have none (bytes,errors,strconv,fmtand arithmetic only). - Adding
store(plan 5: orders, invoices and the webhook outbox on the SQLite driver, opened asledgeris) adds one package entry with 262 paths indocs/capslock.jsonand no capabilityledgerdoes not already have: the same eight (FILES,NETWORK,RUNTIME,SYSTEM_CALLS,ARBITRARY_EXECUTION,UNANALYZED,UNSAFE_POINTER,REFLECT), each reached fromOpen,openDB,migrate,writeand everyStoremethod throughdatabase/sqland the driver. They are the driver artefacts described forledgerabove, not new behaviour:storeimports no network package, runs no program and opens only its one database file (via the driver). By the last function of each path:FILES(33):os.File.Close.SYSTEM_CALLS(33):x/sys/unix.Munmap.ARBITRARY_EXECUTION(33): the driver’svfsAccess(body-less//go:linkname, not assembly).UNSAFE_POINTER(33):go-sqlite3-wasmload16/load32.NETWORK(33): 31 end atnet.netFD.connect$1(context cancellation,context.WithCanceltoafterFuncCtx.cancel, which capslock resolves to everyAfterFuncuser in the program) and 2 atnet/http.body.Close(an interface call).RUNTIME(33): 23runtime.SetFinalizerand 10runtime.Goexit.UNANALYZED(33): 32errors.Is, 1bufio.Writer.Flush.REFLECT(31): 29reflect.Value.SetZero(database/sqlscanning) and 2json.transformMarshalError(an interface call;storedoes not useencoding/json).- No
EXEC, noMODIFY_SYSTEM_STATEand noREAD_SYSTEM_STATE.
- Adding
limits(plan 5; importserrors,fmt,math,sync,time) adds one package entry and no capability todocs/capslock.json: no file, network, exec or reflection path. - Adding
lnpay(plan 5; importsstore,limits,ledger,nwc,log/slogand the standard library) adds one package entry with 246 paths indocs/capslock.json(36 direct, 210 transitive) and no capabilitystoreandnwcdo not already have. It opens no file, socket or program itself: it reaches the wallet only through itsWalletinterface and the databases only throughstoreandledger. By the last function of each path:- The driver artefacts of
storeandledger(New,Pay,Runand the reconciler call their methods):FILESos.File.Close(17),SYSTEM_CALLSx/sys/unix.Munmap(29) andGetpagesize,ARBITRARY_EXECUTIONvfsAccess(29),vfsCheckReservedLockand the blake256 initialiser’scpuid,UNSAFE_POINTERload16/load32(30),RUNTIMESetFinalizer(29) andGoexit(2),REFLECTreflect.Value.SetZero(29),UNANALYZEDerrors.Is(25) anderrors.As(6). slog: the thirteenFILESpaths ending inos.File.Writeand threeNETWORKpaths ending innet.IP.AppendTextare the logger’s handler writing a record and formatting an attribute;lnpaylogs only fixed strings and opaque order ids to the*slog.Loggerit is given. OneREFLECTpath ends inencoding/json.transformMarshalErrorandUNANALYZEDhassync.Pool.Get.NETWORK(28):net.netFD.connect$1, reached throughcontext.WithCancelandWithTimeout(capslock resolves the cancel to everyAfterFuncuser in the program), as forstore.lnpaydials nothing: thenwcclient is passed in.READ_SYSTEM_STATE(1) and thecpuidpath above:lnpay.inittonwc.inittonostr.initto the blake256 initialiser, the same chainnwcalready has.- No
EXECand noMODIFY_SYSTEM_STATE.
- The driver artefacts of
- Adding
webhook(plan 5; importsstore,log/slog,net/http,crypto/hmac,crypto/sha256and the standard library) adds one package entry with 32 paths indocs/capslock.json(16 direct, 16 transitive) and no capability thatstoreandlnpaydo not already have, but it is the first package of ours that makes an HTTP request. By the last function of each path:NETWORK(4 direct):net/http.NewRequestWithContext, in(*Deliverer).post, reached fromRun,RunOnceandattempt. This is real: the deliverer POSTs to the one configured webhook URL (http or https, no user info), through anhttp.Clientwith a 10 s timeout and redirects off, reading at most 4 KiB of the answer. It dials nothing else.FILES(4 direct):os.OpenFileinLoadSecret, which reads the webhook secret from a 0600 file (regular file, no symbolic link, at most 130 bytes); and three(*os.File).Writepaths through thesloghandler, as forlnpay.webhooklogs only fixed messages, opaque event ids and an HTTP status, never the URL, the secret or an error text that could contain them.UNANALYZED(5 direct):io.Copy(4; draining the limited response body intoio.Discard) andio.ReadAll(1; the secret file, limited to 130 bytes). capslock does not analyse either.REFLECT(3 direct):encoding/json.Marshalof the four-field event payload, throughtransformMarshalError.ARBITRARY_EXECUTION(7 transitive):blake256.blocksAVX, the artefactnwcalready has:hmac.HMAC.Sumcalls ahash.Hashthat capslock resolves to blake256’sHasher224.Write.webhookuses SHA-256.RUNTIMESetFinalizer(3),SYSTEM_CALLSx/sys/unix.Munmap(3) andUNSAFE_POINTERload16(3), all transitive: thestoredriver artefacts, as forlnpay.- No
EXEC, noMODIFY_SYSTEM_STATEand noREAD_SYSTEM_STATE.
- Adding
api(plan 5; importsstore,lnpay,net/http,crypto/sha256,crypto/subtle,encoding/jsonand the standard library) adds one package entry with 72 paths indocs/capslock.json(39 direct, 33 transitive) and no capability thatstore,lnpayandwebhookdo not already have. By the last function of each path:NETWORK(6 direct):net/http.MaxBytesReaderand(*net/http.response).Header, the request body cap and the response headers.apidials nothing and makes no outbound request: it is a handler, served on the private listener bycmd/satbooth.FILES(3 direct):os.OpenFileinreadKeyFile, reached fromLoadKeysandReload. It reads the API key file: a regular file (symbolic links refused) with mode exactly 0600, at most 64 KiB, holding only SHA-256 hashes of the keys. Errors are fixed strings and never contain the file’s content. Six transitive(*os.File).Closepaths are the same file’s close.UNANALYZED(15 direct, 1 transitive):io.ReadAll(3; the key file, limited to 64 KiB, and the request body, limited byMaxBytesReaderto 16 KiB),errors.Isanderrors.As(10), and two(*bufio.Writer).Writepaths from the response writer. capslock does not analyse them.REFLECT(15 direct):encoding/jsondecoding and encoding of the request body, the key file and the nine-field response (reflect.TypeFor,reflect.MakeMap,transformMarshalError).ARBITRARY_EXECUTION(7 transitive):cpuid(blake256) viainit, andsqlite3_wrap.vfsAccessandvfsCheckReservedLockviastore, as forlnpay.READ_SYSTEM_STATE(1 transitive,os.Getenvfrominit),RUNTIME(6),SYSTEM_CALLS(6) andUNSAFE_POINTER(6): thestoredriver andnwcinitialiser artefacts, as forlnpay.- No
EXECand noMODIFY_SYSTEM_STATE. - The same update also recorded two
NETWORKpaths ofwebhook(noProxyTransport,(*net/http.Transport).Clone) that the previous commit’s baseline lacked; they are the deliverer’s own HTTP client, already described above.
- Adding
checkout(plan 5; importsstore,lnpay,limits,qr,net/http,html/templateand the standard library) adds one package entry with 71 paths indocs/capslock.jsonand no capability thatstore,lnpayandapido not already have. It is a handler, served on the public listener bycmd/satbooth; it dials nothing, opens no file and runs no program. By the last function of each path:NETWORK(14): 10 direct, fromnet/http.NewServeMux(2),(*net/http.response).Header(7, the response headers) andnet/http.MaxBytesReader(the 1 KiB cap on the pay request body); and 4 transitive:(*ServeMux).ServeHTTPand threenetFD.connect$1paths, the interface-call artefacts described forstore(checkoutdials nothing).REFLECT(7):(*text/template.Template).execute(the page template, with embedded fixed text) and 6reflect.Value.SetZero(database/sqlscanning, viastore).UNANALYZED(13):errors.Is(8),io.WriteString(4) and(*bufio.Writer).Write: error tests and the response writer.FILES(7),RUNTIME(7),SYSTEM_CALLS(7),UNSAFE_POINTER(7),ARBITRARY_EXECUTION(8),READ_SYSTEM_STATE(1): thestoredriver andnwcinitialiser artefacts, as forapi(FILESis(*os.File).Close5 andWrite2, reached throughstoreandlnpay.Pay);checkoutitself touches no file (the page and the stylesheet are embedded).- No
EXECand noMODIFY_SYSTEM_STATE.
- Adding
serviceandcmd/satbooth(plan 5;serviceimports every package above,log/slog,net,net/http,os/signal,reflectandencoding/json;cmd/satboothis packagemainin the baseline, as the other commands) adds one package entry with 96 paths and 19mainpaths indocs/capslock.json; each update also drops older paths that the new call graph no longer reaches. The new capability isMODIFY_SYSTEM_STATE/SIGNALS(1 each):os/signal.Notify, fromservice.Notify, which subscribes the binary to SIGHUP, SIGINT and SIGTERM. NoEXEC. By the last function of each path:NETWORK(13 inservice, 2 inmain): the two listeners and their servers:(*net.ListenConfig).Listen(the configured public and private addresses),(*net/http.Server).Serve(3) andClose,net/http.NewRequestWithContext(the webhook deliverer and the NWC dial, reached throughRun), andnetFD.connect$1and(net.IP).AppendText(the interface-call artefacts described forstoreandnwc). Outbound calls go only tonwc.relay_urlandwebhook.url; the service has no other dialler. (Since plan 13 the webhook receivers are theurlof eachwebhooksentry, one deliverer per entry; the dialler is the same.)FILES(12 inservice, 2 inmain):os.MkdirAll(prepareDataDir:data_dir, 0700),os.Open(the configuration, also inmain) andos.OpenFile(the configuration and the key, API-key and webhook-secret files, throughLoadConfig,CheckFilesandRun),os.Lstatand(*os.File).Write/Close(thenostrkey loader and the store driver).servicewrites no file itself.REFLECT(14 inservice, 2 inmain):reflect.TypeFor[float64]andjson.transformMarshalError(the logger ofnet/http’s error log,fixedWriter, among them), from the configuration’sencoding/jsondecoding and the key walk incheckKeys, and oneSetZerofromdatabase/sql.SYSTEM_CALLS(9),UNSAFE_POINTER(9),ARBITRARY_EXECUTION(10),RUNTIME(10),UNANALYZED(17) andREAD_SYSTEM_STATE(1): thestore,ledgerandnwcinitialiser artefacts described above (Munmap,load16/load32,vfsAccess,blocksAVX/cpuid,SetFinalizer/Goexit,errors.Is/io.ReadAll/io.WriteString,os.Getenv), reached throughRun.- Package
mainshows the same set throughrunandinit, plusos/signal.Notifyinmain.
- Adding
memlock(plan 5:mlockall,PR_SET_DUMPABLEandRLIMIT_CORE0 at start-up) adds one package entry todocs/capslock.jsonwith one path,SYSTEM_CALLS(memlock.Protecttox/sys/unix.Mlockall). That one is real: it is the point of the package. capslock does not listPrctlorSetrlimit.memlockopens no file and no socket.servicegainsservice.Lock(andReadConfig, the first half ofLoadConfig):SYSTEM_CALLSthroughmemlock.Protect(real), andFILES,NETWORK,RUNTIME,ARBITRARY_EXECUTION,UNANALYZED,UNSAFE_POINTER,REFLECTthroughLock’sslogcalls andReadConfig’sos.Open/io.ReadAll/json.Decoder.Token: the same artefacts as the existingRunpaths (sloghandler writing to anio.Writer, resolved to every implementation in the program). - Adding
btcrpc(plan 6: the bitcoind client; importsnet/http,encoding/json,osand the standard library) adds one package entry with 141 paths todocs/capslock.json. The real ones:NETWORK(15 direct:net/http.NewRequestWithContext, the POST to the one configured node, and(*net/http.Client).CloseIdleConnectionsinZero),FILES(os.OpenFileinLoadPassword: the password file,O_NOFOLLOW, mode 0600 or stricter),REFLECT(27:encoding/jsonencoding the request and decoding the answers) andUNANALYZED(28:errors.Is/errors.As). The rest are the artefacts described above:ARBITRARY_EXECUTION(14, the driver’svfsCheckReservedLock),FILES(14,(*os.File).Close),RUNTIME(14,Goexit),SYSTEM_CALLS(14,Getpagesize) andUNSAFE_POINTER(14,load32), reached through interface calls;btcrpcimports neither the driver nornwc. NoEXECand noMODIFY_SYSTEM_STATE.descriptor(plan 6) has no entry: capslock found no capability in it. - Adding
onchain(plan 6: addresses and the startup checks; importsbtcrpcfor its types only,descriptor,store,ledger,lnpay,log/slogand the standard library) adds one package entry with 87 paths todocs/capslock.json, all of them artefacts of calls through interfaces and of the packages it imports; none is a path of its own. The watcher takes it to 260 paths, in the same classes, with no new one: thestoreandledgerdriver artefacts (SetFinalizer,errors.Is,Munmap,vfsAccess,SetZero,load16, about 27 paths each),(*os.File).Write(26) and(net.IP).AppendText(8) throughslog, andnetFD.connect$1(26) through context cancellation. It dials nothing: thebtcrpcclient is passed in asonchain.RPC, andlnpayasonchain.Lightning. It opens no file and runs no program. By the last function of each path:NETWORK(12):(*net.netFD).connect$1(10, reached throughcontext.WithCancel/WithTimeoutAfterFuncinterface calls and through thestoreandledgercalls) and(net.IP).AppendText(2, fromslog’s attribute formatting): the artefacts described forstoreandservice.onchainhas no socket.FILES(10),RUNTIME(12),SYSTEM_CALLS(10),UNSAFE_POINTER(10),ARBITRARY_EXECUTION(11),UNANALYZED(11),REFLECT(10) andREAD_SYSTEM_STATE(1): thestore,ledgerandnwc(vialnpay) driver and initialiser artefacts described above.- No
EXECand noMODIFY_SYSTEM_STATE. onchain/onchaintest(the in-memory bitcoind for tests; standard library andbtcrpctypes only) is imported by tests only, and capslock found no flagged path in it, so it has no entry in the baseline.
- Adding
status(plan 6: one order status from both rails; importsstore,lnpay,onchainand the standard library) adds one package entry with 18 paths todocs/capslock.json, all of them artefacts of the packages it imports and of calls through theLightningandOnchaininterfaces; none is a path of its own. It dials nothing, opens no file and runs no program. By the last function of each path:NETWORK(2),FILES(2),RUNTIME(2),SYSTEM_CALLS(2),UNSAFE_POINTER(2),ARBITRARY_EXECUTION(3),UNANALYZED(2),REFLECT(2) andREAD_SYSTEM_STATE(1): thestoredriver and thelnpay(vianwc) initialiser artefacts described above. NoEXECand noMODIFY_SYSTEM_STATE. - The same plan grows existing entries without a new class:
storefrom 262 to 486 paths (the on-chain tables and queries, all throughdatabase/sqland the driver, 59 to 61 per class),lnpayfrom 246 to 288 (the startup completion and the orders without a Lightning amount) andwebhookfrom 34 to 35 (errors.AsinosReason, from plan 5’s last webhook change, which the baseline then lacked). Plan 6’s last baseline update was that ofstatus; its later changes (api,checkout,service,qualify) added no capability, and their entries were not regenerated then. Plan 7 regenerated the baseline (below). - Adding
rates(plan 7: fiat pricing; importsmath/big,net/http,crypto/tls,encoding/json,log/slogand the standard library, no SatBooth package) adds one package entry with 67 paths todocs/capslock.json. The real ones:NETWORK(5 direct:net/http.NewRequestWithContext, theGETto a provider’s fixed URL or the custom URL, HTTPS only),UNANALYZEDio.Copy(5: draining at most 256 KiB of a non-200 answer intoio.Discard) andREFLECT(encoding/jsondecoding a provider’s answer withUseNumber; itsreflect.TypeFor[float64]paths are insideencoding/json, and the no-float scan covers our code). The rest are artefacts:FILES(*os.File).Write,ARBITRARY_EXECUTION,SYSTEM_CALLSandUNSAFE_POINTERthrough thesloghandler writing a record (anio.Writerresolved to the SQLite driver’s),NETWORKnetFD.connect$1andRUNTIMEthroughcontext.WithTimeout, anderrors.Is/As/Unwrap.NETWORKcame with the client; the background fetcher, which logs and bounds each call with a context, added the slog and context classes every logging package carries (accepted).ratesopens no file and runs no program.rates/ratestest(the fake rate server, imported by tests only) adds 12 paths:net.Listenand the listener of its loopbackhttptestserver (its round tripper re-routes the providers’ hosts there), and the same artefacts. NoEXECand noMODIFY_SYSTEM_STATE. The other plan 7 changes (store,api,webhook,checkout,status,service) added no capability class. The regenerated baseline grows existing entries’ path counts with no new class:store486 to 542,onchain260 to 362,service115 to 131,btcrpc141 to 151,checkout71 to 81 andstatus18 to 26;lnpayshrinks from 288 to 280 (the example paths capslock renders change with the program). - Plan 8 (Monero) adds three package entries and no capability class beyond the packages they mirror. The counts
below are those of the committed baseline, last regenerated with
monero’s subaddresses and checks; the later changes (the watcher, the API, the wiring, the qualification) added no class, whichscripts/capslock.shchecks on everyscripts/check.sh, and were not regenerated. By the last function of each path:rail(the rules the two watched rails share; importsstore,ledger,lnpayand the standard library): 10 paths, all transitive, one per class ofonchain’s set (FILES(*os.File).Close,NETWORKnetFD.connect$1,RUNTIMESetFinalizer,SYSTEM_CALLSMunmap,UNSAFE_POINTERload16,REFLECTSetZero,UNANALYZEDerrors.Is,ARBITRARY_EXECUTIONvfsAccessand the blake256cpuid,READ_SYSTEM_STATEos.Getenv): thestoredriver andnwcinitialiser artefacts reached throughlnpayand the events callback.railopens no file and no socket.xmrrpc(the wallet-rpc client; importsnet/http,crypto/md5,crypto/tls,encoding/json,btcrpcand the standard library): 107 paths, the same classes asbtcrpc. The real ones:NETWORKnet/http.NewRequestWithContext(the POST to the one configured wallet-rpc) and(*net/http.Client).CloseIdleConnections(inZero),REFLECT(encoding/jsonencoding the request and decoding the answers:reflect.MakeMap9,TypeFor[float64]1, insideencoding/json), andUNANALYZED(errors.As/Is,io.Copydraining a 401’s body indrain, andio.WriteStringinClient.Format, which prints a fixed text for every verb). The rest are artefacts: 11NETWORKpaths end at(*net/http.body).Close(an interface call), andFILES(*os.File).Close,RUNTIMESetFinalizer,SYSTEM_CALLSGetpagesize,UNSAFE_POINTERload32andARBITRARY_EXECUTIONvfsCheckReservedLock(10 each) are the SQLite driver reached throughio.Writerandio.Closerinterface calls;xmrrpcimports neither the driver nor any file API. NoEXEC, noMODIFY_SYSTEM_STATE.monero(the rail; importsxmrrpcfor its types,rail,store,ledger,lnpay,log/slogand the standard library): 148 paths,onchain’s classes: thestoreandledgerdriver artefacts (SetFinalizer,errors.Is,Munmap,vfsAccess,SetZero,load16, 13 to 16 each),(*os.File).Write(9) and(net.IP).AppendText(3) throughslog,netFD.connect$1(17) through context cancellation, and the blake256 initialiser (cpuid,os.Getenv) throughlnpay. It dials nothing (the client is passed in asmonero.RPC), opens no file and runs no program.monero/monerotest(the in-memory wallet-rpc, imported by tests only) has no entry: capslock found no flagged path in it.
- GitHub Actions pinned by commit SHA, with least-privilege
permissions:. GitHub-owned actions only (actions/):checkoutv7.0.1,setup-gov7.0.0,upload-artifactv7.0.2,download-artifactv8.0.2 andattest-build-provenancev4.2.2, which wrapsactions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d(v4.2.1).download-artifactandattest-build-provenanceare used byrelease.ymlonly (upload-artifactalso byqualify.yml);attest-build-provenancewrites the signed build provenance of each release image, with the job’s OIDC token. - Each dependency has a row here: version, date, what it requires, OSV records, size, and why stdlib is not enough (CLAUDE.md).
8. Third-party servers in the hub bundle (plan 10)
Section titled “8. Third-party servers in the hub bundle (plan 10)”deploy/hub/ runs third-party servers beside SatBooth’s relay. None is linked into SatBooth, and SatBooth’s own
images never contain them. Alby Hub is its release image, pulled by digest. bitcoind, Fulcrum and the Monero binaries
are official release binaries, checked against pinned SHA-256 hashes in the Dockerfiles (deploy/hub/*/Dockerfile)
and copied onto debian:13-slim pinned by digest (plan 10, decision 5). Each runs as its own non-root user with a
read-only root and no capabilities (deploy/README.md). Alby Hub v1.24.6, Bitcoin Core 32.1 and Monero v0.18.5.4 are
the qualified versions (qualify/REPORT.md); the bundle uses the same pins as dev/regtest/.
| Server | Version (date) | Why | License | Source of the binary and its check | Needs | Record |
|---|---|---|---|---|---|---|
| Alby Hub | v1.24.6 | The Lightning node SatBooth’s NWC connections reach (design.md §2.1); §4 above has what it enforces |
Apache-2.0 | ghcr.io/getalby/hub:v1.24.6@sha256:4bb27770ff2b68ab8342f75909dfcb59db0ca8f903045967e44c908b7f6f85f7, the multi-arch index digest; the tag points at commit 921003b, the source the docs cite (§4) |
bitcoind as its chain source (LDK_BITCOIND_RPC_*), the bundle’s Fulcrum for its address lookups, SatBooth’s relay |
The version qualified against; a new version is re-qualified before its digest replaces this one |
| Bitcoin Core | 32.1 | The hub’s chain source and SatBooth’s on-chain rail (watch-only wallet, whitelisted RPC user); Fulcrum’s index | MIT | bitcoincore.org/bin/bitcoin-core-32.1/bitcoin-32.1-{x86_64,aarch64}-linux-gnu.tar.gz; SHA-256 pinned in deploy/hub/bitcoind/Dockerfile, the same hashes as dev/regtest/bitcoind/Dockerfile, the entries of the release’s SHA256SUMS (8 good signatures from the builder keys of bitcoin-core/guix.sigs at fdc0f963). Only bitcoind and bitcoin-cli are copied |
txindex=1 and an unpruned node, for Fulcrum |
The version qualified against (qualify/REPORT.md, “On-chain end to end”) |
Monero (monerod, monero-wallet-rpc, monero-wallet-cli) |
v0.18.5.4 | The monero profile: the operator’s node and SatBooth’s view-only wallet-rpc; monero-wallet-cli for the operator to make the view-only wallet |
BSD-3-Clause | downloads.getmonero.org/cli/monero-linux-{x64,armv8}-v0.18.5.4.tar.bz2; SHA-256 pinned in deploy/hub/monero/Dockerfile, the same hashes as dev/regtest/monero/Dockerfile, copied from getmonero.org/downloads/hashes.txt after its PGP signature verified with the key in utils/gpg_keys/binaryfate.asc at the tag |
a pruned node is enough | The version qualified against (qualify/REPORT.md, “Monero end to end”); not qualified against v0.19 (docs/monero.md, “Setting up monerod and monero-wallet-rpc”) |
| Fulcrum | 2.1.4 (2026-10-18) | The hub’s on-chain address lookups (whether an address was used, for its address API and swaps) need an Electrum or Esplora server: bitcoind has no address index (getAlby/hub v1.24.6 chain/address_lookup.go:17-26). Without one they go to Alby’s server, which learns the hub’s addresses and the host’s IP. The hub’s Lightning chain monitoring does not use it: with bitcoind configured, LDK’s only chain source is bitcoind (lnclient/ldk/ldk.go:217-232). |
GPL-3.0-or-later (LICENSE.txt in the tarball); run unmodified as a separate program, no code taken |
github.com/cculianu/Fulcrum releases, Fulcrum-2.1.4-{x86_64,arm64}-linux.tar.gz; hashes from Fulcrum-2.1.4-shasums.txt, whose signature verified as “Good signature” from key D465 135F 97D0 047E 18E9 9DC3 2181 0A54 2031 C02C (Calin Culianu), the key published in Electron-Cash/Electron-Cash pubkeys/calinkey.txt (2026-11-11). Dynamically linked against glibc, zlib and libbz2 only, all in the base image |
bitcoind with txindex=1, unpruned; disk for its index (133 GB for Bitcoin as of August 2023, per its README, more since); RAM 1 GB minimum (db_mem default 2048 MiB) |
No CVE in the NVD (keyword searches “Fulcrum SPV” and “cculianu”, 2026-11-11) and no GitHub security advisory. Maintained since 2020 (53 releases; 2.1.0 to 2.1.4 between 2025-11 and 2026-10); 1,704 commits by its author, 49 by the next four contributors. Chosen over electrs (MIT), which publishes no release binaries. |
The hub’s bitcoind RPC calls (plan 11). The hub’s RPC user is confined by rpcwhitelist to the calls its chain
source makes, in the bundle (deploy/hub/config-example/bitcoin.conf) and on regtest
(dev/regtest/bitcoind/bitcoin.conf):
getblockchaininfo,getblockheader,getblock,getblockhash,gettxout,getrawmempool,getmempoolentry,getrawtransaction,getmempoolinfo,estimatesmartfee,sendrawtransactionThe source read, and how sure each link is:
- Alby Hub v1.24.6 is commit
921003b. Itsgo.mod:16recordsgithub.com/getAlby/ldk-node-go v0.0.0-20261021094837-02f88de84f63. Recorded in a file. ldk-node-go02f88deholds only built libraries (“Update bindings.”), with no record of the source commit. That it was built from getAlby/ldk-node87ea53f(ldk-node 0.7.1) is inferred:02f88delanded 9 minutes after87ea53fon the fork’smain, as each bindings commit follows amaincommit;87ea53f’s only change that reaches the build isrustls-webpki0.103.15 to 0.103.16 inCargo.lock, and the shippedlibldk_node.so(x86_64 and aarch64) holds the stringrustls-webpki-0.103.16, where the bindings commit before it holds 0.103.15. The bitcoind code is the same in themaincommit before87ea53f.- ldk-node
87ea53ftakeslightning-block-sync0.2.0 from crates.io (Cargo.toml:50, checksum inCargo.lock). Recorded in files. - Each of the eleven method names is a string in the shipped
libldk_node.so;getblockcount,getnetworkinfo,submitpackageandtestmempoolacceptare not.
Where each call is sent (ldk-node 87ea53f src/chain/bitcoind.rs; lightning-block-sync 0.2.0 src/rpc.rs):
| Method | Sent at | When |
|---|---|---|
getblockchaininfo |
rpc.rs:156 (get_best_block) |
the chain tip: at start, every 2 s poll (poll.rs:222), and in gossip checks (gossip.rs:204) |
getblockheader |
rpc.rs:141 (get_header) |
the tip’s header and the walk back over a reorg (init.rs:29,160, poll.rs:228,249) |
getblock |
rpc.rs:151 (get_block, verbosity 0) |
each new block for the listeners (poll.rs:262); gossip checks (gossip.rs:218) |
getblockhash |
rpc.rs:166 (get_block_hash_by_height) |
gossip checks only (gossip.rs:204) |
gettxout |
rpc.rs:176 (is_output_unspent, mempool excluded) |
gossip checks only (gossip.rs:242) |
getrawmempool |
bitcoind.rs:928 |
each mempool poll of the on-chain wallet |
getmempoolentry |
bitcoind.rs:964 |
each mempool transaction not seen before |
getrawtransaction |
bitcoind.rs:829 |
each mempool transaction not yet handed to the wallet |
getmempoolinfo |
bitcoind.rs:793 |
fee estimates (mempoolminfee), as for estimatesmartfee |
estimatesmartfee |
bitcoind.rs:769 |
fee estimates: once at start, blocking (src/lib.rs:248; on mainnet a failure stops the start, bitcoind.rs:533-536), then after each new block |
sendrawtransaction |
bitcoind.rs:731 |
every broadcast, one transaction at a time (the comment at bitcoind.rs:599-602: no submitpackage) |
- The hub picks the RPC chain source (
lnclient/ldk/ldk.go:217-226,SetChainSourceBitcoindRpc), so ldk-node builds oneRpcClient(bitcoind.rs:660-664).call_method(rpc.rs:66-132) posts one JSON object per call, never a batch, so the whitelist sees each method. Everycall_methodin ldk-node’ssrcis insrc/chain/bitcoind.rs(the six lines above); the rest go throughlightning-block-sync’sBlockSourceandUtxoSourceforRpcClient(rpc.rs:134-180), reached frombitcoind.rs:1189-1231andbitcoind.rs:703-705. The othercall_methodlines ofrpc.rsare its tests. No REST, no ZMQ: ldk-node has no ZMQ code, and the hub never calls the REST variant. - The gossip checks run because the hub leaves
LDK_GOSSIP_SOURCEempty (ldk.go:253-256): P2P gossip, whose verifier (ldk-nodesrc/gossip.rs:65-81) uses theRpcClientas itsUtxoSource. With a rapid gossip sync server set,getblockhashandgettxoutwould not be called. - The hub’s Go code calls bitcoind nowhere else: the four
LDK_BITCOIND_RPC_*settings are only passed to ldk-node. - What qualification ran on the confined user is in
qualify/REPORT.md, “The hubs’ bitcoind user”. Force closes, fee bumps and sweeps run in no phase.
Base images. Every image is pinned by digest; a tag alone is never used. The reason for each file in a release
image, and each pin, is in deploy/README.md (“Release images”).
| Image | Pin | Used for |
|---|---|---|
| Go builder | golang:1.27.4-trixie@sha256:b54b2639de8c1bfe538ec5ffc45f29b1932235ac5f6d63c059853646677112d9 |
the build and certs stages of deploy/image/Dockerfile (only the binaries and the CA bundle leave them: the bundle comes from the certs stage, the linux/amd64 image with no RUN); the volume-init of the public bundle and of dev/regtest/ |
| BuildKit | moby/buildkit:v0.34.1@sha256:99368f3e46177c6405b9740e4461d91cee004128daeecca78d4ceac50ec1da5c (v0.34.1, published 2026-10-20; the newest release at least 14 days old when pinned) |
the docker-container builder of scripts/release-lib.sh: Docker’s own BuildKit, pinned because layer compression and the exporter decide the release image digests |
| Debian | debian:13-slim@sha256:a72b4433de1cf7d6bae0c4cee74d0547f58601a643b3375f070c51600d63b6b2 |
the bitcoind, Fulcrum and Monero images of the hub bundle and of dev/regtest/, the hub bundle’s netns and volume-init |
| Alby Hub | ghcr.io/getalby/hub:v1.24.6@sha256:4bb27770… (the table above) |
the hub bundle and the regtest hubs |
| Registry | registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 |
test only: scripts/release-push_test.sh runs it on 127.0.0.1 and proves the absent, present-equal and present-other cases of the registry tag check of scripts/release-push.sh against a real OCI distribution server. The Docker official image of CNCF Distribution, pulled anonymously from Docker Hub in CI like the other base images; no release image contains it |
satbooth, satbooth-relay, satbooth-tools |
built from deploy/image/Dockerfile, FROM scratch, and published by the release workflow to ghcr.io/satbooth/<name>:<version>; the bundles and deploy/setup.sh pin each by tag@digest, and compose.build.yml or SATBOOTH_BUILD=1 builds from source |
the public bundle, the hub bundle’s relay, the setup command, and (built from source) qualification |
The font. The logo’s wordmark (docs/logo/satbooth-logo*.svg) is Rubik 2.300 (github.com/googlefonts/rubik, the
variable font at weight 800), SIL Open Font License 1.1, with its letters converted to paths. No font file is in the
repository or an image, and nothing renders text at run time: the checkout page writes “SatBooth” in HTML beside the
mark. ACKNOWLEDGEMENTS.md credits it. resvg 0.49.0 (Apache-2.0 OR MIT) renders the logo’s PNGs from the SVGs
(docs/logo/render.sh); it is a local tool, not a dependency.
Documentation from SatBooth v0.1.0-rc.1, Apache-2.0