Go WebSocket server that fans out Bitcoin price, block-height and mempool-fee events to BTClock devices, with optional Nostr republishing.
  • Go 79.6%
  • Svelte 10.4%
  • TypeScript 7.9%
  • Makefile 0.7%
  • JavaScript 0.5%
  • Other 0.8%
Find a file
Djuri Baars 97814ce37b
Some checks failed
Lint / golangci-lint (push) Failing after 1m47s
Lint / gofmt (push) Failing after 1m54s
Tests / cross_arm64 (push) Successful in 5m9s
Tests / test_nozmq (push) Successful in 6m18s
Tests / test (push) Failing after 7m35s
feat(metric): add stable numeric id to MetricInfo
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.
2026-06-18 21:25:08 +02:00
.forgejo ci: prefetch modules + bump golangci-lint timeout to 10m 2026-05-03 20:29:12 +02:00
api feat(metric): add stable numeric id to MetricInfo 2026-06-18 21:25:08 +02:00
cmd feat(metric): add stable numeric id to MetricInfo 2026-06-18 21:25:08 +02:00
docs feat(metric): /api/v2/metrics discovery + EU all-regions sentinel 2026-06-07 16:22:04 +02:00
internal feat(metric): add stable numeric id to MetricInfo 2026-06-18 21:25:08 +02:00
tests feat(metric): /api/v2/metrics/info discovery with labels + currency 2026-06-18 19:29:31 +02:00
web feat(metric): add stable numeric id to MetricInfo 2026-06-18 21:25:08 +02:00
.dockerignore Initial commit 2026-05-03 20:12:07 +02:00
.gitignore feat(pricesource): external USD-quote currencies + decimal precision 2026-06-01 19:41:32 +02:00
.golangci.yaml chore: satisfy golangci-lint (errcheck + revive comments) 2026-05-03 20:36:35 +02:00
compose.yaml Initial commit 2026-05-03 20:12:07 +02:00
Dockerfile feat(web): SvelteKit debug dashboard for v1/v2 WS + Nostr publisher 2026-05-05 22:46:46 +02:00
go.mod feat(api): generate OpenAPI 3 spec from Go types (cmd/openapi-gen) 2026-06-18 19:29:31 +02:00
go.sum feat(api): generate OpenAPI 3 spec from Go types (cmd/openapi-gen) 2026-06-18 19:29:31 +02:00
Makefile feat(web): SvelteKit debug dashboard for v1/v2 WS + Nostr publisher 2026-05-05 22:46:46 +02:00
README.md feat(metric): /api/v2/metrics discovery + EU all-regions sentinel 2026-06-07 16:22:04 +02:00

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)
  • 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 /metrics on a sidecar port; /healthz on 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, tune outboundBuf or 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 Valkey MAXLEN ~ 10000 is 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, toggle blockheight / blockfee / blockfee2 / feeRates, add per-currency price subscriptions (autocompleted from /api/v2/currencies)
  • Nostr — subscribe to kind:30078 events on chosen relays, filter by #source hostname, 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 on ENABLE_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 — against vite preview (the bundled SPA, default)
  • pnpm test:dev — against pnpm 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 ./...