Add share cards

This commit is contained in:
satoshi committed 2026-09-04 00:54:26 +03:00
1 parent ec42ce9b54
commit 4669bf10c0
51 files changed
+1193 -239

No files matched your search

+1 -1
View File
@@ -2,5 +2,5 @@
POOL_BTCADDRESS=tb1qexamplereplacewithyourownaddressforrewards
BITCOIN_RPC_USER=kamado
BITCOIN_RPC_PASSWORD=changeme
# Optional — ZMQ endpoint for instant block notifications from bitcoind
# Optional, ZMQ endpoint for instant block notifications from bitcoind
# ZMQ_BLOCK=tcp://host.docker.internal:28332
+1 -1
View File
@@ -1,5 +1,5 @@
# ----------------------------------------------------------------------------
# Kamado Pool — top-level Makefile
# Kamado Pool, top-level Makefile
# ----------------------------------------------------------------------------
.PHONY: help ckpool api api-test ckpool-shell ui ui-dev ui-check up down logs clean
+43 -43
View File
@@ -24,7 +24,7 @@ Kamado exists because existing CKPool wrappers like Bassin read only a handful o
| `api/` | Go | Socket client, bitcoind RPC, ZMQ subscriber, log tailer, state aggregator, REST + WebSocket API, SQLite persistence |
| `ui/` | Svelte 5 | Real-time dashboard with pool overview, miner stats, block history, best share tracking, transaction accelerator |
The Go binary embeds the built Svelte app via `//go:embed` and serves it at `/` — a single static binary with no external web server.
The Go binary embeds the built Svelte app via `//go:embed` and serves it at `/`, a single static binary with no external web server.
## Improvements Over Upstream CKPool
@@ -50,18 +50,18 @@ Nine patches are applied to upstream commit `cfb0f83` (which itself includes the
The Go API (`kamado-api`) bridges CKPool's Unix socket protocol, bitcoind's JSON-RPC, and ZMQ into a unified HTTP/WebSocket interface:
- **Socket client** — CKPool uses a 4-byte length-prefixed binary protocol on a Unix domain socket. kamado-api opens a fresh connection per request and queries pool, user, worker, and client state in real time.
- **State aggregator** — Merges socket responses, bitcoind chain info, ZMQ events, and log-tailed block solves into a single thread-safe snapshot, refreshed on a configurable interval.
- **ZMQ subscriber** — Listens to bitcoind's `hashblock` topic for sub-second block notifications, triggering an immediate state refresh. Falls back to RPC polling if ZMQ is unavailable.
- **Log tailer** — Watches CKPool's log file (inotify-based) for "Solved and confirmed block" lines, extracts height and hash, enriches via bitcoind RPC (reward, confirmations), and records to SQLite.
- **Block reconciliation** — On startup, cross-references in-memory blocks with the database and bitcoind to detect orphans and fill gaps.
- **Chain reorg detection** — Scoped to the active chain; clears stale state on tip changes.
- **SQLite persistence** — Blocks, accelerated transactions, and log cursor survive restarts. Non-fatal if unavailable (in-memory ring fallback).
- **Transaction accelerator** — Inspects the current block template to find the marginal (lowest fee-rate) transaction, calls `prioritisetransaction` to boost a target tx, and reports the displaced fee as revenue impact. Includes a hard cap (2000 sat/vB) and lifecycle cleanup.
- **Socket client**: CKPool uses a 4-byte length-prefixed binary protocol on a Unix domain socket. kamado-api opens a fresh connection per request and queries pool, user, worker, and client state in real time.
- **State aggregator**: Merges socket responses, bitcoind chain info, ZMQ events, and log-tailed block solves into a single thread-safe snapshot, refreshed on a configurable interval.
- **ZMQ subscriber**: Listens to bitcoind's `hashblock` topic for sub-second block notifications, triggering an immediate state refresh. Falls back to RPC polling if ZMQ is unavailable.
- **Log tailer**: Watches CKPool's log file (inotify-based) for "Solved and confirmed block" lines, extracts height and hash, enriches via bitcoind RPC (reward, confirmations), and records to SQLite.
- **Block reconciliation**: On startup, cross-references in-memory blocks with the database and bitcoind to detect orphans and fill gaps.
- **Chain reorg detection**: Scoped to the active chain; clears stale state on tip changes.
- **SQLite persistence**: Blocks, accelerated transactions, and log cursor survive restarts. Non-fatal if unavailable (in-memory ring fallback).
- **Transaction accelerator**: Inspects the current block template to find the marginal (lowest fee-rate) transaction, calls `prioritisetransaction` to boost a target tx, and reports the displaced fee as revenue impact. Includes a hard cap (2000 sat/vB) and lifecycle cleanup.
### Stratum TLS
Optional encrypted stratum via stunnel. CKPool binds a public plaintext socket plus one loopback-only socket per TLS certificate; stunnel terminates TLS on the external port and forwards decrypted traffic to the matching internal bind. CKPool tags each connection with its `serverurl` index, and the deployment declares what those indices mean via the `STRATUM_SERVERS` env — so the dashboard shows a lock icon next to encrypted miners and names the certificate in use on hover, with no source-IP heuristics.
Optional encrypted stratum via stunnel. CKPool binds a public plaintext socket plus one loopback-only socket per TLS certificate; stunnel terminates TLS on the external port and forwards decrypted traffic to the matching internal bind. CKPool tags each connection with its `serverurl` index, and the deployment declares what those indices mean via the `STRATUM_SERVERS` env, so the dashboard shows a lock icon next to encrypted miners and names the certificate in use on hover, with no source-IP heuristics.
Under StartOS this drives two certificates on one port, chosen per connection by SNI: a CA-issued (Let's Encrypt) certificate for miners connecting over a clearnet domain, and the self-signed one for miners on the LAN, which send no SNI and fall through to it.
@@ -71,33 +71,33 @@ The self-signed certificate is auto-generated on first start with broad SAN cove
The Svelte 5 dashboard connects via WebSocket for real-time push updates (no polling in normal operation) and includes:
- **Pool overview** — Hashrate (1m/5m/1h/24h), uptime, workers online, shares accepted/rejected, expected time to block, round effort
- **Hashrate chart** — Interactive multi-window visualization
- **Miners table** — Per-user and per-worker stats: hashrate, difficulty, latency, best shares, idle detection
- **Hardware detection** — Parses stratum user-agent strings to identify miner hardware (Bitaxe, Bitaxe Hex, NerdMiner, NerdAxe, NerdQAxe, NerdOCTAXE, NerdEKO, NerdNOS, PiAxe, QAxe, 0xAxe, LeafMiner, and more). Open-source hardware is flagged with a star badge so operators can see their fleet composition at a glance.
- **TLS badges** — Miners connected via the encrypted stratum port display a lock icon in the miners table, derived from CKPool's `server` field (no IP heuristics).
- **User/worker detail pages** — Deep dive into individual miner stats, cumulative work, and luck
- **Block history** — Found blocks with height, hash, reward, solving worker, chain name, orphan status
- **Best share leaderboard** — "This round" and "all-time" tracking with per-worker breakdown and glowing difficulty range indicators
- **PoW inspector** — Graphical breakdown of the block header behind the best share (version, prev block, merkle root, time, bits, nonce), the raw 80-byte header hex, and the coinbase + merkle branches that reproduce the merkle root — with one-click in-browser verification that replays the real SHA-256 compression rounds as an animation, ending at the share's block hash — self-contained implementation, no external services, works over plain-HTTP LAN
- **Transaction accelerator** — Boost transactions via `prioritisetransaction` with marginal fee displacement analysis showing the revenue impact of each boost
- **Block-found animation** — Celebratory toast on solve events
- **Health banners** — Live status indicators for CKPool, bitcoind, ZMQ, and submit-gap diagnostics
- **Block-update latency** — Tracks and displays average, last, and wasted-work latency for block notifications so operators can tune ZMQ and polling
- **Shares bar** — Accepted/rejected/stale share summary with ratio visualization
- **Custom mempool explorer** — Transaction and block links can point at a self-hosted mempool instance instead of the public mempool.space
- **Mobile responsive** — Full breakpoint coverage for phone and tablet
- **Donation footer** — BTC address with hover QR code
- **Pool overview**: Hashrate (1m/5m/1h/24h), uptime, workers online, shares accepted/rejected, expected time to block, round effort
- **Hashrate chart**: Interactive multi-window visualization
- **Miners table**: Per-user and per-worker stats: hashrate, difficulty, latency, best shares, idle detection
- **Hardware detection**: Parses stratum user-agent strings to identify miner hardware (Bitaxe, Bitaxe Hex, NerdMiner, NerdAxe, NerdQAxe, NerdOCTAXE, NerdEKO, NerdNOS, PiAxe, QAxe, 0xAxe, LeafMiner, and more). Open-source hardware is flagged with a star badge so operators can see their fleet composition at a glance.
- **TLS badges**: Miners connected via the encrypted stratum port display a lock icon in the miners table, derived from CKPool's `server` field (no IP heuristics).
- **User/worker detail pages**: Deep dive into individual miner stats, cumulative work, and luck
- **Block history**: Found blocks with height, hash, reward, solving worker, chain name, orphan status
- **Best share leaderboard**: "This round" and "all-time" tracking with per-worker breakdown and glowing difficulty range indicators
- **PoW inspector**: Graphical breakdown of the block header behind the best share (version, prev block, merkle root, time, bits, nonce), the raw 80-byte header hex, and the coinbase + merkle branches that reproduce the merkle root, with one-click in-browser verification that replays the real SHA-256 compression rounds as an animation, ending at the share's block hash, self-contained implementation, no external services, works over plain-HTTP LAN
- **Transaction accelerator**: Boost transactions via `prioritisetransaction` with marginal fee displacement analysis showing the revenue impact of each boost
- **Block-found animation**: Celebratory toast on solve events
- **Health banners**: Live status indicators for CKPool, bitcoind, ZMQ, and submit-gap diagnostics
- **Block-update latency**: Tracks and displays average, last, and wasted-work latency for block notifications so operators can tune ZMQ and polling
- **Shares bar**: Accepted/rejected/stale share summary with ratio visualization
- **Custom mempool explorer**: Transaction and block links can point at a self-hosted mempool instance instead of the public mempool.space
- **Mobile responsive**: Full breakpoint coverage for phone and tablet
- **Donation footer**: BTC address with hover QR code
### Reliability
- **Dual block notification** — ZMQ hashblock for instant detection + RPC polling as fallback. Both run simultaneously; every second of stale work in solo mode is hashrate burned on a dead block.
- **ckpool kill-on-failure** — When bitcoind becomes unreachable, kamado-api kills ckpool so miners can failover to another pool. ckpool restarts automatically when bitcoind recovers.
- **Deferred RPC** — Dashboard RPCs wait until the ckpool notifier completes on tip change to prioritize system resources for block reconstruction and validation.
- **Log cursor persistence** — The tailer's file offset is stored in SQLite so block-solve detection resumes correctly after restart.
- **Load before ingest** — Persisted counters are restored *before* the log-tailer goroutines start. They share the same state and persist it on their first event, so starting them against a half-restored aggregator would write a few seconds of new shares over the accumulated all-time history — and then read that back as the new truth. `RebuildShareStatsFromLog` (exposed as `POST /api/admin/rebuild-share-stats`) recounts the all-time difficulty distribution and rejection reasons from the ckpool log if they are ever lost; it only adopts the result when the log accounts for more shares than the stored totals, so it can restore history but never erase it.
- **Clean restarts for miners** — Upstream ckpool sends a reconnecting miner its first job *before* telling it the difficulty, and resets every client to the pool's `startdiff`, discarding what vardiff had tuned. Both produce a burst of "Above target" rejects on every pool restart. Patch `0009` sends `mining.set_difficulty` first, seeds the starting difficulty from the worker's retained hashrate, and accepts shares for the already-in-flight job down to the pool minimum until the next `mining.notify`.
- **Warm-up resilience** — The ckpool socket client handles transient EOFs during startup gracefully instead of crashing.
- **Dual block notification**: ZMQ hashblock for instant detection + RPC polling as fallback. Both run simultaneously; every second of stale work in solo mode is hashrate burned on a dead block.
- **ckpool kill-on-failure**: When bitcoind becomes unreachable, kamado-api kills ckpool so miners can failover to another pool. ckpool restarts automatically when bitcoind recovers.
- **Deferred RPC**: Dashboard RPCs wait until the ckpool notifier completes on tip change to prioritize system resources for block reconstruction and validation.
- **Log cursor persistence**: The tailer's file offset is stored in SQLite so block-solve detection resumes correctly after restart.
- **Load before ingest**: Persisted counters are restored *before* the log-tailer goroutines start. They share the same state and persist it on their first event, so starting them against a half-restored aggregator would write a few seconds of new shares over the accumulated all-time history, and then read that back as the new truth. `RebuildShareStatsFromLog` (exposed as `POST /api/admin/rebuild-share-stats`) recounts the all-time difficulty distribution and rejection reasons from the ckpool log if they are ever lost; it only adopts the result when the log accounts for more shares than the stored totals, so it can restore history but never erase it.
- **Clean restarts for miners**: Upstream ckpool sends a reconnecting miner its first job *before* telling it the difficulty, and resets every client to the pool's `startdiff`, discarding what vardiff had tuned. Both produce a burst of "Above target" rejects on every pool restart. Patch `0009` sends `mining.set_difficulty` first, seeds the starting difficulty from the worker's retained hashrate, and accepts shares for the already-in-flight job down to the pool minimum until the next `mining.notify`.
- **Warm-up resilience**: The ckpool socket client handles transient EOFs during startup gracefully instead of crashing.
## API Reference
@@ -157,9 +157,9 @@ The dashboard is at `http://localhost:8080`. Point a miner at `stratum+tcp://loc
| Variable | Default | Required | Description |
|----------|---------|----------|-------------|
| `BITCOIN_RPC_URL` | — | yes | bitcoind RPC endpoint (e.g. `http://127.0.0.1:8332`) |
| `BITCOIN_RPC_USER` | — | yes | RPC username |
| `BITCOIN_RPC_PASSWORD` | — | yes | RPC password |
| `BITCOIN_RPC_URL` |, | yes | bitcoind RPC endpoint (e.g. `http://127.0.0.1:8332`) |
| `BITCOIN_RPC_USER` |, | yes | RPC username |
| `BITCOIN_RPC_PASSWORD` |, | yes | RPC password |
| `LISTEN_ADDR` | `:8080` | no | HTTP bind address |
| `CKPOOL_SOCKDIR` | `/run/ckpool` | no | CKPool Unix socket directory |
| `CKPOOL_LOGFILE` | `/var/log/ckpool/ckpool.log` | no | CKPool log path for block-solve detection |
@@ -168,7 +168,7 @@ The dashboard is at `http://localhost:8080`. Point a miner at `stratum+tcp://loc
| `BITCOIN_ZMQ_BLOCK` | (disabled) | no | ZMQ hashblock endpoint (e.g. `tcp://127.0.0.1:28332`) |
| `BITCOIN_RPC_TIMEOUT` | `10s` | no | RPC call timeout |
| `MEMPOOL_BASE_URL` | (mempool.space) | no | Custom mempool explorer URL |
| `STRATUM_SERVERS` | (undeclared) | no | JSON array describing ckpool's `serverurl[]` binds, e.g. `[{"kind":"plain","label":"Plaintext"},{"kind":"tls-local","label":"TLS — self-signed"}]`. Entry N labels clients with `server == N` in the dashboard. Unset falls back to treating index 1 as TLS |
| `STRATUM_SERVERS` | (undeclared) | no | JSON array describing ckpool's `serverurl[]` binds, e.g. `[{"kind":"plain","label":"Plaintext"},{"kind":"tls-local","label":"TLS, self-signed"}]`. Entry N labels clients with `server == N` in the dashboard. Unset falls back to treating index 1 as TLS |
### UI Development
@@ -176,7 +176,7 @@ The dashboard is at `http://localhost:8080`. Point a miner at `stratum+tcp://loc
make ui-dev
```
Starts Vite on `:5173` with hot reload. API calls are proxied to `:8080` — run `make up` first so the backend is available.
Starts Vite on `:5173` with hot reload. API calls are proxied to `:8080`, run `make up` first so the backend is available.
### Testing
@@ -202,8 +202,8 @@ The test suite covers the critical mining path with 38 tests across 5 packages:
The smoke test runs two phases on regtest:
1. **Log-inject path** — Injects a synthetic block-solve line into ckpool's log, verifies the tailer detects it, enriches it via bitcoind RPC, and surfaces it at `/api/blocks`. Quick, no ckpool binary needed.
2. **Full stratum path** — Starts ckpool, connects a Python stratum miner, mines a real block, verifies the coinbase contains the "kamado" tag and the configured pool identifier, validates that bitcoind accepted the block with confirmations > 0, and checks that the block validation log shows correct chain acceptance.
1. **Log-inject path**, Injects a synthetic block-solve line into ckpool's log, verifies the tailer detects it, enriches it via bitcoind RPC, and surfaces it at `/api/blocks`. Quick, no ckpool binary needed.
2. **Full stratum path**, Starts ckpool, connects a Python stratum miner, mines a real block, verifies the coinbase contains the "kamado" tag and the configured pool identifier, validates that bitcoind accepted the block with confirmations > 0, and checks that the block validation log shows correct chain acceptance.
The smoke test auto-builds ckpool from the pinned upstream source with patches applied if not cached (build cached in `~/.cache/kamado-dev/`).
@@ -243,10 +243,10 @@ The StartOS wrapper packages Kamado Pool as an `.s9pk` for StartOS 0.3.5.1 with
## Upstream
CKPool by Con Kolivas — https://bitbucket.org/ckolivas/ckpool
CKPool by Con Kolivas, https://bitbucket.org/ckolivas/ckpool
Pinned commit: `cfb0f83b70d7b382b85d2bd0710cf4cb2dda4007`
## License
GPL-3.0 — see [LICENSE](LICENSE).
GPL-3.0, see [LICENSE](LICENSE).
+1 -1
View File
@@ -19,7 +19,7 @@ FROM node:22-bookworm-slim AS ui
WORKDIR /ui
# Copy just the manifest first so npm install is cached across code
# edits. The build context must be the repo root — docker-compose
# edits. The build context must be the repo root, docker-compose
# uses `context: .`, and `make api` invokes docker build with the
# repo root as context.
COPY ui/package.json ./
+2 -2
View File
@@ -49,7 +49,7 @@ func main() {
ck := ckpool.New(cfg.CKPoolSockDir)
rpc := bitcoind.NewRPC(cfg.BitcoinRPCURL, cfg.BitcoinRPCUser, cfg.BitcoinRPCPassword, cfg.BitcoinRPCTimeout)
// Persistent block history. Non-fatal if it can't be opened — the
// Persistent block history. Non-fatal if it can't be opened, the
// aggregator falls back to an in-memory ring so the pool keeps
// running even with a broken data volume.
var blockStore *store.BlockStore
@@ -144,7 +144,7 @@ func main() {
// Wait for the aggregator's first refresh before starting the log
// tailer. This guarantees currentChain is set before any block
// events are processed, so blocks are always stamped with the
// correct chain — preventing false orphans on network switches.
// correct chain, preventing false orphans on network switches.
select {
case <-agg.Ready():
log.Info("first snapshot ready")
+2 -2
View File
@@ -82,7 +82,7 @@ func marginalFeeLost(tpl *bitcoind.BlockTemplate, txid string, txVsize int64) in
}
// The displaced tx's fee is the revenue we lose. We don't add the
// boosted tx's real fee back because the pool never had it — the tx
// boosted tx's real fee back because the pool never had it, the tx
// wasn't in the template before the boost.
lost := marginal.Fee
if lost < 0 {
@@ -261,7 +261,7 @@ func (s *Service) cleanupOnce(ctx context.Context) {
for _, tx := range txs {
_, err := s.RPC.GetMempoolEntry(ctx, tx.Txid)
if err != nil && bitcoind.IsRPCError(err, -5) {
// Tx no longer in mempool — confirmed or dropped.
// Tx no longer in mempool, confirmed or dropped.
if err := s.Store.RemoveBoostedTx(tx.Txid); err != nil {
s.Log.Warn("accelerator cleanup: remove failed", "txid", tx.Txid, "err", err)
} else {
+1 -1
View File
@@ -140,7 +140,7 @@ func isRetryable(err error) bool {
if err == nil {
return false
}
// rpcError values come from bitcoind itself — check for warmup/
// rpcError values come from bitcoind itself, check for warmup/
// loading messages that mean "ask again in a moment".
var rerr *rpcError
if errors.As(err, &rerr) {
+2 -2
View File
@@ -119,7 +119,7 @@ func TestCall_RetryOnTransient503(t *testing.T) {
}
func TestCall_ExhaustsRetries(t *testing.T) {
// Server always returns 503 — all 3 attempts should fail.
// Server always returns 503, all 3 attempts should fail.
srv, calls := jsonRPCServer(t, 99, nil)
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
@@ -134,7 +134,7 @@ func TestCall_ExhaustsRetries(t *testing.T) {
func TestCall_NoRetryOnSemanticRPCError(t *testing.T) {
// Server returns a semantic RPC error (block not found). Should not be
// retried — the answer is definitive.
// retried, the answer is definitive.
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
+1 -1
View File
@@ -53,7 +53,7 @@ var ErrProtocol = errors.New("ckpool: protocol error")
// or SocketConnector.
//
// If the server closes the connection before writing a response (common
// during ckpool warm-up — the stratifier accepts the socket but its stats
// during ckpool warm-up, the stratifier accepts the socket but its stats
// aren't populated yet), Send retries once after a short backoff.
func (c *Client) Send(ctx context.Context, sockName, cmd string) ([]byte, error) {
buf, err := c.sendOnce(ctx, sockName, cmd)
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Server) Handler() http.Handler {
func spaHandler(root fs.FS) http.Handler {
fileServer := http.FileServerFS(root)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defence-in-depth — /api routes are matched by the mux first
// Defence-in-depth, /api routes are matched by the mux first
// with their GET patterns, but a bare POST /api/... would fall
// through here. Return 404 so we don't accidentally shadow
// API semantics with HTML.
+1 -1
View File
@@ -3,7 +3,7 @@
// Scope: push JSON snapshots to subscribed clients. We never expect
// client payloads larger than a ping/pong and we don't negotiate
// extensions (compression, fragmentation). Anything fancier is out of
// scope for Phase 2b — upgrade to a real ws library once we can run
// scope for Phase 2b, upgrade to a real ws library once we can run
// `go mod tidy` in a real build environment.
package httpapi
+10 -10
View File
@@ -1,4 +1,4 @@
// Package logmon tails the ckpool log file looking for notable events —
// Package logmon tails the ckpool log file looking for notable events,
// primarily block-solve lines, which are the most reliable signal we have
// that a block was found, short of a ZMQ hashblock subscription.
//
@@ -39,7 +39,7 @@ type BlockEvent struct {
}
// AttemptEvent is emitted when ckpool logs a "Possible block solve"
// or "Submitting possible block solve" line — i.e. ckpool decided a
// or "Submitting possible block solve" line, i.e. ckpool decided a
// share met network difficulty and is attempting to submit it to
// bitcoind. This precedes the "Solved and confirmed" line that drives
// BlockEvent. Counting attempts vs confirmations surfaces submission
@@ -52,7 +52,7 @@ type AttemptEvent struct {
}
// LatencyEvent is emitted when ckpool logs the "Block update latency"
// line from our patch 0005 — the milliseconds between ZMQ trigger and
// line from our patch 0005, the milliseconds between ZMQ trigger and
// mining.notify broadcast completion.
type LatencyEvent struct {
SeenAt time.Time
@@ -156,8 +156,8 @@ var (
// The first number after "diff " is sdiff (share difficulty solved).
acceptedShareRE = regexp.MustCompile(`Accepted client \S+ share diff ([0-9.]+)/[^:]+:\s*([0-9a-fA-F]+)`)
// Rejected shares come in two forms:
// 1. "dupe diff" / "high diff" — share was valid but duplicate or below target
// 2. "invalid share <reason>" — share was structurally invalid (Stale, etc.)
// 1. "dupe diff" / "high diff", share was valid but duplicate or below target
// 2. "invalid share <reason>", share was structurally invalid (Stale, etc.)
rejectedDiffRE = regexp.MustCompile(`Rejected client \S+ (\w+) diff [0-9.]+/`)
rejectedInvalRE = regexp.MustCompile(`Rejected client \S+ invalid share (.+)`)
)
@@ -171,7 +171,7 @@ const powDataMarker = "Best share PoW data "
// parsePowLine extracts the PoW record from a log line carrying
// powDataMarker. It decodes exactly one JSON value starting at the first
// '{' after the marker and deliberately ignores everything after it —
// '{' after the marker and deliberately ignores everything after it,
// production logs have carried trailing bytes after the closing brace,
// which silently defeated an end-of-line-anchored regex. Returns
// (nil, nil) when the marker is absent, and a non-nil error for a
@@ -368,7 +368,7 @@ func (t *Tailer) handleLine(line string) {
default:
}
}
// Don't return — an accepted share may also be a block solve,
// Don't return, an accepted share may also be a block solve,
// so let the solve-diff regex below get a chance to match too.
}
// Rejected share with diff info: "Rejected client <id> dupe|high diff ..."
@@ -407,7 +407,7 @@ func (t *Tailer) handleLine(line string) {
select {
case t.Attempts <- AttemptEvent{SeenAt: time.Now(), ShareDiff: d, RawLine: line}:
default:
// Best-effort metric — dropping is fine.
// Best-effort metric, dropping is fine.
}
return
}
@@ -484,7 +484,7 @@ func FindBestShareHash(path string) (float64, string) {
// of any length can never abort the scan: an oversized line is passed to
// fn truncated to the buffer size and its remainder is discarded. A
// months-old production log with one pathological line must not silently
// stop a backfill scan partway through — that failure mode is invisible
// stop a backfill scan partway through, that failure mode is invisible
// and permanent.
func scanLines(f *os.File, fn func(line string)) error {
r := bufio.NewReaderSize(f, 256*1024)
@@ -513,7 +513,7 @@ var logStampRE = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`)
// by an "Accepted client" line with the same hash. Used as a one-time
// backfill when kamado-api holds no persisted record but the log
// already contains some (fresh database, or PoW events dropped under
// load) — without it the PoW inspector would sit empty until a share
// load), without it the PoW inspector would sit empty until a share
// beat ckpool's in-process best, which after a long uptime can take
// arbitrarily long. The returned time is parsed from the log line's
// timestamp; zero when unparseable. Returns a nil record if no confirmed
+3 -3
View File
@@ -77,7 +77,7 @@ func TestHandleLine_DiffResetAfterBlock(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Possible block solve diff 16384 !\n")
tl.handleLine("Solved and confirmed block 100\n")
// Second block — no preceding attempt line; diff must NOT carry over.
// Second block, no preceding attempt line; diff must NOT carry over.
tl.handleLine("Solved and confirmed block 101\n")
drain := func() BlockEvent {
@@ -217,7 +217,7 @@ func TestFindBestPowData(t *testing.T) {
// Production ckpool logs have carried bytes after the JSON's closing
// brace (observed in the field: an end-of-line-anchored regex matched 0
// of 54 real PoW lines while synthetic lines passed). The parser must
// decode the JSON value and ignore ANY trailing bytes — NULs, \r,
// decode the JSON value and ignore ANY trailing bytes, NULs, \r,
// or an interleaved second message on the same line.
func TestHandleLine_PowDataTrailingGarbage(t *testing.T) {
suffixes := []string{
@@ -240,7 +240,7 @@ func TestHandleLine_PowDataTrailingGarbage(t *testing.T) {
}
// A pathologically long line earlier in the log must not abort the scan
// before it reaches the PoW records — bufio.Scanner did exactly that,
// before it reaches the PoW records, bufio.Scanner did exactly that,
// silently, which left the backfill permanently empty on real logs.
func TestFindBestPowData_SurvivesOversizedLine(t *testing.T) {
path := tempLog(t)
+18 -18
View File
@@ -37,7 +37,7 @@ type BestSharePow struct {
// StratumServer describes one entry in ckpool's serverurl[] array. ckpool
// tags every client with the index of the bind it arrived on, but the index
// alone doesn't say what that bind *is* — that depends on how the deployment
// alone doesn't say what that bind *is*, that depends on how the deployment
// rendered ckpool.conf. Declaring the array lets the dashboard report the
// actual transport (plaintext, TLS with which certificate) instead of
// hardcoding a bind order.
@@ -77,7 +77,7 @@ type Snapshot struct {
// Full PoW reproduction data (raw block header, coinbase, merkle
// branches) for the best share seen since ckpool patch 0008 was
// deployed. May describe a lower-diff share than BestDiff when the
// all-time best predates the patch — the UI compares hashes and
// all-time best predates the patch, the UI compares hashes and
// shows a disclaimer in that case.
BestSharePow *BestSharePow `json:"best_share_pow,omitempty"`
// Last best-diff value acknowledged by the user via the UI.
@@ -129,7 +129,7 @@ type Snapshot struct {
// Counts of share-submit attempts ("Possible/Submitting block solve"
// log lines) and confirmed solves ("Solved and confirmed block").
// A growing gap means bitcoind is rejecting our submissions or
// dropping the RPC — surface it in the UI as an alert.
// dropping the RPC, surface it in the UI as an alert.
BlockSubmitAttempts int64 `json:"block_submit_attempts"`
BlockSubmitsConfirmed int64 `json:"block_submits_confirmed"`
@@ -186,7 +186,7 @@ const (
// kvCumulativeWork is versioned: "cumulative_shares" (v1) accidentally
// summed pool.shares (raw per-share count), which is meaningless for
// hashrate math. "cumulative_work" (v2) sums pool.accepted — the
// hashrate math. "cumulative_work" (v2) sums pool.accepted, the
// diff-1-normalized work, so cumulative_work * 2^32 is real hashes.
// The old key is orphaned in the kv table on upgrade; harmless.
kvCumulativeWork = "cumulative_work"
@@ -264,14 +264,14 @@ type Aggregator struct {
// Cumulative pool work in diff-1-normalized shares, surviving
// ckpool restarts. Maintained by re-reading pool.Shares each
// refresh and integrating only the positive delta — a decrease
// refresh and integrating only the positive delta, a decrease
// means ckpool restarted and its counter reset to 0, so we reset
// our delta baseline without losing the accumulated total.
//
// hasPoolSharesBaseline guards against double-counting on api
// restart: cumulativeShares is loaded from disk, and the first
// post-load observation of pool.Shares only establishes the
// baseline — the current ckpool counter was already accounted for
// baseline, the current ckpool counter was already accounted for
// before we crashed.
cumulativeShares float64
lastPoolShares float64
@@ -395,7 +395,7 @@ type Aggregator struct {
loadOnce sync.Once
// statsLoaded reports that the alltime share statistics have been
// read back from the store. Persisting before that would write the
// zero-valued in-memory counters over the accumulated history — the
// zero-valued in-memory counters over the accumulated history, the
// first share event triggers a save, so this is the difference
// between a restart and permanent data loss.
statsLoaded bool
@@ -419,7 +419,7 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
// Run calls it, but callers should invoke it directly before starting
// any ingest goroutine: those goroutines mutate the same counters and
// persist them on their first event, so a restore still in flight would
// be overwritten by — and then read back as — near-empty state. Safe to
// be overwritten by, and then read back as, near-empty state. Safe to
// call repeatedly; only the first call does the work.
func (a *Aggregator) LoadPersisted() {
a.loadOnce.Do(func() {
@@ -429,7 +429,7 @@ func (a *Aggregator) LoadPersisted() {
}
// Ready returns a channel that's closed once the aggregator has
// completed its first refresh — used by main to delay HTTP serving
// completed its first refresh, used by main to delay HTTP serving
// until /api/snapshot reflects real state instead of zeros.
func (a *Aggregator) Ready() <-chan struct{} {
return a.ready
@@ -580,7 +580,7 @@ func (a *Aggregator) refresh(ctx context.Context) {
// Measuring to wall-clock time instead adds the partial, in-progress
// interval to the elapsed span without adding an interval to divide
// it by. That skews the estimate toward "difficulty will fall" by
// however long it has been since the last block — which averages a
// however long it has been since the last block, which averages a
// full 600s and, a handful of blocks into an epoch, is most of the
// sample. It washes out only once enough blocks accumulate to dwarf
// it, which is why the estimate used to settle down around mid-epoch.
@@ -651,12 +651,12 @@ func (a *Aggregator) refresh(ctx context.Context) {
// --- cumulative work tracking ---
// Integrate only positive deltas on pool.Accepted, which is ckpool's
// accounted_diff_shares — the sum of each accepted share's
// accounted_diff_shares, the sum of each accepted share's
// difficulty, in diff-1-normalized units. pool.Shares is the raw
// per-share count and is useless for hashrate math. A decrease
// means ckpool restarted (or its state reset); we reset the
// baseline without touching the accumulated total. The first
// observation after load only establishes the baseline — the
// observation after load only establishes the baseline, the
// current counter was already integrated before we shut down.
if next.Pool != nil {
cur := float64(next.Pool.Accepted)
@@ -687,7 +687,7 @@ func (a *Aggregator) refresh(ctx context.Context) {
if curAcc >= a.lastPoolAcceptedRaw {
a.allTimeAccepted += curAcc - a.lastPoolAcceptedRaw
} else {
// ckpool restarted — reset session share stats.
// ckpool restarted, reset session share stats.
a.resetSessionShareStats()
}
if curRej >= a.lastPoolRejectedRaw {
@@ -866,8 +866,8 @@ func estimateRetargetPercent(inEpoch int64, elapsed float64) float64 {
return 0
}
// Consensus budgets retargetInterval*600 seconds for an epoch but
// spans them across retargetInterval-1 inter-block intervals — the
// long-standing off-by-one — so on-target pace is fractionally above
// spans them across retargetInterval-1 inter-block intervals, the
// long-standing off-by-one, so on-target pace is fractionally above
// 600s per block, and a perfectly on-time epoch retargets by ~0%
// rather than a hair negative.
targetPerInterval := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
@@ -1000,7 +1000,7 @@ func (a *Aggregator) IngestShareEvents(ctx context.Context, events <-chan logmon
// stats that were lost or truncated: the log is the only other record of
// per-share difficulty, and ckpool keeps its full history there.
//
// Deliberately never automatic — the scan walks every line of a log that
// Deliberately never automatic, the scan walks every line of a log that
// can hold millions, and it was a long startup scan that made the race
// this repairs so easy to hit in the first place.
func (a *Aggregator) RebuildShareStatsFromLog() (adopted bool, scanned int64, err error) {
@@ -1085,7 +1085,7 @@ func (a *Aggregator) persistShareStats() {
// loadPersistedState restores cumulative work and hashrate history from
// the store so they survive process restarts. Safe to call with a nil
// Store — becomes a no-op.
// Store, becomes a no-op.
func (a *Aggregator) loadPersistedState() {
if a.Store == nil {
return
@@ -1173,7 +1173,7 @@ func (a *Aggregator) loadPersistedState() {
if logDiff, h := logmon.FindBestShareHash(a.LogFilePath); h != "" {
a.mu.Lock()
a.bestShareHash = h
// The log may contain the true best — adopt it if higher.
// The log may contain the true best, adopt it if higher.
if logDiff > a.bestDiff {
a.bestDiff = logDiff
if a.Store != nil {
+15 -15
View File
@@ -168,7 +168,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
// Best-effort enrich with hash + coinbase reward + miner via bitcoind.
// We look up the hash from height, then fetch the full block
// (verbosity 2) to sum the coinbase outputs and extract the
// payout address. Both are fire-and-forget — if bitcoind is
// payout address. Both are fire-and-forget, if bitcoind is
// down we still record the block with whatever we have.
if a.RPC != nil {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
@@ -200,7 +200,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
if err != nil {
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
} else if !inserted {
a.Log.Warn("block at this height already recorded — duplicate or pre-existing entry, not counting as new",
a.Log.Warn("block at this height already recorded, duplicate or pre-existing entry, not counting as new",
"height", rec.Height, "hash", rec.Hash)
isNew = false
}
@@ -257,7 +257,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// We need the current chain for both enrichment and reorg detection.
// If the first refresh hasn't completed yet, currentChain is "" and
// any chain comparison would be meaningless — skip until the next tick.
// any chain comparison would be meaningless, skip until the next tick.
a.mu.RLock()
currentChain := ""
if a.snap.Chain != nil {
@@ -269,7 +269,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
}
// Pass 1: enrichment. Fetch hash + reward for any missing-data rows.
// Skip blocks from a different chain — enriching a testnet block
// Skip blocks from a different chain, enriching a testnet block
// against mainnet RPC would overwrite its hash with the wrong value.
missing, err := a.Store.BlocksNeedingEnrichment(since)
if err != nil {
@@ -350,10 +350,10 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
canonical, herr := a.RPC.GetBlockHash(lookupCtx, b.Height)
cancel()
if herr != nil {
continue // RPC transient error — retry next sweep
continue // RPC transient error, retry next sweep
}
if canonical == b.Hash {
continue // matches the canonical chain — all good
continue // matches the canonical chain, all good
}
// Hash mismatch. Determine whether it's a real reorg or a
// cross-network block by checking if our hash exists on this chain.
@@ -391,7 +391,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// Pass 3: un-orphan blocks that were previously false-positived.
// This covers two cases:
// a) Blocks with Chain != currentChain that got orphaned before this
// fix was deployed — un-orphan them now.
// fix was deployed, un-orphan them now.
// b) Same-chain blocks whose hash now matches the canonical chain
// (e.g. the "reorg" reverted before we checked again).
fixedAny := false
@@ -403,7 +403,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
continue
}
if b.Chain != "" && b.Chain != currentChain {
// Cross-network orphan from before the fix — un-orphan.
// Cross-network orphan from before the fix, un-orphan.
if err := a.Store.UnmarkOrphaned(b.Height); err != nil {
a.Log.Warn("reconcile: unmark cross-network orphan failed", "height", b.Height, "err", err)
continue
@@ -418,7 +418,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
_, berr := a.RPC.GetBlock(lookupCtx, b.Hash)
cancel()
if berr != nil {
// Block doesn't exist on this chain — cross-network.
// Block doesn't exist on this chain, cross-network.
otherChain := b.Chain
if otherChain == "" || otherChain == currentChain {
otherChain = inferOtherChain(currentChain)
@@ -432,7 +432,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
fixedAny = true
continue
}
// Block exists on this chain — check if it's canonical again.
// Block exists on this chain, check if it's canonical again.
lookupCtx2, cancel2 := context.WithTimeout(ctx, 3*time.Second)
canonical, herr := a.RPC.GetBlockHash(lookupCtx2, b.Height)
cancel2()
@@ -444,7 +444,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
a.Log.Warn("reconcile: unmark reverted-reorg failed", "height", b.Height, "err", err)
continue
}
a.Log.Info("reconcile: cleared orphan — hash matches canonical again",
a.Log.Info("reconcile: cleared orphan, hash matches canonical again",
"height", b.Height, "hash", b.Hash)
fixedAny = true
}
@@ -452,9 +452,9 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// Pass 4: stamp chain on legacy/mis-stamped blocks that have a hash.
// Handles Chain="" (never stamped), "other" (old fallback), and "test"
// (wrong identifier — testnet4 reports as "testnet4", not "test").
// (wrong identifier, testnet4 reports as "testnet4", not "test").
// Uses GetBlock(hash) to determine if the block belongs to the current
// network or a different one. No lookback restriction — this is a
// network or a different one. No lookback restriction, this is a
// one-time migration for pre-existing rows.
for _, b := range recent {
if (b.Chain != "" && b.Chain != "other" && b.Chain != "test") || b.Hash == "" {
@@ -464,7 +464,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
_, berr := a.RPC.GetBlock(lookupCtx, b.Hash)
cancel()
if berr != nil {
// Block not found on this network — infer the other chain.
// Block not found on this network, infer the other chain.
inferredChain := inferOtherChain(currentChain)
if err := a.Store.StampChain(b.Height, inferredChain); err != nil {
a.Log.Warn("reconcile: stamp legacy block failed", "height", b.Height, "err", err)
@@ -545,7 +545,7 @@ func (a *Aggregator) appendBlock(rec BlockRecord) Snapshot {
}
// loadPersistedBlocks seeds a.blocks from the store so history survives
// restarts. Safe to call with a nil Store — becomes a no-op.
// restarts. Safe to call with a nil Store, becomes a no-op.
func (a *Aggregator) loadPersistedBlocks() {
if a.Store == nil {
return
+10 -10
View File
@@ -90,7 +90,7 @@ func setChain(agg *Aggregator, chain string) {
// ---- reconcileOnce tests ----
// TestReconcileOnce_ChainFilter verifies that a block recorded on testnet
// is NOT orphaned when the node is now on mainnet — even if the canonical
// is NOT orphaned when the node is now on mainnet, even if the canonical
// hash at that height on mainnet is different.
func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
st := openTempStore(t)
@@ -109,7 +109,7 @@ func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
}
// Mock RPC: getblockhash returns a different mainnet hash; getblock
// for our testnet hash returns "Block not found" — it doesn't exist
// for our testnet hash returns "Block not found", it doesn't exist
// on mainnet at all, confirming it's cross-network not a reorg.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
switch method {
@@ -132,7 +132,7 @@ func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
}
for _, b := range blocks {
if b.Height == 100 && !b.OrphanedAt.IsZero() {
t.Error("testnet block was falsely orphaned after switching to mainnet — chain filter broken")
t.Error("testnet block was falsely orphaned after switching to mainnet, chain filter broken")
}
}
}
@@ -156,7 +156,7 @@ func TestReconcileOnce_GenuineReorg(t *testing.T) {
}
// getblockhash returns a different canonical hash (reorg happened).
// getblock for our hash SUCCEEDS — the block still exists in bitcoind's
// getblock for our hash SUCCEEDS, the block still exists in bitcoind's
// block store after a reorg, it's just no longer on the active chain.
// This distinguishes a real reorg from a cross-network block.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
@@ -223,7 +223,7 @@ func TestReconcileOnce_RPCError_NoOrphan(t *testing.T) {
blocks, _ := st.Recent(10)
for _, b := range blocks {
if b.Height == 300 && !b.OrphanedAt.IsZero() {
t.Error("block was orphaned on RPC error — should only orphan on definitive hash mismatch")
t.Error("block was orphaned on RPC error, should only orphan on definitive hash mismatch")
}
}
}
@@ -240,7 +240,7 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
RewardBT: 3.125,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
Chain: "", // legacy, no chain recorded
})
// Canonical hash differs AND getblock succeeds (block exists on this
@@ -250,7 +250,7 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
case "getblockhash":
return "different_canonical", nil
case "getblock":
// Block exists in this chain's store — genuine reorg
// Block exists in this chain's store, genuine reorg
return map[string]any{"hash": "legacy_hash", "height": 400, "confirmations": -1}, nil
}
return nil, nil
@@ -281,11 +281,11 @@ func TestReconcileOnce_LegacyBlock_CrossNetwork(t *testing.T) {
RewardBT: 50,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
Chain: "", // legacy, no chain recorded
})
// Canonical hash differs AND getblock fails (block doesn't exist on
// this network at all). Should NOT orphan — should stamp as "other".
// this network at all). Should NOT orphan, should stamp as "other".
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
switch method {
case "getblockhash":
@@ -436,6 +436,6 @@ func TestIngestBlockEvents_ChainStamping(t *testing.T) {
t.Fatal("block not found in store")
}
if blocks[0].Chain != "test" {
t.Errorf("Chain = %q, want test — chain stamping broken", blocks[0].Chain)
t.Errorf("Chain = %q, want test, chain stamping broken", blocks[0].Chain)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func TestPowEndToEnd_LogToSnapshot(t *testing.T) {
}
// The snapshot must expose it under best_share_pow with the flattened
// PowData fields — this is the contract the UI reads.
// PowData fields, this is the contract the UI reads.
snap := Snapshot{BestSharePow: func() *BestSharePow {
a.mu.RLock()
defer a.mu.RUnlock()
+3 -3
View File
@@ -103,8 +103,8 @@ func TestPowPairing_UnmatchedIsNotPromoted(t *testing.T) {
// The all-time best share may hugely outrank anything recent (found
// before header capture existed). The PoW record must still populate
// from the first confirmed share — it tracks "best since the feature
// went live", never waiting for the all-time best to be beaten — while
// from the first confirmed share, it tracks "best since the feature
// went live", never waiting for the all-time best to be beaten, while
// leaving the all-time bestDiff/bestShareHash untouched.
func TestPowPairing_BelowAllTimeBestStillPopulates(t *testing.T) {
a := New(nil, nil, time.Minute, discardLog())
@@ -210,7 +210,7 @@ func TestPowPairing_LowerDiffDoesNotReplace(t *testing.T) {
// statistics: the ingest goroutine runs concurrently with the restore,
// and its first event triggers a persist. If that persist is allowed to
// run before the counters are loaded, it writes the zero-valued
// in-memory histogram over the stored history — which the restore then
// in-memory histogram over the stored history, which the restore then
// reads back, making the loss permanent.
func TestShareStats_NotPersistedBeforeLoad(t *testing.T) {
s := openTempStore(t)
+2 -2
View File
@@ -8,7 +8,7 @@ import (
// The estimate must be stable from the very first blocks of an epoch:
// three blocks on target pace should read the same ~0% as a thousand.
// The old wall-clock measurement made early readings swing wildly
// because the time since the last block — 600s on average — was counted
// because the time since the last block, 600s on average, was counted
// into the elapsed span without a matching interval to divide it by.
func TestEstimateRetargetPercent_StableEarlyInEpoch(t *testing.T) {
// Exactly on-pace: each interval takes the consensus target.
@@ -82,7 +82,7 @@ func TestEstimateRetargetPercent_WallClockBiasWasWorstEarly(t *testing.T) {
t.Errorf("mid-epoch wall-clock error = %+.2f%%, expected it to have washed out", mid)
}
// And with block timestamps, that same on-pace epoch reads ~0 at
// both points — which is the actual fix.
// both points, which is the actual fix.
if got := estimateRetargetPercent(3, 3*onPace); math.Abs(got) > 0.01 {
t.Errorf("block-timestamp measurement at 3 blocks = %+.4f%%, want ~0%%", got)
}
+4 -4
View File
@@ -126,7 +126,7 @@ func (s *BlockStore) Close() error {
return s.db.Close()
}
// InsertBlock is idempotent — duplicate heights are ignored so replayed
// InsertBlock is idempotent, duplicate heights are ignored so replayed
// log events after a restart don't trip the primary key constraint.
// Returns (true, nil) if a new row was actually inserted, (false, nil)
// if the height was already present (replay or duplicate).
@@ -247,7 +247,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
// BlocksNeedingEnrichment returns blocks whose hash or reward is still
// unset and that were found within the lookback window. Older entries
// are ignored — if bitcoind couldn't tell us about a day-old block,
// are ignored, if bitcoind couldn't tell us about a day-old block,
// retrying will keep failing.
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
rows, err := s.db.Query(
@@ -300,7 +300,7 @@ func (s *BlockStore) MarkOrphaned(height int64, at time.Time) error {
// UnmarkOrphaned clears the orphaned_at stamp set by MarkOrphaned.
// Used to correct false positives caused by a network switch: a block
// found on testnet is not "orphaned" when the node switches to mainnet —
// found on testnet is not "orphaned" when the node switches to mainnet,
// it simply belongs to a different chain and must not be compared against
// mainnet's canonical history.
func (s *BlockStore) UnmarkOrphaned(height int64) error {
@@ -326,7 +326,7 @@ func (s *BlockStore) UnmarkOrphanedStampChain(height int64, chain string) error
// StampChain sets the chain field on a block without touching orphaned_at.
// Used when the reconcile loop determines a block belongs to a different
// network — the block is not orphaned (a reorg), it just doesn't belong
// network, the block is not orphaned (a reorg), it just doesn't belong
// to the current chain.
func (s *BlockStore) StampChain(height int64, chain string) error {
_, err := s.db.Exec(
+2 -2
View File
@@ -201,14 +201,14 @@ func TestMigrate_AddsChainColumn(t *testing.T) {
}
db.Close()
// Open via our store — migrate() must add the chain column.
// Open via our store, migrate() must add the chain column.
s, err := Open(f.Name())
if err != nil {
t.Fatalf("Open after migration: %v", err)
}
defer s.Close()
// Insert a block with a chain value — would fail if migration didn't add
// Insert a block with a chain value, would fail if migration didn't add
// the column.
inserted, err := s.InsertBlock(Block{
Height: 1000,
+1 -1
View File
@@ -9,7 +9,7 @@
// inside the image build.
//
// A placeholder index.html is committed so `go build` succeeds on a
// fresh checkout without anyone having run `make ui` — it just shows
// fresh checkout without anyone having run `make ui`, it just shows
// a "UI not built" notice instead of the real dashboard.
package webui
+2 -2
View File
@@ -1,7 +1,7 @@
// Package zmqmon subscribes to bitcoind's ZMQ `hashblock` topic and
// emits a lightweight event each time a new chain tip appears.
//
// This is strictly an "immediate refresh trigger" — it does NOT replace
// This is strictly an "immediate refresh trigger", it does NOT replace
// the ckpool log tailer, which still handles our own solved-block
// detection. ZMQ just shortens the latency between bitcoind seeing a
// new tip and the Kamado dashboard reflecting it (otherwise we'd wait
@@ -34,7 +34,7 @@ type Monitor struct {
}
// New returns a Monitor with a buffered event channel. Endpoint may be
// empty, in which case Run exits immediately as a no-op — useful for
// empty, in which case Run exits immediately as a no-op, useful for
// deployments where ZMQ isn't configured on bitcoind.
func New(endpoint string, log *slog.Logger) *Monitor {
return &Monitor{
+1 -1
View File
@@ -2,7 +2,7 @@
# CKPool-solo build stage
# ============================================================================
# Builds a patched ckpool binary from upstream source at a pinned commit.
# Binaries land in /build/ckpool/src/ — we copy `ckpool` and `ckpmsg` into
# Binaries land in /build/ckpool/src/, we copy `ckpool` and `ckpmsg` into
# the runtime image. We do NOT `make install` because upstream's
# install-exec-hook calls setcap which fails inside a docker build.
#
+1 -1
View File
@@ -61,7 +61,7 @@ TEMPLATE=/etc/ckpool/ckpool.conf.template
CONF=/etc/ckpool/ckpool.conf
# Simple placeholder substitution (no envsubst dependency).
# We intentionally don't use eval/printf tricks — sed is safe and predictable.
# We intentionally don't use eval/printf tricks, sed is safe and predictable.
sed \
-e "s|\${BITCOIN_RPC_HOST}|${BITCOIN_RPC_HOST}|g" \
-e "s|\${BITCOIN_RPC_PORT}|${BITCOIN_RPC_PORT}|g" \
@@ -5,15 +5,15 @@ index 7940d4fa..a856b3eb 100644
@@ -5865,6 +5865,65 @@ out_nouserwb:
return wb->coinb2bin;
}
+/* Kamado (patches/0008): highest share diff for which PoW reproduction
+ * data has been logged in this process run. Deliberately unlocked — a
+ * data has been logged in this process run. Deliberately unlocked, a
+ * race between share-processing threads can at worst emit a duplicate
+ * or out-of-order log line, which kamado-api deduplicates by diff. */
+static double best_pow_diff;
+
+/* Kamado (patches/0008): log everything needed to independently
+ * reproduce and verify the proof of work of a best share — the
+ * reproduce and verify the proof of work of a best share, the
+ * serialized 80-byte block header exactly as it was hashed, plus the
+ * assembled coinbase transaction and merkle branch hashes that
+ * reproduce the merkle root committed in that header. kamado-api
@@ -56,7 +56,7 @@ index 7940d4fa..a856b3eb 100644
+ if (likely(s)) {
+ /* Call logmsg directly rather than LOGNOTICE: the LOGMSG
+ * macro splits messages into DEFLOGBUFSIZ (512-byte) chunks,
+ * each written as a separate timestamped log line — and its
+ * each written as a separate timestamped log line, and its
+ * length arithmetic drops everything past ~3 chunks. This
+ * record is ~2.5KB of JSON that kamado-api must read back as
+ * one intact line. logmsg itself is unbounded. */
@@ -71,13 +71,13 @@ index 7940d4fa..a856b3eb 100644
@@ -5937,6 +5996,19 @@ static double submission_diff(sdata_t *sdata, const stratum_instance_t *client,
/* Calculate the diff of the share here */
ret = diff_from_target(hash);
+ /* Kamado (patches/0008): if this is the highest-diff share this
+ * process has seen, log full PoW reproduction data for the
+ * dashboard's best-share header inspector. Skip shares that will
+ * be rejected: stale ones, and those outside the ntime roll
+ * window (mirrors the check in parse_submit). Duplicates cannot
+ * trigger this — an identical share has an equal, not greater,
+ * trigger this, an identical share has an equal, not greater,
+ * diff. */
+ if (unlikely(ret > best_pow_diff && !stale &&
+ ntime32 >= wb->ntime32 && ntime32 <= wb->ntime32 + 7000)) {
@@ -87,4 +87,4 @@ index 7940d4fa..a856b3eb 100644
+
/* Test we haven't solved a block regardless of share status */
test_blocksolve(client, wb, swap, hash, ret, coinbase, cblen, nonce2, nonce, ntime32, version_mask, stale);
@@ -5,7 +5,7 @@ index a856b3eb..fcb9eb94 100644
@@ -5477,6 +5477,69 @@ static void update_solo_client(sdata_t *sdata, workbase_t *wb, const int64_t cli
stratum_add_send(sdata, json_msg, client_id, SM_UPDATE);
}
+/* Kamado (patches/0009): restore a reconnecting miner's difficulty and
+ * grant the work it already holds a difficulty grace window.
+ *
@@ -75,17 +75,17 @@ index a856b3eb..fcb9eb94 100644
@@ -5558,6 +5621,19 @@ out:
sdata_t *sdata = ckp->sdata;
workbase_t *wb;
+ /* Kamado (patches/0009): pick up where vardiff left off and
+ * arm the first-job grace window. Called before any lock is
+ * taken here — it acquires the workbase lock itself. */
+ * taken here, it acquires the workbase lock itself. */
+ resume_client_diff(ckp, sdata, client);
+
+ /* Kamado (patches/0009): mining.set_difficulty must precede
+ * the first mining.notify. Upstream sent the job first, so a
+ * miner started hashing it at whatever difficulty its firmware
+ * defaults to and its early shares came back under the
+ * difficulty we had assigned — a burst of "high diff" rejects
+ * difficulty we had assigned, a burst of "high diff" rejects
+ * from every miner on every pool restart. */
+ stratum_send_diff(sdata, client);
+
@@ -104,7 +104,7 @@ index a856b3eb..fcb9eb94 100644
@@ -6567,9 +6641,12 @@ static void update_client(const stratum_instance_t *client, const int64_t client
{
sdata_t *sdata = client->sdata;
+ /* Kamado (patches/0009): difficulty before work, so a client never
+ * starts a job without knowing the difficulty it will be judged
+ * against. Upstream sent the update first. */
@@ -113,5 +113,5 @@ index a856b3eb..fcb9eb94 100644
stratum_send_update(sdata, client_id, true);
- stratum_send_diff(sdata, client);
}
static json_params_t
+3 -3
View File
@@ -25,7 +25,7 @@ Upstream tracks `best_ever` internally in `user_instance_t` / `worker_instance_t
and zeroes `best_diff` on every block solve via `reset_bestshares()`. That
is correct: `bestdiff` is "best share in the current round". But the runtime
socket API (`userinfo()` / `workerinfo()` in `stratifier.c`) only emits
`bestdiff`, so any consumer that talks to the socket — like `kamado-api` —
`bestdiff`, so any consumer that talks to the socket, like `kamado-api`,
sees the best share reset to 0 after every block and has no all-time field
to fall back on. The on-disk `users.json` / `workers.json` persistence files
do include `bestever`, but polling those is racy and lags the socket.
@@ -46,7 +46,7 @@ backport, plus several improvements:
| `66db3aa3` | Better vardiff for bursty hashers |
| `130c755d` | Fix unlikely fopen segfault |
| `0bd3d751` | Consistent error field in mining.submit rejections |
| `988b2687` | Version 1.0 — longstanding stability bump |
| `988b2687` | Version 1.0, longstanding stability bump |
Kamado therefore starts from a CKPool that is strictly ahead of what Bassin
ships today.
@@ -57,7 +57,7 @@ Use this directory for:
1. Fixes needed before they land upstream (and only after attempting
to submit upstream first).
2. Kamado-specific behavior changes that would not be accepted upstream —
2. Kamado-specific behavior changes that would not be accepted upstream,
e.g. tighter integration hooks with `kamado-api`.
3. Temporary workarounds with a clear removal plan, documented in the
patch commit message.
+1 -1
View File
@@ -1,5 +1,5 @@
# ----------------------------------------------------------------------------
# Kamado Pool — development compose
# Kamado Pool, development compose
#
# Spins up ckpool-solo and kamado-api pointing at a bitcoind. Expects
# bitcoind to be reachable at the host/port set in the env blocks below.
+10 -10
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Regtest smoke test for kamado-api — two phases:
# Regtest smoke test for kamado-api, two phases:
#
# Phase 1 (always): log-inject path
# Injects a "Solved and confirmed block N" line directly into the ckpool log
# and verifies the full tailer → aggregator → bitcoind RPC enrichment → /api/blocks
# chain. Quick, no ckpool binary needed.
#
# Phase 2 (auto): full stratum path — the complete revenue-critical path
# Phase 2 (auto): full stratum path, the complete revenue-critical path
# A Python stratum miner connects to ckpool, mines a real diff-1 SHA256d hash
# using multiprocessing, submits the winning share, ckpool calls bitcoind
# submitblock, logs "Solved and confirmed block N+1", and the kamado-api
@@ -73,7 +73,7 @@ fi
[[ -n "${WORK_DIR:-}" ]] || WORK_DIR=$(mktemp -d)
# Locate or build ckpool — required for Phase 2.
# Locate or build ckpool, required for Phase 2.
# Priority: CKPOOL_BIN env var → PATH → cached build → build from source.
CKPOOL_AVAILABLE=false
CKPOOL_DIR="$SCRIPT_DIR/../ckpool"
@@ -119,12 +119,12 @@ elif command -v gcc autoconf automake libtool pkg-config git >/dev/null 2>&1 \
CKPOOL_AVAILABLE=true
log "ckpool: build complete → $CKPOOL_BIN"
else
log "ckpool not found and build prerequisites missing — Phase 2 will be skipped"
log "ckpool not found and build prerequisites missing, Phase 2 will be skipped"
log " Missing one or more of: gcc, autoconf, automake, libtool, pkg-config, git, libzmq-dev"
log " Or set CKPOOL_BIN=/path/to/ckpool to skip the build"
fi
# Write the Python stratum miner — no build step needed; python3 is in PATH (checked above).
# Write the Python stratum miner, no build step needed; python3 is in PATH (checked above).
# The miner uses hashlib SHA256 midstate optimization + multiprocessing so it scales linearly
# with CPU count. Diff-1 requires on average 2^32 ≈ 4.3B hashes; at ~1.5 MH/s per core this
# takes ~43 minutes on a single core but ~3 minutes on 16 cores.
@@ -268,7 +268,7 @@ def main():
while True:
# Snapshot the job and en2 used to build this round's header.
# These must match exactly what we submit to ckpool — if job is
# These must match exactly what we submit to ckpool, if job is
# updated mid-round we still submit against the header we actually mined.
cur_job = job
cur_en2 = en2_int.to_bytes(en2_size, 'little')
@@ -427,7 +427,7 @@ touch "$CKPOOL_LOG"
# Kill any stale processes from a previous run that bypassed cleanup (e.g.
# killed with SIGKILL). Without this they hold the ports, the new processes
# fail to bind, but the readiness health-checks see the old processes
# and report "ready" — causing very confusing test failures.
# and report "ready", causing very confusing test failures.
log "clearing test ports from any prior stale run…"
for _port in $BTC_RPC_PORT $KAMADO_PORT $STRATUM_PORT; do
fuser -k -TERM "${_port}/tcp" 2>/dev/null || true
@@ -470,7 +470,7 @@ for i in $(seq 1 30); do
done
$ready || die "bitcoind did not become ready within 30s (see $WORK_DIR/bitcoind.log)"
kill -0 "$BITCOIN_PID" 2>/dev/null \
|| die "bitcoind (pid $BITCOIN_PID) is not running — another process may have answered on port $BTC_RPC_PORT"
|| die "bitcoind (pid $BITCOIN_PID) is not running, another process may have answered on port $BTC_RPC_PORT"
log "bitcoind ready (pid $BITCOIN_PID)"
# Create a descriptor wallet and mine enough blocks for coinbase maturity.
@@ -511,7 +511,7 @@ for i in $(seq 1 30); do
done
$ready || die "kamado-api did not become ready within 30s (see $WORK_DIR/kamado.log)"
kill -0 "$KAMADO_PID" 2>/dev/null \
|| die "kamado-api (pid $KAMADO_PID) is not running — port $KAMADO_PORT may already be in use"
|| die "kamado-api (pid $KAMADO_PID) is not running, port $KAMADO_PORT may already be in use"
log "kamado-api ready (pid $KAMADO_PID)"
# ============================================================
@@ -619,7 +619,7 @@ for i in $(seq 1 30); do
done
$ready || die "ckpool stratum port did not open within 30s (see $WORK_DIR/ckpool-stderr.log)"
kill -0 "$CKPOOL_PID" 2>/dev/null \
|| die "ckpool (pid $CKPOOL_PID) is not running — check $WORK_DIR/ckpool-stderr.log"
|| die "ckpool (pid $CKPOOL_PID) is not running, check $WORK_DIR/ckpool-stderr.log"
log "ckpool ready (pid $CKPOOL_PID)"
# ---- run Python stratum miner in background ---------------------------------
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Kamado Pool dashboard — Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
"description": "Kamado Pool dashboard, Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
"scripts": {
"dev": "vite",
"build": "vite build",
+8 -8
View File
@@ -17,7 +17,7 @@ export function formatHashrate(hs: number): string {
return `${v.toFixed(digits)} ${HASHRATE_UNITS[i]}`;
}
// Difficulty is a share-diff value — scale with k/M/G/T suffixes.
// Difficulty is a share-diff value, scale with k/M/G/T suffixes.
export function formatDifficulty(d: number): string {
if (!d || d <= 0 || !isFinite(d)) return "0";
const units = ["", "k", "M", "G", "T", "P"];
@@ -31,7 +31,7 @@ export function formatDifficulty(d: number): string {
return `${v.toFixed(digits)}${units[i]}`;
}
// Cumulative hashes — scale up to ZH / YH since pools accumulate fast.
// Cumulative hashes, scale up to ZH / YH since pools accumulate fast.
// Input is plain hashes (a diff-1 share is 2^32 hashes).
export function formatWork(hashes: number): string {
if (!hashes || hashes <= 0 || !isFinite(hashes)) return "0 H";
@@ -46,7 +46,7 @@ export function formatWork(hashes: number): string {
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return "—";
if (!seconds || seconds < 0) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -87,7 +87,7 @@ export function btcAddressOf(workername: string): string {
// Build the explorer base URL for the active network. If the user
// configured a custom mempool instance via StartOS, we trust that
// verbatim and don't append a /testnet4 / /signet path — the user's
// verbatim and don't append a /testnet4 / /signet path, the user's
// instance presumably runs on a single network already. Otherwise
// fall back to mempool.space with the appropriate network prefix.
export function explorerBaseFor(
@@ -105,8 +105,8 @@ export function explorerBaseFor(
// Resolve how a miner is connected from its ckpool serverurl[] index.
//
// The deployment declares the bind array via STRATUM_SERVERS (the StartOS
// wrapper renders one entry per bind it configures). When it hasn't — the
// dev compose stack, or an older wrapper — fall back to the historical
// wrapper renders one entry per bind it configures). When it hasn't, the
// dev compose stack, or an older wrapper, fall back to the historical
// layout, where index 0 is the plaintext bind and anything else is the
// loopback bind that stunnel forwards TLS traffic to.
export function connectionOf(
@@ -124,13 +124,13 @@ export function connectionOf(
// Detect common Bitcoin mining hardware from the stratum useragent
// string. This is a best-effort heuristic; unknown agents fall back
// to a stripped version of the raw string. Order matters — check
// to a stripped version of the raw string. Order matters, check
// more specific tokens before generic ones.
export function detectHardware(ua: string): string {
if (!ua) return "unknown";
const s = ua.toLowerCase();
const rules: Array<[RegExp, string]> = [
// Bitaxe and Nerd-family open-source ASICs. Order matters — check
// Bitaxe and Nerd-family open-source ASICs. Order matters, check
// narrower variants before the broader Nerd* / Bitaxe roots.
[/bitaxehex/, "Bitaxe Hex"],
[/bitaxe/, "Bitaxe"],
+32
View File
@@ -1,2 +1,34 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
// `qrcode` ships no types and has no @types package. Declare the surface we
// actually use, the donation QR in App.svelte and the proof QR on the share
// card, rather than leaving both call sites as implicit `any`.
declare module "qrcode" {
export type QRCodeErrorCorrectionLevel = "L" | "M" | "Q" | "H";
export interface QRCodeRenderOptions {
width?: number;
margin?: number;
scale?: number;
errorCorrectionLevel?: QRCodeErrorCorrectionLevel;
color?: { dark?: string; light?: string };
}
export function toCanvas(
canvas: HTMLCanvasElement,
text: string,
options?: QRCodeRenderOptions,
): Promise<void>;
export function toDataURL(
text: string,
options?: QRCodeRenderOptions,
): Promise<string>;
const QRCode: {
toCanvas: typeof toCanvas;
toDataURL: typeof toDataURL;
};
export default QRCode;
}
+4 -4
View File
@@ -69,7 +69,7 @@
} else if (feeLostSats > 0) {
success = `Boosted from ${from} to ${to} sat/vB. Revenue impact: -${feeLostSats.toLocaleString()} sats (${(feeLostSats / 1e8).toFixed(8)} BTC) per block mined.`;
} else {
success = `Boosted from ${from} to ${to} sat/vB. No revenue impact — the transaction was already in the template or the mempool fits in one block.`;
success = `Boosted from ${from} to ${to} sat/vB. No revenue impact, the transaction was already in the template or the mempool fits in one block.`;
}
txid = "";
feerateInput = "";
@@ -170,13 +170,13 @@
</p>
<p>
<strong>Revenue impact:</strong> The server inspects the current block template to find
the marginal transaction &mdash; the lowest fee-rate tx that would be displaced by the
the marginal transaction, the lowest fee-rate tx that would be displaced by the
boosted one. Its fee is the revenue you sacrifice per block mined, shown after each boost.
</p>
<p>
Your pool: {formatHashrate(poolHashrate)} /
{formatHashrate(networkHashrate)} network
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "—"} of blocks).
({poolShare > 0 ? `${(poolShare * 100).toFixed(6)}%` : "-"} of blocks).
</p>
</div>
</div>
@@ -202,7 +202,7 @@
</div>
<div class="field">
<label for="feerate-input">Target feerate (sat/vB) — max 2,000</label>
<label for="feerate-input">Target feerate (sat/vB), max 2,000</label>
<div class="feerate-row">
<input
id="feerate-input"
+6 -6
View File
@@ -70,7 +70,7 @@
return { c, cls: hVal === 0 ? "z-have" : "z-need" };
}
if (i === networkHexZeros) {
// Boundary char — must be <= target char
// Boundary char, must be <= target char
return { c, cls: hVal <= tVal ? "z-have" : "z-need" };
}
// Past the significant zone
@@ -87,7 +87,7 @@
});
});
// Count leading zero bits in the hash (not just hex zeros — count
// Count leading zero bits in the hash (not just hex zeros, count
// the actual zero bits in the first non-zero nibble too).
function leadingZeroBits(h: string): number {
let bits = 0;
@@ -159,7 +159,7 @@
// Within the zone that must be zero for a valid block
cls = hb === "0" ? "z-have" : "z-need";
} else {
// Past the required zero zone — doesn't matter
// Past the required zero zone, doesn't matter
cls = "z-rest";
}
out.push({ c: hb, cls });
@@ -233,7 +233,7 @@
<section class="card explainer">
<p>
To mine a block, you must find a hash whose <strong>numeric value</strong>
is less than the network's target. It's not just about leading zeros &mdash;
is less than the network's target. It's not just about leading zeros,
the entire hash must be smaller than the target. Think of it like a lottery:
you need to roll a number below a threshold, and the threshold gets lower as
difficulty rises.
@@ -244,7 +244,7 @@
The current network difficulty of <strong>{formatDifficulty(netDiff)}</strong>
requires a hash with at least <strong>{networkZeroBits}</strong> leading zero bits.
{#if diffRatio <= 1}
Your share meets the network target &mdash; this would be a valid block!
Your share meets the network target, this would be a valid block!
{:else}
The network target is <strong>{diffRatio.toFixed(1)}x</strong> harder than your
best share.
@@ -341,7 +341,7 @@
{#if networkZeroBits > shareZeroBits}
The gap of <strong>{networkZeroBits - shareZeroBits} bits</strong> means the target is roughly
<strong>2<sup>{networkZeroBits - shareZeroBits}</sup> &asymp; {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
harder &mdash; matching the {diffRatio.toFixed(1)}x difficulty ratio.
harder, matching the {diffRatio.toFixed(1)}x difficulty ratio.
{/if}
</p>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@
// disconnect (the instance is freed) and on pool restart (in-memory
// state, not persisted). worker.best_diff is a separate persistent
// counter that survives both events, so we deliberately don't fall
// back to it — that fallback was the bug. Offline workers have no
// back to it, that fallback was the bug. Offline workers have no
// current session, so their session-best is 0.
const rows = $derived.by(() => {
const ws = snap.data?.workers ?? [];
+1 -1
View File
@@ -142,7 +142,7 @@
{/each}
</div>
<!-- Banner — above confetti, interactive -->
<!-- Banner, above confetti, interactive -->
<div class="banner-wrap">
<div class="banner">
<div class="party-row">
+58 -5
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatAgo, formatDifficulty, explorerBaseFor } from "../format";
import ShareCard from "./ShareCard.svelte";
import type { BlockRecord } from "../types";
const chainDisplayName: Record<string, string> = {
main: "mainnet",
@@ -14,6 +16,9 @@
return [...b].reverse();
});
// Block whose share card is open, if any.
let shareBlock: BlockRecord | null = $state(null);
const explorerBase = $derived(
explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url),
);
@@ -26,7 +31,7 @@
}
function minerLabel(miner: string | undefined): string {
if (!miner) return "—";
if (!miner) return "-";
// Show address.worker truncated
const dot = miner.indexOf(".");
if (dot < 0) {
@@ -57,6 +62,7 @@
<th class="num">Reward</th>
<th class="num">Winning share</th>
<th class="num">When</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -75,7 +81,7 @@
{:else if b.chain}
<span class="chain-current">{displayChain(b.chain)}</span>
{:else}
<span class="chain-unknown">—</span>
<span class="chain-unknown">-</span>
{/if}
</td>
<td class="hash-cell">
@@ -88,7 +94,7 @@
title={b.hash}
>{truncHash(b.hash)}</a>
{:else}
<span class="hash mono">&mdash;</span>
<span class="hash mono">-</span>
{/if}
</td>
<td class="num">
@@ -96,11 +102,25 @@
<span class="reward-num">{b.reward_btc.toFixed(4)}</span>
<span class="unit">BTC</span>
{:else}
<span class="reward-num">&mdash;</span>
<span class="reward-num">-</span>
{/if}
</td>
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "—"}</td>
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "-"}</td>
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
<td class="share-cell">
<button
type="button"
class="share-block"
onclick={() => (shareBlock = b)}
title="Make a shareable card for this block"
aria-label="Share block {b.height}"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
</td>
</tr>
{/each}
</tbody>
@@ -109,7 +129,40 @@
{/if}
</section>
{#if shareBlock}
<ShareCard
data={{
kind: "block",
block: shareBlock,
chain: shareBlock.chain || currentChain || "main",
explorerBase,
}}
onclose={() => (shareBlock = null)}
/>
{/if}
<style>
.share-cell {
width: 1%;
white-space: nowrap;
}
.share-block {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.3em 0.5em;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.share-block:hover {
color: var(--accent);
border-color: var(--accent);
}
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
+3 -3
View File
@@ -3,7 +3,7 @@
import { formatAgo } from "../format";
// submit_gap > 0 means ckpool tried to submit at least one block that
// never got the "Solved and confirmed" follow-up — so either bitcoind
// never got the "Solved and confirmed" follow-up, so either bitcoind
// rejected the submission or the RPC dropped. These are persistent
// counters so the banner stays visible until the operator clears it
// by inspecting bitcoind's logs.
@@ -36,7 +36,7 @@
<span class="icon">!</span>
<div class="text">
<strong>Bitcoin Core is unreachable.</strong>
Stratum server has been stopped — miners will failover to backup pools.
Stratum server has been stopped, miners will failover to backup pools.
Service will resume automatically when Bitcoin Core recovers.
</div>
</div>
@@ -47,7 +47,7 @@
<div class="text">
<strong>Submit gap:</strong>
{submitGap} block{submitGap === 1 ? "" : "s"} attempted but not
confirmed by bitcoind. Investigate primary bitcoind logs —
confirmed by bitcoind. Investigate primary bitcoind logs,
submission may have been rejected or the RPC dropped.
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
};
// The "address" field on a ckpool stratum_instance is the SOURCE IP
// the connection arrived on (see connector.c:311 — it's set with
// the connection arrived on (see connector.c:311, it's set with
// inet_ntop). The miner's BTC payout address comes from the worker
// name (which is the stratum username, format <btc>[.label]) or
// from the joined worker_instance, which ckpool keys by full
+8 -8
View File
@@ -38,7 +38,7 @@
// Blocks remaining + ETA until the retarget. The predicted adjustment
// percent comes from the backend (measured between block timestamps
// from the epoch's first block), as does the epoch's observed mean
// block time — projecting the ETA at the pace the network is actually
// block time, projecting the ETA at the pace the network is actually
// keeping rather than a flat 600s, which is what mempool.space shows.
const retarget = $derived.by(() => {
if (!height) return { remaining: 2016, eta: "", progress: 0 };
@@ -53,7 +53,7 @@
const diffPct = $derived(data.next_difficulty_percent ?? 0);
const diffPctLabel = $derived(
diffPct === 0
? "—"
? "-"
: (diffPct > 0 ? "+" : "") + diffPct.toFixed(2) + "%",
);
@@ -74,7 +74,7 @@
return (best / work) * 100;
});
const luckLabel = $derived.by(() => {
if (!data.best_diff || !data.cumulative_shares) return "—";
if (!data.best_diff || !data.cumulative_shares) return "-";
if (luckPct >= 1000) return `${(luckPct / 100).toFixed(1)}x`;
return luckPct.toFixed(0) + "%";
});
@@ -92,7 +92,7 @@
prevHeight = h;
});
// New best share glow — derived from the persisted acked_best_diff.
// New best share glow, derived from the persisted acked_best_diff.
// Glows whenever best_diff > acked_best_diff. Survives restarts and
// works even if multiple new bests are found before the user opens the UI.
//
@@ -191,7 +191,7 @@
<span class="stat-dim">({latencyCount} blocks)</span>
</div>
{:else}
<div class="stat-value">—</div>
<div class="stat-value">-</div>
<div class="stat-sub">waiting for the next block</div>
{/if}
</div>
@@ -211,9 +211,9 @@
<!-- Row 2 -->
<div class="card height-card" class:new-block={heightFlash}>
<div class="stat-label">Block height</div>
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
<div class="stat-value">{height ? height.toLocaleString() : "-"}</div>
<div class="stat-sub">
{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "—"}
{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "-"}
{#if data.peer_count > 0}
<span class="peers" title="{data.peers_in} inbound · {data.peers_out} outbound">&nbsp;· {data.peer_count} peers</span>
{/if}
@@ -223,7 +223,7 @@
<div class="card">
<div class="stat-label">Block reward</div>
<div class="stat-value" title="{data.next_block_reward_btc ? Math.round(data.next_block_reward_btc * 1e8).toLocaleString() + ' sats' : ''}">
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "-"}
<span class="unit">BTC</span>
</div>
<div class="stat-sub">subsidy + fees (next block)</div>
+12 -12
View File
@@ -92,7 +92,7 @@
// ── Formulas, as native MathML ──────────────────────────────────────
// Rendered through {@html}: these are static, authored here, and far
// more readable as markup strings than as ~200 lines of template. No
// library — browsers typeset MathML themselves, which keeps the
// library, browsers typeset MathML themselves, which keeps the
// dashboard dependency-free and works offline on a LAN.
const rot = (n: string) =>
`<msup><mi mathvariant="normal">ROTR</mi><mn>${n}</mn></msup>`;
@@ -547,12 +547,12 @@
<div class="panel">
<div class="lede">The coinbase transaction this pool built, hashed twice, gives its txid:</div>
<div class="hexline big glow">{step.txid}</div>
<div class="foot-note">internal byte order — the form used while hashing</div>
<div class="foot-note">internal byte order, the form used while hashing</div>
</div>
{:else if step.kind === "merkle-fold"}
<div class="panel">
<div class="lede">
Level {step.i} of {step.total} &mdash; join with the branch hash and hash the pair
Level {step.i} of {step.total}, join with the branch hash and hash the pair
</div>
<div class="fold">
<div class="fold-row"><span class="tag">current</span><span class="hexline">{step.left}</span></div>
@@ -579,7 +579,7 @@
<div class="panel wide">
<div class="lede">
80 header bytes, a <span class="sg-pad">0x80</span> marker, 39 zero bytes and the
<span class="sg-len">640-bit</span> length &mdash; exactly two 512-bit blocks
<span class="sg-len">640-bit</span> length, exactly two 512-bit blocks
</div>
{#each padBlocks as blk, bi}
<div class="msgblock">
@@ -597,7 +597,7 @@
<span class="lg"><i class="sw sw-len"></i> message length</span>
</div>
<div class="foot-note">
the <span class="sg-merkle">merkle root</span> straddles the boundary — SHA-256 takes
the <span class="sg-merkle">merkle root</span> straddles the boundary, SHA-256 takes
64 bytes at a time, indifferent to where fields begin and end
</div>
</div>
@@ -610,7 +610,7 @@
{/each}
</div>
<div class="foot-note">
the fractional parts of the square roots of the first eight primes &mdash; "nothing up
the fractional parts of the square roots of the first eight primes, "nothing up
my sleeve" numbers, chosen so nobody could have picked them to hide a weakness
</div>
</div>
@@ -672,7 +672,7 @@
{:else if step.kind === "add-h"}
<div class="panel">
<div class="lede">
64 rounds done &mdash; the result is <em>added</em> into the running state, chaining
64 rounds done, the result is <em>added</em> into the running state, chaining
this block into the next
</div>
<div class="regs">
@@ -681,7 +681,7 @@
{/each}
</div>
<div class="foot-note">
addition, not replacement — the Davies&ndash;Meyer construction, which is what makes
addition, not replacement, the Davies&ndash;Meyer construction, which is what makes
the compression one-way
</div>
</div>
@@ -699,7 +699,7 @@
{#each finalChars as ch}<span class:fz={ch.zero}>{ch.c}</span>{/each}
</div>
<div class="legend">
<span class="lg"><i class="sw sw-zeroes"></i> leading zeros &mdash; the visible sign of the work done</span>
<span class="lg"><i class="sw sw-zeroes"></i> leading zeros, the visible sign of the work done</span>
</div>
</div>
{:else if step.kind === "target"}
@@ -722,7 +722,7 @@
</div>
<div class="math small">{@html MATH_TARGET}</div>
<div class="foot-note">
the hash is below the share target — that is what made it a
the hash is below the share target, that is what made it a
{formatDifficulty(share.sdiff)} share. The network target sits another
<b>{targetGapX >= 1000 ? formatDifficulty(targetGapX) : targetGapX.toFixed(1)}×</b>
lower: clear that, and this exact header would have been block
@@ -784,7 +784,7 @@
to { opacity: 1; }
}
/* Drifting embers — pure decoration, cheap. */
/* Drifting embers, pure decoration, cheap. */
.embers {
position: absolute;
inset: 0;
@@ -905,7 +905,7 @@
display: flex;
/* `safe` matters: a flex item taller than its container, centred,
overflows equally in both directions and the top half becomes
unreachable — which hid register row `a` until the window was
unreachable, which hid register row `a` until the window was
tall enough. Safe alignment falls back to start on overflow.
The unprefixed value is declared first for older engines. */
align-items: center;
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import { untrack } from "svelte";
import { formatDifficulty } from "../format";
import { renderShareCard, type CardData, type CardFormat } from "./shareCard";
let {
data,
onclose,
}: { data: CardData; onclose: () => void } = $props();
const card = untrack(() => data);
const isBlock = card.kind === "block";
// Story is the default (Instagram/X/WhatsApp status); square suits feed
// posts, where a 9:16 image gets cropped or letterboxed.
let format: CardFormat = $state("story");
let canvasEl: HTMLCanvasElement | undefined = $state();
let busy = $state(false);
let note = $state("");
$effect(() => {
const f = format;
const el = canvasEl;
if (el) void renderShareCard(el, card, f);
});
const slug = $derived(
card.kind === "block"
? `block-${card.block.height}`
: `best-share-${formatDifficulty(card.share.sdiff).replace(/\s+/g, "")}`,
);
const fileName = $derived(`kamado-${slug}-${format}.png`);
function toBlob(): Promise<Blob | null> {
return new Promise((resolve) => {
if (!canvasEl) return resolve(null);
canvasEl.toBlob((b) => resolve(b), "image/png");
});
}
async function download(): Promise<void> {
busy = true;
note = "";
const blob = await toBlob();
busy = false;
if (!blob) {
note = "Could not render the image.";
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
note = "Saved.";
}
async function copyImage(): Promise<void> {
busy = true;
note = "";
try {
const blob = await toBlob();
if (!blob) throw new Error("render failed");
// Clipboard image writes need a secure context; LAN dashboards are
// often plain HTTP, so fall back to the download path.
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
note = "Copied. Paste it straight into a post.";
} catch {
note = "Copying images needs HTTPS here, so use Save instead.";
} finally {
busy = false;
}
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape") onclose();
}
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="dialog" aria-modal="true" aria-label="Share card">
<div class="sheet">
<header class="sheet-head">
<div>
<div class="sheet-title">{isBlock ? "Share your block" : "Share your best share"}</div>
<div class="sheet-sub">
{isBlock
? "The card names the chain and links the block, so anyone can look it up for themselves."
: "The card carries the raw header, so anyone who sees it can verify the work themselves."}
</div>
</div>
<button type="button" class="x" onclick={onclose} aria-label="Close">&#10005;</button>
</header>
<div class="preview-wrap">
<canvas bind:this={canvasEl} class="preview" class:square={format === "square"}></canvas>
</div>
<div class="controls">
<div class="seg">
<button type="button" class:on={format === "story"} onclick={() => (format = "story")}>
Story 9:16
</button>
<button type="button" class:on={format === "square"} onclick={() => (format = "square")}>
Square 1:1
</button>
</div>
<div class="acts">
<button type="button" class="ghost" onclick={copyImage} disabled={busy}>Copy image</button>
<button type="button" class="primary" onclick={download} disabled={busy}>Save PNG</button>
</div>
</div>
{#if note}
<div class="note">{note}</div>
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
align-items: center;
align-items: safe center;
justify-content: center;
justify-content: safe center;
padding: 1.2rem;
overflow-y: auto;
background: rgba(5, 7, 12, 0.82);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: fade 0.2s ease-out;
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
.sheet {
width: 100%;
max-width: 560px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.1rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sheet-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.sheet-title {
font-size: 1.05rem;
font-weight: 600;
}
.sheet-sub {
color: var(--fg-dim);
font-size: 0.85rem;
line-height: 1.5;
margin-top: 0.2rem;
}
.x {
font: inherit;
flex-shrink: 0;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35em 0.55em;
cursor: pointer;
}
.x:hover {
color: var(--fg);
border-color: var(--accent);
}
.preview-wrap {
display: flex;
justify-content: center;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem;
}
.preview {
display: block;
width: auto;
max-width: 100%;
max-height: 52vh;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
flex-wrap: wrap;
}
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.seg button {
font: inherit;
font-size: 0.82rem;
background: transparent;
color: var(--fg-dim);
border: none;
padding: 0.45em 0.9em;
cursor: pointer;
}
.seg button:not(:last-child) {
border-right: 1px solid var(--border);
}
.seg button.on {
background: var(--accent);
color: var(--bg);
font-weight: 600;
}
.acts {
display: flex;
gap: 0.5rem;
}
.acts button {
font: inherit;
font-size: 0.88rem;
border-radius: 8px;
padding: 0.5em 1.1em;
cursor: pointer;
border: 1px solid var(--border);
}
.acts .ghost {
background: transparent;
color: var(--fg-dim);
}
.acts .ghost:hover:not(:disabled) {
color: var(--fg);
border-color: var(--accent);
}
.acts .primary {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
font-weight: 600;
}
.acts .primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.acts button:disabled {
opacity: 0.55;
cursor: default;
}
.note {
font-size: 0.82rem;
color: var(--fg-dim);
}
@media (max-width: 480px) {
.preview {
max-height: 44vh;
}
.controls {
justify-content: center;
}
}
</style>
+53 -19
View File
@@ -3,6 +3,7 @@
import { snap } from "../stores/snapshot.svelte";
import { formatDifficulty, explorerBaseFor } from "../format";
import PowVerifyOverlay from "./PowVerifyOverlay.svelte";
import ShareCard from "./ShareCard.svelte";
import type { CheckResult } from "./PowVerifyOverlay.svelte";
import { revHex } from "./sha256";
@@ -11,7 +12,7 @@
// The all-time best share may predate ckpool patch 0008 (which logs
// header data). When the hashes differ, this page is showing the best
// share *since* the patch went live — disclose that.
// share *since* the patch went live, disclose that.
const isNewerThanAllTime = $derived(
!!pow && !!data.best_share_hash && pow.hash !== data.best_share_hash,
);
@@ -73,7 +74,7 @@
bytes: "68–71",
raw: h.slice(136, 144),
value: new Date(time * 1000).toLocaleString(),
sub: `unix ${time} — the miner may roll this forward slightly`,
sub: `unix ${time}, the miner may roll this forward slightly`,
},
{
key: "bits",
@@ -89,7 +90,7 @@
bytes: "76–79",
raw: h.slice(152, 160),
value: nonce.toLocaleString(),
sub: `0x${nonce.toString(16).padStart(8, "0")} — the value the miner iterated`,
sub: `0x${nonce.toString(16).padStart(8, "0")}, the value the miner iterated`,
},
];
});
@@ -113,6 +114,7 @@
// ── Verification: handed to the full-screen overlay ─────────────────
let showOverlay = $state(false);
let showShare = $state(false);
let checks: CheckResult[] = $state([]);
let verified = $state(false);
let finalHash = $state("");
@@ -171,7 +173,7 @@
const foundAt = $derived(pow ? new Date(pow.seen_at).toLocaleString() : "");
function onKey(ev: KeyboardEvent): void {
if (ev.key === "Escape" && !showOverlay) selectBestShare();
if (ev.key === "Escape" && !showOverlay && !showShare) selectBestShare();
}
</script>
@@ -194,13 +196,13 @@
<div class="empty">
<p>
Header capture was not active when the current best share was found, and CKPool
discards job data once work moves on &mdash; so the block header of that share can
discards job data once work moves on, so the block header of that share can
no longer be reconstructed.
</p>
<p>
The pool now records the full header, coinbase, and merkle branches as shares come
in. The next accepted share will populate this page, which then tracks the best
share found since header capture was enabled &mdash; it does not wait for the
share found since header capture was enabled, it does not wait for the
all-time record to be beaten. Once a share does beat the all-time best, the two
converge and this page shows the overall best share.
</p>
@@ -230,14 +232,14 @@
</div>
<div class="card">
<div class="stat-label">Found By</div>
<div class="stat-value worker-name mono">{pow.workername || "—"}</div>
<div class="stat-value worker-name mono">{pow.workername || "-"}</div>
<div class="stat-sub">{foundAt}</div>
</div>
</section>
<!-- Block header visualization -->
<section class="card">
<h3>Block Header &mdash; 80 bytes</h3>
<h3>Block Header, 80 bytes</h3>
<p class="section-intro">
This is the block this share was building, drawn as it exists on the wire. The
header's six fields are serialized little-endian into 80 bytes; the
@@ -280,7 +282,7 @@
</div>
{/each}
<div class="block-txs">
+ transactions &mdash; not part of the header; committed by the
+ transactions, not part of the header; committed by the
<span class="seg-text-merkle">merkle root</span> above
</div>
</div>
@@ -289,7 +291,7 @@
<!-- Raw data & verification -->
<section class="card">
<h3>Raw Header &mdash; verify it yourself</h3>
<h3>Raw Header, verify it yourself</h3>
<p class="section-intro">
Hash these 160 hex characters with double SHA-256 and reverse the result: you get the
share hash. No trust in this dashboard required.
@@ -297,14 +299,14 @@
<div class="proof-note">
<strong>This is what turns a claim into proof.</strong>
A screenshot of a big difficulty number proves nothing &mdash; anyone can edit one, and
A screenshot of a big difficulty number proves nothing, anyone can edit one, and
no one can tell from a picture whether the work behind it was ever done. These 80 bytes
can't be faked that way. They are the exact preimage of the share hash, so anyone can
hash them and see for themselves both that the hash is genuine and that finding it took
the work claimed. The header also commits to the
<span class="seg-text-prev">previous block</span>, fixing when the work happened, and
through the <span class="seg-text-merkle">merkle root</span> to the coinbase
transaction, fixing who it pays &mdash; so the same proof can't be lifted from another
transaction, fixing who it pays, so the same proof can't be lifted from another
miner's share or replayed from a different block. Publish this, and the claim stands on
its own.
</div>
@@ -325,11 +327,18 @@
class:ok={allOk}
onclick={() => (showOverlay = true)}
>
{verified ? (allOk ? "Verified \u2713 — watch again" : "Verification failed — replay") : "Verify in this browser"}
{verified ? (allOk ? "Verified \u2713, watch again" : "Verification failed, replay") : "Verify in this browser"}
</button>
<button type="button" class="share-btn" onclick={() => (showShare = true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
Share card
</button>
<span class="verify-hint">
takes over the screen and replays the whole verification &mdash; merkle proof,
padding, every SHA-256 round &mdash; from the real computation, locally
takes over the screen and replays the whole verification, merkle proof,
padding, every SHA-256 round, from the real computation, locally
</span>
</div>
@@ -341,7 +350,7 @@
</div>
<div class="final-match" class:ok={finalHash === pow.hash} class:fail={finalHash !== pow.hash}>
{finalHash === pow.hash
? "= the recorded share hash — proof of work is genuine"
? "= the recorded share hash, proof of work is genuine"
: "\u2260 the recorded share hash!"}
</div>
<ul class="check-list">
@@ -374,7 +383,7 @@
<h3>Reproducing the Merkle Root</h3>
<p class="section-intro">
The header's merkle root commits to every transaction in the block, but reproducing
it doesn't require the full transaction list &mdash; only the coinbase transaction
it doesn't require the full transaction list, only the coinbase transaction
this pool built and one branch hash per tree level. Each branch hash cryptographically
summarizes the other transactions in its subtree, so the proof below is exactly as
strong as storing all of them. (CKPool discards the full template once work moves on,
@@ -395,7 +404,7 @@
<span class="legend-item">
<span class="swatch cb-swatch-{s.key}"></span>
<span class="legend-name">{s.name}</span>
<span class="legend-desc">— {s.desc}</span>
<span class="legend-desc">, {s.desc}</span>
</span>
{/each}
</div>
@@ -406,7 +415,7 @@
</div>
{#if branches.length === 0}
<div class="stat-sub">
None &mdash; the block template contained only the coinbase transaction, so the
None, the block template contained only the coinbase transaction, so the
merkle root is simply the coinbase txid.
</div>
{:else}
@@ -434,6 +443,13 @@
{/if}
</section>
{#if showShare && pow}
<ShareCard
data={{ kind: "share", share: pow, chain: data.chain?.chain ?? "main" }}
onclose={() => (showShare = false)}
/>
{/if}
{#if showOverlay && pow}
<PowVerifyOverlay
{pow}
@@ -780,6 +796,24 @@
.verify-btn.ok {
background: var(--good);
}
.share-btn {
font: inherit;
display: inline-flex;
align-items: center;
gap: 0.45em;
font-weight: 600;
color: var(--accent);
background: transparent;
border: 1px solid var(--accent-dim);
border-radius: 6px;
padding: 0.5em 1.1em;
cursor: pointer;
}
.share-btn:hover {
border-color: var(--accent);
background: var(--bg-hover);
text-shadow: 0 0 8px rgba(255, 122, 58, 0.4);
}
.verify-hint {
color: var(--fg-dim);
font-size: 0.8rem;
+1 -1
View File
@@ -21,7 +21,7 @@
// Detect new rejected shares to trigger shake animation.
// Skip the first snapshot (baseline), then fire on any increase
// — including from 0 after a pool restart. Declared BEFORE the
// including from 0 after a pool restart. Declared BEFORE the
// accepted effect so `reject` is already set when the accept
// guard reads it.
let prevRejected = $state(0);
+2 -2
View File
@@ -223,7 +223,7 @@
</section>
<style>
/* Page layout — identical to UserDetailPage / WorkerDetailPage */
/* Page layout, identical to UserDetailPage / WorkerDetailPage */
.page {
display: flex;
flex-direction: column;
@@ -259,7 +259,7 @@
line-height: 1.25;
}
/* Stat cards — matches .totals / .stats in other pages */
/* Stat cards, matches .totals / .stats in other pages */
.totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+2 -2
View File
@@ -23,7 +23,7 @@
// Live stratum clients for those workers. ckpool's client.address
// is the SOURCE IP, never the BTC, so we cannot filter on it. The
// join key is workername — match the BTC bare or any "btc.label".
// join key is workername, match the BTC bare or any "btc.label".
const myClients = $derived.by<StratumClient[]>(() => {
if (!address) return [];
const prefix = address + ".";
@@ -81,7 +81,7 @@
idle: !!(c?.idle ?? w.idle),
});
}
// A live client without a worker_instance entry yet (rare —
// A live client without a worker_instance entry yet (rare,
// can happen briefly right after a new miner connects).
for (const c of myClients) {
const wname = c.workername || `${address}.unnamed`;
+2 -2
View File
@@ -4,7 +4,7 @@
// often served over plain HTTP on a LAN, so proof-of-work verification
// uses this rather than the platform digest. The optional hooks expose
// the real message schedule, the working registers after every
// compression round, and the chained state after each block — which is
// compression round, and the chained state after each block, which is
// what the verification animation renders. Nothing shown there is
// simulated; it is all this function's actual intermediate state.
@@ -19,7 +19,7 @@ const K = new Uint32Array([
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
/** The eight initial hash values, as hex — √2…√19 fractional parts. */
/** The eight initial hash values, as hex, √2…√19 fractional parts. */
export const INIT_H = [
"6a09e667", "bb67ae85", "3c6ef372", "a54ff53a", "510e527f", "9b05688c", "1f83d9ab", "5be0cd19",
];
+557
View File
@@ -0,0 +1,557 @@
// Renders the shareable cards to a canvas: the "best share" proof card and
// the celebratory "I found a block!" card.
//
// Kept out of the Svelte components so it can be driven headlessly and the
// output inspected. The layout measures its content first, then spreads the
// leftover height across the section gaps, so the same code fills a 9:16
// story and a 1:1 square without leaving voids or colliding.
import QRCode from "qrcode";
import { formatDifficulty } from "../format";
import type { BestSharePow, BlockRecord } from "../types";
export type CardFormat = "story" | "square";
export type CardData =
| { kind: "share"; share: BestSharePow; chain: string }
| { kind: "block"; block: BlockRecord; chain: string; explorerBase: string };
export const CARD_SIZES: Record<CardFormat, { w: number; h: number }> = {
story: { w: 1080, h: 1920 },
square: { w: 1080, h: 1080 },
};
const C = {
bg: "#080a10",
ember: "#ff7a3a",
fg: "#e6e9ef",
dim: "#8a92a6",
good: "#5ce0a8",
gold: "#ffc65c",
btc: "#f7931a",
panel: "rgba(255,255,255,0.045)",
stroke: "rgba(255,255,255,0.10)",
};
const MONO = '"JetBrains Mono","Fira Code",ui-monospace,SFMono-Regular,Menlo,monospace';
const SANS = '"Inter","Segoe UI",system-ui,-apple-system,sans-serif';
type Scale = {
margin: number; brand: number; hero: number; body: number; small: number;
eyebrow: number; mono: number; monoHeader: number; qr: number; gap: number;
};
const SCALES: Record<CardFormat, Scale> = {
story: {
margin: 88, brand: 44, hero: 170, body: 32, small: 26,
eyebrow: 26, mono: 33, monoHeader: 25, qr: 250, gap: 1,
},
square: {
margin: 64, brand: 34, hero: 104, body: 25, small: 21,
eyebrow: 21, mono: 24, monoHeader: 18, qr: 156, gap: 0.6,
},
};
/**
* Hash counts read as "66.66 EH hashes" if the SI symbol is kept, which
* says hash twice. Spell the magnitude instead.
*/
export function spellHashes(n: number): string {
const units: [number, string][] = [
[1e27, "ronnahashes"], [1e24, "yottahashes"], [1e21, "zettahashes"],
[1e18, "exahashes"], [1e15, "petahashes"], [1e12, "terahashes"],
[1e9, "gigahashes"], [1e6, "megahashes"], [1e3, "kilohashes"],
];
for (const [v, name] of units) {
if (n >= v) return `${(n / v).toFixed(2)} ${name}`;
}
return `${Math.round(n)} hashes`;
}
export function chainLabel(chain: string | undefined): string {
switch (chain) {
case "main": case "": case undefined: return "mainnet";
case "test": return "testnet3";
default: return chain;
}
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, r: number,
): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** Flame mark, drawn rather than relying on an emoji font. */
function drawFlame(ctx: CanvasRenderingContext2D, cx: number, cy: number, s: number): void {
ctx.save();
ctx.translate(cx, cy);
ctx.scale(s, s);
const g = ctx.createLinearGradient(0, -1, 0, 1);
g.addColorStop(0, "#ffd08a");
g.addColorStop(0.45, C.ember);
g.addColorStop(1, "#c0361a");
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(0, -1);
ctx.bezierCurveTo(0.62, -0.28, 0.78, 0.28, 0.36, 0.72);
ctx.bezierCurveTo(0.16, 0.94, -0.2, 0.98, -0.44, 0.78);
ctx.bezierCurveTo(-0.85, 0.42, -0.7, -0.2, -0.18, -0.52);
ctx.bezierCurveTo(-0.24, -0.16, -0.05, -0.02, 0.08, -0.16);
ctx.bezierCurveTo(0.22, -0.34, 0.1, -0.7, 0, -1);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// The official Bitcoin logo (Bitboy, public domain), as its two SVG paths
// on a 64x64 viewBox: the coin, then the ₿ knocked out of it.
const BTC_COIN =
"M63.04 39.741c-4.274 17.143-21.638 27.575-38.783 23.301C7.12 58.768-3.313 41.404.962 " +
"24.262 5.234 7.117 22.598-3.315 39.737.957c17.144 4.274 27.576 21.64 23.303 38.784z";
const BTC_SYMBOL =
"M46.11 27.441c.636-4.258-2.606-6.547-7.039-8.074l1.438-5.768-3.512-.875-1.4 5.616c-.923-.23-" +
"1.871-.447-2.813-.662l1.41-5.653-3.51-.875-1.439 5.766c-.764-.174-1.514-.346-2.242-.527l.004-" +
".018-4.842-1.209-.934 3.75s2.605.597 2.55.634c1.422.355 1.68 1.296 1.636 2.042l-1.638 6.571c." +
"098.025.225.061.365.117l-.37-.092-2.297 9.205c-.174.432-.615 1.08-1.609.834.035.051-2.552-.637-" +
"2.552-.637l-1.743 4.019 4.569 1.139c.85.213 1.683.436 2.503.646l-1.453 5.834 3.507.875 1.439-" +
"5.772c.958.26 1.888.5 2.798.726L27.7 50.83l3.511.875 1.453-5.823c5.987 1.133 10.489.676 12.384-" +
"4.739 1.527-4.36-.076-6.875-3.226-8.515 2.294-.529 4.022-2.038 4.483-5.155l.005-.032zm-8.023 " +
"11.249c-1.085 4.36-8.426 2.003-10.806 1.412l1.928-7.729c2.38.594 10.012 1.77 8.878 6.317zm1.086-" +
"11.312c-.99 3.966-7.1 1.951-9.082 1.457l1.748-7.01c1.982.494 8.365 1.416 7.334 5.553z";
/** The real Bitcoin logo, drawn from its official path data. */
function drawBitcoin(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void {
ctx.save();
ctx.translate(cx - r, cy - r);
ctx.scale((r * 2) / 64, (r * 2) / 64);
ctx.fillStyle = C.btc;
ctx.fill(new Path2D(BTC_COIN));
ctx.fillStyle = "#ffffff";
ctx.fill(new Path2D(BTC_SYMBOL));
ctx.restore();
}
/** Monospace is uniform width, so chunk by measured character. */
function monoChunks(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const cw = ctx.measureText("0").width || 1;
const per = Math.max(8, Math.floor(maxW / cw));
const out: string[] = [];
for (let i = 0; i < text.length; i += per) out.push(text.slice(i, i + per));
return out;
}
function eyebrow(
ctx: CanvasRenderingContext2D, text: string, x: number, y: number,
size: number, color: string,
): void {
ctx.font = `600 ${size}px ${SANS}`;
ctx.fillStyle = color;
ctx.letterSpacing = `${size * 0.18}px`;
ctx.fillText(text.toUpperCase(), x, y);
ctx.letterSpacing = "0px";
}
function panelHeight(lineCount: number, font: number): number {
const lh = font * 1.5;
return lineCount * lh + font * 0.95 * 2 - (lh - font);
}
function hexPanel(
ctx: CanvasRenderingContext2D,
lines: string[], x: number, y: number, w: number,
font: number, opts: { fill: string; stroke: string; leadingZeros?: number },
): number {
const lh = font * 1.5;
const pad = font * 0.95;
const h = panelHeight(lines.length, font);
ctx.fillStyle = opts.fill;
ctx.strokeStyle = opts.stroke;
ctx.lineWidth = 2;
roundRect(ctx, x, y, w, h, 16);
ctx.fill();
ctx.stroke();
let ty = y + pad + font * 0.8;
let consumed = 0;
const zeros = opts.leadingZeros ?? 0;
for (const line of lines) {
const zeroPart = Math.max(0, Math.min(line.length, zeros - consumed));
if (zeroPart > 0) {
ctx.fillStyle = C.good;
ctx.shadowColor = "rgba(92,224,168,0.5)";
ctx.shadowBlur = 16;
ctx.fillText(line.slice(0, zeroPart), x + pad, ty);
ctx.shadowBlur = 0;
}
if (zeroPart < line.length) {
const off = zeroPart > 0 ? ctx.measureText(line.slice(0, zeroPart)).width : 0;
ctx.fillStyle = opts.leadingZeros !== undefined ? C.fg : "rgba(230,233,239,0.88)";
ctx.fillText(line.slice(zeroPart), x + pad + off, ty);
}
consumed += line.length;
ty += lh;
}
return h;
}
/** Rounded pill, used for the chain badge. */
function badge(
ctx: CanvasRenderingContext2D, text: string, x: number, y: number,
size: number, fg: string, bg: string, border: string,
): number {
ctx.font = `600 ${size}px ${SANS}`;
ctx.letterSpacing = `${size * 0.1}px`;
const tw = ctx.measureText(text.toUpperCase()).width;
const padX = size * 0.75;
const h = size * 2;
ctx.fillStyle = bg;
ctx.strokeStyle = border;
ctx.lineWidth = 2;
roundRect(ctx, x, y - h * 0.72, tw + padX * 2, h, h / 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = fg;
ctx.fillText(text.toUpperCase(), x + padX, y);
ctx.letterSpacing = "0px";
return tw + padX * 2;
}
function paintBackground(
ctx: CanvasRenderingContext2D, w: number, h: number, celebratory: boolean,
): void {
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, w, h);
const top = ctx.createRadialGradient(w * 0.5, -h * 0.08, 0, w * 0.5, -h * 0.08, h * 0.66);
top.addColorStop(0, celebratory ? "rgba(255,180,60,0.34)" : "rgba(255,110,40,0.30)");
top.addColorStop(1, "rgba(255,110,40,0)");
ctx.fillStyle = top;
ctx.fillRect(0, 0, w, h);
const low = ctx.createRadialGradient(w * 0.06, h, 0, w * 0.06, h, h * 0.5);
low.addColorStop(0, celebratory ? "rgba(255,140,30,0.22)" : "rgba(190,45,20,0.18)");
low.addColorStop(1, "rgba(190,45,20,0)");
ctx.fillStyle = low;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "rgba(40,64,48,0.055)";
const cell = 54;
for (let yy = 0; yy < h; yy += cell * 2) {
for (let xx = 0; xx < w; xx += cell * 2) {
ctx.fillRect(xx, yy, cell, cell);
ctx.fillRect(xx + cell, yy + cell, cell, cell);
}
}
}
/** Sparks radiating behind the headline on the block card. */
function drawSparks(ctx: CanvasRenderingContext2D, w: number, top: number, h: number): void {
ctx.save();
let seed = 20260903;
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
for (let i = 0; i < 46; i++) {
const x = rnd() * w;
const y = top + rnd() * h;
const r = 1.5 + rnd() * 3.5;
ctx.globalAlpha = 0.18 + rnd() * 0.5;
ctx.fillStyle = rnd() > 0.4 ? C.gold : C.ember;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
export async function renderShareCard(
canvas: HTMLCanvasElement,
data: CardData,
format: CardFormat,
): Promise<void> {
const { w, h } = CARD_SIZES[format];
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return;
try {
await document.fonts.ready;
} catch {
/* fall back to whatever the stack resolves to */
}
const s = SCALES[format];
const M = s.margin;
const innerW = w - M * 2;
const isBlock = data.kind === "block";
paintBackground(ctx, w, h, isBlock);
ctx.textBaseline = "alphabetic";
ctx.textAlign = "left";
const footY = h - M * 0.75;
const ruleY = footY - s.body * 2.0;
const startY = M + s.brand * 0.9;
// ── QR payload: for a mined block the chain itself is the proof, so
// link the explorer; for a share, carry the header bytes.
const qrPayload = isBlock && data.block.hash
? `${data.explorerBase}/block/${data.block.hash}`
: data.kind === "share" ? data.share.header : "";
const qrCaption = isBlock
? ["See it on the chain", "anyone can look it up"]
: ["Scan for the header", "verify it yourself, no trust required"];
// QR block sits centred at the bottom, above the footer rule.
const qrSize = s.qr;
const qrCaptionH = s.body * 0.94 + s.small * 1.5;
const qrBlockH = qrSize + s.body * 0.9 + qrCaptionH;
// ── measure, then share the leftover height across the gaps
ctx.font = `500 ${s.mono}px ${MONO}`;
const hashText = isBlock ? (data.block.hash ?? "") : data.share.hash;
const hashLines = hashText ? monoChunks(ctx, hashText, innerW - s.mono * 1.9) : [];
const hashPanelH = hashLines.length ? panelHeight(hashLines.length, s.mono) : 0;
let hdrLines: string[] = [];
let hdrPanelH = 0;
if (data.kind === "share") {
ctx.font = `400 ${s.monoHeader}px ${MONO}`;
hdrLines = monoChunks(ctx, data.share.header, innerW - s.monoHeader * 2.2);
hdrPanelH = panelHeight(hdrLines.length, s.monoHeader);
}
const gapUnits = isBlock
? [70, 24, 56, 60, 26, 44, 40]
: [78, 24, 58, 74, 26, 46, 66, 26, 42, 40];
const gapTotal = gapUnits.reduce((a, b) => a + b, 0) * s.gap;
const gapX = data.kind === "share" && data.share.sdiff > 0
? data.share.netdiff / data.share.sdiff : 0;
const fixedH =
s.small * 1.5 +
s.hero * 0.78 +
(isBlock ? s.body * 2.6 : (gapX > 1 ? s.body * 1.45 : 0)) +
hashPanelH + hdrPanelH + qrBlockH;
const natural = fixedH + gapTotal;
const available = ruleY - s.body * 1.1 - startY;
// Spread the slack, but cap how far any one gap can grow. Without the
// cap a card with few sections (the block one) turns every gap into a
// ~80px canyon; with it, the remainder becomes balanced breathing room
// above the content and before the call to action.
const slack = Math.max(0, available - natural);
const extra = Math.min(slack / gapUnits.length, s.body * 1.7);
const leftover = slack - extra * gapUnits.length;
const topOffset = leftover * 0.45;
const G = (n: number) => n * s.gap + extra;
// ── brand lockup, with the real bitcoin logo balancing it
let y = startY;
drawFlame(ctx, M + s.brand * 0.42, y - s.brand * 0.42, s.brand * 0.62);
ctx.font = `700 ${s.brand}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.letterSpacing = "2px";
ctx.fillText("KAMADO POOL", M + s.brand * 1.08, y);
ctx.letterSpacing = "0px";
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("solo bitcoin mining", M + s.brand * 1.12, y + s.small * 1.5);
drawBitcoin(ctx, w - M - s.brand * 0.6, y - s.brand * 0.16, s.brand * 0.6);
y += s.small * 1.5 + G(isBlock ? 70 : 78) + topOffset;
if (isBlock) {
const b = data.block;
drawSparks(ctx, w, y - s.hero * 0.9, s.hero * 1.5);
// headline
ctx.font = `800 ${s.hero * 0.46}px ${SANS}`;
const grad = ctx.createLinearGradient(M, y - s.hero * 0.3, M + innerW * 0.9, y);
grad.addColorStop(0, C.gold);
grad.addColorStop(1, C.ember);
ctx.fillStyle = grad;
ctx.shadowColor = "rgba(255,180,60,0.5)";
ctx.shadowBlur = 50;
ctx.fillText("I FOUND A BLOCK!", M, y);
ctx.shadowBlur = 0;
// block height as the hero figure
y += G(24) + s.hero * 0.78;
const hero = `#${b.height.toLocaleString()}`;
ctx.font = `700 ${s.hero}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.shadowColor = "rgba(255,180,60,0.4)";
ctx.shadowBlur = 50;
ctx.fillText(hero, M, y);
ctx.shadowBlur = 0;
// chain badge beside the height, so a testnet block is never mistaken
const heroW = ctx.measureText(hero).width;
const label = chainLabel(b.chain || data.chain);
const isMain = label === "mainnet";
badge(
ctx, label, M + heroW + s.body * 0.8, y - s.hero * 0.18, s.small,
isMain ? C.gold : "#7aa2f7",
isMain ? "rgba(255,198,92,0.12)" : "rgba(122,162,247,0.12)",
isMain ? "rgba(255,198,92,0.5)" : "rgba(122,162,247,0.5)",
);
// reward and the difficulty of the winning share
y += G(56);
ctx.font = `600 ${s.body * 1.25}px ${SANS}`;
ctx.fillStyle = C.gold;
const reward = b.reward_btc ? `${b.reward_btc.toFixed(8)} BTC` : "block reward";
ctx.fillText(reward, M, y);
const rw = ctx.measureText(reward).width;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("paid straight to the miner", M + rw + s.body * 0.6, y);
y += s.body * 1.35;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
const solved = b.share_diff
? `solved with a ${formatDifficulty(b.share_diff)} share`
: "solved by this pool";
ctx.fillText(
`${solved} · ${new Date(b.found_at).toLocaleDateString(undefined, {
day: "numeric", month: "short", year: "numeric",
})}`,
M, y,
);
// block hash
y += G(60);
eyebrow(ctx, "Block hash", M, y, s.eyebrow * 0.92, C.dim);
y += G(26);
if (hashLines.length) {
ctx.font = `500 ${s.mono}px ${MONO}`;
const zeros = hashText.length - hashText.replace(/^0+/, "").length;
hexPanel(ctx, hashLines, M, y, innerW, s.mono, {
fill: C.panel, stroke: "rgba(255,198,92,0.28)", leadingZeros: zeros,
});
y += hashPanelH;
}
y += G(44);
} else {
const share = data.share;
eyebrow(ctx, "Best share", M, y, s.eyebrow, C.ember);
y += G(24) + s.hero * 0.78;
const heroText = formatDifficulty(share.sdiff);
ctx.font = `700 ${s.hero}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.shadowColor = "rgba(255,122,58,0.45)";
ctx.shadowBlur = 55;
ctx.fillText(heroText, M, y);
ctx.shadowBlur = 0;
const heroW = ctx.measureText(heroText).width;
ctx.font = `500 ${s.body * 1.12}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("difficulty", M + heroW + s.body * 0.6, y);
y += G(58);
ctx.font = `400 ${s.body}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText(`≈ ${spellHashes(share.sdiff * 2 ** 32)} to find`, M, y);
if (gapX > 1) {
y += s.body * 1.45;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
const times = gapX >= 1000 ? formatDifficulty(gapX) : gapX.toFixed(1);
ctx.fillText(`a block needed ${times}× more`, M, y);
}
y += G(74);
eyebrow(ctx, "Block header hash", M, y, s.eyebrow * 0.92, C.dim);
y += G(26);
ctx.font = `500 ${s.mono}px ${MONO}`;
const zeros = hashText.length - hashText.replace(/^0+/, "").length;
hexPanel(ctx, hashLines, M, y, innerW, s.mono, {
fill: C.panel, stroke: C.stroke, leadingZeros: zeros,
});
y += hashPanelH;
y += G(46);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText(
`block #${share.height.toLocaleString()} · ${chainLabel(data.chain)} · ` +
`${new Date(share.seen_at).toLocaleDateString(undefined, {
day: "numeric", month: "short", year: "numeric",
})}`,
M, y,
);
y += G(66);
eyebrow(ctx, "Proof · raw 80-byte header", M, y, s.eyebrow * 0.92, C.ember);
y += G(26);
ctx.font = `400 ${s.monoHeader}px ${MONO}`;
hexPanel(ctx, hdrLines, M, y, innerW, s.monoHeader, {
fill: "rgba(255,122,58,0.06)", stroke: "rgba(255,122,58,0.28)",
});
y += hdrPanelH;
y += G(42);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText("SHA-256 it twice, reverse the bytes, and you get the hash above", M, y);
}
// ── footer, pinned
ctx.strokeStyle = C.stroke;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(M, ruleY);
ctx.lineTo(w - M, ruleY);
ctx.stroke();
drawFlame(ctx, M + s.small * 0.5, footY - s.small * 0.42, s.small * 0.72);
ctx.font = `600 ${s.body * 0.94}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText("Run your own solo pool", M + s.small * 1.5, footY);
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.ember;
const cta = "Kamado Pool on StartOS";
ctx.fillText(cta, w - M - ctx.measureText(cta).width, footY);
// ── QR, centred above the footer
if (qrPayload) {
// Flow position: the slack distribution already sizes the gaps so this
// lands just above the footer. Clamping to the bottom instead would
// undo that and reopen a void above the code.
const qy = Math.min(
y + Math.max(s.small * 1.25, G(40)),
ruleY - s.body * 1.0 - qrBlockH,
);
const qx = (w - qrSize) / 2;
try {
const qc = document.createElement("canvas");
await QRCode.toCanvas(qc, qrPayload, {
width: qrSize, margin: 1, errorCorrectionLevel: "L",
color: { dark: "#0b0e14", light: "#e6e9ef" },
});
const pad = qrSize * 0.05;
ctx.fillStyle = C.fg;
roundRect(ctx, qx - pad, qy - pad, qrSize + pad * 2, qrSize + pad * 2, 14);
ctx.fill();
ctx.drawImage(qc, qx, qy, qrSize, qrSize);
ctx.textAlign = "center";
let cy = qy + qrSize + pad + s.body * 1.15;
ctx.font = `600 ${s.body * 0.94}px ${SANS}`;
ctx.fillStyle = C.fg;
ctx.fillText(qrCaption[0], w / 2, cy);
cy += s.small * 1.5;
ctx.font = `400 ${s.small}px ${SANS}`;
ctx.fillStyle = C.dim;
ctx.fillText(qrCaption[1], w / 2, cy);
ctx.textAlign = "left";
} catch {
/* the hash and header are printed above regardless */
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ let socket: WebSocket | null = null;
let retryMs = 1000;
export async function connect(): Promise<void> {
// Initial REST fetch — populates the first paint and tells us if
// Initial REST fetch, populates the first paint and tells us if
// the API is reachable at all before we commit to a WebSocket.
try {
const res = await fetch("/api/snapshot", { cache: "no-store" });
+6 -6
View File
@@ -61,7 +61,7 @@ export type StratumClient = {
lastshare: number;
starttime: number;
// Source IP, NOT the BTC payout address. ckpool sets this from
// inet_ntop(client_addr) in connector.c — see stratum_add_instance.
// inet_ntop(client_addr) in connector.c, see stratum_add_instance.
// The BTC address comes from `workername` (or the joined Worker.user).
address: string;
subscribed: boolean;
@@ -73,7 +73,7 @@ export type StratumClient = {
// Index into ckpool's serverurl[] array; identifies which stratum
// bind the client connected on. What each index *means* depends on
// how the deployment rendered ckpool.conf, so resolve it through
// Snapshot.stratum_servers rather than hardcoding — see
// Snapshot.stratum_servers rather than hardcoding, see
// connectionOf() in format.ts.
server: number;
bestdiff: number;
@@ -115,7 +115,7 @@ export type HashratePoint = {
// PoW reproduction data for the best share seen since ckpool patch 0008
// went live (logged by ckpool, ingested from the log by kamado-api).
// `header` is the raw 80-byte block header in hashing byte order —
// `header` is the raw 80-byte block header in hashing byte order,
// double-SHA256 it and reverse the digest to get `hash`. `coinbase` plus
// `merklebranches` reproduce the merkle root committed in the header.
export type BestSharePow = {
@@ -159,7 +159,7 @@ export type Snapshot = {
best_share_net_diff?: number;
// Full header/coinbase/merkle-branch record for the best share seen
// since ckpool patch 0008 was deployed. May describe a lower-diff
// share than best_diff when the all-time best predates the patch —
// share than best_diff when the all-time best predates the patch,
// compare hashes and disclose.
best_share_pow?: BestSharePow;
acked_best_diff: number;
@@ -178,9 +178,9 @@ export type Snapshot = {
mempool_base_url?: string;
// Describes ckpool's serverurl[] binds: entry N says what a client
// with `server === N` is connected over. Undefined when the
// deployment didn't declare them — see connectionOf() in format.ts.
// deployment didn't declare them, see connectionOf() in format.ts.
stratum_servers?: StratumServer[];
// Submission attempt tracking — count of "Possible block solve"
// Submission attempt tracking, count of "Possible block solve"
// log lines vs "Solved and confirmed" lines. A growing gap means
// submissions are being rejected by bitcoind.
block_submit_attempts: number;