diff --git a/api/cmd/kamado-api/main.go b/api/cmd/kamado-api/main.go index a6cd74d..3bf27f4 100644 --- a/api/cmd/kamado-api/main.go +++ b/api/cmd/kamado-api/main.go @@ -65,6 +65,7 @@ func main() { agg := state.New(ck, rpc, cfg.PollInterval, log) agg.Store = blockStore agg.MempoolBaseURL = cfg.MempoolBaseURL + agg.LogFilePath = cfg.CKPoolLogFile // Transaction accelerator (prioritisetransaction). var accSvc *accelerator.Service @@ -136,6 +137,7 @@ func main() { go agg.IngestBlockEvents(ctx, tailer.Events) go agg.IngestAttemptEvents(ctx, tailer.Attempts) go agg.IngestLatencyEvents(ctx, tailer.Latencies) + go agg.IngestShareEvents(ctx, tailer.Shares) if accSvc != nil { go accSvc.Cleanup(ctx) diff --git a/api/internal/bitcoind/rpc.go b/api/internal/bitcoind/rpc.go index 84bd9e6..186e944 100644 --- a/api/internal/bitcoind/rpc.go +++ b/api/internal/bitcoind/rpc.go @@ -222,8 +222,13 @@ type BlockTx struct { } type BlockVout struct { - Value float64 `json:"value"` - N int `json:"n"` + Value float64 `json:"value"` + N int `json:"n"` + ScriptPubKey BlockScriptPubKey `json:"scriptPubKey"` +} + +type BlockScriptPubKey struct { + Address string `json:"address"` } // GetBlock returns a verbose-level-2 block decode for the given hash. @@ -249,6 +254,15 @@ func (b *BlockVerbose2) CoinbaseReward() float64 { return total } +// CoinbaseAddress returns the address of the first coinbase output. +// In ckpool-solo mode this is the miner's BTC payout address. +func (b *BlockVerbose2) CoinbaseAddress() string { + if len(b.Tx) == 0 || len(b.Tx[0].Vout) == 0 { + return "" + } + return b.Tx[0].Vout[0].ScriptPubKey.Address +} + // NetworkHashPS returns the network hashrate at the given block height. // `blocks` is a window (default 120). Pass -1 to use the default. func (c *RPC) GetNetworkHashPS(ctx context.Context, blocks, height int) (float64, error) { diff --git a/api/internal/logmon/tailer.go b/api/internal/logmon/tailer.go index ad56ca7..f0883bf 100644 --- a/api/internal/logmon/tailer.go +++ b/api/internal/logmon/tailer.go @@ -21,6 +21,7 @@ import ( "os" "regexp" "strconv" + "strings" "time" ) @@ -57,6 +58,17 @@ type LatencyEvent struct { LatencyMs int64 } +// ShareEvent is emitted for every individual share submission logged by +// ckpool (both accepted and rejected). Used to build rejection-reason +// breakdowns and accepted-share difficulty distributions. +type ShareEvent struct { + SeenAt time.Time + Diff float64 // share difficulty (always set for accepted; 0 for rejected) + Hash string // block header hash (hex, accepted shares only) + Rejected bool + Reason string // rejection reason (e.g. "Stale", "Duplicate"); empty for accepted +} + // Tailer follows a log file, surviving rotation/truncation, and emits // parsed events. Create with New, then Run in a goroutine. type Tailer struct { @@ -64,6 +76,7 @@ type Tailer struct { Events chan BlockEvent Attempts chan AttemptEvent Latencies chan LatencyEvent + Shares chan ShareEvent Log *slog.Logger PollWait time.Duration // how long to sleep between EOF polls @@ -90,6 +103,7 @@ func New(path string, log *slog.Logger) *Tailer { Events: make(chan BlockEvent, 16), Attempts: make(chan AttemptEvent, 16), Latencies: make(chan LatencyEvent, 16), + Shares: make(chan ShareEvent, 64), Log: log, PollWait: 500 * time.Millisecond, } @@ -107,6 +121,19 @@ var ( // Matches the latency line from our patch 0005: // "Block update latency: 92ms (ZMQ trigger to mining.notify broadcast)" latencyRE = regexp.MustCompile(`Block update latency:\s+(\d+)ms`) + + // Share events from ckpool's stratifier.c (v1.0 / cfb0f83b): + // "Accepted client 42 share diff 1234.5/65536/1.234G: " + // "Rejected client 42 dupe diff 1234.5/65536/1.234G: " + // "Rejected client 42 high diff 1234.5/65536/1.234G: " + // "Rejected client 42 invalid share Stale" + // 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 " — share was structurally invalid (Stale, etc.) + rejectedDiffRE = regexp.MustCompile(`Rejected client \S+ (\w+) diff [0-9.]+/`) + rejectedInvalRE = regexp.MustCompile(`Rejected client \S+ invalid share (.+)`) ) // Run blocks until ctx is cancelled. It opens the file, seeks to either @@ -118,6 +145,7 @@ func (t *Tailer) Run(ctx context.Context) { defer close(t.Events) defer close(t.Attempts) defer close(t.Latencies) + defer close(t.Shares) var ( f *os.File @@ -256,6 +284,35 @@ func (t *Tailer) Run(ctx context.Context) { } func (t *Tailer) handleLine(line string) { + // Accepted share: extract share difficulty and block header hash. + if m := acceptedShareRE.FindStringSubmatch(line); m != nil { + if d, err := strconv.ParseFloat(m[1], 64); err == nil { + select { + case t.Shares <- ShareEvent{SeenAt: time.Now(), Diff: d, Hash: m[2]}: + default: + } + } + // 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 dupe|high diff ..." + if m := rejectedDiffRE.FindStringSubmatch(line); m != nil { + reason := rejectKeyword(m[1]) + select { + case t.Shares <- ShareEvent{SeenAt: time.Now(), Rejected: true, Reason: reason}: + default: + } + return + } + // Rejected share without diff: "Rejected client invalid share " + if m := rejectedInvalRE.FindStringSubmatch(line); m != nil { + reason := strings.TrimSpace(m[1]) + select { + case t.Shares <- ShareEvent{SeenAt: time.Now(), Rejected: true, Reason: reason}: + default: + } + return + } if m := latencyRE.FindStringSubmatch(line); m != nil { if ms, err := strconv.ParseInt(m[1], 10, 64); err == nil { select { @@ -301,6 +358,55 @@ func (t *Tailer) handleLine(line string) { } } +// rejectKeyword maps the short keyword ckpool uses in "Rejected client +// diff ..." log lines to a human-readable reason. +func rejectKeyword(kw string) string { + switch strings.ToLower(kw) { + case "dupe": + return "Duplicate" + case "high": + return "Above target" + default: + return kw + } +} + +// FindBestShareHash scans the log file for the accepted share with the +// highest difficulty and returns its diff and block header hash. Used as +// a one-time backfill when bestDiff is persisted but the hash is not +// (pre-upgrade shares). Returns (0, "") if no accepted shares are found. +func FindBestShareHash(path string) (float64, string) { + f, err := os.Open(path) + if err != nil { + return 0, "" + } + defer f.Close() + + var bestDiff float64 + var bestHash string + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "Accepted client") { + continue + } + m := acceptedShareRE.FindStringSubmatch(line) + if m == nil { + continue + } + d, err := strconv.ParseFloat(m[1], 64) + if err != nil { + continue + } + if d >= bestDiff { + bestDiff = d + bestHash = m[2] + } + } + return bestDiff, bestHash +} + // sleep returns false if ctx was cancelled during the wait. func sleep(ctx context.Context, d time.Duration) bool { select { diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index a88a654..c55d2c4 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -6,6 +6,7 @@ package state import ( "context" + "encoding/json" "log/slog" "strconv" "sync" @@ -13,6 +14,7 @@ import ( "github.com/kamadopool/kamado-api/internal/bitcoind" "github.com/kamadopool/kamado-api/internal/ckpool" + "github.com/kamadopool/kamado-api/internal/logmon" "github.com/kamadopool/kamado-api/internal/store" "github.com/kamadopool/kamado-api/internal/zmqmon" ) @@ -43,6 +45,10 @@ type Snapshot struct { // Best share difficulty ever seen across all workers. BestDiff float64 `json:"best_diff"` + // Block header hash of the best share ever found (hex string). + BestShareHash string `json:"best_share_hash,omitempty"` + // Network difficulty at the time the best share was found. + BestShareNetDiff float64 `json:"best_share_net_diff,omitempty"` // Last best-diff value acknowledged by the user via the UI. // The frontend shows a "new best" glow when best_diff > this. AckedBestDiff float64 `json:"acked_best_diff"` @@ -99,6 +105,17 @@ type Snapshot struct { AllTimeAccepted int64 `json:"alltime_accepted"` AllTimeRejected int64 `json:"alltime_rejected"` + // Share statistics: rejection reasons and difficulty distribution. + // Session resets on ckpool restart; AllTime persisted across restarts. + RejectReasons map[string]int64 `json:"reject_reasons_session,omitempty"` + RejectReasonsAll map[string]int64 `json:"reject_reasons_alltime,omitempty"` + // Difficulty distribution buckets: [<1M, 1M-100M, 100M-1G, 1G-100G, 100G-1T, >1T] + DiffDist [6]int64 `json:"diff_dist_session"` + DiffDistAll [6]int64 `json:"diff_dist_alltime"` + // Average share difficulty (arithmetic mean of all accepted shares). + AvgDiffSession float64 `json:"avg_diff_session"` + AvgDiffAlltime float64 `json:"avg_diff_alltime"` + // Block update latency diagnostics (ZMQ trigger → mining.notify). LatencyCount int64 `json:"latency_count"` LatencyAvgMs int64 `json:"latency_avg_ms"` @@ -129,12 +146,17 @@ const ( kvLatencyLastMs = "latency_last_ms" kvStaleWorkHashes = "stale_work_hashes" - kvAckedBestDiff = "acked_best_diff" - kvBestDiff = "best_diff" + kvAckedBestDiff = "acked_best_diff" + kvBestDiff = "best_diff" + kvBestShareHash = "best_share_hash" + kvBestShareNetDiff = "best_share_net_diff" kvAllTimeAccepted = "alltime_accepted" kvAllTimeRejected = "alltime_rejected" + kvRejectReasonsAll = "reject_reasons_alltime" + kvDiffDistAll = "diff_dist_alltime" + // reconcileInterval is how often we sweep recent blocks looking // for missing hash/reward enrichment and reorg-orphaned hashes. // 60s is fast enough to recover from a transient bitcoind hiccup @@ -159,6 +181,11 @@ type Aggregator struct { // MempoolBaseURL leaves the UI on its mempool.space defaults. MempoolBaseURL string + // LogFilePath is the ckpool log path, used for one-time backfill + // of the best share hash when upgrading from a version that didn't + // capture it. Set by main before calling Run. + LogFilePath string + // OnRefresh, if set, is called (non-blocking) after each snapshot // refresh. Used by the WebSocket hub to push updates to clients. OnRefresh func(Snapshot) @@ -246,17 +273,35 @@ type Aggregator struct { // High-water mark for the best share difficulty ever seen. // Persisted to kv so it survives restarts and transient ckpool gaps. bestDiff float64 + // Block header hash of the best share ever found. Captured from the + // ckpool log "Accepted client ... : " line for the share that + // set the bestDiff record. Persisted alongside bestDiff. + bestShareHash string + bestShareNetDiff float64 // network difficulty when best share was found // Last best-diff value acknowledged by the user in the UI. ackedBestDiff float64 + + // Share statistics from log parsing. + sessionRejectReasons map[string]int64 + alltimeRejectReasons map[string]int64 + sessionDiffDist [6]int64 + alltimeDiffDist [6]int64 + sessionDiffCount int64 // total accepted shares (session) + alltimeDiffCount int64 // total accepted shares (alltime) + sessionDiffSum float64 // sum of share difficulties (session) + alltimeDiffSum float64 // sum of share difficulties (alltime) + lastShareStatsSave time.Time } func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog.Logger) *Aggregator { return &Aggregator{ - CK: ck, - RPC: rpc, - Interval: interval, - Log: log, + CK: ck, + RPC: rpc, + Interval: interval, + Log: log, + sessionRejectReasons: make(map[string]int64), + alltimeRejectReasons: make(map[string]int64), ready: make(chan struct{}), } } @@ -443,6 +488,8 @@ func (a *Aggregator) refresh(ctx context.Context) { } } next.BestDiff = a.bestDiff + next.BestShareHash = a.bestShareHash + next.BestShareNetDiff = a.bestShareNetDiff next.AckedBestDiff = a.ackedBestDiff now := time.Now() @@ -483,6 +530,9 @@ func (a *Aggregator) refresh(ctx context.Context) { if a.hasShareCountBaseline { if curAcc >= a.lastPoolAcceptedRaw { a.allTimeAccepted += curAcc - a.lastPoolAcceptedRaw + } else { + // ckpool restarted — reset session share stats. + a.resetSessionShareStats() } if curRej >= a.lastPoolRejectedRaw { a.allTimeRejected += curRej - a.lastPoolRejectedRaw @@ -569,6 +619,31 @@ func (a *Aggregator) refresh(ctx context.Context) { } next.LatencyLastMs = a.latencyLastMs next.StaleWorkHashes = a.staleWorkHashes + + // Share statistics (from log parsing). + if len(a.sessionRejectReasons) > 0 { + m := make(map[string]int64, len(a.sessionRejectReasons)) + for k, v := range a.sessionRejectReasons { + m[k] = v + } + next.RejectReasons = m + } + if len(a.alltimeRejectReasons) > 0 { + m := make(map[string]int64, len(a.alltimeRejectReasons)) + for k, v := range a.alltimeRejectReasons { + m[k] = v + } + next.RejectReasonsAll = m + } + next.DiffDist = a.sessionDiffDist + next.DiffDistAll = a.alltimeDiffDist + if a.sessionDiffCount > 0 { + next.AvgDiffSession = a.sessionDiffSum / float64(a.sessionDiffCount) + } + if a.alltimeDiffCount > 0 { + next.AvgDiffAlltime = a.alltimeDiffSum / float64(a.alltimeDiffCount) + } + a.snap = next cb := a.OnRefresh pushed := next @@ -605,6 +680,126 @@ func (a *Aggregator) ResetAckedBestDiff() { } } +// diffBucket returns the index [0..5] for a share difficulty: +// +// 0: < 1M 1: 1M–100M 2: 100M–1G +// 3: 1G–100G 4: 100G–1T 5: ≥ 1T +func diffBucket(d float64) int { + switch { + case d < 1e6: + return 0 + case d < 1e8: + return 1 + case d < 1e9: + return 2 + case d < 1e11: + return 3 + case d < 1e12: + return 4 + default: + return 5 + } +} + +// DiffBucketLabels are the human-readable labels for each difficulty bucket. +var DiffBucketLabels = [6]string{"< 1M", "1M – 100M", "100M – 1G", "1G – 100G", "100G – 1T", "≥ 1T"} + + +// IngestShareEvents reads individual share events from the log tailer +// and maintains rejection-reason counts and difficulty-distribution +// histograms. Session counters reset when ckpool restarts (detected by +// the aggregator's existing pool.Shares decrease logic). Alltime +// counters are persisted to the kv store. +func (a *Aggregator) IngestShareEvents(ctx context.Context, events <-chan logmon.ShareEvent) { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-events: + if !ok { + return + } + a.mu.Lock() + if ev.Rejected { + reason := ev.Reason + if reason == "" { + reason = "Unknown" + } + a.sessionRejectReasons[reason]++ + a.alltimeRejectReasons[reason]++ + } else { + bucket := diffBucket(ev.Diff) + a.sessionDiffDist[bucket]++ + a.alltimeDiffDist[bucket]++ + a.sessionDiffCount++ + a.alltimeDiffCount++ + a.sessionDiffSum += ev.Diff + a.alltimeDiffSum += ev.Diff + // Track hash of the best share from log parsing. + if ev.Hash != "" && ev.Diff > a.bestDiff { + a.bestDiff = ev.Diff + a.bestShareHash = ev.Hash + // Capture the current network difficulty at time of finding. + if a.snap.Chain != nil && a.snap.Chain.Difficulty > 0 { + a.bestShareNetDiff = a.snap.Chain.Difficulty + } + if a.Store != nil { + _ = a.Store.SetKV(kvBestDiff, strconv.FormatFloat(ev.Diff, 'f', -1, 64)) + _ = a.Store.SetKV(kvBestShareHash, ev.Hash) + if a.bestShareNetDiff > 0 { + _ = a.Store.SetKV(kvBestShareNetDiff, strconv.FormatFloat(a.bestShareNetDiff, 'f', -1, 64)) + } + } + } + } + save := a.Store != nil && time.Since(a.lastShareStatsSave) >= time.Minute + if save { + a.lastShareStatsSave = time.Now() + } + a.mu.Unlock() + if save { + a.persistShareStats() + } + } + } +} + +// ResetSessionShareStats clears session-level share statistics. Called +// when a ckpool restart is detected (pool.Shares decreases). +func (a *Aggregator) resetSessionShareStats() { + a.sessionRejectReasons = make(map[string]int64) + a.sessionDiffDist = [6]int64{} + a.sessionDiffCount = 0 + a.sessionDiffSum = 0 +} + +func (a *Aggregator) persistShareStats() { + if a.Store == nil { + return + } + a.mu.Lock() + reasonsCopy := make(map[string]int64, len(a.alltimeRejectReasons)) + for k, v := range a.alltimeRejectReasons { + reasonsCopy[k] = v + } + distCopy := a.alltimeDiffDist + countCopy := a.alltimeDiffCount + sumCopy := a.alltimeDiffSum + a.mu.Unlock() + + if data, err := json.Marshal(reasonsCopy); err == nil { + _ = a.Store.SetKV(kvRejectReasonsAll, string(data)) + } + type distPersist struct { + Buckets [6]int64 `json:"b"` + Count int64 `json:"n"` + Sum float64 `json:"s"` + } + if data, err := json.Marshal(distPersist{Buckets: distCopy, Count: countCopy, Sum: sumCopy}); err == nil { + _ = a.Store.SetKV(kvDiffDistAll, string(data)) + } +} + // 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. @@ -655,7 +850,7 @@ func (a *Aggregator) loadPersistedState() { } } - // Restore best diff high-water mark. + // Restore best diff high-water mark and its block header hash. if v, err := a.Store.GetKV(kvBestDiff); err == nil && v != "" { if f, perr := strconv.ParseFloat(v, 64); perr == nil { a.mu.Lock() @@ -663,6 +858,44 @@ func (a *Aggregator) loadPersistedState() { a.mu.Unlock() } } + if v, err := a.Store.GetKV(kvBestShareHash); err == nil && v != "" { + a.mu.Lock() + a.bestShareHash = v + a.mu.Unlock() + } + if v, err := a.Store.GetKV(kvBestShareNetDiff); err == nil && v != "" { + if f, perr := strconv.ParseFloat(v, 64); perr == nil { + a.mu.Lock() + a.bestShareNetDiff = f + a.mu.Unlock() + } + } + + // One-time backfill: if we have no hash (pre-upgrade), scan the + // ckpool log for the highest-difficulty accepted share and use its hash. + a.mu.RLock() + needsBackfill := a.bestShareHash == "" && a.LogFilePath != "" + a.mu.RUnlock() + if needsBackfill { + 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. + if logDiff > a.bestDiff { + a.bestDiff = logDiff + if a.Store != nil { + _ = a.Store.SetKV(kvBestDiff, strconv.FormatFloat(logDiff, 'f', -1, 64)) + } + } + a.mu.Unlock() + if a.Store != nil { + _ = a.Store.SetKV(kvBestShareHash, h) + } + a.Log.Info("backfilled best share hash from log", "diff", logDiff, "hash", h) + } else { + a.Log.Info("best share hash backfill: no accepted shares found in log") + } + } // Restore acknowledged best diff. if v, err := a.Store.GetKV(kvAckedBestDiff); err == nil && v != "" { @@ -701,6 +934,30 @@ func (a *Aggregator) loadPersistedState() { } a.mu.Unlock() + // Restore share statistics. + if v, err := a.Store.GetKV(kvRejectReasonsAll); err == nil && v != "" { + var m map[string]int64 + if jerr := json.Unmarshal([]byte(v), &m); jerr == nil { + a.mu.Lock() + a.alltimeRejectReasons = m + a.mu.Unlock() + } + } + if v, err := a.Store.GetKV(kvDiffDistAll); err == nil && v != "" { + var dp struct { + Buckets [6]int64 `json:"b"` + Count int64 `json:"n"` + Sum float64 `json:"s"` + } + if jerr := json.Unmarshal([]byte(v), &dp); jerr == nil { + a.mu.Lock() + a.alltimeDiffDist = dp.Buckets + a.alltimeDiffCount = dp.Count + a.alltimeDiffSum = dp.Sum + a.mu.Unlock() + } + } + cutoff := time.Now().Add(-24 * time.Hour).Unix() if samples, err := a.Store.HashrateSince(cutoff); err != nil { a.Log.Warn("hashrate history load failed", "err", err) diff --git a/api/internal/state/blocks.go b/api/internal/state/blocks.go index c2344a4..72375a2 100644 --- a/api/internal/state/blocks.go +++ b/api/internal/state/blocks.go @@ -3,6 +3,7 @@ package state import ( "context" "strconv" + "strings" "time" "github.com/kamadopool/kamado-api/internal/logmon" @@ -24,6 +25,7 @@ type BlockRecord struct { ShareDiff float64 `json:"share_diff,omitempty"` OrphanedAt *time.Time `json:"orphaned_at,omitempty"` Chain string `json:"chain,omitempty"` // "main", "test", "signet" + Miner string `json:"miner,omitempty"` // workername (address.worker) who found the block } // timePtr returns a pointer to t if non-zero, nil otherwise. @@ -157,17 +159,18 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon ShareDiff: ev.ShareDiff, Chain: currentChain, } - // Best-effort enrich with hash + coinbase reward via bitcoind. + // 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. Both are fire- - // and-forget — if bitcoind is down we still record the block - // with whatever we have. + // (verbosity 2) to sum the coinbase outputs and extract the + // 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) if hash, err := a.RPC.GetBlockHash(lookupCtx, ev.Height); err == nil { rec.Hash = hash if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil { rec.RewardBT = blk.CoinbaseReward() + rec.Miner = a.minerFromCoinbase(blk.CoinbaseAddress()) } else { a.Log.Warn("bitcoind getblock failed", "hash", hash, "err", err) } @@ -186,6 +189,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon Source: rec.Source, ShareDiff: rec.ShareDiff, Chain: rec.Chain, + Miner: rec.Miner, }) if err != nil { a.Log.Warn("block persist failed", "height", rec.Height, "err", err) @@ -273,6 +277,7 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) { lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second) hash := b.Hash reward := b.RewardBT + miner := b.Miner if hash == "" { if h, herr := a.RPC.GetBlockHash(lookupCtx, b.Height); herr == nil { hash = h @@ -281,18 +286,23 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) { continue } } - if reward == 0 && hash != "" { + if (reward == 0 || miner == "") && hash != "" { if blk, berr := a.RPC.GetBlock(lookupCtx, hash); berr == nil { - reward = blk.CoinbaseReward() + if reward == 0 { + reward = blk.CoinbaseReward() + } + if miner == "" { + miner = a.minerFromCoinbase(blk.CoinbaseAddress()) + } } } cancel() - if hash != b.Hash || reward != b.RewardBT { - if err := a.Store.UpdateEnrichment(b.Height, hash, reward); err != nil { + if hash != b.Hash || reward != b.RewardBT || miner != b.Miner { + if err := a.Store.UpdateEnrichment(b.Height, hash, reward, miner); err != nil { a.Log.Warn("reconcile: update enrichment failed", "height", b.Height, "err", err) continue } - a.Log.Info("reconcile: enriched block", "height", b.Height, "hash", hash, "reward", reward) + a.Log.Info("reconcile: enriched block", "height", b.Height, "hash", hash, "reward", reward, "miner", miner) enrichedAny = true } } @@ -480,6 +490,32 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) { } } +// minerFromCoinbase matches a coinbase payout address to a full workername +// from the current worker list. In solo mode, stratum usernames are +// "address" or "address.label", and the coinbase pays the address portion. +// Returns the first matching workername (address.worker), or just the +// address if no worker match is found. +func (a *Aggregator) minerFromCoinbase(addr string) string { + if addr == "" { + return "" + } + a.mu.RLock() + workers := a.snap.Workers + a.mu.RUnlock() + for _, w := range workers { + // Worker.User is "address.workername" in ckpool. The address + // portion is everything before the first dot. + wAddr := w.User + if dot := strings.IndexByte(wAddr, '.'); dot >= 0 { + wAddr = wAddr[:dot] + } + if wAddr == addr { + return w.User + "." + w.Worker + } + } + return addr +} + // maxBlockHistory caps in-memory block history. Persistence comes in // Phase 2b.5 via SQLite; for now recent blocks survive only this // process's lifetime. @@ -526,6 +562,7 @@ func (a *Aggregator) loadPersistedBlocks() { ShareDiff: r.ShareDiff, OrphanedAt: timePtr(r.OrphanedAt), Chain: r.Chain, + Miner: r.Miner, }) } a.mu.Lock() @@ -581,6 +618,7 @@ func (a *Aggregator) BlocksFromStore() []BlockRecord { ShareDiff: r.ShareDiff, OrphanedAt: timePtr(r.OrphanedAt), Chain: r.Chain, + Miner: r.Miner, }) } return out diff --git a/api/internal/store/blocks.go b/api/internal/store/blocks.go index 15908c0..3659495 100644 --- a/api/internal/store/blocks.go +++ b/api/internal/store/blocks.go @@ -29,6 +29,7 @@ type Block struct { ShareDiff float64 OrphanedAt time.Time // zero value = not orphaned Chain string // "main", "test", "signet", or "" for legacy rows + Miner string // workername (address.worker) who found the block } const schema = ` @@ -90,6 +91,11 @@ func (s *BlockStore) migrate() error { return fmt.Errorf("store: add chain: %w", err) } } + if !have["miner"] { + if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN miner TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("store: add miner: %w", err) + } + } return nil } @@ -132,9 +138,9 @@ func (s *BlockStore) Close() error { // unnoticed. func (s *BlockStore) InsertBlock(b Block) (bool, error) { res, err := s.db.Exec( - `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff, chain) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, b.Chain, + `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff, chain, miner) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, b.Chain, b.Miner, ) if err != nil { return false, err @@ -212,7 +218,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) { limit = 256 } rows, err := s.db.Query( - `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain + `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain, miner FROM blocks ORDER BY height DESC LIMIT ?`, limit, ) @@ -224,7 +230,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) { for rows.Next() { var b Block var foundUnix, orphanedUnix int64 - if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { + if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain, &b.Miner); err != nil { return nil, err } b.FoundAt = time.Unix(foundUnix, 0).UTC() @@ -245,9 +251,9 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) { // retrying will keep failing. func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) { rows, err := s.db.Query( - `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain + `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain, miner FROM blocks - WHERE found_at >= ? AND (hash = '' OR reward_btc = 0) + WHERE found_at >= ? AND (hash = '' OR reward_btc = 0 OR miner = '') ORDER BY height ASC`, since.Unix(), ) @@ -259,7 +265,7 @@ func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) { for rows.Next() { var b Block var foundUnix, orphanedUnix int64 - if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { + if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain, &b.Miner); err != nil { return nil, err } b.FoundAt = time.Unix(foundUnix, 0).UTC() @@ -271,12 +277,12 @@ func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) { return out, rows.Err() } -// UpdateEnrichment fills in hash and reward for an already-recorded +// UpdateEnrichment fills in hash, reward, and miner for an already-recorded // block. No-op if the row doesn't exist. -func (s *BlockStore) UpdateEnrichment(height int64, hash string, reward float64) error { +func (s *BlockStore) UpdateEnrichment(height int64, hash string, reward float64, miner string) error { _, err := s.db.Exec( - `UPDATE blocks SET hash = ?, reward_btc = ? WHERE height = ?`, - hash, reward, height, + `UPDATE blocks SET hash = ?, reward_btc = ?, miner = ? WHERE height = ?`, + hash, reward, miner, height, ) return err } diff --git a/api/internal/store/blocks_test.go b/api/internal/store/blocks_test.go index a2c15fa..449def2 100644 --- a/api/internal/store/blocks_test.go +++ b/api/internal/store/blocks_test.go @@ -124,7 +124,7 @@ func TestUpdateEnrichment(t *testing.T) { s := openTemp(t) s.InsertBlock(Block{Height: 300, FoundAt: time.Now(), Chain: "main"}) - if err := s.UpdateEnrichment(300, "newhash", 3.125); err != nil { + if err := s.UpdateEnrichment(300, "newhash", 3.125, "bc1qtest.rig1"); err != nil { t.Fatalf("UpdateEnrichment: %v", err) } @@ -135,6 +135,9 @@ func TestUpdateEnrichment(t *testing.T) { if blocks[0].RewardBT != 3.125 { t.Errorf("RewardBT = %v, want 3.125", blocks[0].RewardBT) } + if blocks[0].Miner != "bc1qtest.rig1" { + t.Errorf("Miner = %q, want bc1qtest.rig1", blocks[0].Miner) + } } func TestBlocksNeedingEnrichment(t *testing.T) { @@ -146,7 +149,7 @@ func TestBlocksNeedingEnrichment(t *testing.T) { // Block with hash but no reward → needs enrichment. s.InsertBlock(Block{Height: 2, Hash: "abc", RewardBT: 0, FoundAt: now, Chain: "main"}) // Block fully enriched → does NOT need enrichment. - s.InsertBlock(Block{Height: 3, Hash: "def", RewardBT: 3.125, FoundAt: now, Chain: "main"}) + s.InsertBlock(Block{Height: 3, Hash: "def", RewardBT: 3.125, FoundAt: now, Chain: "main", Miner: "bc1q.rig1"}) since := now.Add(-time.Hour) missing, err := s.BlocksNeedingEnrichment(since) diff --git a/ckpool/entrypoint.sh b/ckpool/entrypoint.sh index f7ee992..d798dd9 100644 --- a/ckpool/entrypoint.sh +++ b/ckpool/entrypoint.sh @@ -48,6 +48,7 @@ ZMQ_BLOCK="${ZMQ_BLOCK:-}" LOGDIR="${LOGDIR:-/var/log/ckpool}" SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}" SHARE_LOG="${SHARE_LOG:-1}" +CKPOOL_LOGLEVEL="${CKPOOL_LOGLEVEL:-6}" mkdir -p "$LOGDIR" "$SOCKET_DIR" @@ -86,8 +87,10 @@ echo "[kamado] rendered ckpool.conf:" sed "s|\"pass\": \".*\"|\"pass\": \"\"|" "$CONF" echo -# Build command line -CMD="/usr/local/bin/ckpool --btcsolo --config $CONF --sockdir $SOCKET_DIR" +# Build command line. +# -l 6 = LOG_INFO: enables per-share Accepted/Rejected log lines +# that the kamado-api log tailer uses for share statistics. +CMD="/usr/local/bin/ckpool --btcsolo --config $CONF --sockdir $SOCKET_DIR -l $CKPOOL_LOGLEVEL" if [ "$SHARE_LOG" = "1" ]; then CMD="$CMD --log-shares" fi diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 2cdf3ce..1191234 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -12,6 +12,8 @@ import UserDetailPage from "./lib/UserDetailPage.svelte"; import WorkerDetailPage from "./lib/WorkerDetailPage.svelte"; import AcceleratorPage from "./lib/AcceleratorPage.svelte"; + import StatsPage from "./lib/StatsPage.svelte"; + import BestSharePage from "./lib/BestSharePage.svelte"; import SharesBar from "./lib/SharesBar.svelte"; import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte"; @@ -41,6 +43,18 @@ {/key} + {:else if selection.page === "stats"} + {#key 'stats'} +
+ +
+ {/key} + {:else if selection.page === "bestshare"} + {#key 'bestshare'} +
+ +
+ {/key} {:else if selection.worker} {#key selection.worker}
diff --git a/ui/src/lib/BestSharePage.svelte b/ui/src/lib/BestSharePage.svelte new file mode 100644 index 0000000..b8a7366 --- /dev/null +++ b/ui/src/lib/BestSharePage.svelte @@ -0,0 +1,575 @@ + + + + +
+ + +
+
Best Share Analysis
+

How close to a block?

+
+ + +
+ + +
+ {#if diffView === "found" && isLegacyNetDiff} +
+ Network difficulty at time of finding was not recorded for this share. Using approximate value of 136.6T based on historical data. +
+ {/if} + {#if diffView === "current" && currentNetDiff !== foundNetDiff} +
+ Comparing against the current network difficulty ({formatDifficulty(currentNetDiff)}), which may differ from the difficulty when this share was found{isLegacyNetDiff ? "" : ` (${formatDifficulty(foundNetDiff)})`}. +
+ {/if} + + +
+

+ To mine a block, you must find a hash whose numeric value + 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. +

+

+ Your best share has difficulty {formatDifficulty(diff)}, + producing a hash with {shareZeroBits} leading zero bits. + The current network difficulty of {formatDifficulty(netDiff)} + requires a hash with at least {networkZeroBits} leading zero bits. + {#if diffRatio <= 1} + Your share meets the network target — this would be a valid block! + {:else} + The network target is {diffRatio.toFixed(1)}x harder than your + best share. + {/if} +

+
+ + +
+
+
Your Best Share
+
{formatDifficulty(diff)}
+
{shareZeroBits} leading zero bits ({shareHexZeros} hex zeros){!hash ? " est." : ""}
+
+
+
Network Target
+
{formatDifficulty(netDiff)}
+
{networkZeroBits} leading zero bits ({networkHexZeros} hex zeros)
+
+
+
Gap
+
+ {diffRatio <= 1 ? "Block!" : diffRatio < 1000 ? diffRatio.toFixed(1) + "x" : formatDifficulty(diffRatio)} +
+
+ {#if diffRatio <= 1} + This share satisfies the network target + {:else} + {networkZeroBits - shareZeroBits} more leading zero {networkZeroBits - shareZeroBits === 1 ? "bit" : "bits"} needed + {/if} +
+
+
+ + +
+

Progress to Network Target

+
+
+
= 100} + style="width:{Math.min(progressPct, 100)}%" + >
+
+ {progressPct.toFixed(1)}% +
+
+ {formatDifficulty(diff)} / {formatDifficulty(netDiff)} +
+
+ + {#if hash} + +
+

Share Hash vs Network Target (hex)

+
+ YOUR HASH +
+ {#each hashChars as ch}{ch.c}{/each} +
+
+
+ TARGET +
+ {#each targetChars as ch}{ch.c}{/each} +
+
+
+ Below target (good) + Above target (too high) + Remaining +
+
+ + +
+

Share Hash (binary)

+
+ {#each binaryChars as ch}{#if ch.cls === "z-sep"} {:else}{ch.c}{/if}{/each} +
+

+ Every additional leading zero bit makes the hash 2x harder to find. + The first {networkZeroBits} bits must all be zero for a valid block. + Green bits are already correct (zero), + yellow bits are the ones that + prevented this share from being a valid block. + {#if networkZeroBits > shareZeroBits} + The gap of {networkZeroBits - shareZeroBits} bits means the target is roughly + 2{networkZeroBits - shareZeroBits} ≈ {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x + harder — matching the {diffRatio.toFixed(1)}x difficulty ratio. + {/if} +

+
+ {:else} +
+
+ Block header hash will appear here once a share is accepted after deploying this version. + The stats above are estimated from difficulty. +
+
+ {/if} +
+ + diff --git a/ui/src/lib/BlocksTable.svelte b/ui/src/lib/BlocksTable.svelte index 16e809c..5e794b6 100644 --- a/ui/src/lib/BlocksTable.svelte +++ b/ui/src/lib/BlocksTable.svelte @@ -19,6 +19,26 @@ ); const currentChain = $derived(snap.data?.chain?.chain ?? ""); + + function truncHash(hash: string): string { + if (!hash || hash.length <= 16) return hash; + return hash.slice(0, 8) + "…" + hash.slice(-8); + } + + function minerLabel(miner: string | undefined): string { + if (!miner) return "—"; + // Show address.worker truncated + const dot = miner.indexOf("."); + if (dot < 0) { + // Just an address, truncate middle + if (miner.length > 20) return miner.slice(0, 8) + "…" + miner.slice(-6); + return miner; + } + const addr = miner.slice(0, dot); + const worker = miner.slice(dot + 1); + const shortAddr = addr.length > 12 ? addr.slice(0, 6) + "…" + addr.slice(-4) : addr; + return shortAddr + "." + worker; + }
@@ -31,6 +51,7 @@ Height + Miner Chain Hash Reward @@ -47,6 +68,7 @@ orphaned {/if} + {minerLabel(b.miner)} {#if b.chain && currentChain && b.chain !== currentChain} {displayChain(b.chain)} @@ -63,8 +85,8 @@ href="{explorerBase}/block/{b.hash}" target="_blank" rel="noopener noreferrer" - title="Open block on mempool.space" - >{b.hash} + title={b.hash} + >{truncHash(b.hash)} {:else} {/if} @@ -100,11 +122,16 @@ .table-wrap { overflow-x: auto; } + .miner-cell { + font-size: 0.85em; + color: var(--fg-dim); + white-space: nowrap; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + } .hash-cell { - /* Let the 64-char hash wrap inside the cell instead of stretching - * the whole table. */ - max-width: 620px; - word-break: break-all; + white-space: nowrap; } .hash { color: var(--fg-dim); diff --git a/ui/src/lib/HashrateChart.svelte b/ui/src/lib/HashrateChart.svelte index dfc63e2..dbc0837 100644 --- a/ui/src/lib/HashrateChart.svelte +++ b/ui/src/lib/HashrateChart.svelte @@ -67,10 +67,13 @@ } area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`; - const yTicks = [0, vMax * 0.5, vMax].map((v) => ({ - y: toY(v), - label: formatHashrate(v), - })); + // Generate 5-7 evenly spaced Y ticks for better readability. + const tickCount = 6; + const yTicks: Array<{ y: number; label: string }> = []; + for (let i = 0; i <= tickCount; i++) { + const v = (i / tickCount) * vMax; + yTicks.push({ y: toY(v), label: formatHashrate(v) }); + } const xTicks: Array<{ x: number; label: string }> = []; const count = Math.min(6, points.length); diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte index f05b04e..8c679e1 100644 --- a/ui/src/lib/PoolOverview.svelte +++ b/ui/src/lib/PoolOverview.svelte @@ -1,6 +1,7 @@
@@ -149,11 +145,7 @@ {/each}
-
- Best share - - -
+
Best share
{formatDifficulty(data.best_diff)}
-
Nice
+
Nice
+ {/if} + {#if data.best_diff > 0} + + +
+ + + + Inspect +
{/if}
@@ -446,16 +448,27 @@ transform: scale(1.05); } - .debug-btn { - font-size: 0.6rem; - padding: 0.1em 0.4em; - opacity: 0.3; - border-radius: 3px; - margin-left: 0.5em; - vertical-align: middle; + .inspect-btn { + display: flex; + align-items: center; + gap: 0.3em; + width: fit-content; + margin: 0.4em auto 0; + padding: 0.2em 0.7em; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, background 0.2s; } - .debug-btn:hover { - opacity: 1; + .inspect-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--bg-hover); } .height-card { diff --git a/ui/src/lib/SharesBar.svelte b/ui/src/lib/SharesBar.svelte index ec410c6..f36c9a9 100644 --- a/ui/src/lib/SharesBar.svelte +++ b/ui/src/lib/SharesBar.svelte @@ -1,6 +1,7 @@ + + + +
+ + +
+
Share Statistics
+

Difficulty & Rejections

+
+ + +
+
+
Session Avg Difficulty
+
{data.avg_diff_session ? formatDifficulty(data.avg_diff_session) : "\u2014"}
+
{(data.diff_dist_session ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares
+
+
+
All-time Avg Difficulty
+
{data.avg_diff_alltime ? formatDifficulty(data.avg_diff_alltime) : "\u2014"}
+
{(data.diff_dist_alltime ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares
+
+
+
Session Rejected
+
{(data.session_rejected ?? 0).toLocaleString()}
+
+ {#if (data.session_accepted ?? 0) > 0} + {((data.session_rejected ?? 0) / ((data.session_accepted ?? 0) + (data.session_rejected ?? 0)) * 100).toFixed(2)}% reject rate + {:else} + no shares yet + {/if} +
+
+
+
All-time Rejected
+
{(data.alltime_rejected ?? 0).toLocaleString()}
+
+ {#if (data.alltime_accepted ?? 0) > 0} + {((data.alltime_rejected ?? 0) / ((data.alltime_accepted ?? 0) + (data.alltime_rejected ?? 0)) * 100).toFixed(2)}% reject rate + {:else} + no shares yet + {/if} +
+
+
+ + +
+
+

Rejection Reasons (Session)

+ {#if sessionReasons.length === 0} +
No rejected shares this session
+ {:else} +
+ + + + {#each sessionReasons as r} + + + + + + {/each} + +
ReasonCount%
{fmtReason(r.reason)}{r.count.toLocaleString()}{r.pct.toFixed(1)}%
+
+ {/if} +
+
+

Rejection Reasons (All-time)

+ {#if alltimeReasons.length === 0} +
No rejected shares recorded
+ {:else} +
+ + + + {#each alltimeReasons as r} + + + + + + {/each} + +
ReasonCount%
{fmtReason(r.reason)}{r.count.toLocaleString()}{r.pct.toFixed(1)}%
+
+ {/if} +
+
+ + +
+

Difficulty Distribution

+ {#if !hasDistData} +
No accepted shares yet
+ {:else} +
+ + + + + + + + + + + + {#each bucketRows as row} + + + + + + + + {/each} + +
RangeSession%All-time%
{row.label}{row.session.toLocaleString()}{row.sessionPct.toFixed(1)}%{row.alltime.toLocaleString()}{row.alltimePct.toFixed(1)}%
+
+ {/if} +
+ +
+ + diff --git a/ui/src/stores/selection.svelte.ts b/ui/src/stores/selection.svelte.ts index 74251d4..9113f51 100644 --- a/ui/src/stores/selection.svelte.ts +++ b/ui/src/stores/selection.svelte.ts @@ -12,6 +12,8 @@ const USER_PREFIX = "#/user/"; const WORKER_PREFIX = "#/worker/"; const ACCELERATOR_HASH = "#/accelerator"; +const STATS_HASH = "#/stats"; +const BESTSHARE_HASH = "#/bestshare"; type Selection = { user: string | null; worker: string | null; page: string | null }; @@ -23,6 +25,12 @@ function readHash(): Selection { if (h === ACCELERATOR_HASH) { return { user: null, worker: null, page: "accelerator" }; } + if (h === STATS_HASH) { + return { user: null, worker: null, page: "stats" }; + } + if (h === BESTSHARE_HASH) { + return { user: null, worker: null, page: "bestshare" }; + } if (h.startsWith(WORKER_PREFIX)) { const w = decodeURIComponent(h.slice(WORKER_PREFIX.length)); return { user: null, worker: w || null, page: null }; @@ -57,12 +65,22 @@ export function selectAccelerator(): void { window.location.hash = ACCELERATOR_HASH; } +export function selectStats(): void { + window.location.hash = STATS_HASH; +} + +export function selectBestShare(): void { + window.location.hash = BESTSHARE_HASH; +} + export function clearSelection(): void { if ( window.history.length > 1 && (window.location.hash.startsWith(USER_PREFIX) || window.location.hash.startsWith(WORKER_PREFIX) || - window.location.hash === ACCELERATOR_HASH) + window.location.hash === ACCELERATOR_HASH || + window.location.hash === STATS_HASH || + window.location.hash === BESTSHARE_HASH) ) { window.history.back(); } else { diff --git a/ui/src/types.ts b/ui/src/types.ts index 13e3f0c..150274a 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -103,6 +103,8 @@ export type BlockRecord = { // Bitcoin network the block was mined on ("main", "test", "signet"). // Absent for legacy rows recorded before this field was added. chain?: string; + // Workername (address.worker) of the miner who found the block. + miner?: string; }; export type HashratePoint = { @@ -122,6 +124,8 @@ export type Snapshot = { hashrate_hs_1h: number; hashrate_hs_24h: number; best_diff: number; + best_share_hash?: string; + best_share_net_diff?: number; acked_best_diff: number; cumulative_shares: number; next_block_reward_btc: number; @@ -149,6 +153,15 @@ export type Snapshot = { alltime_accepted: number; alltime_rejected: number; + // Share statistics: rejection reasons and difficulty distribution. + reject_reasons_session?: Record; + reject_reasons_alltime?: Record; + // Difficulty distribution buckets: [<1M, 1M-100M, 100M-1G, 1G-100G, 100G-1T, >=1T] + diff_dist_session: number[]; + diff_dist_alltime: number[]; + avg_diff_session: number; + avg_diff_alltime: number; + // Block update latency diagnostics (ZMQ → mining.notify). latency_count: number; latency_avg_ms: number;