diff --git a/api/internal/state/blocks.go b/api/internal/state/blocks.go index 00c7ffe..598351b 100644 --- a/api/internal/state/blocks.go +++ b/api/internal/state/blocks.go @@ -23,6 +23,7 @@ type BlockRecord struct { 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 @@ -67,11 +68,20 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon 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 @@ -101,6 +111,7 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon 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) @@ -199,6 +210,18 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) { // 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) @@ -213,6 +236,13 @@ func (a *Aggregator) reconcileOnce(ctx context.Context) { 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() @@ -291,6 +321,7 @@ func (a *Aggregator) loadPersistedBlocks() { Source: r.Source, ShareDiff: r.ShareDiff, OrphanedAt: r.OrphanedAt, + Chain: r.Chain, }) } a.mu.Lock() diff --git a/api/internal/store/blocks.go b/api/internal/store/blocks.go index 4a35156..8057ef0 100644 --- a/api/internal/store/blocks.go +++ b/api/internal/store/blocks.go @@ -28,6 +28,7 @@ type Block struct { Source string ShareDiff float64 OrphanedAt time.Time // zero value = not orphaned + Chain string // "main", "test", "signet", or "" for legacy rows } const schema = ` @@ -38,7 +39,8 @@ CREATE TABLE IF NOT EXISTS blocks ( found_at INTEGER NOT NULL, source TEXT NOT NULL DEFAULT '', share_diff REAL NOT NULL DEFAULT 0, - orphaned_at INTEGER NOT NULL DEFAULT 0 + orphaned_at INTEGER NOT NULL DEFAULT 0, + chain TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS blocks_found_at_idx ON blocks(found_at); @@ -83,6 +85,11 @@ func (s *BlockStore) migrate() error { return fmt.Errorf("store: add orphaned_at: %w", err) } } + if !have["chain"] { + if _, err := s.db.Exec(`ALTER TABLE blocks ADD COLUMN chain TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("store: add chain: %w", err) + } + } return nil } @@ -121,9 +128,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) - VALUES (?, ?, ?, ?, ?, ?)`, - b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, + `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, ) if err != nil { return false, err @@ -201,7 +208,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 + `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain FROM blocks ORDER BY height DESC LIMIT ?`, limit, ) @@ -213,7 +220,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); err != nil { + if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { return nil, err } b.FoundAt = time.Unix(foundUnix, 0).UTC() @@ -234,7 +241,7 @@ 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 + `SELECT height, hash, reward_btc, found_at, source, share_diff, orphaned_at, chain FROM blocks WHERE found_at >= ? AND (hash = '' OR reward_btc = 0) ORDER BY height ASC`, @@ -248,7 +255,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); err != nil { + if err := rows.Scan(&b.Height, &b.Hash, &b.RewardBT, &foundUnix, &b.Source, &b.ShareDiff, &orphanedUnix, &b.Chain); err != nil { return nil, err } b.FoundAt = time.Unix(foundUnix, 0).UTC() diff --git a/ui/src/lib/BlocksTable.svelte b/ui/src/lib/BlocksTable.svelte index a1dd739..ae55c7b 100644 --- a/ui/src/lib/BlocksTable.svelte +++ b/ui/src/lib/BlocksTable.svelte @@ -10,6 +10,8 @@ const explorerBase = $derived( explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url), ); + + const currentChain = $derived(snap.data?.chain?.chain ?? "");
@@ -36,6 +38,9 @@ {#if b.orphaned_at} orphaned {/if} + {#if b.chain && currentChain && b.chain !== currentChain} + {b.chain} + {/if} {#if b.hash} @@ -121,6 +126,18 @@ text-decoration: none; vertical-align: middle; } + .chain-tag { + display: inline-block; + margin-left: 0.4em; + padding: 0.05em 0.4em; + border-radius: 3px; + background: rgba(80, 160, 220, 0.15); + color: rgb(100, 170, 220); + font-size: 0.7em; + font-weight: 600; + text-decoration: none; + vertical-align: middle; + } .unit { color: var(--fg-dim); font-size: 0.75em; diff --git a/ui/src/types.ts b/ui/src/types.ts index 79ba898..f257504 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -98,6 +98,9 @@ export type BlockRecord = { // longer matches the canonical block at this height (network reorged // us out). UI renders these strikethrough. orphaned_at?: string; // RFC 3339, omitted when zero + // Bitcoin network the block was mined on ("main", "test", "signet"). + // Absent for legacy rows recorded before this field was added. + chain?: string; }; export type HashratePoint = {