mirror of
https://github.com/luxfi/evm.git
synced 2026-09-24 00:27:44 +00:00
eth,plugin/evm: clamp finalized/safe to accept tip + clear export height on rewind (RED MEDIUM/LOW)
Follow-up to the export-boundary re-point, closing RED's re-review MEDIUM (a finality-safety hole reachable via our own EVM-rewind→re-import restore runbook) and LOW. MEDIUM — the rewind path. A rewind / RLP re-import (SetLastAcceptedBlockDirect) can lower the accept tip BELOW the persisted EXPORT-FINAL (Quasar) height; the monotone SetLastQuasarHeight + advance-only SyncQuasarFrontier then keep it stuck high, and after the chain rebuilds past that height with a DIFFERENT canonical, `finalized` would name a block that was NEVER ⅔-certified — reported export-final to every bridge/DEX consumer. Two parts, both needed: - Belt: api_backend quasarHeight() resolves finalized/safe at min(LastQuasarHeight, LastAccepted) — finalized/safe can NEVER exceed the served accept tip, even with a stale-high durable key. - Suspenders: VM.reconcileQuasarWithAccept clears the durable quasarHeightKey + the in-mem height (eth.ResetLastQuasarHeight, bypassing the monotone guard) when the accept tip is below the export height — the ONE legal regression of the otherwise-irreversible frontier. Conservative reset to 0 (nothing export-final until the rebuilt chain re-certifies). Called on boot (against the restored accept tip — catches an offline rewind) and after admin_importChain (the live re-import). On the next boot the node re-seeds consensus' export frontier from the cleared (0) height, so the advance-only SyncQuasarFrontier yields to the rewind without touching the four shipped fixes. LOW — SetLastQuasarFinalized now PERSISTS the durable key BEFORE advancing the in-mem height, so the durable key is the source of truth: a crash between the two can only leave in-mem at/behind the durable value on boot, never ahead of it (no monotonicity dip). A persist failure does not advance. Tests: augmented TestFinalizedSafeResolveToQuasarNeverAboveIt with the belt case (seed export height ABOVE the accept tip → finalized CLAMPS to the tip — the case RED flagged as false confidence, now ENFORCED); new TestReconcileQuasarWithAccept_ClearsStaleHeightOnRewind (durable key + in-mem cleared on an accept regression) and TestSetLastQuasarFinalized_PersistsBeforeAdvancing (persist-first, no-advance-on-failure). eth suite green under -race. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+21
-8
@@ -110,18 +110,31 @@ func (b *EthAPIBackend) LastAcceptedBlock() *types.Block {
|
||||
return b.eth.LastAcceptedBlock()
|
||||
}
|
||||
|
||||
// quasarHeader / quasarBlock resolve the highest EXPORT-FINAL (Quasar, ⅔-by-stake) block — the
|
||||
// ONLY block the eth `finalized`/`safe` tags may return. The two-tier consensus advances the
|
||||
// local (Nova) accept tip at a BARE MAJORITY, which is reorgable and MUST NOT be exported; the
|
||||
// export-final height is pushed by consensus strictly after accept and is durable across restart
|
||||
// (eth.Ethereum.LastQuasarHeight). It is 0 (→ genesis) before the first export forms, and NEVER
|
||||
// above the accept tip. Returns nil only if the block at that height is not (yet) canonical.
|
||||
// quasarHeight is the EXPORT-FINAL (Quasar, ⅔-by-stake) height the eth `finalized`/`safe` tags
|
||||
// resolve to, CLAMPED to the served accept tip. The two-tier consensus advances the local (Nova)
|
||||
// accept tip at a BARE MAJORITY, which is reorgable and MUST NOT be exported; the export-final
|
||||
// height is pushed by consensus strictly after accept and is durable across restart
|
||||
// (eth.Ethereum.LastQuasarHeight), so in normal operation it is ≤ accept and the clamp is
|
||||
// invisible. But a rewind / RLP re-import (SetLastAcceptedBlockDirect) can lower the accept tip
|
||||
// BELOW a persisted quasar height; until reconcileQuasarWithAccept clears that stale key, this
|
||||
// clamp (belt to that suspenders) guarantees `finalized`/`safe` never name a block above the
|
||||
// currently-served accept tip. 0 (→ genesis) before the first export.
|
||||
func (b *EthAPIBackend) quasarHeight() uint64 {
|
||||
h := b.eth.LastQuasarHeight()
|
||||
if acc := b.eth.LastAcceptedBlock(); acc != nil {
|
||||
if n := acc.NumberU64(); n < h {
|
||||
h = n
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (b *EthAPIBackend) quasarHeader() *types.Header {
|
||||
return b.eth.blockchain.GetHeaderByNumber(b.eth.LastQuasarHeight())
|
||||
return b.eth.blockchain.GetHeaderByNumber(b.quasarHeight())
|
||||
}
|
||||
|
||||
func (b *EthAPIBackend) quasarBlock() *types.Block {
|
||||
return b.eth.blockchain.GetBlockByNumber(b.eth.LastQuasarHeight())
|
||||
return b.eth.blockchain.GetBlockByNumber(b.quasarHeight())
|
||||
}
|
||||
|
||||
func (b *EthAPIBackend) HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error) {
|
||||
|
||||
@@ -101,6 +101,19 @@ func TestFinalizedSafeResolveToQuasarNeverAboveIt(t *testing.T) {
|
||||
eth.SetLastQuasarHeight(numBlocks)
|
||||
require.EqualValues(t, numBlocks, num(rpc.FinalizedBlockNumber))
|
||||
|
||||
// BELT — the case RED flagged as false confidence: seed the export height ABOVE the accept tip
|
||||
// (the rewind-stale-key scenario, where a re-import lowered the accept tip below a persisted
|
||||
// quasar height) and assert finalized/safe CLAMP to the accept tip. The code now ENFORCES this;
|
||||
// previously it was only incidentally true because normal op keeps quasar ≤ accept, so a
|
||||
// stale-high key would have named a block above (or off) the served chain.
|
||||
eth.SetLastQuasarHeight(numBlocks + 100) // stale-high, far above the accept tip
|
||||
require.EqualValues(t, tip, num(rpc.FinalizedBlockNumber), "finalized MUST clamp to the accept tip when the export height is stale-high")
|
||||
require.EqualValues(t, tip, num(rpc.SafeBlockNumber), "safe MUST clamp to the accept tip when the export height is stale-high")
|
||||
require.EqualValues(t, tip, hdrNum(rpc.FinalizedBlockNumber))
|
||||
require.LessOrEqual(t, num(rpc.FinalizedBlockNumber), tip, "finalized must NEVER exceed the accept tip, even with a stale-high export key")
|
||||
// Restore for the exhaustive loop below.
|
||||
eth.ResetLastQuasarHeight(numBlocks)
|
||||
|
||||
// THE INVARIANT, exhaustively: for EVERY export height, `finalized` resolves to exactly that
|
||||
// height and NEVER above the accepted tip.
|
||||
for h := uint64(0); h <= numBlocks; h++ {
|
||||
|
||||
@@ -506,6 +506,13 @@ func (s *Ethereum) SetLastQuasarHeight(h uint64) {
|
||||
// NEVER above it, because a Nova-accepted block above it is reorgable and must not be exported.
|
||||
func (s *Ethereum) LastQuasarHeight() uint64 { return s.lastQuasarHeight.Load() }
|
||||
|
||||
// ResetLastQuasarHeight FORCE-sets the in-memory export height, bypassing the monotone guard of
|
||||
// SetLastQuasarHeight. It exists for the ONE legal regression of the otherwise-irreversible
|
||||
// export frontier: a rewind / RLP re-import that lowered the accept tip below the persisted
|
||||
// quasar height (the plugin VM's reconcileQuasarWithAccept calls this with 0 — conservative:
|
||||
// nothing export-final until the rebuilt chain re-certifies). Normal operation NEVER calls it.
|
||||
func (s *Ethereum) ResetLastQuasarHeight(h uint64) { s.lastQuasarHeight.Store(h) }
|
||||
|
||||
// precheckPopulateMissingTries returns an error if config flags should prevent
|
||||
// [populateMissingTries]
|
||||
//
|
||||
|
||||
@@ -87,6 +87,13 @@ func (api *AdminAPI) ImportChain(ctx context.Context, file string) (*ImportChain
|
||||
}
|
||||
log.Info("admin_importChain: complete", "hash", lastHash.Hex(), "height", lastHeight)
|
||||
|
||||
// A re-import (the EVM-rewind→re-import restore runbook) can leave the accept tip below a
|
||||
// persisted EXPORT-FINAL (Quasar) height. Clear the now-stale export height so `finalized`/
|
||||
// `safe` and warp never report a rebuilt (never ⅔-certified) block as export-final — the exact
|
||||
// two-tier safety class this path could otherwise reopen. Conservative reset to 0; the rebuilt
|
||||
// chain re-certifies from there (the belt clamp in api_backend guards the interim window).
|
||||
api.vm.reconcileQuasarWithAccept(lastHeight)
|
||||
|
||||
return &ImportChainResult{
|
||||
Success: true,
|
||||
BlocksImported: totalImported,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package evm
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/evm/eth"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReconcileQuasarWithAccept_ClearsStaleHeightOnRewind proves the ONE legal regression of the
|
||||
// otherwise-irreversible EXPORT (Quasar) frontier: a rewind / RLP re-import (SetLastAcceptedBlockDirect,
|
||||
// the EVM-rewind→re-import restore runbook) that lowers the accept tip below a persisted export
|
||||
// height MUST clear that stale height — both the durable acceptedBlockDB key and the in-memory value
|
||||
// — so a rebuilt-with-a-DIFFERENT-canonical block is never reported export-final to a bridge/DEX
|
||||
// consumer. This is the case RED flagged: the durable key + the monotone/advance-only guards keep the
|
||||
// height stuck high across a rewind unless this reconcile yields to it.
|
||||
func TestReconcileQuasarWithAccept_ClearsStaleHeightOnRewind(t *testing.T) {
|
||||
e := ð.Ethereum{}
|
||||
e.SetLastQuasarHeight(1085000) // a persisted export height (pre-rewind)
|
||||
db := memdb.New()
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], 1085000)
|
||||
require.NoError(t, db.Put(quasarHeightKey, buf[:]))
|
||||
vm := &VM{eth: e, acceptedBlockDB: db}
|
||||
|
||||
// Normal operation: the accept tip is at/above the export height → a clean no-op.
|
||||
vm.reconcileQuasarWithAccept(1085000)
|
||||
require.EqualValues(t, 1085000, e.LastQuasarHeight(), "no regression: export height unchanged when the accept tip is at/above it")
|
||||
got, err := db.Get(quasarHeightKey)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1085000, binary.BigEndian.Uint64(got), "durable key preserved in normal operation")
|
||||
|
||||
// REWIND: the accept tip drops to 1084996 (below the persisted export height) → clear it.
|
||||
vm.reconcileQuasarWithAccept(1084996)
|
||||
require.Zero(t, e.LastQuasarHeight(), "export height MUST reset to 0 when the accept tip rewinds below it")
|
||||
_, err = db.Get(quasarHeightKey)
|
||||
require.ErrorIs(t, err, database.ErrNotFound, "durable export (quasar) key MUST be cleared on a rewind")
|
||||
|
||||
// Idempotent: reconciling again after the reset is a clean no-op (no stale height to clear).
|
||||
vm.reconcileQuasarWithAccept(1084996)
|
||||
require.Zero(t, e.LastQuasarHeight())
|
||||
}
|
||||
|
||||
// TestSetLastQuasarFinalized_PersistsBeforeAdvancing proves the LOW fix: the durable key is the
|
||||
// source of truth. SetLastQuasarFinalized writes the acceptedBlockDB BEFORE advancing the in-memory
|
||||
// height, so a crash between the two can only ever leave the in-mem height at or BEHIND the durable
|
||||
// key on the next boot — never ahead of it (which would be a monotonicity dip that briefly reports a
|
||||
// Nova block as export-final). On a persist FAILURE the in-mem height must NOT advance.
|
||||
func TestSetLastQuasarFinalized_PersistsBeforeAdvancing(t *testing.T) {
|
||||
e := ð.Ethereum{}
|
||||
db := memdb.New()
|
||||
vm := &VM{eth: e, acceptedBlockDB: db}
|
||||
|
||||
vm.SetLastQuasarFinalized(42)
|
||||
require.EqualValues(t, 42, e.LastQuasarHeight(), "in-mem advances after a successful persist")
|
||||
got, err := db.Get(quasarHeightKey)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 42, binary.BigEndian.Uint64(got), "durable key holds the advanced height")
|
||||
|
||||
// Monotone: a lower height is ignored (in-mem and durable both unchanged).
|
||||
vm.SetLastQuasarFinalized(40)
|
||||
require.EqualValues(t, 42, e.LastQuasarHeight())
|
||||
|
||||
// Persist failure ⇒ in-mem must NOT advance (the durable key stays authoritative). A closed DB
|
||||
// makes Put fail.
|
||||
require.NoError(t, db.Close())
|
||||
vm.SetLastQuasarFinalized(100)
|
||||
require.EqualValues(t, 42, e.LastQuasarHeight(), "in-mem must NOT advance when the durable persist fails")
|
||||
}
|
||||
+40
-2
@@ -1059,6 +1059,12 @@ func (vm *VM) initializeChain(lastAcceptedHash common.Hash, ethConfig ethconfig.
|
||||
if h := vm.readLastQuasarHeight(); h > 0 {
|
||||
vm.eth.SetLastQuasarHeight(h)
|
||||
}
|
||||
// Reconcile against the RESTORED accept tip: if an offline rewind / RLP re-import lowered the
|
||||
// accept tip below the persisted quasar height, clear the now-stale export height (conservative
|
||||
// reset to 0) so a rebuilt-with-different-canonical block is never reported export-final.
|
||||
if acc := vm.eth.LastAcceptedBlock(); acc != nil {
|
||||
vm.reconcileQuasarWithAccept(acc.NumberU64())
|
||||
}
|
||||
// Upgrade the consensus context on the live chain to the FULL chain Runtime, which adds
|
||||
// the cross-chain atomic SharedMemory capability (the DEX 0x9999 C<->D shared-memory seam)
|
||||
// on top of the identity already bound at construction. Also covers the warp message
|
||||
@@ -2076,12 +2082,44 @@ func (vm *VM) SetLastQuasarFinalized(height uint64) {
|
||||
if height <= vm.eth.LastQuasarHeight() {
|
||||
return // monotone: the export frontier only advances
|
||||
}
|
||||
vm.eth.SetLastQuasarHeight(height)
|
||||
// PERSIST BEFORE advancing the in-memory value: the durable key is the source of truth. A
|
||||
// crash between the two must never leave the in-mem height ahead of what boot restores — that
|
||||
// would be a monotonicity DIP on the next boot (a Nova block briefly reported export-final).
|
||||
// Only advance in-mem once the write has committed.
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], height)
|
||||
if err := vm.acceptedBlockDB.Put(quasarHeightKey, buf[:]); err != nil {
|
||||
log.Warn("failed to persist EXPORT-FINAL (quasar) height", "height", height, "err", err)
|
||||
log.Warn("failed to persist EXPORT-FINAL (quasar) height; NOT advancing in-mem", "height", height, "err", err)
|
||||
return
|
||||
}
|
||||
vm.eth.SetLastQuasarHeight(height)
|
||||
}
|
||||
|
||||
// reconcileQuasarWithAccept clears the durable + in-memory EXPORT (Quasar) height when it exceeds
|
||||
// the accept tip — the ONE legal regression of the otherwise-irreversible export frontier. A
|
||||
// rewind / RLP re-import (SetLastAcceptedBlockDirect, e.g. the EVM-rewind→1084996 restore runbook)
|
||||
// can lower the accept tip below a persisted quasar height; after the chain rebuilds past that
|
||||
// height with a DIFFERENT canonical, the stale quasar height would name a block that was NEVER
|
||||
// ⅔-certified — reported as export-final to every bridge / DEX consumer. Conservative: reset to 0
|
||||
// (nothing export-final until the rebuilt chain re-certifies) rather than guess a still-valid
|
||||
// lower height. Idempotent — a no-op when the frontier is already within the accept tip. Called
|
||||
// on boot (against the restored last-accepted height) and after each SetLastAcceptedBlockDirect
|
||||
// (the live re-import path). On the next boot the node re-seeds consensus' export frontier from
|
||||
// this cleared (0) height, so its advance-only guard yields to the rewind without special-casing.
|
||||
func (vm *VM) reconcileQuasarWithAccept(acceptHeight uint64) {
|
||||
if vm.eth == nil {
|
||||
return
|
||||
}
|
||||
if vm.eth.LastQuasarHeight() <= acceptHeight {
|
||||
return
|
||||
}
|
||||
vm.eth.ResetLastQuasarHeight(0)
|
||||
if err := vm.acceptedBlockDB.Delete(quasarHeightKey); err != nil {
|
||||
log.Warn("failed to clear stale EXPORT (quasar) height on rewind", "err", err)
|
||||
}
|
||||
log.Warn("EXPORT (quasar) height RESET to 0 — accept tip rewound below it (rewind/re-import); "+
|
||||
"export re-certifies from the rebuilt chain (nothing is export-final until then)",
|
||||
"acceptHeight", acceptHeight)
|
||||
}
|
||||
|
||||
// LastQuasarHeight returns the highest EXPORT-FINAL (Quasar) block number the eth `finalized`/
|
||||
|
||||
Reference in New Issue
Block a user