graph/indexer: gate re-genesis rewind on a deterministic genesis-hash change

Item 4: the 3-consecutive-poll hysteresis alone could be satisfied by a deeply-stale
load-balanced RPC backend held >reorgDepth behind for the full window (~6s), wiping a
still-canonical chain and re-indexing from genesis (DoS amplification).

Keep the streak as a CHEAP PRE-FILTER, but gate the actual rewind on a deterministic
signal: the chain's genesis (block 0) hash. Every node serves block 0 regardless of
how far behind its head is, so an unchanged genesis hash deterministically rules out
a re-genesis even on a deeply-lagging backend (unlike a cursor by-number/by-hash probe,
which a backend whose head is below the cursor cannot serve and would false-trip).
Honors the no-eth_getCode rule (only eth_getBlockByNumber 0x0 header is read). The
probe is a struct field (genesisHashFn) so tests drive canonicality without a live RPC;
baseline captured lazily before any reset decision. Fail-closed: a probe error or
missing baseline never wipes.

3 existing regenesis tests updated to the new signature/semantics + 3 new
(deeply-behind-but-canonical => NO wipe; changed genesis => wipe; probe-fails => NO
wipe). Full indexer+engine suites green.
This commit is contained in:
2026-06-21 13:51:33 -07:00
parent 305636ac07
commit d24bd62198
2 changed files with 235 additions and 33 deletions
+115 -9
View File
@@ -1,6 +1,7 @@
package indexer
import (
"context"
"fmt"
"math/big"
"testing"
@@ -8,6 +9,12 @@ import (
"github.com/luxfi/graph/storage"
)
// fixedGenesis returns a genesisHashFn that always reports the same hash — the
// signature of a canonical chain (no re-genesis), even when a backend is lagging.
func fixedGenesis(hash string) func(context.Context) (string, error) {
return func(context.Context) (string, error) { return hash, nil }
}
// word renders a non-negative big.Int as a 64-hex-char ABI word.
func word(n *big.Int) string { return fmt.Sprintf("%064x", n) }
@@ -251,7 +258,8 @@ func TestBumpFactory_PreservesTVLAndVolume(t *testing.T) {
// The re-genesis self-heal: a persisted cursor far above a freshly-relaunched
// chain's head must rewind to StartBlock so the new chain indexes from genesis —
// but only after the low head is CONFIRMED across regenesisConfirmations polls.
// but only after the low head is CONFIRMED across regenesisConfirmations polls AND
// the genesis hash has actually CHANGED (the deterministic re-genesis signal).
func TestPoll_RegenesisSelfHeal(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(5_000_000) // stale cursor from the previous chain
@@ -259,22 +267,30 @@ func TestPoll_RegenesisSelfHeal(t *testing.T) {
if idx.lastBlock != 5_000_000 {
t.Fatalf("precondition: lastBlock=%d", idx.lastBlock)
}
ctx := context.Background()
// Baseline: the OLD chain's genesis hash, captured before the relaunch.
idx.genesisHash = "0xold"
// The fresh chain reports a DIFFERENT genesis hash — the re-genesis signal.
idx.genesisHashFn = fixedGenesis("0xnew")
// A fresh chain reports head=3, far below cursor — sustained across the
// confirmation window. Only the FINAL confirming poll rewinds.
for i := 0; i < regenesisConfirmations-1; i++ {
idx.maybeResetForRegenesis(3)
idx.maybeResetForRegenesis(ctx, 3)
if idx.lastBlock != 5_000_000 {
t.Fatalf("must not rewind before %d confirmations (poll %d), lastBlock=%d", regenesisConfirmations, i+1, idx.lastBlock)
}
}
idx.maybeResetForRegenesis(3) // the confirming poll
idx.maybeResetForRegenesis(ctx, 3) // the confirming poll
if idx.lastBlock != 0 {
t.Fatalf("expected rewind to StartBlock 0 after %d confirmations, got %d", regenesisConfirmations, idx.lastBlock)
t.Fatalf("expected rewind to StartBlock 0 after %d confirmations + changed genesis, got %d", regenesisConfirmations, idx.lastBlock)
}
if s.GetLastBlock() != 0 {
t.Fatalf("expected persisted cursor reset to 0, got %d", s.GetLastBlock())
}
if idx.genesisHash != "0xnew" {
t.Fatalf("expected baseline to advance to the new chain's genesis, got %s", idx.genesisHash)
}
}
// A shallow reorg (head dips a few blocks) must NOT trigger a reset.
@@ -282,8 +298,13 @@ func TestPoll_ShallowReorgNoReset(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(1000)
idx := NewWithConfig(Config{RPC: "http://unused", StartBlock: 0}, s)
idx.genesisHash = "0xsame"
idx.genesisHashFn = func(context.Context) (string, error) {
t.Fatal("shallow reorg must not even probe the genesis hash")
return "", nil
}
idx.maybeResetForRegenesis(995) // 5 blocks back — within reorgDepth
idx.maybeResetForRegenesis(context.Background(), 995) // 5 blocks back — within reorgDepth
if idx.lastBlock != 1000 {
t.Fatalf("shallow reorg must not reset; lastBlock=%d", idx.lastBlock)
}
@@ -296,16 +317,22 @@ func TestPoll_IdleLowHeadRecoversNoWipe(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(5_000_000)
idx := NewWithConfig(Config{RPC: "http://unused", StartBlock: 0}, s)
ctx := context.Background()
idx.genesisHash = "0xsame"
idx.genesisHashFn = func(context.Context) (string, error) {
t.Fatal("transient/recovering low head must not reach the genesis probe")
return "", nil
}
// A couple of transient low readings (idle "Building block" / lag) — fewer than
// the confirmation window — must not rewind.
idx.maybeResetForRegenesis(0) // idle: eth_blockNumber=0x0
idx.maybeResetForRegenesis(1)
idx.maybeResetForRegenesis(ctx, 0) // idle: eth_blockNumber=0x0
idx.maybeResetForRegenesis(ctx, 1)
if idx.lastBlock != 5_000_000 {
t.Fatalf("transient low head must not wipe; lastBlock=%d", idx.lastBlock)
}
// RPC recovers to the true height — the streak must clear, no wipe ever.
idx.maybeResetForRegenesis(5_000_001)
idx.maybeResetForRegenesis(ctx, 5_000_001)
if idx.lastBlock != 5_000_000 {
t.Fatalf("recovered head must not wipe; lastBlock=%d", idx.lastBlock)
}
@@ -313,8 +340,87 @@ func TestPoll_IdleLowHeadRecoversNoWipe(t *testing.T) {
t.Fatalf("recovery must clear the low-head streak, got %d", idx.lowHeadStreak)
}
// And a subsequent single low blip still must not wipe (streak restarts at 1).
idx.maybeResetForRegenesis(2)
idx.maybeResetForRegenesis(ctx, 2)
if idx.lastBlock != 5_000_000 {
t.Fatalf("post-recovery single blip must not wipe; lastBlock=%d", idx.lastBlock)
}
}
// TestPoll_DeeplyBehindButCanonicalNoWipe is the item-4 hardening: a deeply-stale
// load-balanced RPC backend can report a head >reorgDepth below our cursor for the
// FULL confirmation window — satisfying the poll-count pre-filter — yet the chain is
// still canonical. The genesis hash is UNCHANGED, so the cursor must NOT be wiped.
// Under the old poll-count-only gate this scenario wiped progress and re-indexed
// from genesis (the false-positive Red flagged).
func TestPoll_DeeplyBehindButCanonicalNoWipe(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(5_000_000)
idx := NewWithConfig(Config{RPC: "http://unused", StartBlock: 0}, s)
ctx := context.Background()
idx.genesisHash = "0xcanonical"
// The cursor block hash is still valid / genesis unchanged — same chain, just behind.
probes := 0
idx.genesisHashFn = func(context.Context) (string, error) {
probes++
return "0xcanonical", nil
}
// Hold the head deeply below the cursor for WELL past the confirmation window.
for i := 0; i < regenesisConfirmations*3; i++ {
idx.maybeResetForRegenesis(ctx, 3)
if idx.lastBlock != 5_000_000 {
t.Fatalf("deeply-behind-but-canonical RPC must NOT wipe (iter %d); lastBlock=%d", i, idx.lastBlock)
}
}
if s.GetLastBlock() != 5_000_000 {
t.Fatalf("persisted cursor must be intact; got %d", s.GetLastBlock())
}
if probes == 0 {
t.Fatal("expected at least one genesis-hash probe once the streak confirmed")
}
}
// TestPoll_RegenesisHashChangeWipes is the positive companion: a true re-genesis
// (genesis hash GONE/changed) must wipe to StartBlock, but only after the streak
// confirms. This is the "cursor hash gone ⇒ wipe" half of the deterministic gate.
func TestPoll_RegenesisHashChangeWipes(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(5_000_000)
idx := NewWithConfig(Config{RPC: "http://unused", StartBlock: 0}, s)
ctx := context.Background()
idx.genesisHash = "0xchainA"
idx.genesisHashFn = fixedGenesis("0xchainB") // fresh chain — different genesis
// Pre-confirmation polls must not wipe even though the hash differs (hysteresis
// still gates the probe; we only probe once the streak completes).
for i := 0; i < regenesisConfirmations-1; i++ {
idx.maybeResetForRegenesis(ctx, 2)
if idx.lastBlock != 5_000_000 {
t.Fatalf("must not wipe before confirmation (iter %d)", i)
}
}
idx.maybeResetForRegenesis(ctx, 2) // confirming poll — genesis changed ⇒ wipe
if idx.lastBlock != 0 || s.GetLastBlock() != 0 {
t.Fatalf("changed genesis must wipe to StartBlock; lastBlock=%d persisted=%d", idx.lastBlock, s.GetLastBlock())
}
}
// TestPoll_RegenesisProbeFailsNoWipe: if the genesis probe errors at the confirming
// poll, we cannot prove a reset, so we must NOT wipe.
func TestPoll_RegenesisProbeFailsNoWipe(t *testing.T) {
s := newMemSQLiteStore(t)
s.SetLastBlock(5_000_000)
idx := NewWithConfig(Config{RPC: "http://unused", StartBlock: 0}, s)
ctx := context.Background()
idx.genesisHash = "0xbaseline"
idx.genesisHashFn = func(context.Context) (string, error) {
return "", fmt.Errorf("backend unreachable")
}
for i := 0; i < regenesisConfirmations+2; i++ {
idx.maybeResetForRegenesis(ctx, 3)
}
if idx.lastBlock != 5_000_000 {
t.Fatalf("probe failure must not wipe; lastBlock=%d", idx.lastBlock)
}
}
+120 -24
View File
@@ -48,15 +48,24 @@ type Config struct {
const reorgDepth = 128
// regenesisConfirmations is how many CONSECUTIVE polls must observe a head far
// below our cursor before we treat it as a re-genesis and rewind. A single low
// reading is NOT enough: an idle chain ("Building block", head=0x0) or a briefly
// lagging RPC backend can momentarily report a head below our cursor, and a
// single-poll rewind would wipe progress and re-index from genesis on every blip
// (DoS amplification). A genuine relaunch keeps the head low for many polls as the
// fresh chain rebuilds; a transient dip recovers within one or two. Requiring a
// sustained streak distinguishes the two without an extra RPC in the common case.
// below our cursor before we even SPEND an RPC to confirm a re-genesis. A single
// low reading is NOT enough: an idle chain ("Building block", head=0x0) or a briefly
// lagging RPC backend can momentarily report a head below our cursor, and probing on
// every blip would be wasteful. A genuine relaunch keeps the head low for many polls
// as the fresh chain rebuilds; a transient dip recovers within one or two. The streak
// is a CHEAP PRE-FILTER only — the rewind itself is gated on a deterministic signal
// (a changed genesis hash), so even a head held low for the full window by a deeply
// lagging load-balanced backend never wipes a still-canonical chain.
const regenesisConfirmations = 3
// genesisBlockTag is the block selector for the genesis (block 0) header. The
// genesis hash is the chain-identity fingerprint we compare to decide a re-genesis:
// every node of a chain has block 0 regardless of how far behind its head is, so an
// equal genesis hash deterministically rules out a re-genesis even on a deeply-stale
// backend — unlike a by-number/by-hash probe of the CURSOR block, which a backend
// whose head is below the cursor cannot serve (it would null-out and false-trip).
const genesisBlockTag = "0x0"
// Indexer watches an EVM RPC and writes events to storage.
type Indexer struct {
rpc string
@@ -66,8 +75,15 @@ type Indexer struct {
client *http.Client
lastBlock uint64
lowHeadStreak int // consecutive polls observing a head far below the cursor (re-genesis hysteresis)
lowHeadStreak int // consecutive polls observing a head far below the cursor (re-genesis pre-filter)
genesisHash string // chain-identity fingerprint; a change ⇒ true re-genesis (lazily captured)
status Status
// genesisHashFn fetches the chain's genesis (block 0) hash. It is a field so
// tests can drive the canonicality decision deterministically without a live
// RPC; in production it is fetchGenesisHash (eth_getBlockByNumber 0x0). Honors
// the no-eth_getCode rule — only a block header is read.
genesisHashFn func(ctx context.Context) (string, error)
}
// New creates an indexer connected to the given RPC endpoint with default
@@ -96,6 +112,7 @@ func NewWithConfig(cfg Config, store *storage.Store) *Indexer {
},
},
}
idx.genesisHashFn = idx.fetchGenesisHash
idx.lastBlock = idx.store.GetLastBlock()
if idx.lastBlock < cfg.StartBlock {
idx.lastBlock = cfg.StartBlock
@@ -103,24 +120,76 @@ func NewWithConfig(cfg Config, store *storage.Store) *Indexer {
return idx
}
// fetchGenesisHash reads the chain's genesis (block 0) header hash via
// eth_getBlockByNumber. Only the header is fetched (txs=false); this honors the
// no-eth_getCode rule and is cheap. The genesis hash is the chain-identity
// fingerprint used to distinguish a true re-genesis (hash changed) from a merely
// lagging/idle backend on the same chain (hash unchanged).
func (idx *Indexer) fetchGenesisHash(ctx context.Context) (string, error) {
raw, err := idx.rpcCall(ctx, "eth_getBlockByNumber", []interface{}{genesisBlockTag, false})
if err != nil {
return "", err
}
var head struct {
Hash string `json:"hash"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return "", fmt.Errorf("parse genesis header: %w", err)
}
if head.Hash == "" {
return "", fmt.Errorf("genesis header missing hash")
}
return strings.ToLower(head.Hash), nil
}
// ensureGenesisHash lazily captures the current chain's genesis hash as the
// baseline the first time it succeeds. Idempotent and a no-op once set (a string
// check), so it costs one RPC only on the first successful poll. Capturing the
// baseline before any reset decision guarantees maybeResetForRegenesis always has
// something to compare against.
func (idx *Indexer) ensureGenesisHash(ctx context.Context) {
if idx.genesisHash != "" {
return
}
h, err := idx.genesisHashFn(ctx)
if err != nil {
log.Printf("[indexer] genesis hash baseline unavailable (will retry): %v", err)
return
}
idx.genesisHash = h
}
// Status returns current indexer progress.
func (idx *Indexer) Status() Status {
return idx.status
}
// maybeResetForRegenesis rewinds the cursor to StartBlock when the observed chain
// head stays far below our persisted cursor for regenesisConfirmations CONSECUTIVE
// polls — the signature of a clean relaunch (fresh genesis), not a shallow reorg or
// a transient dip. A re-genesised chain reports a head near zero while our store
// still holds the old chain's height; without this rewind `latest <= lastBlock`
// would hold forever and the new chain would never index.
// maybeResetForRegenesis rewinds the cursor to StartBlock ONLY on a confirmed
// re-genesis: the chain's genesis hash has changed. A re-genesised chain reports a
// head near zero while our store still holds the old chain's height; without this
// rewind `latest <= lastBlock` would hold forever and the new chain would never index.
//
// The hysteresis is the fix for an idle/lagging RPC: an idle chain ("Building block",
// head=0x0) or a backend that briefly reports a stale low head would, under a
// single-poll rewind, wipe progress and re-index from genesis on every blip (DoS
// amplification). A genuine relaunch holds the low head across many polls; a transient
// dip recovers within one or two, clearing the streak before any wipe.
func (idx *Indexer) maybeResetForRegenesis(latest uint64) {
// Two gates, in order:
//
// 1. CHEAP PRE-FILTER (hysteresis): the head must stay far below our cursor for
// regenesisConfirmations consecutive polls. A shallow reorg, an idle chain
// ("Building block", head=0x0), or a transient lag clears the streak before we
// spend any extra RPC. This keeps the common case free of probes.
//
// 2. DETERMINISTIC SIGNAL: only after the streak confirms do we fetch the current
// genesis hash and compare it to our captured baseline. A CHANGED hash ⇒ a
// different chain ⇒ true re-genesis ⇒ rewind. An UNCHANGED hash ⇒ the same chain
// behind a deeply-stale/idle/load-balanced backend ⇒ we must NOT wipe; we clear
// the streak (it is proven not a reset) and keep the cursor. Because every node
// of a chain serves block 0 regardless of how far behind its head is, this signal
// cannot be false-tripped by lag — unlike the poll count alone, which a backend
// held >reorgDepth behind for the full window would satisfy on a still-canonical
// chain.
//
// If the baseline genesis hash is not yet captured (first run, or the probe failed),
// we capture the current one and decline to wipe this round — a reset cannot be
// proven without a baseline to compare against.
func (idx *Indexer) maybeResetForRegenesis(ctx context.Context, latest uint64) {
farBelow := idx.lastBlock > idx.startBlock+reorgDepth && latest+reorgDepth < idx.lastBlock
if !farBelow {
idx.lowHeadStreak = 0 // head recovered (or normal) — abandon any suspicion.
@@ -132,10 +201,35 @@ func (idx *Indexer) maybeResetForRegenesis(latest uint64) {
latest, idx.lastBlock, reorgDepth, idx.lowHeadStreak, regenesisConfirmations)
return
}
log.Printf("[indexer] head %d << cursor %d for %d consecutive polls — chain reset confirmed, rewinding to %d",
latest, idx.lastBlock, idx.lowHeadStreak, idx.startBlock)
// Streak confirmed — spend one RPC for the deterministic check.
current, err := idx.genesisHashFn(ctx)
if err != nil {
log.Printf("[indexer] head %d << cursor %d for %d polls but genesis-hash probe failed (%v) — "+
"NOT wiping; cannot confirm a reset without a canonical signal", latest, idx.lastBlock, idx.lowHeadStreak, err)
return
}
if idx.genesisHash == "" {
// No baseline yet — adopt the current hash and decline to wipe this round.
idx.genesisHash = current
log.Printf("[indexer] head %d << cursor %d but no genesis baseline yet — captured %s, NOT wiping",
latest, idx.lastBlock, current)
return
}
if current == idx.genesisHash {
// Same chain, just deeply behind/idle. The poll-count gate alone would have
// wiped here; the genesis hash proves it canonical, so we hold the cursor.
log.Printf("[indexer] head %d << cursor %d for %d polls but genesis hash unchanged (%s) — "+
"canonical chain behind a stale backend, NOT wiping", latest, idx.lastBlock, idx.lowHeadStreak, current)
idx.lowHeadStreak = 0
return
}
log.Printf("[indexer] genesis hash changed %s -> %s (head %d << cursor %d) — re-genesis confirmed, rewinding to %d",
idx.genesisHash, current, latest, idx.lastBlock, idx.startBlock)
idx.lastBlock = idx.startBlock
idx.store.SetLastBlock(idx.startBlock)
idx.genesisHash = current
idx.lowHeadStreak = 0
}
@@ -254,8 +348,10 @@ func (idx *Indexer) poll(ctx context.Context) error {
return fmt.Errorf("parse hex block: %w", err)
}
// Re-genesis self-heal (see maybeResetForRegenesis).
idx.maybeResetForRegenesis(latest)
// Capture the genesis-hash baseline (once) before any reset decision, then
// run the re-genesis self-heal (see maybeResetForRegenesis).
idx.ensureGenesisHash(ctx)
idx.maybeResetForRegenesis(ctx, latest)
// Nothing new
if latest <= idx.lastBlock {