mirror of
https://github.com/luxfi/evm.git
synced 2026-09-24 00:27:44 +00:00
consensus v1.36 export-boundary: close 3 LOW items for tag (DRY clamp, one admin_importChain, locked export writes + boot-wiring tests)
Two-tier finality (Nova = local majority accept, drives VM.Accept; Quasar = ⅔-by-stake export) requires that NO export surface ever names a block above the served accept tip. RED's final pass approved SHIP with three LOW items to close first. LOW-1 — DRY the accept-tip clamp into ONE place. New eth.Ethereum.ClampedQuasarHeight() = min(LastQuasarHeight, LastAccepted.Number) is the single clamp; BOTH export surfaces read it: EthAPIBackend.quasarHeight() (RPC finalized/safe) and warpBlockClient.LastQuasarHeight() (warp export gate, via the BlockClient interface). Removes the second inlined clamp copy that could drift. LastQuasarHeight() is documented as RAW (node boot re-seed only, where reconcile has already run). LOW-2 — exactly ONE admin_importChain. Deleted the eth-package AdminAPI.ImportChain (which neither reconciled the export height nor skipped <=head blocks); the plugin VM's (plugin/evm/admin_api.go) is the sole one — it runs under vmLock and calls reconcileQuasarWithAccept after each SetLastAcceptedBlockDirect. Left a NOTE so it is not re-added and reopens the rewind hole. Dropped the now-unused rlp import. LOW-3 — exclude concurrent export-height writers + prove the real boot wiring. SetLastQuasarFinalized now takes vmLock (the same lock reconcileQuasarWithAccept's admin_importChain caller holds), so the observer callback's durable Put+Store can never interleave with a re-import's Reset(0)+Delete on the same key. Fixed a boot ordering gap: the startup auto-import (importBlocksFromFile) runs AFTER initializeChain's reconcile, so a --import-chain-data rewind could leave a stale export height — added a reconcile right after the auto-import. New eth.NewTestEthereum test-support ctor. Tests (all green): eth -race adds TestClampedQuasarHeight_RealRewind (genuine SetLastAcceptedBlockDirect rewind; belt clamps finalized/safe to the rewound tip while raw is unchanged); plugin/evm adds TestBootReconcile_RealLastAccepted_ClearsStaleQuasarOnRewind (drives the REAL restored LastAcceptedBlock through belt + suspenders) and TestSetLastQuasarFinalized_LockedAgainstReconcile (concurrency: durable key <-> in-mem always consistent). gofmt/vet/ontology clean. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+7
-93
@@ -38,7 +38,6 @@ import (
|
||||
|
||||
"github.com/luxfi/geth/core/rawdb"
|
||||
"github.com/luxfi/geth/core/types"
|
||||
"github.com/luxfi/geth/rlp"
|
||||
log "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
@@ -92,98 +91,13 @@ func (api *AdminAPI) ExportChain(file string, first *uint64, last *uint64) (bool
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ImportChain imports a blockchain from a local file.
|
||||
// State is committed periodically during import for restart-safety.
|
||||
func (api *AdminAPI) ImportChain(file string) (bool, error) {
|
||||
in, err := os.Open(file)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
var reader io.Reader = in
|
||||
if strings.HasSuffix(file, ".gz") {
|
||||
if reader, err = gzip.NewReader(reader); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
chain := api.eth.BlockChain()
|
||||
if err := chain.EnsureGenesisState(); err != nil {
|
||||
return false, fmt.Errorf("failed to ensure genesis state: %w", err)
|
||||
}
|
||||
|
||||
stream := rlp.NewStream(reader, 0)
|
||||
blocks, index := make([]*types.Block, 0, 2500), 0
|
||||
var lastInsertedBlock *types.Block
|
||||
commitInterval := chain.CommitInterval()
|
||||
var lastCommitHeight uint64
|
||||
|
||||
log.Info("ImportChain: starting", "file", file, "currentBlock", chain.CurrentBlock().Number, "commitInterval", commitInterval)
|
||||
|
||||
for batch := 0; ; batch++ {
|
||||
for len(blocks) < cap(blocks) {
|
||||
block := new(types.Block)
|
||||
if err := stream.Decode(block); err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return false, fmt.Errorf("block %d: failed to parse: %v", index, err)
|
||||
}
|
||||
if block.NumberU64() == 0 {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
index++
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
log.Info("ImportChain: inserting batch", "batch", batch, "blocks", len(blocks), "firstNum", blocks[0].NumberU64())
|
||||
n, err := chain.InsertChain(blocks)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("batch %d: failed to insert after %d blocks: %v", batch, n, err)
|
||||
}
|
||||
if n > 0 {
|
||||
lastInsertedBlock = blocks[n-1]
|
||||
currentHeight := lastInsertedBlock.NumberU64()
|
||||
|
||||
if err := chain.SetLastAcceptedBlockDirect(lastInsertedBlock); err != nil {
|
||||
return false, fmt.Errorf("failed to set last accepted block: %v", err)
|
||||
}
|
||||
|
||||
// Periodically commit state trie to disk for restart-safety
|
||||
if currentHeight-lastCommitHeight >= commitInterval {
|
||||
log.Info("ImportChain: committing state", "block", currentHeight)
|
||||
if err := chain.ForceCommitState(lastInsertedBlock); err != nil {
|
||||
return false, fmt.Errorf("failed to commit state at block %d: %v", currentHeight, err)
|
||||
}
|
||||
lastCommitHeight = currentHeight
|
||||
}
|
||||
}
|
||||
blocks = blocks[:0]
|
||||
}
|
||||
|
||||
if lastInsertedBlock != nil {
|
||||
// Final state commit
|
||||
finalHeight := lastInsertedBlock.NumberU64()
|
||||
if finalHeight > lastCommitHeight {
|
||||
log.Info("ImportChain: final state commit", "block", finalHeight)
|
||||
if err := chain.ForceCommitState(lastInsertedBlock); err != nil {
|
||||
return false, fmt.Errorf("failed to commit final state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update VM layer via callback
|
||||
if err := api.eth.CallPostImportCallback(lastInsertedBlock.Hash(), finalHeight); err != nil {
|
||||
log.Warn("ImportChain: post-import callback failed", "error", err)
|
||||
}
|
||||
|
||||
log.Info("ImportChain: completed", "blocks", index, "height", finalHeight)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
// NOTE: admin_importChain is DELIBERATELY not implemented on this eth-package AdminAPI. There is
|
||||
// EXACTLY ONE admin_importChain — the plugin VM's (plugin/evm/admin_api.go), which runs under
|
||||
// vmLock AND calls reconcileQuasarWithAccept after each SetLastAcceptedBlockDirect so a re-import
|
||||
// that lowers the accept tip clears the stale EXPORT-FINAL (Quasar) height. An eth-package
|
||||
// ImportChain here (which neither reconciled nor skipped ≤-head blocks) would reopen the two-tier
|
||||
// finality-safety rewind hole the moment a future RegisterName reorder stopped the plugin one from
|
||||
// shadowing it. Do not re-add it here.
|
||||
|
||||
// WriteGenesisStateSpec writes the genesis state spec to the database.
|
||||
// This is required for RLP import to work when the genesis state trie is not accessible.
|
||||
|
||||
+7
-14
@@ -111,22 +111,15 @@ func (b *EthAPIBackend) LastAcceptedBlock() *types.Block {
|
||||
}
|
||||
|
||||
// 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)
|
||||
// resolve to — the accept-tip-CLAMPED export height (eth.Ethereum.ClampedQuasarHeight, the ONE
|
||||
// shared clamp the warp export gate also reads). 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.
|
||||
// height is pushed by consensus strictly after accept and is durable across restart, so normally it
|
||||
// is ≤ accept and the clamp is invisible — it binds only in the transient rewind window before
|
||||
// reconcileQuasarWithAccept clears the stale key, guaranteeing `finalized`/`safe` never name a block
|
||||
// above the 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
|
||||
return b.eth.ClampedQuasarHeight()
|
||||
}
|
||||
|
||||
func (b *EthAPIBackend) quasarHeader() *types.Header {
|
||||
|
||||
@@ -26,22 +26,20 @@ import (
|
||||
// trails, and can never exceed, the accepted tip). This proves `eth_getBlockByNumber("finalized")`
|
||||
// never returns a block above the Quasar height, that it is monotone (never regresses on a
|
||||
// restart-seed race), and that `latest` stays on the accepted tip (a correct non-finality tag).
|
||||
func TestFinalizedSafeResolveToQuasarNeverAboveIt(t *testing.T) {
|
||||
// buildAcceptedChain builds a REAL canonical chain (genesis + n accepted blocks) so the
|
||||
// `finalized`/`safe` + export-height resolution is exercised against a real chain (LastAccepted /
|
||||
// GetBlockByNumber / the clamp), not a mock. Accept tip = n.
|
||||
func buildAcceptedChain(t *testing.T, n int) *core.BlockChain {
|
||||
t.Helper()
|
||||
key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
|
||||
cryptoAddr := crypto.PubkeyToAddress(key.PublicKey)
|
||||
addr := common.BytesToAddress(cryptoAddr[:])
|
||||
bal, _ := new(big.Int).SetString("100000000000000000000000", 10)
|
||||
|
||||
gspec := &core.Genesis{
|
||||
Config: params.TestChainConfig,
|
||||
Alloc: types.GenesisAlloc{addr: {Balance: bal}},
|
||||
}
|
||||
gspec := &core.Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{addr: {Balance: bal}}}
|
||||
engine := dummy.NewETHFaker()
|
||||
|
||||
const numBlocks = 5
|
||||
// The dummy engine requires the fee-recipient (blackhole) coinbase on generated blocks.
|
||||
coinbase := common.HexToAddress("0x0100000000000000000000000000000000000000")
|
||||
genDb, blocks, _, err := core.GenerateChainWithGenesis(gspec, engine, numBlocks, 10, func(i int, b *core.BlockGen) {
|
||||
genDb, blocks, _, err := core.GenerateChainWithGenesis(gspec, engine, n, 10, func(i int, b *core.BlockGen) {
|
||||
b.SetCoinbase(coinbase)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -50,12 +48,17 @@ func TestFinalizedSafeResolveToQuasarNeverAboveIt(t *testing.T) {
|
||||
t.Cleanup(func() { chain.Stop() })
|
||||
_, err = chain.InsertChain(blocks)
|
||||
require.NoError(t, err)
|
||||
// Accept every block so the blockchain's LastAcceptedBlock is the tip — the Nova/accept tip
|
||||
// the `latest` tag tracks (distinct from the export/Quasar tip the `finalized` tag tracks).
|
||||
for _, blk := range blocks {
|
||||
require.NoError(t, chain.Accept(blk))
|
||||
}
|
||||
chain.DrainAcceptorQueue()
|
||||
require.EqualValues(t, n, chain.LastAcceptedBlock().NumberU64())
|
||||
return chain
|
||||
}
|
||||
|
||||
func TestFinalizedSafeResolveToQuasarNeverAboveIt(t *testing.T) {
|
||||
const numBlocks = 5
|
||||
chain := buildAcceptedChain(t, numBlocks)
|
||||
|
||||
// The eth backend's blockchain is the canonical chain (genesis..numBlocks). LastAccepted /
|
||||
// `latest` is the Nova tip (numBlocks); the export (Quasar) height is set separately below.
|
||||
@@ -126,3 +129,35 @@ func TestFinalizedSafeResolveToQuasarNeverAboveIt(t *testing.T) {
|
||||
require.LessOrEqual(t, blk.NumberU64(), tip, "finalized must never exceed the accepted tip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampedQuasarHeight_RealRewind exercises the BELT against a GENUINE rewind
|
||||
// (SetLastAcceptedBlockDirect — the primitive the EVM-rewind→re-import restore runbook uses): with a
|
||||
// persisted export height of 8 and the accept tip rewound to 5, ClampedQuasarHeight (the ONE shared
|
||||
// clamp both `finalized`/`safe` and the warp gate read) resolves to 5 — never the stale 8 above the
|
||||
// served accept tip — WITHOUT relying on the VM's reconcile having run. This is the structural belt
|
||||
// that the warp gate now shares (LOW-1), verified under -race at the eth level.
|
||||
func TestClampedQuasarHeight_RealRewind(t *testing.T) {
|
||||
chain := buildAcceptedChain(t, 10)
|
||||
eth := &Ethereum{blockchain: chain}
|
||||
backend := &EthAPIBackend{eth: eth}
|
||||
ctx := context.Background()
|
||||
|
||||
eth.SetLastQuasarHeight(8) // valid, ≤ accept tip 10
|
||||
require.EqualValues(t, 8, eth.ClampedQuasarHeight())
|
||||
|
||||
// REWIND to block 5 (SetLastAcceptedBlockDirect): accept tip drops below the export height.
|
||||
require.NoError(t, chain.SetLastAcceptedBlockDirect(chain.GetBlockByNumber(5)))
|
||||
require.EqualValues(t, 5, chain.LastAcceptedBlock().NumberU64())
|
||||
|
||||
// The RAW export height is still 8 (clearing it is the VM reconcile's job) — but the BELT clamps.
|
||||
require.EqualValues(t, 8, eth.LastQuasarHeight())
|
||||
require.EqualValues(t, 5, eth.ClampedQuasarHeight(), "ClampedQuasarHeight MUST clamp to the rewound accept tip")
|
||||
|
||||
// `finalized`/`safe` therefore resolve to block 5, never the stale block 8.
|
||||
fin, err := backend.BlockByNumber(ctx, rpc.FinalizedBlockNumber)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 5, fin.NumberU64(), "finalized MUST clamp to the rewound accept tip, never the stale export height")
|
||||
safe, err := backend.BlockByNumber(ctx, rpc.SafeBlockNumber)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 5, safe.NumberU64())
|
||||
}
|
||||
|
||||
+30
-3
@@ -485,6 +485,15 @@ func (s *Ethereum) LastAcceptedBlock() *types.Block {
|
||||
return s.blockchain.LastAcceptedBlock()
|
||||
}
|
||||
|
||||
// NewTestEthereum builds an Ethereum wrapping only the given blockchain — TEST SUPPORT so a test
|
||||
// in another package (e.g. plugin/evm's boot-wiring reconcile test) can drive the
|
||||
// `finalized`/`safe` + export-height resolution against a REAL chain without the full node stack.
|
||||
// ONLY the blockchain-backed reads (LastAcceptedBlock, GetBlockByNumber, the export-height clamp)
|
||||
// are wired; everything else is nil. Not for production use.
|
||||
func NewTestEthereum(chain *core.BlockChain) *Ethereum {
|
||||
return &Ethereum{blockchain: chain}
|
||||
}
|
||||
|
||||
// SetLastQuasarHeight records the highest EXPORT-FINAL (Quasar, ⅔-by-stake) block number.
|
||||
// Monotone: a value at or below the current height is ignored (the export frontier only ever
|
||||
// advances). Called by the plugin VM from the consensus export-frontier observer, and once on
|
||||
@@ -501,11 +510,29 @@ func (s *Ethereum) SetLastQuasarHeight(h uint64) {
|
||||
}
|
||||
}
|
||||
|
||||
// LastQuasarHeight returns the highest EXPORT-FINAL (Quasar) block number (0 before the first
|
||||
// export forms). The eth `finalized`/`safe` block tags resolve to the block at this number —
|
||||
// NEVER above it, because a Nova-accepted block above it is reorgable and must not be exported.
|
||||
// LastQuasarHeight returns the RAW highest EXPORT-FINAL (Quasar) block number the VM has recorded
|
||||
// (0 before the first export). This is the persisted/in-mem value; export SURFACES must instead
|
||||
// read ClampedQuasarHeight (which never exceeds the accept tip). Used by the node's boot re-seed
|
||||
// of the consensus export frontier, where the reconcile has already run so raw == clamped.
|
||||
func (s *Ethereum) LastQuasarHeight() uint64 { return s.lastQuasarHeight.Load() }
|
||||
|
||||
// ClampedQuasarHeight is the EXPORT-FINAL (Quasar) height CLAMPED to the served accept tip —
|
||||
// min(LastQuasarHeight, LastAccepted.Number). This is the ONE place the clamp lives: BOTH the RPC
|
||||
// `finalized`/`safe` resolver (EthAPIBackend.quasarHeight) and the warp export gate
|
||||
// (warpBlockClient.LastQuasarHeight) read it, so an export surface can NEVER name a block above the
|
||||
// served accept tip — STRUCTURALLY, regardless of reconcile timing — with no second inlined copy of
|
||||
// the clamp to drift. In normal operation quasar ≤ accept so the clamp is invisible; it binds only
|
||||
// in the transient rewind window before reconcileQuasarWithAccept clears the stale key.
|
||||
func (s *Ethereum) ClampedQuasarHeight() uint64 {
|
||||
h := s.lastQuasarHeight.Load()
|
||||
if acc := s.blockchain.LastAcceptedBlock(); acc != nil {
|
||||
if n := acc.NumberU64(); n < h {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package evm
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/evm/consensus/dummy"
|
||||
"github.com/luxfi/evm/core"
|
||||
"github.com/luxfi/evm/eth"
|
||||
"github.com/luxfi/evm/params"
|
||||
gethcommon "github.com/luxfi/geth/common"
|
||||
gethtypes "github.com/luxfi/geth/core/types"
|
||||
gethvm "github.com/luxfi/geth/core/vm"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildAcceptedTestChain builds a REAL canonical chain (genesis + n accepted blocks) and returns
|
||||
// the eth backend wrapping it. Accept tip = n. This is the actual blockchain the boot reconcile
|
||||
// reads LastAcceptedBlock() / GetBlockByNumber() from — no hand-picked heights.
|
||||
func buildAcceptedTestChain(t *testing.T, n int) (*core.BlockChain, *eth.Ethereum) {
|
||||
t.Helper()
|
||||
key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
|
||||
cryptoAddr := crypto.PubkeyToAddress(key.PublicKey)
|
||||
addr := gethcommon.BytesToAddress(cryptoAddr[:])
|
||||
bal, _ := new(big.Int).SetString("100000000000000000000000", 10)
|
||||
gspec := &core.Genesis{Config: params.TestChainConfig, Alloc: gethtypes.GenesisAlloc{addr: {Balance: bal}}}
|
||||
engine := dummy.NewETHFaker()
|
||||
coinbase := gethcommon.HexToAddress("0x0100000000000000000000000000000000000000")
|
||||
genDb, blocks, _, err := core.GenerateChainWithGenesis(gspec, engine, n, 10, func(i int, b *core.BlockGen) {
|
||||
b.SetCoinbase(coinbase)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
chain, err := core.NewBlockChain(genDb, core.DefaultCacheConfig, gspec, engine, gethvm.Config{}, gethcommon.Hash{}, false, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { chain.Stop() })
|
||||
_, err = chain.InsertChain(blocks)
|
||||
require.NoError(t, err)
|
||||
for _, b := range blocks {
|
||||
require.NoError(t, chain.Accept(b))
|
||||
}
|
||||
chain.DrainAcceptorQueue()
|
||||
require.EqualValues(t, n, chain.LastAcceptedBlock().NumberU64())
|
||||
return chain, eth.NewTestEthereum(chain)
|
||||
}
|
||||
|
||||
// TestBootReconcile_RealLastAccepted_ClearsStaleQuasarOnRewind exercises the REAL boot wiring
|
||||
// (vm.go initializeChain:1065-1066 and the auto-import-after-reconcile at Initialize): the reconcile
|
||||
// is driven by the ACTUAL restored LastAcceptedBlock of a real chain — not a hand-picked height —
|
||||
// across a genuine rewind (SetLastAcceptedBlockDirect, the primitive both the runbook rewind and
|
||||
// admin/auto importBlocksFromFile use). It proves: (a) the belt (ClampedQuasarHeight) clamps
|
||||
// `finalized` to the rewound tip IMMEDIATELY; and (b) the suspenders (reconcileQuasarWithAccept)
|
||||
// clear the stale durable + in-mem export height once given the real post-rewind accept tip.
|
||||
func TestBootReconcile_RealLastAccepted_ClearsStaleQuasarOnRewind(t *testing.T) {
|
||||
const n = 10
|
||||
chain, e := buildAcceptedTestChain(t, n)
|
||||
db := memdb.New()
|
||||
pvm := &VM{eth: e, acceptedBlockDB: db}
|
||||
|
||||
// A valid export height (8 ≤ accept tip 10). Boot reconcile with the REAL LastAcceptedBlock is a
|
||||
// clean no-op (export frontier within the accept tip).
|
||||
pvm.SetLastQuasarFinalized(8)
|
||||
require.EqualValues(t, 8, e.LastQuasarHeight())
|
||||
pvm.reconcileQuasarWithAccept(pvm.eth.LastAcceptedBlock().NumberU64()) // real height = 10
|
||||
require.EqualValues(t, 8, e.LastQuasarHeight(), "no reconcile while export height ≤ real accept tip")
|
||||
require.EqualValues(t, 8, e.ClampedQuasarHeight(), "finalized == export height when it is within the accept tip")
|
||||
|
||||
// REWIND to block 5 (the SetLastAcceptedBlockDirect primitive the rewind/re-import runbook uses):
|
||||
// accept tip drops to 5, below the persisted export height 8.
|
||||
require.NoError(t, chain.SetLastAcceptedBlockDirect(chain.GetBlockByNumber(5)))
|
||||
require.EqualValues(t, 5, e.LastAcceptedBlock().NumberU64())
|
||||
|
||||
// BELT — structural, before any reconcile: `finalized` (ClampedQuasarHeight) clamps to the new
|
||||
// accept tip (5), NEVER naming the stale block 8 above the served tip.
|
||||
require.EqualValues(t, 5, e.ClampedQuasarHeight(), "finalized MUST clamp to the rewound accept tip immediately")
|
||||
|
||||
// SUSPENDERS — the REAL boot/auto-import wiring: reconcile with the ACTUAL restored
|
||||
// LastAcceptedBlock (5), not a hand-picked height → clears the stale export height (8).
|
||||
pvm.reconcileQuasarWithAccept(pvm.eth.LastAcceptedBlock().NumberU64())
|
||||
require.Zero(t, e.LastQuasarHeight(), "boot reconcile must clear the stale export height on a real rewind")
|
||||
_, err := db.Get(quasarHeightKey)
|
||||
require.ErrorIs(t, err, database.ErrNotFound, "durable export key must be cleared on a real rewind")
|
||||
require.Zero(t, e.ClampedQuasarHeight(), "nothing export-final until the rebuilt chain re-certifies")
|
||||
}
|
||||
|
||||
// TestSetLastQuasarFinalized_LockedAgainstReconcile drives the observer callback
|
||||
// (SetLastQuasarFinalized) and the re-import reconcile (reconcileQuasarWithAccept, run under the
|
||||
// caller's vmLock exactly as admin_importChain does) concurrently against the SAME atomic + durable
|
||||
// key. With SetLastQuasarFinalized taking vmLock, the two writers are mutually excluded: the final
|
||||
// state is always consistent (durable key == in-mem height), never a torn Put(H)/Delete interleave.
|
||||
// Runs under -race in a CGO-on environment; the lock is the fix.
|
||||
func TestSetLastQuasarFinalized_LockedAgainstReconcile(t *testing.T) {
|
||||
e := ð.Ethereum{}
|
||||
db := memdb.New()
|
||||
pvm := &VM{eth: e, acceptedBlockDB: db}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := uint64(1); i <= 200; i++ {
|
||||
wg.Add(2)
|
||||
go func(h uint64) { defer wg.Done(); pvm.SetLastQuasarFinalized(h) }(i)
|
||||
go func(h uint64) {
|
||||
defer wg.Done()
|
||||
// Mirror admin_importChain: hold vmLock across the reconcile (its documented invariant).
|
||||
pvm.vmLock.Lock()
|
||||
pvm.reconcileQuasarWithAccept(h % 50) // sometimes below the export height ⇒ a reset
|
||||
pvm.vmLock.Unlock()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Consistency invariant: the durable key and the in-mem height agree (no torn write). A cleared
|
||||
// key ⇔ in-mem 0; a present key ⇔ its uint64 == in-mem height.
|
||||
inMem := e.LastQuasarHeight()
|
||||
b, err := db.Get(quasarHeightKey)
|
||||
if err == database.ErrNotFound {
|
||||
require.Zero(t, inMem, "durable key cleared ⇒ in-mem must be 0")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, binary.BigEndian.Uint64(b), inMem, "durable key and in-mem height must agree (no torn write)")
|
||||
}
|
||||
}
|
||||
@@ -680,6 +680,13 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("startup import failed: %w", err)
|
||||
}
|
||||
// A startup import can leave the accept tip below a persisted EXPORT-FINAL (Quasar) height.
|
||||
// reconcile AFTER it — the boot reconcile in initializeChain ran BEFORE this import (against
|
||||
// the pre-import tip), and importBlocksFromFile (unlike admin_importChain) does not reconcile
|
||||
// itself — so without this a stale export height would survive an --import-chain-data rewind
|
||||
// and `finalized`/warp would report a rebuilt (never-⅔-certified) block export-final. Single
|
||||
// threaded at boot (no observer yet), so no lock needed here.
|
||||
vm.reconcileQuasarWithAccept(lastHeight)
|
||||
log.Info("Startup import complete", "blocks", imported, "lastHash", lastHash.Hex(), "height", lastHeight)
|
||||
}
|
||||
|
||||
@@ -2076,6 +2083,16 @@ func (vm *VM) readLastAccepted() (common.Hash, uint64, error) {
|
||||
// surface survives a restart without regressing (the in-memory consensus frontier resets to 0
|
||||
// until a cert re-forms). No-op before the eth backend exists.
|
||||
func (vm *VM) SetLastQuasarFinalized(height uint64) {
|
||||
// Acquire vmLock — the SAME lock reconcileQuasarWithAccept relies on (held by its
|
||||
// admin_importChain caller) — so the durable-key writes here (Put + Store) are MUTUALLY
|
||||
// EXCLUDED with a concurrent reconcile's Reset(0)+Delete on the same atomic + acceptedBlockDB
|
||||
// key. This observer callback (fired by the consensus export frontier, holding NO VM lock) could
|
||||
// otherwise interleave a live cert-finalization against a re-import's reconcile and leave the
|
||||
// in-mem height and the durable key inconsistent. Never re-entrant: the only caller is the
|
||||
// consensus observer, which holds no VM lock; reconcile itself does NOT take vmLock (its caller
|
||||
// does), so no double-acquire.
|
||||
vm.vmLock.Lock()
|
||||
defer vm.vmLock.Unlock()
|
||||
if vm.eth == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ func (w *warpBlockClient) GetAcceptedBlock(ctx context.Context, blkID ids.ID) (n
|
||||
return w.vm.newBlock(ethBlock), nil
|
||||
}
|
||||
|
||||
// LastQuasarHeight returns the VM's highest EXPORT-FINAL (Quasar, ⅔-by-stake) block number — the
|
||||
// export-tier gate for warp block signatures (a cross-chain export must sit at export finality,
|
||||
// not the reorgable local Accept tip).
|
||||
// LastQuasarHeight returns the accept-tip-CLAMPED EXPORT-FINAL (Quasar, ⅔-by-stake) height — the
|
||||
// export-tier gate for warp block signatures (a cross-chain export must sit at export finality, not
|
||||
// the reorgable local Accept tip). It reads the SAME shared clamp (eth.ClampedQuasarHeight) the RPC
|
||||
// `finalized`/`safe` resolver uses, so the warp gate is structurally belt-protected — it can never
|
||||
// admit a block above the served accept tip even during the transient rewind window (before
|
||||
// reconcileQuasarWithAccept clears a stale key), independent of reconcile ordering.
|
||||
func (w *warpBlockClient) LastQuasarHeight() uint64 {
|
||||
return w.vm.LastQuasarHeight()
|
||||
if w.vm.eth == nil {
|
||||
return 0
|
||||
}
|
||||
return w.vm.eth.ClampedQuasarHeight()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user