Record and display winning share difficulty on found blocks
Logmon now captures the share diff from ckpool's "Possible block solve" line preceding the confirmation and attaches it to the BlockEvent. Persisted as share_diff alongside height/hash/reward and rendered as a new column in the dashboard block history.
This commit is contained in:
@@ -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"`
|
||||
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 <float>" 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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type BlockRecord struct {
|
||||
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
|
||||
@@ -34,6 +35,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan 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
|
||||
@@ -61,6 +63,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
|
||||
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)
|
||||
}
|
||||
@@ -119,6 +122,7 @@ func (a *Aggregator) loadPersistedBlocks() {
|
||||
RewardBT: r.RewardBT,
|
||||
FoundAt: r.FoundAt,
|
||||
Source: r.Source,
|
||||
ShareDiff: r.ShareDiff,
|
||||
})
|
||||
}
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -26,6 +26,7 @@ type Block struct {
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { formatAgo } from "../format";
|
||||
import { formatAgo, formatDifficulty } from "../format";
|
||||
|
||||
const blocks = $derived.by(() => {
|
||||
const b = snap.data?.recent_blocks ?? [];
|
||||
@@ -12,18 +12,16 @@
|
||||
<section class="card">
|
||||
<h2>Blocks found</h2>
|
||||
{#if blocks.length === 0}
|
||||
<div class="empty">
|
||||
No blocks found yet. Any solve will appear here instantly via the
|
||||
log tailer, and persist to SQLite once Phase 2b.5 ships.
|
||||
</div>
|
||||
<div class="empty">No blocks found yet.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Height</th>
|
||||
<th>Hash</th>
|
||||
<th class="num">Reward</th>
|
||||
<th class="num">Winning share</th>
|
||||
<th class="num">When</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -31,8 +29,9 @@
|
||||
<tr>
|
||||
<td class="mono">{b.height}</td>
|
||||
<td class="hash mono">{b.hash ? b.hash.slice(0, 16) + "…" : "—"}</td>
|
||||
<td class="num">{b.reward_btc ? b.reward_btc.toFixed(4) + " BTC" : "—"}</td>
|
||||
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "—"}</td>
|
||||
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
|
||||
<td>{b.source}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -85,6 +85,7 @@ export type BlockRecord = {
|
||||
reward_btc?: number;
|
||||
found_at: string; // RFC 3339
|
||||
source: string;
|
||||
share_diff?: number;
|
||||
};
|
||||
|
||||
export type Snapshot = {
|
||||
|
||||
Reference in New Issue
Block a user