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"` } // 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 } rec := BlockRecord{ 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 // (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, }) 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. 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 } 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, }) } 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 }