Add share cards

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

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ FROM node:22-bookworm-slim AS ui
WORKDIR /ui
# Copy just the manifest first so npm install is cached across code
# edits. The build context must be the repo root — docker-compose
# edits. The build context must be the repo root, docker-compose
# uses `context: .`, and `make api` invokes docker build with the
# repo root as context.
COPY ui/package.json ./
+2 -2
View File
@@ -49,7 +49,7 @@ func main() {
ck := ckpool.New(cfg.CKPoolSockDir)
rpc := bitcoind.NewRPC(cfg.BitcoinRPCURL, cfg.BitcoinRPCUser, cfg.BitcoinRPCPassword, cfg.BitcoinRPCTimeout)
// Persistent block history. Non-fatal if it can't be opened — the
// Persistent block history. Non-fatal if it can't be opened, the
// aggregator falls back to an in-memory ring so the pool keeps
// running even with a broken data volume.
var blockStore *store.BlockStore
@@ -144,7 +144,7 @@ func main() {
// Wait for the aggregator's first refresh before starting the log
// tailer. This guarantees currentChain is set before any block
// events are processed, so blocks are always stamped with the
// correct chain — preventing false orphans on network switches.
// correct chain, preventing false orphans on network switches.
select {
case <-agg.Ready():
log.Info("first snapshot ready")
+2 -2
View File
@@ -82,7 +82,7 @@ func marginalFeeLost(tpl *bitcoind.BlockTemplate, txid string, txVsize int64) in
}
// The displaced tx's fee is the revenue we lose. We don't add the
// boosted tx's real fee back because the pool never had it — the tx
// boosted tx's real fee back because the pool never had it, the tx
// wasn't in the template before the boost.
lost := marginal.Fee
if lost < 0 {
@@ -261,7 +261,7 @@ func (s *Service) cleanupOnce(ctx context.Context) {
for _, tx := range txs {
_, err := s.RPC.GetMempoolEntry(ctx, tx.Txid)
if err != nil && bitcoind.IsRPCError(err, -5) {
// Tx no longer in mempool — confirmed or dropped.
// Tx no longer in mempool, confirmed or dropped.
if err := s.Store.RemoveBoostedTx(tx.Txid); err != nil {
s.Log.Warn("accelerator cleanup: remove failed", "txid", tx.Txid, "err", err)
} else {
+1 -1
View File
@@ -140,7 +140,7 @@ func isRetryable(err error) bool {
if err == nil {
return false
}
// rpcError values come from bitcoind itself — check for warmup/
// rpcError values come from bitcoind itself, check for warmup/
// loading messages that mean "ask again in a moment".
var rerr *rpcError
if errors.As(err, &rerr) {
+2 -2
View File
@@ -119,7 +119,7 @@ func TestCall_RetryOnTransient503(t *testing.T) {
}
func TestCall_ExhaustsRetries(t *testing.T) {
// Server always returns 503 — all 3 attempts should fail.
// Server always returns 503, all 3 attempts should fail.
srv, calls := jsonRPCServer(t, 99, nil)
rpc := NewRPC(srv.URL, "u", "p", 5*time.Second)
@@ -134,7 +134,7 @@ func TestCall_ExhaustsRetries(t *testing.T) {
func TestCall_NoRetryOnSemanticRPCError(t *testing.T) {
// Server returns a semantic RPC error (block not found). Should not be
// retried — the answer is definitive.
// retried, the answer is definitive.
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
+1 -1
View File
@@ -53,7 +53,7 @@ var ErrProtocol = errors.New("ckpool: protocol error")
// or SocketConnector.
//
// If the server closes the connection before writing a response (common
// during ckpool warm-up — the stratifier accepts the socket but its stats
// during ckpool warm-up, the stratifier accepts the socket but its stats
// aren't populated yet), Send retries once after a short backoff.
func (c *Client) Send(ctx context.Context, sockName, cmd string) ([]byte, error) {
buf, err := c.sendOnce(ctx, sockName, cmd)
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Server) Handler() http.Handler {
func spaHandler(root fs.FS) http.Handler {
fileServer := http.FileServerFS(root)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defence-in-depth — /api routes are matched by the mux first
// Defence-in-depth, /api routes are matched by the mux first
// with their GET patterns, but a bare POST /api/... would fall
// through here. Return 404 so we don't accidentally shadow
// API semantics with HTML.
+1 -1
View File
@@ -3,7 +3,7 @@
// Scope: push JSON snapshots to subscribed clients. We never expect
// client payloads larger than a ping/pong and we don't negotiate
// extensions (compression, fragmentation). Anything fancier is out of
// scope for Phase 2b — upgrade to a real ws library once we can run
// scope for Phase 2b, upgrade to a real ws library once we can run
// `go mod tidy` in a real build environment.
package httpapi
+10 -10
View File
@@ -1,4 +1,4 @@
// Package logmon tails the ckpool log file looking for notable events —
// Package logmon tails the ckpool log file looking for notable events,
// primarily block-solve lines, which are the most reliable signal we have
// that a block was found, short of a ZMQ hashblock subscription.
//
@@ -39,7 +39,7 @@ type BlockEvent struct {
}
// AttemptEvent is emitted when ckpool logs a "Possible block solve"
// or "Submitting possible block solve" line — i.e. ckpool decided a
// or "Submitting possible block solve" line, i.e. ckpool decided a
// share met network difficulty and is attempting to submit it to
// bitcoind. This precedes the "Solved and confirmed" line that drives
// BlockEvent. Counting attempts vs confirmations surfaces submission
@@ -52,7 +52,7 @@ type AttemptEvent struct {
}
// LatencyEvent is emitted when ckpool logs the "Block update latency"
// line from our patch 0005 — the milliseconds between ZMQ trigger and
// line from our patch 0005, the milliseconds between ZMQ trigger and
// mining.notify broadcast completion.
type LatencyEvent struct {
SeenAt time.Time
@@ -156,8 +156,8 @@ var (
// The first number after "diff " is sdiff (share difficulty solved).
acceptedShareRE = regexp.MustCompile(`Accepted client \S+ share diff ([0-9.]+)/[^:]+:\s*([0-9a-fA-F]+)`)
// Rejected shares come in two forms:
// 1. "dupe diff" / "high diff" — share was valid but duplicate or below target
// 2. "invalid share <reason>" — share was structurally invalid (Stale, etc.)
// 1. "dupe diff" / "high diff", share was valid but duplicate or below target
// 2. "invalid share <reason>", share was structurally invalid (Stale, etc.)
rejectedDiffRE = regexp.MustCompile(`Rejected client \S+ (\w+) diff [0-9.]+/`)
rejectedInvalRE = regexp.MustCompile(`Rejected client \S+ invalid share (.+)`)
)
@@ -171,7 +171,7 @@ const powDataMarker = "Best share PoW data "
// parsePowLine extracts the PoW record from a log line carrying
// powDataMarker. It decodes exactly one JSON value starting at the first
// '{' after the marker and deliberately ignores everything after it —
// '{' after the marker and deliberately ignores everything after it,
// production logs have carried trailing bytes after the closing brace,
// which silently defeated an end-of-line-anchored regex. Returns
// (nil, nil) when the marker is absent, and a non-nil error for a
@@ -368,7 +368,7 @@ func (t *Tailer) handleLine(line string) {
default:
}
}
// Don't return — an accepted share may also be a block solve,
// Don't return, an accepted share may also be a block solve,
// so let the solve-diff regex below get a chance to match too.
}
// Rejected share with diff info: "Rejected client <id> dupe|high diff ..."
@@ -407,7 +407,7 @@ func (t *Tailer) handleLine(line string) {
select {
case t.Attempts <- AttemptEvent{SeenAt: time.Now(), ShareDiff: d, RawLine: line}:
default:
// Best-effort metric — dropping is fine.
// Best-effort metric, dropping is fine.
}
return
}
@@ -484,7 +484,7 @@ func FindBestShareHash(path string) (float64, string) {
// of any length can never abort the scan: an oversized line is passed to
// fn truncated to the buffer size and its remainder is discarded. A
// months-old production log with one pathological line must not silently
// stop a backfill scan partway through — that failure mode is invisible
// stop a backfill scan partway through, that failure mode is invisible
// and permanent.
func scanLines(f *os.File, fn func(line string)) error {
r := bufio.NewReaderSize(f, 256*1024)
@@ -513,7 +513,7 @@ var logStampRE = regexp.MustCompile(`^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`)
// by an "Accepted client" line with the same hash. Used as a one-time
// backfill when kamado-api holds no persisted record but the log
// already contains some (fresh database, or PoW events dropped under
// load) — without it the PoW inspector would sit empty until a share
// load), without it the PoW inspector would sit empty until a share
// beat ckpool's in-process best, which after a long uptime can take
// arbitrarily long. The returned time is parsed from the log line's
// timestamp; zero when unparseable. Returns a nil record if no confirmed
+3 -3
View File
@@ -77,7 +77,7 @@ func TestHandleLine_DiffResetAfterBlock(t *testing.T) {
tl := New("/unused", discardLog())
tl.handleLine("Possible block solve diff 16384 !\n")
tl.handleLine("Solved and confirmed block 100\n")
// Second block — no preceding attempt line; diff must NOT carry over.
// Second block, no preceding attempt line; diff must NOT carry over.
tl.handleLine("Solved and confirmed block 101\n")
drain := func() BlockEvent {
@@ -217,7 +217,7 @@ func TestFindBestPowData(t *testing.T) {
// Production ckpool logs have carried bytes after the JSON's closing
// brace (observed in the field: an end-of-line-anchored regex matched 0
// of 54 real PoW lines while synthetic lines passed). The parser must
// decode the JSON value and ignore ANY trailing bytes — NULs, \r,
// decode the JSON value and ignore ANY trailing bytes, NULs, \r,
// or an interleaved second message on the same line.
func TestHandleLine_PowDataTrailingGarbage(t *testing.T) {
suffixes := []string{
@@ -240,7 +240,7 @@ func TestHandleLine_PowDataTrailingGarbage(t *testing.T) {
}
// A pathologically long line earlier in the log must not abort the scan
// before it reaches the PoW records — bufio.Scanner did exactly that,
// before it reaches the PoW records, bufio.Scanner did exactly that,
// silently, which left the backfill permanently empty on real logs.
func TestFindBestPowData_SurvivesOversizedLine(t *testing.T) {
path := tempLog(t)
+18 -18
View File
@@ -37,7 +37,7 @@ type BestSharePow struct {
// StratumServer describes one entry in ckpool's serverurl[] array. ckpool
// tags every client with the index of the bind it arrived on, but the index
// alone doesn't say what that bind *is* — that depends on how the deployment
// alone doesn't say what that bind *is*, that depends on how the deployment
// rendered ckpool.conf. Declaring the array lets the dashboard report the
// actual transport (plaintext, TLS with which certificate) instead of
// hardcoding a bind order.
@@ -77,7 +77,7 @@ type Snapshot struct {
// Full PoW reproduction data (raw block header, coinbase, merkle
// branches) for the best share seen since ckpool patch 0008 was
// deployed. May describe a lower-diff share than BestDiff when the
// all-time best predates the patch — the UI compares hashes and
// all-time best predates the patch, the UI compares hashes and
// shows a disclaimer in that case.
BestSharePow *BestSharePow `json:"best_share_pow,omitempty"`
// Last best-diff value acknowledged by the user via the UI.
@@ -129,7 +129,7 @@ type Snapshot struct {
// Counts of share-submit attempts ("Possible/Submitting block solve"
// log lines) and confirmed solves ("Solved and confirmed block").
// A growing gap means bitcoind is rejecting our submissions or
// dropping the RPC — surface it in the UI as an alert.
// dropping the RPC, surface it in the UI as an alert.
BlockSubmitAttempts int64 `json:"block_submit_attempts"`
BlockSubmitsConfirmed int64 `json:"block_submits_confirmed"`
@@ -186,7 +186,7 @@ const (
// kvCumulativeWork is versioned: "cumulative_shares" (v1) accidentally
// summed pool.shares (raw per-share count), which is meaningless for
// hashrate math. "cumulative_work" (v2) sums pool.accepted — the
// hashrate math. "cumulative_work" (v2) sums pool.accepted, the
// diff-1-normalized work, so cumulative_work * 2^32 is real hashes.
// The old key is orphaned in the kv table on upgrade; harmless.
kvCumulativeWork = "cumulative_work"
@@ -264,14 +264,14 @@ type Aggregator struct {
// Cumulative pool work in diff-1-normalized shares, surviving
// ckpool restarts. Maintained by re-reading pool.Shares each
// refresh and integrating only the positive delta — a decrease
// refresh and integrating only the positive delta, a decrease
// means ckpool restarted and its counter reset to 0, so we reset
// our delta baseline without losing the accumulated total.
//
// hasPoolSharesBaseline guards against double-counting on api
// restart: cumulativeShares is loaded from disk, and the first
// post-load observation of pool.Shares only establishes the
// baseline — the current ckpool counter was already accounted for
// baseline, the current ckpool counter was already accounted for
// before we crashed.
cumulativeShares float64
lastPoolShares float64
@@ -395,7 +395,7 @@ type Aggregator struct {
loadOnce sync.Once
// statsLoaded reports that the alltime share statistics have been
// read back from the store. Persisting before that would write the
// zero-valued in-memory counters over the accumulated history — the
// zero-valued in-memory counters over the accumulated history, the
// first share event triggers a save, so this is the difference
// between a restart and permanent data loss.
statsLoaded bool
@@ -419,7 +419,7 @@ func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog
// Run calls it, but callers should invoke it directly before starting
// any ingest goroutine: those goroutines mutate the same counters and
// persist them on their first event, so a restore still in flight would
// be overwritten by — and then read back as — near-empty state. Safe to
// be overwritten by, and then read back as, near-empty state. Safe to
// call repeatedly; only the first call does the work.
func (a *Aggregator) LoadPersisted() {
a.loadOnce.Do(func() {
@@ -429,7 +429,7 @@ func (a *Aggregator) LoadPersisted() {
}
// Ready returns a channel that's closed once the aggregator has
// completed its first refresh — used by main to delay HTTP serving
// completed its first refresh, used by main to delay HTTP serving
// until /api/snapshot reflects real state instead of zeros.
func (a *Aggregator) Ready() <-chan struct{} {
return a.ready
@@ -580,7 +580,7 @@ func (a *Aggregator) refresh(ctx context.Context) {
// Measuring to wall-clock time instead adds the partial, in-progress
// interval to the elapsed span without adding an interval to divide
// it by. That skews the estimate toward "difficulty will fall" by
// however long it has been since the last block — which averages a
// however long it has been since the last block, which averages a
// full 600s and, a handful of blocks into an epoch, is most of the
// sample. It washes out only once enough blocks accumulate to dwarf
// it, which is why the estimate used to settle down around mid-epoch.
@@ -651,12 +651,12 @@ func (a *Aggregator) refresh(ctx context.Context) {
// --- cumulative work tracking ---
// Integrate only positive deltas on pool.Accepted, which is ckpool's
// accounted_diff_shares — the sum of each accepted share's
// accounted_diff_shares, the sum of each accepted share's
// difficulty, in diff-1-normalized units. pool.Shares is the raw
// per-share count and is useless for hashrate math. A decrease
// means ckpool restarted (or its state reset); we reset the
// baseline without touching the accumulated total. The first
// observation after load only establishes the baseline — the
// observation after load only establishes the baseline, the
// current counter was already integrated before we shut down.
if next.Pool != nil {
cur := float64(next.Pool.Accepted)
@@ -687,7 +687,7 @@ func (a *Aggregator) refresh(ctx context.Context) {
if curAcc >= a.lastPoolAcceptedRaw {
a.allTimeAccepted += curAcc - a.lastPoolAcceptedRaw
} else {
// ckpool restarted — reset session share stats.
// ckpool restarted, reset session share stats.
a.resetSessionShareStats()
}
if curRej >= a.lastPoolRejectedRaw {
@@ -866,8 +866,8 @@ func estimateRetargetPercent(inEpoch int64, elapsed float64) float64 {
return 0
}
// Consensus budgets retargetInterval*600 seconds for an epoch but
// spans them across retargetInterval-1 inter-block intervals — the
// long-standing off-by-one — so on-target pace is fractionally above
// spans them across retargetInterval-1 inter-block intervals, the
// long-standing off-by-one, so on-target pace is fractionally above
// 600s per block, and a perfectly on-time epoch retargets by ~0%
// rather than a hair negative.
targetPerInterval := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
@@ -1000,7 +1000,7 @@ func (a *Aggregator) IngestShareEvents(ctx context.Context, events <-chan logmon
// stats that were lost or truncated: the log is the only other record of
// per-share difficulty, and ckpool keeps its full history there.
//
// Deliberately never automatic — the scan walks every line of a log that
// Deliberately never automatic, the scan walks every line of a log that
// can hold millions, and it was a long startup scan that made the race
// this repairs so easy to hit in the first place.
func (a *Aggregator) RebuildShareStatsFromLog() (adopted bool, scanned int64, err error) {
@@ -1085,7 +1085,7 @@ func (a *Aggregator) persistShareStats() {
// loadPersistedState restores cumulative work and hashrate history from
// the store so they survive process restarts. Safe to call with a nil
// Store — becomes a no-op.
// Store, becomes a no-op.
func (a *Aggregator) loadPersistedState() {
if a.Store == nil {
return
@@ -1173,7 +1173,7 @@ func (a *Aggregator) loadPersistedState() {
if logDiff, h := logmon.FindBestShareHash(a.LogFilePath); h != "" {
a.mu.Lock()
a.bestShareHash = h
// The log may contain the true best — adopt it if higher.
// The log may contain the true best, adopt it if higher.
if logDiff > a.bestDiff {
a.bestDiff = logDiff
if a.Store != nil {
+15 -15
View File
@@ -168,7 +168,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
// Best-effort enrich with hash + coinbase reward + miner via bitcoind.
// We look up the hash from height, then fetch the full block
// (verbosity 2) to sum the coinbase outputs and extract the
// payout address. Both are fire-and-forget — if bitcoind is
// payout address. Both are fire-and-forget, if bitcoind is
// down we still record the block with whatever we have.
if a.RPC != nil {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
@@ -200,7 +200,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
if err != nil {
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
} else if !inserted {
a.Log.Warn("block at this height already recorded — duplicate or pre-existing entry, not counting as new",
a.Log.Warn("block at this height already recorded, duplicate or pre-existing entry, not counting as new",
"height", rec.Height, "hash", rec.Hash)
isNew = false
}
@@ -257,7 +257,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// We need the current chain for both enrichment and reorg detection.
// If the first refresh hasn't completed yet, currentChain is "" and
// any chain comparison would be meaningless — skip until the next tick.
// any chain comparison would be meaningless, skip until the next tick.
a.mu.RLock()
currentChain := ""
if a.snap.Chain != nil {
@@ -269,7 +269,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
}
// Pass 1: enrichment. Fetch hash + reward for any missing-data rows.
// Skip blocks from a different chain — enriching a testnet block
// Skip blocks from a different chain, enriching a testnet block
// against mainnet RPC would overwrite its hash with the wrong value.
missing, err := a.Store.BlocksNeedingEnrichment(since)
if err != nil {
@@ -350,10 +350,10 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
canonical, herr := a.RPC.GetBlockHash(lookupCtx, b.Height)
cancel()
if herr != nil {
continue // RPC transient error — retry next sweep
continue // RPC transient error, retry next sweep
}
if canonical == b.Hash {
continue // matches the canonical chain — all good
continue // matches the canonical chain, all good
}
// Hash mismatch. Determine whether it's a real reorg or a
// cross-network block by checking if our hash exists on this chain.
@@ -391,7 +391,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// Pass 3: un-orphan blocks that were previously false-positived.
// This covers two cases:
// a) Blocks with Chain != currentChain that got orphaned before this
// fix was deployed — un-orphan them now.
// fix was deployed, un-orphan them now.
// b) Same-chain blocks whose hash now matches the canonical chain
// (e.g. the "reorg" reverted before we checked again).
fixedAny := false
@@ -403,7 +403,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
continue
}
if b.Chain != "" && b.Chain != currentChain {
// Cross-network orphan from before the fix — un-orphan.
// Cross-network orphan from before the fix, un-orphan.
if err := a.Store.UnmarkOrphaned(b.Height); err != nil {
a.Log.Warn("reconcile: unmark cross-network orphan failed", "height", b.Height, "err", err)
continue
@@ -418,7 +418,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
_, berr := a.RPC.GetBlock(lookupCtx, b.Hash)
cancel()
if berr != nil {
// Block doesn't exist on this chain — cross-network.
// Block doesn't exist on this chain, cross-network.
otherChain := b.Chain
if otherChain == "" || otherChain == currentChain {
otherChain = inferOtherChain(currentChain)
@@ -432,7 +432,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
fixedAny = true
continue
}
// Block exists on this chain — check if it's canonical again.
// Block exists on this chain, check if it's canonical again.
lookupCtx2, cancel2 := context.WithTimeout(ctx, 3*time.Second)
canonical, herr := a.RPC.GetBlockHash(lookupCtx2, b.Height)
cancel2()
@@ -444,7 +444,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
a.Log.Warn("reconcile: unmark reverted-reorg failed", "height", b.Height, "err", err)
continue
}
a.Log.Info("reconcile: cleared orphan — hash matches canonical again",
a.Log.Info("reconcile: cleared orphan, hash matches canonical again",
"height", b.Height, "hash", b.Hash)
fixedAny = true
}
@@ -452,9 +452,9 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
// Pass 4: stamp chain on legacy/mis-stamped blocks that have a hash.
// Handles Chain="" (never stamped), "other" (old fallback), and "test"
// (wrong identifier — testnet4 reports as "testnet4", not "test").
// (wrong identifier, testnet4 reports as "testnet4", not "test").
// Uses GetBlock(hash) to determine if the block belongs to the current
// network or a different one. No lookback restriction — this is a
// network or a different one. No lookback restriction, this is a
// one-time migration for pre-existing rows.
for _, b := range recent {
if (b.Chain != "" && b.Chain != "other" && b.Chain != "test") || b.Hash == "" {
@@ -464,7 +464,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) {
_, berr := a.RPC.GetBlock(lookupCtx, b.Hash)
cancel()
if berr != nil {
// Block not found on this network — infer the other chain.
// Block not found on this network, infer the other chain.
inferredChain := inferOtherChain(currentChain)
if err := a.Store.StampChain(b.Height, inferredChain); err != nil {
a.Log.Warn("reconcile: stamp legacy block failed", "height", b.Height, "err", err)
@@ -545,7 +545,7 @@ func (a *Aggregator) appendBlock(rec BlockRecord) Snapshot {
}
// loadPersistedBlocks seeds a.blocks from the store so history survives
// restarts. Safe to call with a nil Store — becomes a no-op.
// restarts. Safe to call with a nil Store, becomes a no-op.
func (a *Aggregator) loadPersistedBlocks() {
if a.Store == nil {
return
+10 -10
View File
@@ -90,7 +90,7 @@ func setChain(agg *Aggregator, chain string) {
// ---- reconcileOnce tests ----
// TestReconcileOnce_ChainFilter verifies that a block recorded on testnet
// is NOT orphaned when the node is now on mainnet — even if the canonical
// is NOT orphaned when the node is now on mainnet, even if the canonical
// hash at that height on mainnet is different.
func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
st := openTempStore(t)
@@ -109,7 +109,7 @@ func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
}
// Mock RPC: getblockhash returns a different mainnet hash; getblock
// for our testnet hash returns "Block not found" — it doesn't exist
// for our testnet hash returns "Block not found", it doesn't exist
// on mainnet at all, confirming it's cross-network not a reorg.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
switch method {
@@ -132,7 +132,7 @@ func TestReconcileOnce_ChainFilter_NoFalseOrphan(t *testing.T) {
}
for _, b := range blocks {
if b.Height == 100 && !b.OrphanedAt.IsZero() {
t.Error("testnet block was falsely orphaned after switching to mainnet — chain filter broken")
t.Error("testnet block was falsely orphaned after switching to mainnet, chain filter broken")
}
}
}
@@ -156,7 +156,7 @@ func TestReconcileOnce_GenuineReorg(t *testing.T) {
}
// getblockhash returns a different canonical hash (reorg happened).
// getblock for our hash SUCCEEDS — the block still exists in bitcoind's
// getblock for our hash SUCCEEDS, the block still exists in bitcoind's
// block store after a reorg, it's just no longer on the active chain.
// This distinguishes a real reorg from a cross-network block.
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
@@ -223,7 +223,7 @@ func TestReconcileOnce_RPCError_NoOrphan(t *testing.T) {
blocks, _ := st.Recent(10)
for _, b := range blocks {
if b.Height == 300 && !b.OrphanedAt.IsZero() {
t.Error("block was orphaned on RPC error — should only orphan on definitive hash mismatch")
t.Error("block was orphaned on RPC error, should only orphan on definitive hash mismatch")
}
}
}
@@ -240,7 +240,7 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
RewardBT: 3.125,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
Chain: "", // legacy, no chain recorded
})
// Canonical hash differs AND getblock succeeds (block exists on this
@@ -250,7 +250,7 @@ func TestReconcileOnce_LegacyBlock_StillChecked(t *testing.T) {
case "getblockhash":
return "different_canonical", nil
case "getblock":
// Block exists in this chain's store — genuine reorg
// Block exists in this chain's store, genuine reorg
return map[string]any{"hash": "legacy_hash", "height": 400, "confirmations": -1}, nil
}
return nil, nil
@@ -281,11 +281,11 @@ func TestReconcileOnce_LegacyBlock_CrossNetwork(t *testing.T) {
RewardBT: 50,
FoundAt: time.Now(),
Source: "test",
Chain: "", // legacy — no chain recorded
Chain: "", // legacy, no chain recorded
})
// Canonical hash differs AND getblock fails (block doesn't exist on
// this network at all). Should NOT orphan — should stamp as "other".
// this network at all). Should NOT orphan, should stamp as "other".
rpc := mockRPC(t, func(method string) (any, *rpcErrResp) {
switch method {
case "getblockhash":
@@ -436,6 +436,6 @@ func TestIngestBlockEvents_ChainStamping(t *testing.T) {
t.Fatal("block not found in store")
}
if blocks[0].Chain != "test" {
t.Errorf("Chain = %q, want test — chain stamping broken", blocks[0].Chain)
t.Errorf("Chain = %q, want test, chain stamping broken", blocks[0].Chain)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func TestPowEndToEnd_LogToSnapshot(t *testing.T) {
}
// The snapshot must expose it under best_share_pow with the flattened
// PowData fields — this is the contract the UI reads.
// PowData fields, this is the contract the UI reads.
snap := Snapshot{BestSharePow: func() *BestSharePow {
a.mu.RLock()
defer a.mu.RUnlock()
+3 -3
View File
@@ -103,8 +103,8 @@ func TestPowPairing_UnmatchedIsNotPromoted(t *testing.T) {
// The all-time best share may hugely outrank anything recent (found
// before header capture existed). The PoW record must still populate
// from the first confirmed share — it tracks "best since the feature
// went live", never waiting for the all-time best to be beaten — while
// from the first confirmed share, it tracks "best since the feature
// went live", never waiting for the all-time best to be beaten, while
// leaving the all-time bestDiff/bestShareHash untouched.
func TestPowPairing_BelowAllTimeBestStillPopulates(t *testing.T) {
a := New(nil, nil, time.Minute, discardLog())
@@ -210,7 +210,7 @@ func TestPowPairing_LowerDiffDoesNotReplace(t *testing.T) {
// statistics: the ingest goroutine runs concurrently with the restore,
// and its first event triggers a persist. If that persist is allowed to
// run before the counters are loaded, it writes the zero-valued
// in-memory histogram over the stored history — which the restore then
// in-memory histogram over the stored history, which the restore then
// reads back, making the loss permanent.
func TestShareStats_NotPersistedBeforeLoad(t *testing.T) {
s := openTempStore(t)
+2 -2
View File
@@ -8,7 +8,7 @@ import (
// The estimate must be stable from the very first blocks of an epoch:
// three blocks on target pace should read the same ~0% as a thousand.
// The old wall-clock measurement made early readings swing wildly
// because the time since the last block — 600s on average — was counted
// because the time since the last block, 600s on average, was counted
// into the elapsed span without a matching interval to divide it by.
func TestEstimateRetargetPercent_StableEarlyInEpoch(t *testing.T) {
// Exactly on-pace: each interval takes the consensus target.
@@ -82,7 +82,7 @@ func TestEstimateRetargetPercent_WallClockBiasWasWorstEarly(t *testing.T) {
t.Errorf("mid-epoch wall-clock error = %+.2f%%, expected it to have washed out", mid)
}
// And with block timestamps, that same on-pace epoch reads ~0 at
// both points — which is the actual fix.
// both points, which is the actual fix.
if got := estimateRetargetPercent(3, 3*onPace); math.Abs(got) > 0.01 {
t.Errorf("block-timestamp measurement at 3 blocks = %+.4f%%, want ~0%%", got)
}
+4 -4
View File
@@ -126,7 +126,7 @@ func (s *BlockStore) Close() error {
return s.db.Close()
}
// InsertBlock is idempotent — duplicate heights are ignored so replayed
// InsertBlock is idempotent, duplicate heights are ignored so replayed
// log events after a restart don't trip the primary key constraint.
// Returns (true, nil) if a new row was actually inserted, (false, nil)
// if the height was already present (replay or duplicate).
@@ -247,7 +247,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
// BlocksNeedingEnrichment returns blocks whose hash or reward is still
// unset and that were found within the lookback window. Older entries
// are ignored — if bitcoind couldn't tell us about a day-old block,
// are ignored, if bitcoind couldn't tell us about a day-old block,
// retrying will keep failing.
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
rows, err := s.db.Query(
@@ -300,7 +300,7 @@ func (s *BlockStore) MarkOrphaned(height int64, at time.Time) error {
// UnmarkOrphaned clears the orphaned_at stamp set by MarkOrphaned.
// Used to correct false positives caused by a network switch: a block
// found on testnet is not "orphaned" when the node switches to mainnet —
// found on testnet is not "orphaned" when the node switches to mainnet,
// it simply belongs to a different chain and must not be compared against
// mainnet's canonical history.
func (s *BlockStore) UnmarkOrphaned(height int64) error {
@@ -326,7 +326,7 @@ func (s *BlockStore) UnmarkOrphanedStampChain(height int64, chain string) error
// StampChain sets the chain field on a block without touching orphaned_at.
// Used when the reconcile loop determines a block belongs to a different
// network — the block is not orphaned (a reorg), it just doesn't belong
// network, the block is not orphaned (a reorg), it just doesn't belong
// to the current chain.
func (s *BlockStore) StampChain(height int64, chain string) error {
_, err := s.db.Exec(
+2 -2
View File
@@ -201,14 +201,14 @@ func TestMigrate_AddsChainColumn(t *testing.T) {
}
db.Close()
// Open via our store — migrate() must add the chain column.
// Open via our store, migrate() must add the chain column.
s, err := Open(f.Name())
if err != nil {
t.Fatalf("Open after migration: %v", err)
}
defer s.Close()
// Insert a block with a chain value — would fail if migration didn't add
// Insert a block with a chain value, would fail if migration didn't add
// the column.
inserted, err := s.InsertBlock(Block{
Height: 1000,
+1 -1
View File
@@ -9,7 +9,7 @@
// inside the image build.
//
// A placeholder index.html is committed so `go build` succeeds on a
// fresh checkout without anyone having run `make ui` — it just shows
// fresh checkout without anyone having run `make ui`, it just shows
// a "UI not built" notice instead of the real dashboard.
package webui
+2 -2
View File
@@ -1,7 +1,7 @@
// Package zmqmon subscribes to bitcoind's ZMQ `hashblock` topic and
// emits a lightweight event each time a new chain tip appears.
//
// This is strictly an "immediate refresh trigger" — it does NOT replace
// This is strictly an "immediate refresh trigger", it does NOT replace
// the ckpool log tailer, which still handles our own solved-block
// detection. ZMQ just shortens the latency between bitcoind seeing a
// new tip and the Kamado dashboard reflecting it (otherwise we'd wait
@@ -34,7 +34,7 @@ type Monitor struct {
}
// New returns a Monitor with a buffered event channel. Endpoint may be
// empty, in which case Run exits immediately as a no-op — useful for
// empty, in which case Run exits immediately as a no-op, useful for
// deployments where ZMQ isn't configured on bitcoind.
func New(endpoint string, log *slog.Logger) *Monitor {
return &Monitor{