Add share cards
This commit is contained in:
51 files changed
+1186
-232
No files matched your search
+1
-1
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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 ./
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -7,13 +7,13 @@ index 7940d4fa..a856b3eb 100644
|
||||
}
|
||||
|
||||
+/* 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. */
|
||||
@@ -77,7 +77,7 @@ index 7940d4fa..a856b3eb 100644
|
||||
+ * 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)) {
|
||||
|
||||
@@ -78,14 +78,14 @@ index a856b3eb..fcb9eb94 100644
|
||||
|
||||
+ /* 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);
|
||||
+
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
Vendored
+32
@@ -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;
|
||||
}
|
||||
@@ -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 — 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"
|
||||
|
||||
@@ -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 —
|
||||
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 — 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> ≈ {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
|
||||
harder — matching the {diffRatio.toFixed(1)}x difficulty ratio.
|
||||
harder, matching the {diffRatio.toFixed(1)}x difficulty ratio.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">—</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">—</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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"> · {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>
|
||||
|
||||
@@ -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} — 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 — 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 — "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 — 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–Meyer construction, which is what makes
|
||||
addition, not replacement, the Davies–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 — 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;
|
||||
|
||||
@@ -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">✕</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>
|
||||
@@ -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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — merkle proof,
|
||||
padding, every SHA-256 round — 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 — 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 — 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user