mirror of
https://github.com/luxfi/graph.git
synced 2026-09-24 00:26:07 +00:00
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:
+115
-9
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user