Files
KamadoPool/api/internal/state/blocks.go
T
satoshi 40c60922c8 Scope block reorg detection to the current chain
Blocks are now stamped with the Bitcoin network name ("main", "test",
"signet") at ingest time. The reconcile loop's Pass 2 skips any stored
block whose chain differs from the node's current chain, preventing
testnet blocks from being falsely orphaned after switching back to
mainnet. Legacy rows with an empty chain field fall through unchanged.

The UI shows a blue "test" / "signet" badge next to blocks from a
non-current network so operators can distinguish cross-chain history
from genuine reorg-orphaned blocks.
2026-04-28 02:20:54 +03:00

357 lines
11 KiB
Go

package state
import (
"context"
"strconv"
"time"
"github.com/kamadopool/kamado-api/internal/logmon"
"github.com/kamadopool/kamado-api/internal/store"
)
// BlockRecord is a found block, merged from a logmon event with bitcoind
// data if available. Hash and Reward are populated best-effort via the
// RPC lookup scheduled right after the log line is seen; the reconcile
// loop fills any holes later. OrphanedAt is set if a periodic chain
// check finds the recorded hash no longer matches the canonical block
// at this height (i.e. the network reorged us out).
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
ShareDiff float64 `json:"share_diff,omitempty"`
OrphanedAt time.Time `json:"orphaned_at,omitempty"`
Chain string `json:"chain,omitempty"` // "main", "test", "signet"
}
// IngestAttemptEvents counts "Possible/Submitting block solve" log
// lines so we can compare attempts vs confirmations in the snapshot.
// A growing gap means bitcoind is rejecting our submissions or the
// RPC is failing. Persists the running counter so it survives restarts.
func (a *Aggregator) IngestAttemptEvents(ctx context.Context, events <-chan logmon.AttemptEvent) {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
a.mu.Lock()
a.blockSubmitAttempts++
n := a.blockSubmitAttempts
save := a.Store != nil && time.Since(a.lastSubmitCountSave) >= 30*time.Second
if save {
a.lastSubmitCountSave = time.Now()
}
a.mu.Unlock()
a.Log.Info("logmon: submit attempt", "share_diff", ev.ShareDiff, "attempts", n)
if save {
if err := a.Store.SetKV(kvSubmitAttempts, strconv.FormatInt(n, 10)); err != nil {
a.Log.Warn("submit attempts persist failed", "err", err)
}
}
}
}
}
// IngestBlockEvents reads block events from the tailer and appends them
// to the snapshot's block history. Runs until ctx is cancelled.
func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon.BlockEvent) {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
// Stamp the chain name at the moment the block is seen so
// the reconcile loop can skip it if the node switches networks.
a.mu.RLock()
currentChain := ""
if a.snap.Chain != nil {
currentChain = a.snap.Chain.Chain
}
a.mu.RUnlock()
rec := BlockRecord{
Height: ev.Height,
FoundAt: ev.SeenAt,
Source: "logmon",
ShareDiff: ev.ShareDiff,
Chain: currentChain,
}
// Best-effort enrich with hash + coinbase reward 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.
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()
} else {
a.Log.Warn("bitcoind getblock failed", "hash", hash, "err", err)
}
} else {
a.Log.Warn("bitcoind getblockhash failed", "height", ev.Height, "err", err)
}
cancel()
}
isNew := true
if a.Store != nil {
inserted, err := a.Store.InsertBlock(store.Block{
Height: rec.Height,
Hash: rec.Hash,
RewardBT: rec.RewardBT,
FoundAt: rec.FoundAt,
Source: rec.Source,
ShareDiff: rec.ShareDiff,
Chain: rec.Chain,
})
if err != nil {
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
} else if !inserted {
a.Log.Warn("block at this height already recorded — duplicate or pre-existing entry, not counting as new",
"height", rec.Height, "hash", rec.Hash)
isNew = false
}
}
if !isNew {
continue
}
a.mu.Lock()
a.blockSubmitsConfirmed++
confirmed := a.blockSubmitsConfirmed
a.mu.Unlock()
if a.Store != nil {
if err := a.Store.SetKV(kvSubmitsConfirmed, strconv.FormatInt(confirmed, 10)); err != nil {
a.Log.Warn("confirmed count persist failed", "err", err)
}
}
pushed := a.appendBlock(rec)
a.Log.Info("block recorded", "height", rec.Height, "hash", rec.Hash, "confirmed", confirmed)
// Push immediately so WebSocket clients see the solve
// without waiting for the next poll tick.
if a.OnRefresh != nil {
a.OnRefresh(pushed)
}
}
}
}
// ReconcileBlocks runs until ctx is cancelled, periodically:
// 1. Filling in missing hash/reward for blocks where the initial RPC
// lookup failed (bitcoind hadn't indexed yet, or was down).
// 2. Comparing each non-orphaned hash against the canonical block at
// its height; a mismatch means the network reorged us out and we
// mark the row orphaned so the UI can render it accordingly.
// Both checks are bounded to the last reconcileLookback, so the cost
// stays constant regardless of total history size.
func (a *Aggregator) ReconcileBlocks(ctx context.Context) {
if a.Store == nil || a.RPC == nil {
return
}
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
a.reconcileOnce(ctx)
}
}
}
func (a *Aggregator) reconcileOnce(ctx context.Context) {
since := time.Now().Add(-reconcileLookback)
// Pass 1: enrichment. Fetch hash + reward for any missing-data rows.
missing, err := a.Store.BlocksNeedingEnrichment(since)
if err != nil {
a.Log.Warn("reconcile: load missing failed", "err", err)
}
enrichedAny := false
for _, b := range missing {
lookupCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
hash := b.Hash
reward := b.RewardBT
if hash == "" {
if h, herr := a.RPC.GetBlockHash(lookupCtx, b.Height); herr == nil {
hash = h
} else {
cancel()
continue
}
}
if reward == 0 && hash != "" {
if blk, berr := a.RPC.GetBlock(lookupCtx, hash); berr == nil {
reward = blk.CoinbaseReward()
}
}
cancel()
if hash != b.Hash || reward != b.RewardBT {
if err := a.Store.UpdateEnrichment(b.Height, hash, reward); 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)
enrichedAny = true
}
}
// Pass 2: reorg detection. For non-orphaned blocks within the
// lookback, confirm the canonical hash at that height still
// matches our record. Don't bother with rows that are already
// orphaned — we won't un-orphan, since the network already
// chose another chain.
//
// Skip blocks whose recorded chain doesn't match the current
// bitcoind network — they were mined on a different node config
// (e.g. testnet) and cannot be checked against mainnet's tip.
// A mismatch here is a false positive, not a real reorg.
a.mu.RLock()
currentChain := ""
if a.snap.Chain != nil {
currentChain = a.snap.Chain.Chain
}
a.mu.RUnlock()
recent, err := a.Store.Recent(64)
if err != nil {
a.Log.Warn("reconcile: load recent failed", "err", err)
return
}
orphanedAny := false
now := time.Now()
for _, b := range recent {
if b.Hash == "" || !b.OrphanedAt.IsZero() {
continue
}
if b.FoundAt.Before(since) {
continue
}
// Both chain values must be known before we can compare them.
// Legacy rows (Chain=="") fall through so existing installs keep
// their reorg detection — there was no network switch before this
// feature landed.
if b.Chain != "" && currentChain != "" && b.Chain != currentChain {
continue
}
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
canonical, herr := a.RPC.GetBlockHash(lookupCtx, b.Height)
cancel()
if herr != nil {
// Most likely cause: our bitcoind doesn't have this
// height yet (index lag). Try again next sweep — don't
// orphan on a transient RPC error.
continue
}
if canonical != b.Hash {
if err := a.Store.MarkOrphaned(b.Height, now); err != nil {
a.Log.Warn("reconcile: mark orphaned failed", "height", b.Height, "err", err)
continue
}
a.Log.Warn("reconcile: block orphaned by reorg",
"height", b.Height, "ours", b.Hash, "canonical", canonical)
orphanedAny = true
}
}
if enrichedAny || orphanedAny {
// Refresh the in-memory ring so the snapshot picks up the
// changes immediately rather than waiting for the next poll.
a.loadPersistedBlocks()
a.mu.RLock()
snap := a.snap
a.mu.RUnlock()
if a.OnRefresh != nil && len(snap.RecentBlocks) > 0 {
a.OnRefresh(snap)
}
}
}
// maxBlockHistory caps in-memory block history. Persistence comes in
// Phase 2b.5 via SQLite; for now recent blocks survive only this
// process's lifetime.
const maxBlockHistory = 256
// appendBlock records a new block and returns a copy of the current
// snapshot with the updated history attached, suitable for an immediate
// WebSocket broadcast.
func (a *Aggregator) appendBlock(rec BlockRecord) Snapshot {
a.mu.Lock()
defer a.mu.Unlock()
a.blocks = append(a.blocks, rec)
if len(a.blocks) > maxBlockHistory {
a.blocks = a.blocks[len(a.blocks)-maxBlockHistory:]
}
snap := a.snap
snap.RecentBlocks = make([]BlockRecord, len(a.blocks))
copy(snap.RecentBlocks, a.blocks)
a.snap = snap
return snap
}
// loadPersistedBlocks seeds a.blocks from the store so history survives
// restarts. Safe to call with a nil Store — becomes a no-op.
func (a *Aggregator) loadPersistedBlocks() {
if a.Store == nil {
return
}
rows, err := a.Store.Recent(maxBlockHistory)
if err != nil {
a.Log.Warn("block history load failed", "err", err)
return
}
// Store returns newest-first; a.blocks is newest-last.
recs := make([]BlockRecord, 0, len(rows))
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,
ShareDiff: r.ShareDiff,
OrphanedAt: r.OrphanedAt,
Chain: r.Chain,
})
}
a.mu.Lock()
a.blocks = recs
// Surface refreshed history into the live snapshot so /api/snapshot
// reflects the latest store state without waiting for the next
// refresh tick (used by the reconcile loop).
if a.snap.GeneratedAt.IsZero() {
a.mu.Unlock()
a.Log.Info("block history loaded", "count", len(recs))
return
}
snap := a.snap
if len(recs) > 0 {
snap.RecentBlocks = make([]BlockRecord, len(recs))
copy(snap.RecentBlocks, recs)
} else {
snap.RecentBlocks = nil
}
a.snap = snap
a.mu.Unlock()
a.Log.Info("block history loaded", "count", len(recs))
}
// Blocks returns a copy of the recent block history, newest last.
func (a *Aggregator) Blocks() []BlockRecord {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]BlockRecord, len(a.blocks))
copy(out, a.blocks)
return out
}