From c556c9c182dd1256aa51a976db85f3b00d1c9add Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Mon, 20 Jul 2026 19:33:57 +0800 Subject: [PATCH] fix(core): store gap snapshot before writeHeadBlock to close crash window UpdateMasternodes (called via UpdateM1) ran after writeHeadBlock, leaving a window in which the persisted head pointed at a gap block while no matching snapshot existed in the database. A kill or restart during the lengthy per-candidate EVM calls in UpdateM1 made that snapshot permanently missing, and on the next boot the node failed with "Cannot find snapshot from last gap block" on every block of the following epoch, silently dropping out of consensus. Add storeGapSnapshot(header), which derives candidates and stakes from the committed state trie of the given block the same way downloader.generateSnapshot does, instead of depending on bc.CurrentBlock() or bc.CurrentHeader(). The canonical path calls it before writeHeadBlock; the reorg path derives the snapshots of the whole new chain up front, so a failure aborts the reorg before it mutates anything instead of leaving it half applied. Both paths report a failure as an error instead of log.Crit: the head is left on a block that has a snapshot and the import is retried later, rather than taking the node down. UpdateM1 becomes a thin wrapper that resolves the current head and its state and delegates to updateM1ForBlock. It no longer queries the validator contract through bc.GetClient(), which drops the IPC dependency. The snapshot is unchanged: getCandidateCap() returns validatorsState[_candidate].cap, the very slot StateDB.GetCandidateCap reads, and the old "latest" resolved to the same gap block because the call ran after writeHeadBlock under chainmu. This closes the crash window only, two holes remain and both need getSnapshot to rebuild a missing snapshot on demand: - Fast sync leaves no gap snapshots below the pivot, because commitFastSyncData bypasses writeBlockWithState. - A reorg only gives up on the new head when the masternode set cannot be derived. An older block of the new chain whose tries are already pruned is skipped with a warning, because aborting there would strand the node on the old chain forever, so that block keeps the very hole this change prevents. Tests record the raw database write order through an ethdb wrapper and assert that the gap snapshot is persisted before the head markers on the canonical path, on the reorg path and under the v2 engine, that neither path advances the head when the masternode set cannot be derived, that an aborted reorg leaves the old chain untouched, and that UpdateM1 works without a client. --- consensus/XDPoS/utils/errors.go | 5 + core/blockchain.go | 167 +++++---- core/blockchain_xdpos_gap_test.go | 558 ++++++++++++++++++++++++++++++ core/error.go | 6 + eth/downloader/downloader.go | 5 + 5 files changed, 675 insertions(+), 66 deletions(-) create mode 100644 core/blockchain_xdpos_gap_test.go diff --git a/consensus/XDPoS/utils/errors.go b/consensus/XDPoS/utils/errors.go index ec2a7c5ea346..20e4a4f874be 100644 --- a/consensus/XDPoS/utils/errors.go +++ b/consensus/XDPoS/utils/errors.go @@ -99,6 +99,11 @@ var ( ErrRoundInvalid = errors.New("invalid Round, it shall be bigger than QC round") ErrAlreadyMined = errors.New("already mined") + + // ErrGapSnapshotUnavailable is returned when the masternode snapshot of a gap + // block cannot be derived locally. The block itself passed verification, so the + // peer that delivered it must not be blamed for this failure. + ErrGapSnapshotUnavailable = errors.New("gap block masternode snapshot unavailable") ) type ErrIncomingMessageRoundNotEqualCurrentRound struct { diff --git a/core/blockchain.go b/core/blockchain.go index daffceb42984..fe8891bbf619 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -38,7 +38,6 @@ import ( "github.com/XinFinOrg/XDPoSChain/consensus" "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS" "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS/utils" - contractValidator "github.com/XinFinOrg/XDPoSChain/contracts/validator/contract" "github.com/XinFinOrg/XDPoSChain/core/rawdb" "github.com/XinFinOrg/XDPoSChain/core/state" "github.com/XinFinOrg/XDPoSChain/core/tracing" @@ -1626,6 +1625,7 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. // Second clause in the if statement reduces the vulnerability to selfish mining. // Please refer to http://www.cs.cornell.edu/~ie53/publications/btcProcFC.pdf reorg := externTd.Cmp(localTd) > 0 + reorged := false currentBlock = bc.CurrentBlock() if !reorg && externTd.Cmp(localTd) == 0 { // Split same-difficulty blocks by number @@ -1637,6 +1637,7 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. if err := bc.reorg(currentBlock, block.Header()); err != nil { return NonStatTy, err } + reorged = true } status = CanonStatTy } else { @@ -1645,14 +1646,17 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. // Set new head. if status == CanonStatTy { - // WriteBlock has already been called, no need to write again - bc.writeHeadBlock(block, false) - // prepare set of masternodes for the next epoch - if bc.chainConfig.XDPoS != nil && ((block.NumberU64() % bc.chainConfig.XDPoS.Epoch) == (bc.chainConfig.XDPoS.Epoch - bc.chainConfig.XDPoS.Gap)) { - if err := bc.UpdateM1(); err != nil { - log.Crit("Fail to update masternodes during writeBlockWithState", "number", block.Number, "hash", block.Hash().Hex(), "err", err) + // The snapshot of a gap block has to reach the disk before its head markers, so + // that a crash in between can never leave the head on a gap block whose snapshot + // is missing. A reorg already derived it for the whole new chain. + if !reorged { + if err := bc.storeGapSnapshot(block.Header()); err != nil { + log.Error("Fail to store gap snapshot, head not advanced", "number", block.Number(), "hash", block.Hash().Hex(), "err", err) + return NonStatTy, fmt.Errorf("failed to store gap snapshot of block %d (%s): %w", block.NumberU64(), block.Hash().Hex(), err) } } + // WriteBlock has already been called, no need to write again + bc.writeHeadBlock(block, false) } // save cache BlockSigners if bc.chainConfig.XDPoS != nil && bc.chainConfig.IsTIPSigning(block.Number()) { @@ -2306,6 +2310,8 @@ func (bc *BlockChain) insertBlock(block *types.Block) ([]interface{}, []*types.L return nil, nil, errChainStopped } defer bc.chainmu.Unlock() + // A block whose head update failed earlier is skipped here: the downloader replays + // it, or a later child moves the head onto it through a reorg. if bc.HasBlockAndFullState(block.Hash(), block.NumberU64()) { return events, coalescedLogs, nil } @@ -2451,6 +2457,30 @@ func (bc *BlockChain) reorg(oldHead, newHead *types.Header) error { } } + // The snapshot of a gap block has to reach the disk before its head markers, so + // derive the snapshots of the whole new chain before anything is mutated, and + // before the reorg is even announced. A failure here aborts the reorg without + // side effects, leaving the head on a block that still has a snapshot behind + // it. Snapshots already written when a later step fails are harmless, they are + // keyed by block hash. + for i := len(newChain) - 1; i >= 0; i-- { + err := bc.storeGapSnapshot(newChain[i]) + if err == nil { + continue + } + // Aborting on an old block whose tries are pruned would strand the node on the + // old chain forever. Only the new head, whose state was just computed by the + // importer, is worth giving up the reorg for. The skipped block keeps the hole + // this function is meant to prevent, so the next epoch depends on getSnapshot + // rebuilding the missing snapshot on demand. + if i > 0 && errors.Is(err, errGapBlockStateMissing) { + log.Warn("Skipped gap snapshot of reorged block", "number", newChain[i].Number, "hash", newChain[i].Hash().Hex(), "err", err) + continue + } + log.Error("Fail to store gap snapshot, reorg aborted", "number", newChain[i].Number, "hash", newChain[i].Hash().Hex(), "err", err) + return fmt.Errorf("failed to store gap snapshot of block %d (%s) during reorg: %w", newChain[i].Number.Uint64(), newChain[i].Hash().Hex(), err) + } + // Ensure the user sees large reorgs if len(oldChain) > 0 && len(newChain) > 0 { logFn := log.Info @@ -2552,12 +2582,6 @@ func (bc *BlockChain) reorg(oldHead, newHead *types.Header) error { } // Update the head block bc.writeHeadBlock(block, true) - // prepare set of masternodes for the next epoch - if bc.chainConfig.XDPoS != nil && ((block.NumberU64() % bc.chainConfig.XDPoS.Epoch) == (bc.chainConfig.XDPoS.Epoch - bc.chainConfig.XDPoS.Gap)) { - if err := bc.UpdateM1(); err != nil { - log.Crit("Fail to update masternodes during reorg", "number", block.Number, "hash", block.Hash().Hex(), "err", err) - } - } } if len(rebirthLogs) > 0 { bc.logsFeed.Send(rebirthLogs) @@ -2719,75 +2743,86 @@ func (bc *BlockChain) GetClient() (bind.ContractBackend, error) { return bc.Client, nil } -func (bc *BlockChain) UpdateM1() error { +// xdposEngine returns the XDPoS engine if this chain runs on it. +func (bc *BlockChain) xdposEngine() (*XDPoS.XDPoS, bool) { engine, ok := bc.Engine().(*XDPoS.XDPoS) - if bc.Config().XDPoS == nil || !ok { - return ErrNotXDPoS + if !ok || bc.chainConfig.XDPoS == nil { + return nil, false } - log.Info("It's time to update new set of masternodes for the next epoch...") - // get masternodes information from smart contract - client, err := bc.GetClient() - if err != nil { - return fmt.Errorf("failed to get client: %w", err) + return engine, true +} + +// storeGapSnapshot persists the masternode snapshot of a gap block, derived from +// the committed state of that very block. It is a no-op for any other block. +// Callers must invoke it before writeHeadBlock, so that a crash in between can +// never leave the persisted head on a gap block whose snapshot is missing. +func (bc *BlockChain) storeGapSnapshot(header *types.Header) error { + engine, ok := bc.xdposEngine() + if !ok { + return nil } - addr := common.MasternodeVotingSMCBinary - validator, err := contractValidator.NewXDCValidator(addr, client) - if err != nil { - return fmt.Errorf("failed to create validator contract: %w", err) + if header.Number.Uint64()%bc.chainConfig.XDPoS.Epoch != bc.chainConfig.XDPoS.Epoch-bc.chainConfig.XDPoS.Gap { + return nil } - opts := new(bind.CallOpts) - - var candidates []common.Address - // get candidates from slot of stateDB - // if can't get anything, request from contracts - stateDB, err := bc.State() + // A fresh StateDB from the committed root avoids depending on the importer's + // post-Commit statedb object, whose internal caches may have been cleared. + statedb, err := bc.StateAt(header.Root) if err != nil { - candidates, err = validator.GetCandidates(opts) - if err != nil { - return err - } - } else if stateDB == nil { - return errors.New("nil stateDB in UpdateM1") - } else { - candidates = stateDB.GetCandidates() + return fmt.Errorf("%w: %w", errGapBlockStateMissing, err) + } + if err := bc.updateM1ForBlock(engine, header, statedb); err != nil { + return fmt.Errorf("%w: %w", utils.ErrGapSnapshotUnavailable, err) } + return nil +} + +// updateM1ForBlock computes the masternode candidate set for a gap block from the +// given committed state, then asks the consensus engine to update/store the +// corresponding snapshot. It does not depend on bc.CurrentBlock() or +// bc.CurrentHeader(), so it can safely be called before writeHeadBlock. +func (bc *BlockChain) updateM1ForBlock(engine *XDPoS.XDPoS, header *types.Header, statedb *state.StateDB) error { + log.Info("It's time to update new set of masternodes for the next epoch...", "number", header.Number, "hash", header.Hash().Hex()) var ms []utils.Masternode - for _, candidate := range candidates { - v, err := validator.GetCandidateCap(opts, candidate) - if err != nil { - return err - } - // TODO: smart contract shouldn't return "0x0000000000000000000000000000000000000000" - if !candidate.IsZero() { - ms = append(ms, utils.Masternode{Address: candidate, Stake: v}) + for _, candidate := range statedb.GetCandidates() { + // GetCandidates only skips zero-valued slots, a malformed entry can still decode to the zero address. + if candidate.IsZero() { + continue } + ms = append(ms, utils.Masternode{Address: candidate, Stake: statedb.GetCandidateCap(candidate)}) } if len(ms) == 0 { - log.Error("No masternode found. Stopping node") return errors.New("no masternode found") - } else { - xdc_sort.Slice(ms, func(i, j int) bool { - return ms[i].Stake.Cmp(ms[j].Stake) >= 0 - }) - log.Info("Ordered list of masternode candidates") - for _, m := range ms { - log.Info("", "address", m.Address, "stake", m.Stake) - } - // update masternodes - - log.Info("Updating new set of masternodes") - // get block header - header := bc.CurrentHeader() - err = engine.UpdateMasternodes(bc, header, ms) - if err != nil { - return err - } - log.Info("Masternodes are ready for the next epoch") } + xdc_sort.Slice(ms, func(i, j int) bool { + return ms[i].Stake.Cmp(ms[j].Stake) >= 0 + }) + log.Info("Ordered list of masternode candidates") + for _, m := range ms { + log.Info("", "address", m.Address, "stake", m.Stake) + } + log.Info("Updating new set of masternodes") + if err := engine.UpdateMasternodes(bc, header, ms); err != nil { + return err + } + log.Info("Masternodes are ready for the next epoch") return nil } +// UpdateM1 stores the masternode snapshot derived from the current chain head. +func (bc *BlockChain) UpdateM1() error { + engine, ok := bc.xdposEngine() + if !ok { + return ErrNotXDPoS + } + head := bc.CurrentBlock() + statedb, err := bc.StateAt(head.Root) + if err != nil { + return fmt.Errorf("failed to open head state for UpdateM1: %w", err) + } + return bc.updateM1ForBlock(engine, head, statedb) +} + func (bc *BlockChain) AddMatchingResult(txHash common.Hash, matchingResults map[common.Hash]tradingstate.MatchingResult) { for hash, result := range matchingResults { cacheKey := crypto.Keccak256Hash(txHash.Bytes(), hash.Bytes()) diff --git a/core/blockchain_xdpos_gap_test.go b/core/blockchain_xdpos_gap_test.go new file mode 100644 index 000000000000..c7933f5f9685 --- /dev/null +++ b/core/blockchain_xdpos_gap_test.go @@ -0,0 +1,558 @@ +// Copyright 2025 The XDPoSChain Authors +// This file is part of the XDPoSChain library. +// +// The XDPoSChain library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The XDPoSChain library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the XDPoSChain library. If not, see . + +package core + +import ( + "bytes" + "errors" + "math/big" + "strings" + "sync" + "testing" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS" + "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS/utils" + "github.com/XinFinOrg/XDPoSChain/core/rawdb" + "github.com/XinFinOrg/XDPoSChain/core/state" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/core/vm" + "github.com/XinFinOrg/XDPoSChain/crypto" + "github.com/XinFinOrg/XDPoSChain/ethdb" + "github.com/XinFinOrg/XDPoSChain/params" +) + +// Slot layout of the masternode voting smart contract, mirrored from +// core/state/statedb_utils.go. +const ( + votingSCCandidatesSlot = 8 + votingSCValidatorsStateSlot = 1 +) + +// keyCapture records the key a rawdb writer emits, so tests can learn raw +// database keys without duplicating the private schema of core/rawdb. +type keyCapture struct { + key []byte +} + +func (c *keyCapture) Put(key []byte, value []byte) error { + c.key = common.CopyBytes(key) + return nil +} + +func (c *keyCapture) Delete(key []byte) error { return nil } + +func dbKey(write func(db ethdb.KeyValueWriter)) []byte { + capture := new(keyCapture) + write(capture) + return capture.key +} + +func xdposV1SnapshotKey(hash common.Hash) []byte { + return dbKey(func(db ethdb.KeyValueWriter) { + _ = rawdb.WriteXdposV1Snapshot(db, hash, nil) + }) +} + +func xdposV2SnapshotKey(hash common.Hash) []byte { + return dbKey(func(db ethdb.KeyValueWriter) { + _ = rawdb.WriteXdposV2Snapshot(db, hash, nil) + }) +} + +func headBlockMarkerKey() []byte { + return dbKey(func(db ethdb.KeyValueWriter) { + rawdb.WriteHeadBlockHash(db, common.Hash{}) + }) +} + +// writeRecorder wraps a database and records, in order, every key/value pair +// that reaches the disk, either directly or through a batch. It lets tests +// assert the relative ordering of writes issued by different components. +type writeRecorder struct { + ethdb.Database + + lock sync.Mutex + writes []recordedWrite +} + +type recordedWrite struct { + key []byte + value []byte +} + +func (r *writeRecorder) Put(key []byte, value []byte) error { + if err := r.Database.Put(key, value); err != nil { + return err + } + r.append(recordedWrite{key: common.CopyBytes(key), value: common.CopyBytes(value)}) + return nil +} + +func (r *writeRecorder) NewBatch() ethdb.Batch { + return &recordingBatch{Batch: r.Database.NewBatch(), recorder: r} +} + +func (r *writeRecorder) NewBatchWithSize(size int) ethdb.Batch { + return &recordingBatch{Batch: r.Database.NewBatchWithSize(size), recorder: r} +} + +func (r *writeRecorder) append(writes ...recordedWrite) { + r.lock.Lock() + defer r.lock.Unlock() + r.writes = append(r.writes, writes...) +} + +func (r *writeRecorder) reset() { + r.lock.Lock() + defer r.lock.Unlock() + r.writes = nil +} + +// indexOf returns the position of the first recorded write of the given key, +// optionally restricted to a specific value, or -1 if there is none. +func (r *writeRecorder) indexOf(key []byte, value []byte) int { + r.lock.Lock() + defer r.lock.Unlock() + for i, write := range r.writes { + if !bytes.Equal(write.key, key) { + continue + } + if value != nil && !bytes.Equal(write.value, value) { + continue + } + return i + } + return -1 +} + +type recordingBatch struct { + ethdb.Batch + + recorder *writeRecorder + pending []recordedWrite +} + +func (b *recordingBatch) Put(key []byte, value []byte) error { + if err := b.Batch.Put(key, value); err != nil { + return err + } + b.pending = append(b.pending, recordedWrite{key: common.CopyBytes(key), value: common.CopyBytes(value)}) + return nil +} + +func (b *recordingBatch) Write() error { + if err := b.Batch.Write(); err != nil { + return err + } + b.recorder.append(b.pending...) + b.pending = nil + return nil +} + +func (b *recordingBatch) Reset() { + b.Batch.Reset() + b.pending = nil +} + +var ( + gapTestSealKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + gapTestCandidates = []common.Address{ + common.HexToAddress("0x1000000000000000000000000000000000000001"), + common.HexToAddress("0x1000000000000000000000000000000000000002"), + common.HexToAddress("0x1000000000000000000000000000000000000003"), + } +) + +// gapTestChainConfig shrinks the epoch so that block 1 is a gap block, keeping +// the generated test chains short. Gap blocks satisfy number%epoch == epoch-gap. +func gapTestChainConfig() *params.ChainConfig { + config := *params.TestXDPoSMockChainConfig + xdpos := *config.XDPoS + xdpos.Epoch = 4 + xdpos.Gap = 3 + xdpos.V2 = xdpos.V2.Clone() + config.XDPoS = &xdpos + return &config +} + +// drainCheckpointCh consumes the unbuffered epoch-switch notifications that +// insertChain emits, which would otherwise deadlock chains crossing an epoch. +// CheckpointCh is a package global, so no other test may read from it while this +// drainer is running. +func drainCheckpointCh(t *testing.T) { + t.Helper() + + done := make(chan struct{}) + t.Cleanup(func() { close(done) }) + go func() { + for { + select { + case <-CheckpointCh: + case <-done: + return + } + } + }() +} + +// votingSCStorage builds the raw storage of the masternode voting contract so +// that StateDB.GetCandidates/GetCandidateCap resolve the given candidates. +func votingSCStorage(candidates []common.Address) map[common.Hash]common.Hash { + storage := make(map[common.Hash]common.Hash) + slotHash := common.BigToHash(new(big.Int).SetUint64(votingSCCandidatesSlot)) + storage[slotHash] = common.BigToHash(new(big.Int).SetUint64(uint64(len(candidates)))) + for i, candidate := range candidates { + storage[state.GetLocDynamicArrAtElement(slotHash, uint64(i), 1)] = candidate.Hash() + capLoc := state.GetLocMappingAtKey(candidate.Hash(), votingSCValidatorsStateSlot) + capLoc.Add(capLoc, common.Big1) + storage[common.BigToHash(capLoc)] = common.BigToHash(big.NewInt(int64(1000 + i))) + } + return storage +} + +func gapTestGenesis(config *params.ChainConfig, candidates []common.Address) *Genesis { + extra := make([]byte, 32) + for _, candidate := range candidates { + extra = append(extra, candidate.Bytes()...) + } + extra = append(extra, make([]byte, crypto.SignatureLength)...) + + return &Genesis{ + Config: config, + ExtraData: extra, + GasLimit: 10000000, + BaseFee: big.NewInt(params.InitialBaseFee), + Alloc: types.GenesisAlloc{ + common.MasternodeVotingSMCBinary: { + Balance: big.NewInt(1), + Storage: votingSCStorage(candidates), + }, + }, + } +} + +// gapTestExtra returns a header extra field carrying a well formed seal, which +// the V1 snapshot machinery needs to recover a signer from the header. +func gapTestExtra(vanity byte) []byte { + extra := make([]byte, 32) + extra[0] = vanity + sig, err := crypto.Sign(common.Hash{}.Bytes(), gapTestSealKey) + if err != nil { + panic(err) + } + return append(extra, sig...) +} + +// gapTestChain generates blocks on top of the given genesis, tagging them with +// the given vanity byte so that competing chains end up with distinct hashes. +func gapTestChain(t *testing.T, gspec *Genesis, blocks int, vanity byte, gen func(int, *BlockGen)) []*types.Block { + t.Helper() + + engine := XDPoS.NewFaker(rawdb.NewMemoryDatabase(), gspec.Config) + if engine == nil { + t.Fatal("failed to create fake XDPoS engine") + } + _, chain, _ := GenerateChainWithGenesis(gspec, engine, blocks, func(i int, block *BlockGen) { + block.SetExtra(gapTestExtra(vanity)) + if gen != nil { + gen(i, block) + } + }) + return chain +} + +func newGapTestBlockChain(t *testing.T, gspec *Genesis) (*BlockChain, *writeRecorder) { + t.Helper() + + db := &writeRecorder{Database: rawdb.NewMemoryDatabase()} + engine := XDPoS.NewFaker(db, gspec.Config) + if engine == nil { + t.Fatal("failed to create fake XDPoS engine") + } + chain, err := NewBlockChain(db, nil, gspec, engine, vm.Config{}) + if err != nil { + t.Fatalf("failed to create blockchain: %v", err) + } + t.Cleanup(chain.Stop) + return chain, db +} + +// TestGapBlockSnapshotStoredBeforeHead asserts that importing a canonical gap +// block persists the masternode snapshot before the head markers, so a crash in +// between can never leave the head pointing at a gap block without a snapshot. +func TestGapBlockSnapshotStoredBeforeHead(t *testing.T) { + gspec := gapTestGenesis(gapTestChainConfig(), gapTestCandidates) + blocks := gapTestChain(t, gspec, 1, 0xaa, nil) + gapBlock := blocks[0] + + chain, db := newGapTestBlockChain(t, gspec) + db.reset() + if _, err := chain.InsertChain(blocks); err != nil { + t.Fatalf("failed to insert gap block: %v", err) + } + if head := chain.CurrentBlock().Hash(); head != gapBlock.Hash() { + t.Fatalf("head mismatch: have %x, want %x", head, gapBlock.Hash()) + } + + snapIndex := db.indexOf(xdposV1SnapshotKey(gapBlock.Hash()), nil) + if snapIndex < 0 { + t.Fatal("no snapshot was stored for the canonical gap block") + } + headIndex := db.indexOf(headBlockMarkerKey(), gapBlock.Hash().Bytes()) + if headIndex < 0 { + t.Fatal("head block marker was never written for the gap block") + } + if snapIndex > headIndex { + t.Fatalf("gap snapshot stored after head marker: snapshot at %d, head at %d", snapIndex, headIndex) + } + if _, err := rawdb.ReadXdposV1Snapshot(db, gapBlock.Hash()); err != nil { + t.Fatalf("gap snapshot is not readable after import: %v", err) + } +} + +// TestGapBlockSnapshotStoredBeforeHeadV2 pins the same ordering for the V2 +// engine, whose snapshots live under a different database key and whose loss is +// what drops a node out of consensus. A V2 header carries a quorum certificate +// that cannot be produced here, so an empty block is handed to +// WriteBlockWithState directly instead of going through header verification. +func TestGapBlockSnapshotStoredBeforeHeadV2(t *testing.T) { + config := gapTestChainConfig() + config.XDPoS.V2.SwitchBlock = big.NewInt(0) + + gspec := gapTestGenesis(config, gapTestCandidates) + chain, db := newGapTestBlockChain(t, gspec) + + parent := chain.Genesis().Header() + gapBlock := types.NewBlockWithHeader(&types.Header{ + ParentHash: parent.Hash(), + Number: new(big.Int).Add(parent.Number, common.Big1), + Root: parent.Root, + GasLimit: parent.GasLimit, + Time: parent.Time + 1, + Difficulty: big.NewInt(1), + BaseFee: new(big.Int).Set(parent.BaseFee), + }) + statedb, err := chain.StateAt(parent.Root) + if err != nil { + t.Fatalf("failed to open genesis state: %v", err) + } + db.reset() + status, err := chain.WriteBlockWithState(gapBlock, nil, statedb, nil, nil) + if err != nil { + t.Fatalf("failed to write gap block: %v", err) + } + if status != CanonStatTy { + t.Fatalf("gap block did not become canonical: status %v", status) + } + + snapIndex := db.indexOf(xdposV2SnapshotKey(gapBlock.Hash()), nil) + if snapIndex < 0 { + t.Fatal("no V2 snapshot was stored for the canonical gap block") + } + headIndex := db.indexOf(headBlockMarkerKey(), gapBlock.Hash().Bytes()) + if headIndex < 0 { + t.Fatal("head block marker was never written for the gap block") + } + if snapIndex > headIndex { + t.Fatalf("gap snapshot stored after head marker: snapshot at %d, head at %d", snapIndex, headIndex) + } +} + +// TestGapBlockSnapshotStoredBeforeHeadOnReorg asserts the same ordering for gap +// blocks that only become canonical through a reorg. +func TestGapBlockSnapshotStoredBeforeHeadOnReorg(t *testing.T) { + gspec := gapTestGenesis(gapTestChainConfig(), gapTestCandidates) + canonical := gapTestChain(t, gspec, 1, 0xaa, nil) + side := gapTestChain(t, gspec, 2, 0xbb, nil) + sideGapBlock := side[0] + + chain, db := newGapTestBlockChain(t, gspec) + if _, err := chain.InsertChain(canonical); err != nil { + t.Fatalf("failed to insert canonical gap block: %v", err) + } + db.reset() + if _, err := chain.InsertChain(side); err != nil { + t.Fatalf("failed to insert side chain: %v", err) + } + if head := chain.CurrentBlock().Hash(); head != side[len(side)-1].Hash() { + t.Fatalf("reorg did not happen: head is %x", head) + } + + snapIndex := db.indexOf(xdposV1SnapshotKey(sideGapBlock.Hash()), nil) + if snapIndex < 0 { + t.Fatal("no snapshot was stored for the reorged gap block") + } + headIndex := db.indexOf(headBlockMarkerKey(), sideGapBlock.Hash().Bytes()) + if headIndex < 0 { + t.Fatal("head block marker was never written for the reorged gap block") + } + if snapIndex > headIndex { + t.Fatalf("reorged gap snapshot stored after head marker: snapshot at %d, head at %d", snapIndex, headIndex) + } +} + +// TestGapBlockHeadNotAdvancedWithoutSnapshot asserts that a gap block whose +// masternode set cannot be derived is rejected instead of becoming the head +// with no snapshot behind it. +func TestGapBlockHeadNotAdvancedWithoutSnapshot(t *testing.T) { + gspec := gapTestGenesis(gapTestChainConfig(), nil) + blocks := gapTestChain(t, gspec, 1, 0xaa, nil) + gapBlock := blocks[0] + + chain, db := newGapTestBlockChain(t, gspec) + genesisHash := chain.Genesis().Hash() + _, err := chain.InsertChain(blocks) + if err == nil { + t.Fatal("expected gap block import to fail without masternode candidates") + } + if !errors.Is(err, utils.ErrGapSnapshotUnavailable) { + t.Fatalf("import error does not report a local snapshot failure: %v", err) + } + if head := chain.CurrentBlock().Hash(); head != genesisHash { + t.Fatalf("head advanced to a gap block without snapshot: have %x, want %x", head, genesisHash) + } + if head := rawdb.ReadHeadBlockHash(db); head != genesisHash { + t.Fatalf("persisted head marker advanced: have %x, want %x", head, genesisHash) + } + if _, err := rawdb.ReadXdposV1Snapshot(db, gapBlock.Hash()); err == nil { + t.Fatal("snapshot unexpectedly stored for the rejected gap block") + } +} + +// gapTestWipeGenesis returns a genesis whose voting contract clears its +// candidate list when it receives a plain call, so a later gap block can no +// longer derive a masternode set. +func gapTestWipeGenesis() *Genesis { + gspec := gapTestGenesis(gapTestChainConfig(), gapTestCandidates) + gspec.Alloc[crypto.PubkeyToAddress(gapTestSealKey.PublicKey)] = types.Account{Balance: big.NewInt(1e18)} + votingSC := gspec.Alloc[common.MasternodeVotingSMCBinary] + // sstore(candidates slot, 0): wipes the candidate list when called. + votingSC.Code = common.FromHex("600060085500") + gspec.Alloc[common.MasternodeVotingSMCBinary] = votingSC + return gspec +} + +// gapTestWipeTx is the call that triggers the candidate wipe. +func gapTestWipeTx(config *params.ChainConfig) *types.Transaction { + votingSCAddr := common.MasternodeVotingSMCBinary + return types.MustSignNewTx(gapTestSealKey, types.LatestSigner(config), &types.LegacyTx{ + Nonce: 0, + To: &votingSCAddr, + Gas: 100000, + GasPrice: big.NewInt(params.InitialBaseFee), + }) +} + +// TestGapBlockReorgAbortedWithoutSnapshot asserts that a reorg onto a gap block +// whose masternode set cannot be derived is aborted with an error instead of +// killing the node, leaving the head on a chain that still has a snapshot. +func TestGapBlockReorgAbortedWithoutSnapshot(t *testing.T) { + gspec := gapTestWipeGenesis() + canonical := gapTestChain(t, gspec, 1, 0xaa, nil) + side := gapTestChain(t, gspec, 2, 0xbb, func(i int, block *BlockGen) { + if i == 0 { + block.AddTx(gapTestWipeTx(gspec.Config)) + } + }) + + chain, db := newGapTestBlockChain(t, gspec) + if _, err := chain.InsertChain(canonical); err != nil { + t.Fatalf("failed to insert canonical gap block: %v", err) + } + _, err := chain.InsertChain(side) + if err == nil { + t.Fatal("expected reorg onto a gap block without masternode candidates to fail") + } + if !strings.Contains(err.Error(), "during reorg") { + t.Fatalf("import failed outside of the reorg gap handling: %v", err) + } + if !errors.Is(err, utils.ErrGapSnapshotUnavailable) { + t.Fatalf("reorg error does not report a local snapshot failure: %v", err) + } + if head := chain.CurrentBlock().Hash(); head != canonical[0].Hash() { + t.Fatalf("head moved to a gap block without snapshot: have %x, want %x", head, canonical[0].Hash()) + } + if _, err := rawdb.ReadXdposV1Snapshot(db, canonical[0].Hash()); err != nil { + t.Fatalf("snapshot of the retained head is missing: %v", err) + } + if _, err := rawdb.ReadXdposV1Snapshot(db, side[0].Hash()); err == nil { + t.Fatal("snapshot unexpectedly stored for the rejected gap block") + } +} + +// TestGapBlockReorgAbortedLeavesChainUntouched asserts that a reorg aborted on a +// gap block applies no part of the new chain: the head and the canonical number +// markers of the old chain all survive, so no block number can resolve to an +// abandoned block. +func TestGapBlockReorgAbortedLeavesChainUntouched(t *testing.T) { + drainCheckpointCh(t) + + gspec := gapTestWipeGenesis() + canonical := gapTestChain(t, gspec, 5, 0xaa, nil) + // Wiping the candidates in block 2 keeps gap block 1 of the new chain valid + // while gap block 5 can no longer derive a masternode set, so the reorg is + // aborted before it mutates anything. + side := gapTestChain(t, gspec, 6, 0xbb, func(i int, block *BlockGen) { + if i == 1 { + block.AddTx(gapTestWipeTx(gspec.Config)) + } + }) + + chain, db := newGapTestBlockChain(t, gspec) + if _, err := chain.InsertChain(canonical); err != nil { + t.Fatalf("failed to insert canonical chain: %v", err) + } + if _, err := chain.InsertChain(side); err == nil { + t.Fatal("expected the reorg onto the candidate-less gap block to fail") + } + head := chain.CurrentBlock() + if want := canonical[len(canonical)-1]; head.Hash() != want.Hash() { + t.Fatalf("head moved during the aborted reorg: have #%d %x, want #%d %x", head.Number, head.Hash(), want.NumberU64(), want.Hash()) + } + for i, block := range canonical { + if hash := rawdb.ReadCanonicalHash(db, uint64(i+1)); hash != block.Hash() { + t.Fatalf("canonical marker at #%d changed: have %x, want %x", i+1, hash, block.Hash()) + } + } + if hash := rawdb.ReadCanonicalHash(db, uint64(len(canonical))+1); hash != (common.Hash{}) { + t.Fatalf("orphan canonical marker left above the head: %x", hash) + } +} + +// TestUpdateM1WithoutContractClient asserts that UpdateM1 derives the masternode +// set from the head state and no longer needs an RPC client to call the voting +// contract. +func TestUpdateM1WithoutContractClient(t *testing.T) { + gspec := gapTestGenesis(gapTestChainConfig(), gapTestCandidates) + blocks := gapTestChain(t, gspec, 1, 0xaa, nil) + gapBlock := blocks[0] + + chain, db := newGapTestBlockChain(t, gspec) + if _, err := chain.InsertChain(blocks); err != nil { + t.Fatalf("failed to insert gap block: %v", err) + } + if chain.Client != nil { + t.Fatal("test expects a blockchain without contract client") + } + if err := chain.UpdateM1(); err != nil { + t.Fatalf("UpdateM1 failed without contract client: %v", err) + } + if _, err := rawdb.ReadXdposV1Snapshot(db, gapBlock.Hash()); err != nil { + t.Fatalf("UpdateM1 did not store a snapshot for the head gap block: %v", err) + } +} diff --git a/core/error.go b/core/error.go index 87bde0c6bd79..a96fcc563bf6 100644 --- a/core/error.go +++ b/core/error.go @@ -18,7 +18,9 @@ package core import ( "errors" + "fmt" + "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS/utils" "github.com/XinFinOrg/XDPoSChain/core/types" ) @@ -109,6 +111,10 @@ var ( ErrNotFoundM1 = errors.New("list M1 not found ") ErrStopPreparingBlock = errors.New("stop calculating a block not verified by M2") + // errGapBlockStateMissing reports that the state of a gap block is gone, which + // happens for old side chain blocks whose tries have already been pruned. + errGapBlockStateMissing = fmt.Errorf("%w: state unavailable", utils.ErrGapSnapshotUnavailable) + // -- EIP-7702 errors -- // Message validation errors: diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index e901d4439632..25730b05f687 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -1606,6 +1606,11 @@ func (d *Downloader) importBlockResults(results []*fetchResult) error { // of the blocks delivered from the downloader, and the indexing will be off. log.Debug("Downloaded item processing failed on sidechain import", "index", index, "err", err) } + if errors.Is(err, utils.ErrGapSnapshotUnavailable) { + // The blocks are valid, only the local snapshot derivation failed. Retry the + // sync later instead of dropping the peer that delivered them. + return err + } return fmt.Errorf("%w: %v", errInvalidChain, err) } if d.handleProposedBlock != nil {