diff --git a/api/internal/logmon/tailer.go b/api/internal/logmon/tailer.go index 99882f1..2295a99 100644 --- a/api/internal/logmon/tailer.go +++ b/api/internal/logmon/tailer.go @@ -26,11 +26,14 @@ import ( // BlockEvent is emitted when the tailer sees a "Solved and confirmed block" // line in the ckpool log. Hash and worker are populated later by the -// aggregator once it cross-references bitcoind. +// aggregator once it cross-references bitcoind. ShareDiff is the +// difficulty of the winning share, captured from the "Possible block +// solve" line that precedes the confirmation line. type BlockEvent struct { - Height int64 `json:"height"` - SeenAt time.Time `json:"seen_at"` - RawLine string `json:"raw_line"` + Height int64 `json:"height"` + SeenAt time.Time `json:"seen_at"` + RawLine string `json:"raw_line"` + ShareDiff float64 `json:"share_diff,omitempty"` } // Tailer follows a log file, surviving rotation/truncation, and emits @@ -40,6 +43,11 @@ type Tailer struct { Events chan BlockEvent Log *slog.Logger PollWait time.Duration // how long to sleep between EOF polls + + // lastSolveDiff remembers the share diff from the most recent + // "Possible block solve" line so handleLine can attach it to the + // subsequent "Solved and confirmed block" event. Reset after use. + lastSolveDiff float64 } func New(path string, log *slog.Logger) *Tailer { @@ -51,7 +59,16 @@ func New(path string, log *slog.Logger) *Tailer { } } -var solvedRE = regexp.MustCompile(`Solved and confirmed block\s+(\d+)`) +var ( + solvedRE = regexp.MustCompile(`Solved and confirmed block\s+(\d+)`) + // Matches the three "Possible ... block solve ... diff " lines + // ckpool emits from stratifier.c right before a block is submitted: + // "Possible block solve diff N !" + // "Possible stale share block solve diff N !" + // "Submitting possible block solve share diff N !" + // "Possible remote block solve diff N !" + solveDiffRE = regexp.MustCompile(`(?:Possible|Submitting[^"]*possible).*block solve.*diff\s+([0-9eE.+-]+)`) +) // Run blocks until ctx is cancelled. It opens the file, seeks to the end, // and reads new lines as they are appended. If the file is rotated @@ -146,6 +163,12 @@ func (t *Tailer) Run(ctx context.Context) { } func (t *Tailer) handleLine(line string) { + if m := solveDiffRE.FindStringSubmatch(line); m != nil { + if d, err := strconv.ParseFloat(m[1], 64); err == nil { + t.lastSolveDiff = d + } + return + } m := solvedRE.FindStringSubmatch(line) if m == nil { return @@ -154,10 +177,16 @@ func (t *Tailer) handleLine(line string) { if err != nil { return } - ev := BlockEvent{Height: height, SeenAt: time.Now(), RawLine: line} + ev := BlockEvent{ + Height: height, + SeenAt: time.Now(), + RawLine: line, + ShareDiff: t.lastSolveDiff, + } + t.lastSolveDiff = 0 select { case t.Events <- ev: - t.Log.Info("logmon: block solved", "height", height) + t.Log.Info("logmon: block solved", "height", height, "share_diff", ev.ShareDiff) default: t.Log.Warn("logmon: events channel full, dropping", "height", height) } diff --git a/api/internal/state/blocks.go b/api/internal/state/blocks.go index feeed32..1df615d 100644 --- a/api/internal/state/blocks.go +++ b/api/internal/state/blocks.go @@ -12,11 +12,12 @@ import ( // data if available. Hash and Reward are populated best-effort via the // RPC lookup scheduled right after the log line is seen. type BlockRecord struct { - Height int64 `json:"height"` - Hash string `json:"hash,omitempty"` - RewardBT float64 `json:"reward_btc,omitempty"` - FoundAt time.Time `json:"found_at"` - Source string `json:"source"` // "logmon" for now; "zmq" later + Height int64 `json:"height"` + Hash string `json:"hash,omitempty"` + RewardBT float64 `json:"reward_btc,omitempty"` + FoundAt time.Time `json:"found_at"` + Source string `json:"source"` // "logmon" for now; "zmq" later + ShareDiff float64 `json:"share_diff,omitempty"` } // IngestBlockEvents reads block events from the tailer and appends them @@ -31,9 +32,10 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon return } rec := BlockRecord{ - Height: ev.Height, - FoundAt: ev.SeenAt, - Source: "logmon", + Height: ev.Height, + FoundAt: ev.SeenAt, + Source: "logmon", + ShareDiff: ev.ShareDiff, } // Best-effort enrich with hash + coinbase reward via bitcoind. // We look up the hash from height, then fetch the full block @@ -56,11 +58,12 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon } if a.Store != nil { if err := a.Store.InsertBlock(store.Block{ - Height: rec.Height, - Hash: rec.Hash, - RewardBT: rec.RewardBT, - FoundAt: rec.FoundAt, - Source: rec.Source, + Height: rec.Height, + Hash: rec.Hash, + RewardBT: rec.RewardBT, + FoundAt: rec.FoundAt, + Source: rec.Source, + ShareDiff: rec.ShareDiff, }); err != nil { a.Log.Warn("block persist failed", "height", rec.Height, "err", err) } @@ -114,11 +117,12 @@ func (a *Aggregator) loadPersistedBlocks() { for i := len(rows) - 1; i >= 0; i-- { r := rows[i] recs = append(recs, BlockRecord{ - Height: r.Height, - Hash: r.Hash, - RewardBT: r.RewardBT, - FoundAt: r.FoundAt, - Source: r.Source, + Height: r.Height, + Hash: r.Hash, + RewardBT: r.RewardBT, + FoundAt: r.FoundAt, + Source: r.Source, + ShareDiff: r.ShareDiff, }) } a.mu.Lock() diff --git a/api/internal/store/blocks.go b/api/internal/store/blocks.go index beda5ef..5bfc897 100644 --- a/api/internal/store/blocks.go +++ b/api/internal/store/blocks.go @@ -21,11 +21,12 @@ type BlockStore struct { // package is the lower layer; the state package converts to/from this // shape when reading and writing. type Block struct { - Height int64 - Hash string - RewardBT float64 - FoundAt time.Time - Source string + Height int64 + Hash string + RewardBT float64 + FoundAt time.Time + Source string + ShareDiff float64 } const schema = ` @@ -34,11 +35,40 @@ CREATE TABLE IF NOT EXISTS blocks ( hash TEXT NOT NULL DEFAULT '', reward_btc REAL NOT NULL DEFAULT 0, found_at INTEGER NOT NULL, - source TEXT NOT NULL DEFAULT '' + source TEXT NOT NULL DEFAULT '', + share_diff REAL NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at); ` +// migrations additive only; safe to run every startup. SQLite ignores +// ADD COLUMN that already exists only if we check, so we query +// pragma table_info and apply missing ones. +func (s *BlockStore) migrate() error { + rows, err := s.db.Query(`PRAGMA table_info(blocks)`) + if err != nil { + return err + } + defer rows.Close() + have := map[string]bool{} + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return err + } + have[name] = true + } + if !have["share_diff"] { + if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN share_diff REAL NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("store: add share_diff: %w", err) + } + } + return nil +} + // Open initializes the store at path, creating the schema if needed. // Callers are responsible for Close(). func Open(path string) (*BlockStore, error) { @@ -50,7 +80,12 @@ func Open(path string) (*BlockStore, error) { _ = db.Close() return nil, fmt.Errorf("store: schema: %w", err) } - return &BlockStore{db: db}, nil + s := &BlockStore{db: db} + if err := s.migrate(); err != nil { + _ = db.Close() + return nil, err + } + return s, nil } func (s *BlockStore) Close() error { @@ -61,9 +96,9 @@ func (s *BlockStore) Close() error { // log events after a restart don't trip the primary key constraint. func (s *BlockStore) InsertBlock(b Block) error { _, err := s.db.Exec( - `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source) - VALUES (?, ?, ?, ?, ?)`, - b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, + `INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff) + VALUES (?, ?, ?, ?, ?, ?)`, + b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, ) return err } @@ -74,7 +109,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) { limit = 256 } rows, err := s.db.Query( - `SELECT height, hash, reward_btc, found_at, source + `SELECT height, hash, reward_btc, found_at, source, share_diff FROM blocks ORDER BY height DESC LIMIT ?`, limit, ) @@ -86,7 +121,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) { for rows.Next() { var b Block var unix int64 - if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &unix, &b.Source); err != nil { + if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &unix, &b.Source, &b.ShareDiff); err != nil { return nil, err } b.FoundAt = time.Unix(unix, 0).UTC() diff --git a/ui/src/lib/BlocksTable.svelte b/ui/src/lib/BlocksTable.svelte index 600bb1e..1e2ec25 100644 --- a/ui/src/lib/BlocksTable.svelte +++ b/ui/src/lib/BlocksTable.svelte @@ -1,6 +1,6 @@