Add best share analysis page, miner column, and UI improvements
- Best share page: hex + binary hash comparison against network target, per-bit coloring showing exactly which bits prevented a valid block, toggle between network diff at time of finding vs current diff - Capture best share hash from ckpool logs with one-time backfill - Persist network difficulty at time of best share for historical accuracy - Add miner (worker) column to blocks table via coinbase address matching - Truncate block hashes in table with full hash on hover - Increase hashrate chart Y-axis to 7 ticks for better readability
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: <hexhash>"
|
||||
// "Rejected client 42 dupe diff 1234.5/65536/1.234G: <hexhash>"
|
||||
// "Rejected client 42 high diff 1234.5/65536/1.234G: <hexhash>"
|
||||
// "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 <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 (.+)`)
|
||||
)
|
||||
|
||||
// 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 <id> 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 <id> invalid share <reason>"
|
||||
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
|
||||
// <id> <keyword> 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 {
|
||||
|
||||
@@ -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 ... : <hash>" 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user