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.
This commit is contained in:
@@ -23,6 +23,7 @@ type BlockRecord struct {
|
|||||||
Source string `json:"source"` // "logmon" for now; "zmq" later
|
Source string `json:"source"` // "logmon" for now; "zmq" later
|
||||||
ShareDiff float64 `json:"share_diff,omitempty"`
|
ShareDiff float64 `json:"share_diff,omitempty"`
|
||||||
OrphanedAt time.Time `json:"orphaned_at,omitempty"`
|
OrphanedAt time.Time `json:"orphaned_at,omitempty"`
|
||||||
|
Chain string `json:"chain,omitempty"` // "main", "test", "signet"
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestAttemptEvents counts "Possible/Submitting block solve" log
|
// IngestAttemptEvents counts "Possible/Submitting block solve" log
|
||||||
@@ -67,11 +68,20 @@ func (a *Aggregator) IngestBlockEvents(ctx context.Context, events <-chan logmon
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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{
|
rec := BlockRecord{
|
||||||
Height: ev.Height,
|
Height: ev.Height,
|
||||||
FoundAt: ev.SeenAt,
|
FoundAt: ev.SeenAt,
|
||||||
Source: "logmon",
|
Source: "logmon",
|
||||||
ShareDiff: ev.ShareDiff,
|
ShareDiff: ev.ShareDiff,
|
||||||
|
Chain: currentChain,
|
||||||
}
|
}
|
||||||
// Best-effort enrich with hash + coinbase reward via bitcoind.
|
// Best-effort enrich with hash + coinbase reward via bitcoind.
|
||||||
// We look up the hash from height, then fetch the full block
|
// 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,
|
FoundAt: rec.FoundAt,
|
||||||
Source: rec.Source,
|
Source: rec.Source,
|
||||||
ShareDiff: rec.ShareDiff,
|
ShareDiff: rec.ShareDiff,
|
||||||
|
Chain: rec.Chain,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Log.Warn("block persist failed", "height", rec.Height, "err", err)
|
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
|
// matches our record. Don't bother with rows that are already
|
||||||
// orphaned — we won't un-orphan, since the network already
|
// orphaned — we won't un-orphan, since the network already
|
||||||
// chose another chain.
|
// 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)
|
recent, err := a.Store.Recent(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.Log.Warn("reconcile: load recent failed", "err", err)
|
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) {
|
if b.FoundAt.Before(since) {
|
||||||
continue
|
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)
|
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
canonical, herr := a.RPC.GetBlockHash(lookupCtx, b.Height)
|
canonical, herr := a.RPC.GetBlockHash(lookupCtx, b.Height)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -291,6 +321,7 @@ func (a *Aggregator) loadPersistedBlocks() {
|
|||||||
Source: r.Source,
|
Source: r.Source,
|
||||||
ShareDiff: r.ShareDiff,
|
ShareDiff: r.ShareDiff,
|
||||||
OrphanedAt: r.OrphanedAt,
|
OrphanedAt: r.OrphanedAt,
|
||||||
|
Chain: r.Chain,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Block struct {
|
|||||||
Source string
|
Source string
|
||||||
ShareDiff float64
|
ShareDiff float64
|
||||||
OrphanedAt time.Time // zero value = not orphaned
|
OrphanedAt time.Time // zero value = not orphaned
|
||||||
|
Chain string // "main", "test", "signet", or "" for legacy rows
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
@@ -38,7 +39,8 @@ CREATE TABLE IF NOT EXISTS blocks (
|
|||||||
found_at INTEGER NOT NULL,
|
found_at INTEGER NOT NULL,
|
||||||
source TEXT NOT NULL DEFAULT '',
|
source TEXT NOT NULL DEFAULT '',
|
||||||
share_diff REAL NOT NULL DEFAULT 0,
|
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);
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +128,9 @@ func (s *BlockStore) Close() error {
|
|||||||
// unnoticed.
|
// unnoticed.
|
||||||
func (s *BlockStore) InsertBlock(b Block) (bool, error) {
|
func (s *BlockStore) InsertBlock(b Block) (bool, error) {
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff)
|
`INSERT OR IGNORE INTO blocks(height, hash, reward_btc, found_at, source, share_diff, chain)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff,
|
b.Height, b.Hash, b.RewardBT, b.FoundAt.Unix(), b.Source, b.ShareDiff, b.Chain,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -201,7 +208,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
|||||||
limit = 256
|
limit = 256
|
||||||
}
|
}
|
||||||
rows, err := s.db.Query(
|
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 ?`,
|
FROM blocks ORDER BY height DESC LIMIT ?`,
|
||||||
limit,
|
limit,
|
||||||
)
|
)
|
||||||
@@ -213,7 +220,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b Block
|
var b Block
|
||||||
var foundUnix, orphanedUnix int64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
||||||
@@ -234,7 +241,7 @@ func (s *BlockStore) Recent(limit int) ([]Block, error) {
|
|||||||
// retrying will keep failing.
|
// retrying will keep failing.
|
||||||
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
|
func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
|
||||||
rows, err := s.db.Query(
|
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
|
FROM blocks
|
||||||
WHERE found_at >= ? AND (hash = '' OR reward_btc = 0)
|
WHERE found_at >= ? AND (hash = '' OR reward_btc = 0)
|
||||||
ORDER BY height ASC`,
|
ORDER BY height ASC`,
|
||||||
@@ -248,7 +255,7 @@ func (s *BlockStore) BlocksNeedingEnrichment(since time.Time) ([]Block, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b Block
|
var b Block
|
||||||
var foundUnix, orphanedUnix int64
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
b.FoundAt = time.Unix(foundUnix, 0).UTC()
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
const explorerBase = $derived(
|
const explorerBase = $derived(
|
||||||
explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url),
|
explorerBaseFor(snap.data?.chain?.chain, snap.data?.mempool_base_url),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentChain = $derived(snap.data?.chain?.chain ?? "");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -36,6 +38,9 @@
|
|||||||
{#if b.orphaned_at}
|
{#if b.orphaned_at}
|
||||||
<span class="orphan-tag" title="Reorged out of the canonical chain at {b.orphaned_at}">orphaned</span>
|
<span class="orphan-tag" title="Reorged out of the canonical chain at {b.orphaned_at}">orphaned</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if b.chain && currentChain && b.chain !== currentChain}
|
||||||
|
<span class="chain-tag" title="Mined on {b.chain} — current node is {currentChain}">{b.chain}</span>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="hash-cell">
|
<td class="hash-cell">
|
||||||
{#if b.hash}
|
{#if b.hash}
|
||||||
@@ -121,6 +126,18 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: middle;
|
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 {
|
.unit {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export type BlockRecord = {
|
|||||||
// longer matches the canonical block at this height (network reorged
|
// longer matches the canonical block at this height (network reorged
|
||||||
// us out). UI renders these strikethrough.
|
// us out). UI renders these strikethrough.
|
||||||
orphaned_at?: string; // RFC 3339, omitted when zero
|
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 = {
|
export type HashratePoint = {
|
||||||
|
|||||||
Reference in New Issue
Block a user