- Go 79.6%
- Svelte 10.4%
- TypeScript 7.9%
- Makefile 0.7%
- JavaScript 0.5%
- Other 0.8%
Firmware needs a permanent unique handle per metric, independent of the translatable label. Add MetricInfo.ID, backed by a frozen key->id registry in metricmeta (MetricID): debt:US=100, debt:EU27=101, EU members 102+ in alphabetical geo-code order (AT..SK). IDs never renumber on EU accession; new keys take the next free number. Regenerate the OpenAPI spec. |
||
|---|---|---|
| .forgejo | ||
| api | ||
| cmd | ||
| docs | ||
| internal | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yaml | ||
| compose.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
btclock-ws-nostr-publish-go
Go WebSocket server that fans out Bitcoin price, block-height, and mempool-fee events to BTClock devices, and optionally republishes them to Nostr relays. Built to run behind HAProxy at 10k+ concurrent connections and to fit on a Raspberry Pi 4 / 5.
Features
- v1 JSON flat-broadcast publisher (
/ws,/api/v1/ws) - v2 MessagePack subscription publisher (
/api/v2/ws) with per-event and per-currency subscriber indexes, unknown-currency error path, integer fee dedup, and decimal fee2 every tick - v2 rate-based metric channel (national debt, …): keyed
{base, rate, ref}rate-frames the device ticks locally — see docs/debt-clock.md - Pluggable upstream drivers:
mempool-ws,bitcoind-zmq(requires cgo- libzmq),
bitcoind-rpc(polling)
- libzmq),
- Driver supervisor with primary/secondary failover (grace windows 30s block / 10s fee)
- Pluggable event bus: in-process (default) or Valkey Streams
- Nostr parameterized-replaceable publisher (kind 30078, one d-tag per datum) — no kind-5 cleanup, self-superseding at the relay
- Prometheus
/metricson a sidecar port;/healthzon the main port - Backpressure-aware gorilla/websocket writes with a bounded outbound channel per client (cap 256); overflow closes the socket
- Graceful shutdown on SIGTERM/SIGINT
Environment variables
| Variable | Default | Purpose |
|---|---|---|
LISTEN_ADDR |
:8080 |
Main HTTP listen address |
METRICS_ADDR |
:9090 |
Prometheus metrics sidecar address |
UPSTREAM |
mempool-ws |
mempool-ws | bitcoind-zmq | bitcoind-rpc |
UPSTREAM_FALLBACK |
(empty) | Optional secondary driver |
MEMPOOL_INSTANCE |
mempool.space |
Mempool WS hostname |
BITCOIND_RPC |
127.0.0.1:8332 |
bitcoind RPC host:port |
BITCOIND_USER / BITCOIND_PASS |
(empty) | RPC credentials |
BITCOIND_ZMQ |
tcp://127.0.0.1:28332 |
bitcoind ZMQ endpoint |
BROKER |
inproc |
inproc | valkey |
BROKER_URL |
localhost:6379 |
Valkey host:port |
PUBLISH_TO_NOSTR |
false |
Enable Nostr publishing |
NOSTR_PRIV |
(empty) | Nostr private key (hex or nsec) |
NOSTR_RELAYS |
wss://relay.damus.io |
Comma-separated relay URLs |
LOGLEVEL |
info |
zerolog level |
ENABLE_PPROF |
false |
Mount /debug/pprof/* on the metrics sidecar (sensitive — leaks stack traces) |
BTCLOCK_EXTRA_CURRENCIES |
false |
Set to true / 1 to enable the full set of demand-activated currencies (pricesource.DefaultExtraCurrencies, matching the lnbits supported-currencies list — 162 codes covering every fiat / precious metal Yadio or Coinbase rates quote against BTC). Polled lazily only when a ws2 client subscribes. The full list is exposed via /api/v2/currencies; first subscriber for each currency sees a short delay before the first price tick. Polling stops when the last subscriber disconnects or unsubscribes. |
BTCLOCK_EXTERNAL_CURRENCIES |
(empty) | Comma-separated currencies (e.g. XAU,XAG) fed by an external application via POST /api/external-rate. The app pushes a USD-quote (USD per 1 unit — e.g. the USD price of one troy ounce of gold); the server derives BTC/<CCY> = BTC/USD ÷ USD-quote and re-derives on every BTC/USD tick. Demand-activated like the extras above, and additive: if the same code is also enabled via BTCLOCK_EXTRA_CURRENCIES, the external feed is medianed with Yadio + Coinbase rates. Listed in /api/v2/currencies. |
ENABLE_EXTERNAL_PUSH |
false |
Set to true / 1 to mount POST /api/external-rate on the metrics sidecar (METRICS_ADDR, internal — not the public main listener / HAProxy /api split). Body: {"currency":"XAU","usd":"2000.50"}. Responses: 204 ok, 400 unknown currency / bad value, 401 bad token, 502 replica propagation failed. With BROKER=valkey, pushes are mirrored to the btclock:extrate hash so every replica converges. |
EXTERNAL_PUSH_TOKEN |
(empty) | When set, POST /api/external-rate requires Authorization: Bearer <token>. Recommended even on the internal sidecar. |
EXTERNAL_RATE_MAX_AGE |
5m |
How long a pushed rate stays trusted. Past it (no fresh push within this window) the external source withdraws from the aggregate, so the pair falls back to the always-on feeds (Yadio + Coinbase) instead of carrying a frozen USD-quote re-derived against live BTC/USD. Keep it comfortably above the pusher's push/heartbeat cadence so a healthy feed never trips it. Set 0 to disable (never withdraw). |
ENABLE_DASHBOARD |
false |
Set to true / 1 to mount the SvelteKit debug dashboard at /. With no DASHBOARD_DIR set the binary serves the SPA bundled in at build time (run make all to populate it); a non-empty directory takes precedence and is served from disk (useful for dev). |
DASHBOARD_DIR |
(empty) | When non-empty, serves the dashboard SPA from this directory instead of the embedded bundle. Convenient for pnpm build-then-reload workflows without rebuilding the Go binary. |
ENABLE_METRICS_DEMO |
false |
Set to true / 1 to start a static demo source for the v2 metric channel (debt:US, debt:EU27) so the rate-frame pipeline is exercisable end-to-end without a real upstream. Scaffolding ahead of the real debt pollers; not for production. See docs/debt-clock.md. |
ENABLE_DEBT_US |
false |
Set to true / 1 to start the US Treasury debt:US poller (FiscalData debt_to_penny), publishing a rate-frame whose per-second rate is the trailing-DEBT_US_RATE_WINDOW slope. Don't combine with ENABLE_METRICS_DEMO (both publish debt:US). See docs/debt-clock.md. |
DEBT_US_POLL_INTERVAL |
1h |
How often to poll FiscalData for debt:US. The series changes once per business day, so hourly is ample. |
DEBT_US_RATE_WINDOW |
8760h |
Trailing span used to derive the debt:US per-second rate (365 days). Long on purpose: the daily series is lumpy and goes flat/jumps around debt-ceiling events, so a short window would make the clock lurch. |
ENABLE_DEBT_EU |
false |
Set to true / 1 to start the Eurostat EU debt poller (gov_10q_ggdebt), publishing debt:EU27 plus any configured countries with a quarterly two-point rate. See docs/debt-clock.md. |
DEBT_EU_REGIONS |
EU27_2020 |
Comma-separated Eurostat geo codes to publish. EU27_2020 → debt:EU27; country codes (DE, NL, …) → debt:<code>. The sentinel ALL expands to EU27 + all 27 member states. One poll covers all configured regions. |
DEBT_EU_POLL_INTERVAL |
24h |
How often to poll Eurostat. The data is quarterly, so daily is ample. |
A .env in the working directory is loaded automatically via godotenv.
HTTP timeout behaviour
The main listener sets ReadHeaderTimeout=10s, IdleTimeout=120s, and
MaxHeaderBytes=16384 to defend against Slowloris-style attacks without
killing long-lived WebSocket connections (there is no WriteTimeout on
the main listener — gorilla's write loop uses per-frame deadlines so the
connection stays alive through idle periods). The metrics sidecar is
plain HTTP and carries a WriteTimeout=30s in addition.
After a successful Upgrade the server sets SetKeepAlive(true) +
SetKeepAlivePeriod(30s) + SetNoDelay(true) on the underlying TCP
conn so dead peers are detected promptly and frames aren't held for
Nagle.
Build and run
make build # → bin/ws-node (host arch)
make all # build dashboard SPA + Go binary (bundled embed)
make test # go test ./...
make vet # go vet ./...
make lint # golangci-lint if present, else go vet
make cross-arm64 # statically-linked Pi 4 / 5 build, nozmq
Run locally against public mempool.space:
./bin/ws-node
Run against a local bitcoind with RPC:
UPSTREAM=bitcoind-rpc BITCOIND_USER=u BITCOIND_PASS=p ./bin/ws-node
Run with ZMQ primary and RPC fallback:
UPSTREAM=bitcoind-zmq UPSTREAM_FALLBACK=bitcoind-rpc \
BITCOIND_USER=u BITCOIND_PASS=p BITCOIND_ZMQ=tcp://127.0.0.1:28332 \
./bin/ws-node
Run behind HAProxy with a Valkey bus (multi-node fan-out):
BROKER=valkey BROKER_URL=valkey.internal:6379 ./bin/ws-node
Driver selection
| Driver | Requires | Best for |
|---|---|---|
mempool-ws |
network egress to mempool.space or a self-hosted instance | hobbyist deployments; no bitcoind needed |
bitcoind-zmq |
local bitcoind with zmqpub enabled, cgo + libzmq |
lowest latency, full self-hosted path |
bitcoind-rpc |
local bitcoind with RPC enabled | fallback; polling cadence 2s |
The ZMQ driver subscribes to hashblock + sequence. Height is read via
getblockcount RPC on every hashblock notification. Fee estimation
computes a rolling median (sat/vB) over the top-fee transactions that
would fit in roughly the next block, taken from getrawmempool true —
matching DataStorage.lastMedianFee semantics from the mempool-WS driver
so a ZMQ → mempool-ws failover is wire-transparent. A sequence event
debounces the recomputation at 2 s by default (override via
MempoolRefreshInterval on the struct) to avoid RPC-storming a busy
node. If getrawmempool fails the driver falls back to
estimatesmartfee so the feed doesn't go silent.
Valkey setup
Minimal single-instance Valkey is enough for a single-host multi-ws-node deployment. For HA use Valkey/Redis Sentinel or Cluster.
The bus writes MSET btclock:price:<CCY> per price update and XADD btclock:events MAXLEN ~ 10000 * per event. Consumers start from the
stream tail ($) on connect; XREAD BLOCK 5s drives liveness. On trim-past
the consumer re-snapshots from the tail and continues — see
internal/bus/valkey.go.
Integration tests against a live broker:
REDIS_URL=localhost:6379 go test ./tests/... -run TestValkeyBus
Raspberry Pi deployment
make cross-arm64
scp bin/ws-node-arm64 pi@btclock:/usr/local/bin/ws-node
ssh pi@btclock 'sudo systemctl restart ws-node'
A sample systemd unit:
[Unit]
Description=BTClock WS node
After=network-online.target
[Service]
Environment=LISTEN_ADDR=:8080 METRICS_ADDR=:9090
Environment=UPSTREAM=mempool-ws
ExecStart=/usr/local/bin/ws-node
Restart=always
RestartSec=2
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Raise LimitNOFILE — at 10k clients each needs one fd. Kernel tuning
(net.ipv4.tcp_mem, net.core.somaxconn) may also be needed at that
scale.
The ZMQ driver needs cgo + libzmq at both build and run time; cross-build
from macOS to linux/arm64 with cgo enabled requires a cross toolchain.
The cross-arm64 target intentionally uses -tags nozmq for a clean
static build; use mempool-ws or bitcoind-rpc in that scenario, or
build on the target.
Metrics
Exposed at http://$METRICS_ADDR/metrics. Scrape each ws-node
independently; Grafana sums.
Nostr
When PUBLISH_TO_NOSTR=true and NOSTR_PRIV is set, every price update,
new block, and median fee change is published to the configured relays
as a parameterized-replaceable event (kind 30078, d tag per datum).
Relays supersede the previous event for that d tag automatically — no
delete-event loop is needed.
Metric / debt channel
The v2 publisher also carries rate-based counters — values that tick
continuously, like national debt. Instead of pushing a number every second,
the server pushes a rate-frame {base, rate, ref} and the device
extrapolates value(t) = base + rate × (t − ref) on its own NTP clock,
re-anchored whenever the upstream (Eurostat quarterly, US Treasury daily)
publishes fresh data. Metrics are keyed (debt:US, debt:EU27, …) and
subscribed exactly like per-currency prices.
ENABLE_METRICS_DEMO=true starts a static demo source so the channel is
exercisable today; real debt pollers land next. Full design, wire protocol,
and rate-derivation details: docs/debt-clock.md.
Deployment
File descriptors
10k clients + upstream + RPC + metrics plus headroom → aim for at least
ulimit -n 65536. The process logs the current soft limit on startup
and warns if it's below that target. Raise via:
# systemd
LimitNOFILE=65536
# or interactively
ulimit -n 65536
HAProxy
Terminate client TCP on HAProxy, upgrade to WS, and load-balance across N ws-node backends. Health-check on the metrics sidecar port (not the WS port) to avoid flapping on a slow tick.
frontend ws_in
bind *:443 ssl crt /etc/haproxy/btclock.pem alpn h2,http/1.1
mode http
default_backend ws_nodes
backend ws_nodes
mode http
balance leastconn
timeout tunnel 1h
timeout client 1h
timeout server 1h
option httpchk GET /healthz
http-check expect status 200
# Health check goes to the metrics sidecar (:9090), not the WS port.
server ws1 10.0.0.11:8080 check port 9090 inter 2s fall 2 rise 2
server ws2 10.0.0.12:8080 check port 9090 inter 2s fall 2 rise 2
server ws3 10.0.0.13:8080 check port 9090 inter 2s fall 2 rise 2
/healthz returns 503 with a reason string (upstream down / broker stale) when the node shouldn't serve traffic — HAProxy drains the
backend automatically.
Prometheus
Scrape each ws-node independently; Grafana sums across instances.
scrape_configs:
- job_name: btclock-ws-node
scrape_interval: 15s
static_configs:
- targets:
- 10.0.0.11:9090
- 10.0.0.12:9090
- 10.0.0.13:9090
Key metrics for alerting:
btclock_ws_backpressure_drops_total— non-zero = slow clients, tuneoutboundBufor scale out.btclock_ws_client_send_queue_depth{proto}— peak 5s-sampled depth; climbing toward 256 indicates queueing before backpressure triggers.btclock_upstream_source_active{driver}— Grafana stat panel for the current driver per node.btclock_broker_seq_gaps_total— a steady non-zero rate means ValkeyMAXLEN ~ 10000is too tight for the aggregator pause budget.
pprof
Opt-in via ENABLE_PPROF=true. Exposes /debug/pprof/* on the metrics
sidecar only. Sensitive (leaks stack traces, goroutine dumps, heap
snapshots) — keep it off the public internet; run curl localhost:9090/debug/pprof/goroutine?debug=1 over an SSH tunnel
instead.
Debug dashboard
A SvelteKit + Tailwind v4 + DaisyUI 5 single-page app under web/
ships a debug dashboard for poking at the v1 / v2 WebSocket streams,
listening to the Nostr publisher feed, browsing REST snapshots, and
rendering the BTClock display via the same WASM module that produces
the documentation screen images in btclock_v4.
Pages:
- Overview — live status pills + key metric tiles, embedded device preview
- v1 — connect to
/ws, watch JSON frames, decoded price/block/fee tiles - v2 — connect to
/api/v2/ws, toggleblockheight/blockfee/blockfee2/feeRates, add per-currency price subscriptions (autocompleted from/api/v2/currencies) - Nostr — subscribe to
kind:30078events on chosen relays, filter by#sourcehostname, with browser-side signature verification - Display — full hardware-accurate panel preview (acrylic / PCB look matching
the docs render in
btclock_v4/tools/wasm/render_doc_screens.mjs), every screen mode and font, fed live from the WS streams or manual inputs - REST — one-click probes of
/healthz,/api/lastblock,/api/lastprice,/api/lastfee,/api/v2/currencies - Inject — POSTs to
/api/_inject(gated onENABLE_INJECT=true) - Settings — point the dashboard at a remote backend without rebuilding
Local dev (HMR)
cd web
pnpm install
pnpm dev # http://localhost:5173, proxies /api + /ws to :8080
# pin a non-default backend:
BTCLOCK_BACKEND=http://staging.example.com:8080 pnpm dev
Production (single-binary)
make all # builds the SPA into cmd/ws-node/web/build then go build
ENABLE_DASHBOARD=true ./bin/ws-node
# → dashboard at http://localhost:8080/
Production (separate static deploy)
If you'd rather host the SPA somewhere else (CDN, nginx, GitHub Pages),
keep ENABLE_DASHBOARD=false on the Go binary and serve the
cmd/ws-node/web/build/ output from your static host. In the
dashboard's Settings page, enter the ws-node origin (e.g.
https://api.example.com:8080) so the SPA targets it cross-origin.
Quality pipeline
The dashboard ships with the standard SvelteKit toolchain:
| Tool | Command | Purpose |
|---|---|---|
| ESLint + eslint-plugin-svelte | pnpm lint |
Catches Svelte/TS bugs (each-key, unused vars, missing types) |
| Prettier (+ prettier-plugin-svelte) | pnpm format / pnpm lint |
Code style |
| svelte-check | pnpm check |
Type-checks .svelte and .ts, surfaces a11y warnings |
| Vitest | pnpm test:unit |
Unit tests on pure-logic modules (21 tests: frame geometry, ring buffer, v1 parser, config helpers) |
| Playwright | pnpm test |
Headless-Chromium smoke suite — fails on any console error, page error, or asset request failure |
Run the whole pipeline:
cd web
pnpm test:install # one-time: download Chromium
pnpm test:all # lint + check + unit + e2e
# or from the repo root:
make web-test-all
Playwright runs in three modes:
pnpm test— againstvite preview(the bundled SPA, default)pnpm test:dev— againstpnpm dev(catches HMR-only issues)pnpm test:go— against the actual Go binary serving the embed bundle (catches embed-dir-out-of-sync, MIME-type, and SPA-fallback regressions)
Backend endpoints are stubbed via page.route() so the suite needs no
Go server running. Repo-root targets: make web-lint, make web-check,
make web-test-unit, make web-test, make web-test-go, make web-test-all.
Vendored WASM
The display preview uses the same WASM module that builds the BTClock
documentation screen images. Artifacts live in web/static/wasm/ (committed):
pnpm fetch-wasm # copy from ../btclock_v4/tools/wasm/dist
pnpm fetch-wasm:build # rebuild WASM in btclock_v4 first, then copy
BTCLOCK_V4_DIR=/path/to/btclock_v4 pnpm fetch-wasm
Contract tests
tests/ws{1,2}_test.go pin the v1 / v2 wire format byte-for-byte.
tests/upstream_supervisor_test.go covers driver failover.
tests/bus_valkey_test.go is an integration test (gated on REDIS_URL).
Run all:
go test ./...