diff --git a/core/blockchain.go b/core/blockchain.go index ca337fb42a4f..b1aba99f0ece 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -736,12 +736,23 @@ func (bc *BlockChain) loadLastState() error { headerTd = bc.GetTd(headHeader.Hash(), headHeader.Number.Uint64()) blockTd = bc.GetTd(headBlock.Hash(), headBlock.NumberU64()) ) + // Legacy chaindata can leave the head without a TD entry: display a zero + // value rather than the "" of a nil pointer. + if headerTd == nil { + headerTd = common.Big0 + } + if blockTd == nil { + blockTd = common.Big0 + } if headHeader.Hash() != headBlock.Hash() { log.Info("Loaded most recent local header", "number", headHeader.Number, "hash", headHeader.Hash(), "td", headerTd, "age", common.PrettyAge(time.Unix(int64(headHeader.Time), 0))) } log.Info("Loaded most recent local block", "number", headBlock.Number(), "hash", headBlock.Hash(), "td", blockTd, "age", common.PrettyAge(time.Unix(int64(headBlock.Time()), 0))) if headBlock.Hash() != currentSnapBlock.Hash() { fastTd := bc.GetTd(currentSnapBlock.Hash(), currentSnapBlock.Number.Uint64()) + if fastTd == nil { + fastTd = common.Big0 + } log.Info("Loaded most recent local snap block", "number", currentSnapBlock.Number, "hash", currentSnapBlock.Hash(), "td", fastTd, "age", common.PrettyAge(time.Unix(int64(currentSnapBlock.Time), 0))) } @@ -1439,13 +1450,19 @@ func (bc *BlockChain) InsertReceiptChain(blockChain types.Blocks, receiptChain [ return 0, errChainStopped } head := blockChain[len(blockChain)-1] - if td := bc.GetTd(head.Hash(), head.NumberU64()); td != nil { // Rewind may have occurred, skip in that case - currentSnapBlock := bc.CurrentSnapBlock() - if bc.GetTd(currentSnapBlock.Hash(), currentSnapBlock.Number.Uint64()).Cmp(td) < 0 { - rawdb.WriteHeadFastBlockHash(bc.db, head.Hash()) - bc.currentSnapBlock.Store(head.Header()) - headFastBlockGauge.Update(int64(head.NumberU64())) - } + currentSnapBlock := bc.CurrentSnapBlock() + // The receipt data above is written without chainmu, so a concurrent + // rewind (SetHead) may have deleted the batch head's header, TD and + // canonical-hash markers before the lock was acquired. Such a stale head + // has a nil TD and would win the height fallback below, repointing the + // snap marker above the rewind. Require the head to still be the + // canonical header at its height: a legacy canonical head with a missing + // TD index passes this check and can still advance the snap block. + canonical := bc.GetHeaderByNumber(head.NumberU64()) + if canonical != nil && canonical.Hash() == head.Hash() && forkChoiceCmp(bc.chainConfig, head.NumberU64(), bc.GetTd(head.Hash(), head.NumberU64()), currentSnapBlock.Number.Uint64(), bc.GetTd(currentSnapBlock.Hash(), currentSnapBlock.Number.Uint64())) > 0 { + rawdb.WriteHeadFastBlockHash(bc.db, head.Hash()) + bc.currentSnapBlock.Store(head.Header()) + headFastBlockGauge.Update(int64(head.NumberU64())) } bc.chainmu.Unlock() @@ -1473,7 +1490,9 @@ func (bc *BlockChain) writeBlockWithoutState(block *types.Block, td *big.Int) (e } batch := bc.db.NewBatch() - rawdb.WriteTd(batch, block.Hash(), block.NumberU64(), td) + if td != nil { + rawdb.WriteTd(batch, block.Hash(), block.NumberU64(), td) + } rawdb.WriteBlock(batch, block) if err := batch.Write(); err != nil { log.Crit("Failed to write block into disk", "err", err) @@ -1497,22 +1516,31 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. return NonStatTy, errInsertionInterrupted } - // Calculate the total difficulty of the block - ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1) - if ptd == nil { + // Calculate the total difficulty of the block. The parent header must + // exist for the chain to be importable, but its total difficulty may be + // unknown on legacy XDPoS chaindata that predates the TD index. The block + // is then stored without a TD entry and the fork choice falls back to a + // height comparison. + if bc.GetHeader(block.ParentHash(), block.NumberU64()-1) == nil { return NonStatTy, consensus.ErrUnknownAncestor } + ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1) // Make sure no inconsistent state is leaked during insertion currentBlock := bc.CurrentBlock() localTd := bc.GetTd(currentBlock.Hash(), currentBlock.Number.Uint64()) - externTd := new(big.Int).Add(block.Difficulty(), ptd) + var externTd *big.Int + if ptd != nil { + externTd = new(big.Int).Add(block.Difficulty(), ptd) + } // Irrelevant of the canonical status, write the block itself to the database. // // Note all the components of block(td, hash->number map, header, body, receipts) // should be written atomically. BlockBatch is used for containing all components. blockBatch := bc.db.NewBatch() - rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd) + if externTd != nil { + rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd) + } rawdb.WriteBlock(blockBatch, block) rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts) rawdb.WritePreimages(blockBatch, state.Preimages()) @@ -1664,13 +1692,14 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types. } } - // If the total difficulty is higher than our known, add it to the canonical chain + // If the fork-choice weight is higher than our known, add it to the canonical chain // 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 + cmp := forkChoiceCmp(bc.chainConfig, block.NumberU64(), externTd, currentBlock.Number.Uint64(), localTd) + reorg := cmp > 0 currentBlock = bc.CurrentBlock() - if !reorg && externTd.Cmp(localTd) == 0 { - // Split same-difficulty blocks by number + if !reorg && cmp == 0 { + // Split equal-weight blocks by number reorg = block.NumberU64() > currentBlock.Number.Uint64() } if reorg { @@ -1976,6 +2005,13 @@ func (bc *BlockChain) processBlock(block *types.Block, parent *types.Header, sta // TODO(daniel): implement CurrentFinalBlock() and CurrentSafeBlock(), ref PR #29189 if bc.logger != nil && bc.logger.OnBlockStart != nil { td := bc.GetTd(block.ParentHash(), block.NumberU64()-1) + // Legacy chaindata predating the TD index can leave the parent TD + // missing: report a zero value to block tracer hooks, whose + // implementations may dereference the difficulty. Use a fresh value + // rather than the shared common.Big0, which hooks could mutate. + if td == nil { + td = new(big.Int) + } bc.logger.OnBlockStart(tracing.BlockEvent{ Block: block, TD: td, @@ -2084,9 +2120,13 @@ func (bc *BlockChain) insertSidechain(block *types.Block, it *insertIterator) (i } } if externTd == nil { - externTd = bc.GetTd(block.ParentHash(), block.NumberU64()-1) + if ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1); ptd != nil { + externTd = ptd + } + } + if externTd != nil { + externTd = new(big.Int).Add(externTd, block.Difficulty()) } - externTd = new(big.Int).Add(externTd, block.Difficulty()) if !bc.HasBlock(block.Hash(), block.NumberU64()) { start := time.Now() @@ -2106,7 +2146,7 @@ func (bc *BlockChain) insertSidechain(block *types.Block, it *insertIterator) (i // If the externTd was larger than our local TD, we now need to reimport the previous // blocks to regenerate the required state localTd := bc.GetTd(bc.CurrentBlock().Hash(), current) - if localTd.Cmp(externTd) > 0 { + if forkChoiceCmp(bc.chainConfig, it.previous().Number.Uint64(), externTd, current, localTd) < 0 { log.Info("Sidechain written to disk", "start", it.first().NumberU64(), "end", it.previous().Number, "sidetd", externTd, "localtd", localTd) return it.index, nil, nil, err } @@ -2233,11 +2273,14 @@ func (bc *BlockChain) getResultBlock(block *types.Block, verifiedM2 bool) (*Resu } case err == consensus.ErrPrunedAncestor: // Block competing with the canonical chain, store in the db, but don't process - // until the competitor TD goes above the canonical TD + // until the competitor fork-choice weight goes above the canonical one currentBlock := bc.CurrentBlock() localTd := bc.GetTd(currentBlock.Hash(), currentBlock.Number.Uint64()) - externTd := new(big.Int).Add(bc.GetTd(block.ParentHash(), block.NumberU64()-1), block.Difficulty()) - if localTd.Cmp(externTd) > 0 { + var externTd *big.Int + if ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1); ptd != nil { + externTd = new(big.Int).Add(ptd, block.Difficulty()) + } + if forkChoiceCmp(bc.chainConfig, block.NumberU64(), externTd, currentBlock.Number.Uint64(), localTd) < 0 { return nil, err } // Competitor chain beat canonical, gather all blocks from the common ancestor diff --git a/core/blockchain_td_test.go b/core/blockchain_td_test.go new file mode 100644 index 000000000000..facbac08b1fc --- /dev/null +++ b/core/blockchain_td_test.go @@ -0,0 +1,323 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package core + +import ( + "math/big" + "testing" + + "github.com/XinFinOrg/XDPoSChain/consensus" + "github.com/XinFinOrg/XDPoSChain/consensus/ethash" + "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" +) + +// newTdTestChain creates a header-only chain of n blocks whose config switches +// to XDPoS v2 strictly above switchBlock. With full=true a full block chain is +// injected instead. +func newTdTestChain(t *testing.T, n int, switchBlock int64, full bool) (*BlockChain, ethdb.Database) { + t.Helper() + + genesis := &Genesis{ + BaseFee: big.NewInt(params.InitialBaseFee), + ExtraData: make([]byte, 32+crypto.SignatureLength), // XDPoS genesis needs a signer slot + Config: func() *params.ChainConfig { + cfg := params.TestXDPoSMockChainConfig.Clone() + cfg.XDPoS.V2.SwitchBlock = big.NewInt(switchBlock) + return cfg + }(), + } + engine := ethash.NewFaker() + blockchain, err := NewBlockChain(rawdb.NewMemoryDatabase(), nil, genesis, engine, vm.Config{}) + if err != nil { + t.Fatalf("failed to create test chain: %v", err) + } + t.Cleanup(func() { blockchain.Stop() }) + + if full { + genDb, blocks := makeBlockChainWithGenesis(genesis, n, engine, canonicalSeed) + if _, err := blockchain.InsertChain(blocks); err != nil { + t.Fatalf("failed to seed test chain: %v", err) + } + return blockchain, genDb + } + genDb, headers := makeHeaderChainWithGenesis(genesis, n, engine, canonicalSeed) + if _, err := blockchain.InsertHeaderChain(headers, 1); err != nil { + t.Fatalf("failed to seed test chain: %v", err) + } + return blockchain, genDb +} + +// TestWriteHeaderExtendsMissingParentTd verifies that a chain head without a +// TD entry (legacy chaindata predating the TD index) no longer blocks header +// insertion: the parent header exists, so the header is written without a TD +// entry and the fork choice falls back to a height comparison. +func TestWriteHeaderExtendsMissingParentTd(t *testing.T) { + for _, tc := range []struct { + name string + switchBlock int64 // 900: all blocks v1, 0: all blocks v2 + }{ + {"v1 region", 900}, + {"v2 region", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + blockchain, genDb := newTdTestChain(t, 10, tc.switchBlock, false) + + // Drop the head's TD entry to simulate legacy chaindata. + head := blockchain.CurrentHeader() + rawdb.DeleteTd(blockchain.db, head.Hash(), head.Number.Uint64()) + blockchain.hc.tdCache.Purge() + + // Generate one more header extending the head. + chain := makeHeaderChain(blockchain.chainConfig, head, 1, ethash.NewFaker(), genDb, forkSeed) + if _, err := blockchain.InsertHeaderChain(chain, 1); err != nil { + t.Fatalf("extending a head without TD failed: %v", err) + } + newHead := blockchain.CurrentHeader() + if newHead.Number.Uint64() != 11 { + t.Fatalf("expected canonical head to advance to 11, have %d", newHead.Number.Uint64()) + } + // The TD of the new header cannot be computed and must stay absent. + if td := blockchain.GetTd(newHead.Hash(), newHead.Number.Uint64()); td != nil { + t.Fatalf("expected missing TD for extended header, have %v", td) + } + }) + } +} + +// TestWriteBlockWithStateExtendsMissingParentTd verifies the full-block path: +// a head without a TD entry accepts a new block whose TD stays absent. The +// write path is exercised directly because the consensus insertion loop +// requires a real XDPoS engine. +func TestWriteBlockWithStateExtendsMissingParentTd(t *testing.T) { + for _, tc := range []struct { + name string + switchBlock int64 // 900: all blocks v1, 0: all blocks v2 + }{ + {"v1 region", 900}, + {"v2 region", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + blockchain, genDb := newTdTestChain(t, 10, tc.switchBlock, false) + + // Drop the head's TD entry to simulate legacy chaindata. + head := blockchain.CurrentBlock() + rawdb.DeleteTd(blockchain.db, head.Hash(), head.Number.Uint64()) + blockchain.hc.tdCache.Purge() + + // Build the next block directly and run its write path. + chain := makeHeaderChain(blockchain.chainConfig, head, 1, ethash.NewFaker(), genDb, forkSeed) + block := types.NewBlockWithHeader(chain[0]) + statedb, err := state.New(head.Root, blockchain.stateCache) + if err != nil { + t.Fatalf("failed to create parent state: %v", err) + } + status, err := blockchain.writeBlockWithState(block, nil, statedb, nil, nil) + if err != nil { + t.Fatalf("extending a head without TD failed: %v", err) + } + if status != CanonStatTy { + t.Fatalf("expected CanonStatTy, have %v", status) + } + // The TD of the new block cannot be computed and must stay absent. + if td := blockchain.GetTd(block.Hash(), block.NumberU64()); td != nil { + t.Fatalf("expected missing TD for extended block, have %v", td) + } + }) + } +} + +// TestWriteHeaderRejectsMissingParentHeader verifies that the ErrUnknownAncestor +// sentinel is retained for a genuinely unknown parent header, keeping the sync +// gap-detection semantics intact. +func TestWriteHeaderRejectsMissingParentHeader(t *testing.T) { + blockchain, genDb := newTdTestChain(t, 5, 0, false) + + // Generate two headers, skipping the first one: the second has an unknown + // parent from the chain's perspective. + head := blockchain.CurrentHeader() + chain := makeHeaderChain(blockchain.chainConfig, head, 2, ethash.NewFaker(), genDb, forkSeed) + if _, err := blockchain.hc.WriteHeader(chain[1]); err != consensus.ErrUnknownAncestor { + t.Fatalf("expected ErrUnknownAncestor for missing parent header, have %v", err) + } +} + +// TestInsertReceiptChainMissingHeadTd verifies that legacy chaindata with a +// missing head TD no longer freezes the snap block: the fork-choice comparison +// falls back to a height check and the snap head still advances. +func TestInsertReceiptChainMissingHeadTd(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(1000000000000000000) + gspec = &Genesis{ + Alloc: types.GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + Config: params.TestChainConfig, + } + ) + _, blocks, receipts := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 11, nil) + + db := rawdb.NewMemoryDatabase() + chain, err := NewBlockChain(db, nil, gspec, ethash.NewFaker(), vm.Config{}) + if err != nil { + t.Fatalf("failed to create blockchain: %v", err) + } + headers := make([]*types.Header, len(blocks)) + for i, block := range blocks { + headers[i] = block.Header() + } + if n, err := chain.InsertHeaderChain(headers, 1); err != nil { + t.Fatalf("failed to insert headers: n=%d err=%v", n, err) + } + if n, err := chain.InsertReceiptChain(blocks[:10], receipts[:10]); err != nil { + t.Fatalf("failed to insert receipts: n=%d err=%v", n, err) + } + if snap := chain.CurrentSnapBlock().Number.Uint64(); snap != 10 { + t.Fatalf("snap head mismatch before TD deletion: have %d, want 10", snap) + } + // Delete the TDs of the snap head and of the next block to simulate legacy + // chaindata, then reopen with a fresh TD cache so the deletion is visible. + chain.Stop() + rawdb.DeleteTd(db, blocks[9].Hash(), blocks[9].NumberU64()) + rawdb.DeleteTd(db, blocks[10].Hash(), blocks[10].NumberU64()) + + chain, err = NewBlockChain(db, nil, gspec, ethash.NewFaker(), vm.Config{}) + if err != nil { + t.Fatalf("failed to reopen blockchain: %v", err) + } + defer chain.Stop() + + // The nil head TD must not freeze the snap head: the fork-choice comparison + // falls back to a height check and advances the snap block. + if n, err := chain.InsertReceiptChain(blocks[10:], receipts[10:]); err != nil { + t.Fatalf("failed to insert receipts with missing TD: n=%d err=%v", n, err) + } + if snap := chain.CurrentSnapBlock().Number.Uint64(); snap != 11 { + t.Fatalf("snap head mismatch after missing-TD insert: have %d, want 11", snap) + } +} + +// TestInsertReceiptChainSkipsRewoundHead verifies that a receipt batch whose +// head was removed by a concurrent rewind does not repoint the snap marker +// above the rewind. The race is simulated deterministically by removing the +// head's TD and canonical-hash markers, exactly what SetHead deletes when it +// rewinds past the batch head. The header itself is retained so the insertion +// loop passes its header-existence check, reproducing the state seen when the +// rewind interleaves between that check and the snap promotion. +func TestInsertReceiptChainSkipsRewoundHead(t *testing.T) { + var ( + key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291") + address = crypto.PubkeyToAddress(key.PublicKey) + funds = big.NewInt(1000000000000000000) + gspec = &Genesis{ + Alloc: types.GenesisAlloc{address: {Balance: funds}}, + BaseFee: big.NewInt(params.InitialBaseFee), + Config: params.TestChainConfig, + } + ) + _, blocks, receipts := GenerateChainWithGenesis(gspec, ethash.NewFaker(), 11, nil) + + db := rawdb.NewMemoryDatabase() + chain, err := NewBlockChain(db, nil, gspec, ethash.NewFaker(), vm.Config{}) + if err != nil { + t.Fatalf("failed to create blockchain: %v", err) + } + headers := make([]*types.Header, len(blocks)) + for i, block := range blocks { + headers[i] = block.Header() + } + if n, err := chain.InsertHeaderChain(headers, 1); err != nil { + t.Fatalf("failed to insert headers: n=%d err=%v", n, err) + } + if n, err := chain.InsertReceiptChain(blocks[:10], receipts[:10]); err != nil { + t.Fatalf("failed to insert receipts: n=%d err=%v", n, err) + } + if snap := chain.CurrentSnapBlock().Number.Uint64(); snap != 10 { + t.Fatalf("snap head mismatch before rewind simulation: have %d, want 10", snap) + } + // Simulate a concurrent SetHead rewinding past the batch head: delete the + // head's TD and canonical-hash markers and reopen with fresh caches so the + // deletions are visible. + chain.Stop() + rawdb.DeleteTd(db, blocks[10].Hash(), blocks[10].NumberU64()) + rawdb.DeleteCanonicalHash(db, blocks[10].NumberU64()) + + chain, err = NewBlockChain(db, nil, gspec, ethash.NewFaker(), vm.Config{}) + if err != nil { + t.Fatalf("failed to reopen blockchain: %v", err) + } + defer chain.Stop() + + if n, err := chain.InsertReceiptChain(blocks[10:], receipts[10:]); err != nil { + t.Fatalf("failed to insert receipts: n=%d err=%v", n, err) + } + // The rewind-deleted head must not advance the snap marker: it is no + // longer canonical, so the promotion must be skipped. + if snap := chain.CurrentSnapBlock().Number.Uint64(); snap != 10 { + t.Fatalf("snap head mismatch after rewind simulation: have %d, want 10", snap) + } + if hash := rawdb.ReadHeadFastBlockHash(db); hash != blocks[9].Hash() { + t.Fatalf("head fast block hash mismatch: have %x, want %x", hash, blocks[9].Hash()) + } +} + +// TestWriteHeaderTieKeepsCurrentWithoutTds verifies that a competing header at +// the same height as the current head does not win a coin-flip reorganisation +// when the fork-choice weight of either side is unknown (missing TD entries on +// legacy chaindata). The comparison falls back to a height tie, and the +// competing header must deterministically stay on the side chain. +func TestWriteHeaderTieKeepsCurrentWithoutTds(t *testing.T) { + for _, tc := range []struct { + name string + switchBlock int64 // 900: all blocks v1, 0: all blocks v2 + }{ + {"v1 region", 900}, + {"v2 region", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + blockchain, genDb := newTdTestChain(t, 10, tc.switchBlock, false) + + // Drop the TD entries of the head and of its parent to simulate + // legacy chaindata on both sides of the comparison. + head := blockchain.CurrentHeader() + rawdb.DeleteTd(blockchain.db, head.Hash(), head.Number.Uint64()) + rawdb.DeleteTd(blockchain.db, head.ParentHash, head.Number.Uint64()-1) + blockchain.hc.tdCache.Purge() + + // Generate a competing header at the same height extending the + // parent and write it through the header chain. + parent := blockchain.GetHeaderByNumber(head.Number.Uint64() - 1) + chain := makeHeaderChain(blockchain.chainConfig, parent, 1, ethash.NewFaker(), genDb, forkSeed) + status, err := blockchain.hc.WriteHeader(chain[0]) + if err != nil { + t.Fatalf("writing competing header failed: %v", err) + } + if status != SideStatTy { + t.Fatalf("expected competing header to stay on the side chain, have status %v", status) + } + if current := blockchain.CurrentHeader(); current.Hash() != head.Hash() { + t.Fatalf("canonical head changed on a missing-TD tie: have %x, want %x", current.Hash(), head.Hash()) + } + }) + } +} diff --git a/core/forkchoice.go b/core/forkchoice.go new file mode 100644 index 000000000000..bbae414c554d --- /dev/null +++ b/core/forkchoice.go @@ -0,0 +1,75 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package core + +import ( + "math/big" + + "github.com/XinFinOrg/XDPoSChain/params" +) + +// forkChoiceCmp compares the fork-choice weight of two competing chains. It +// returns a positive value if the candidate chain ending at (number, td) +// should replace the current chain ending at (currentNumber, currentTd), a +// negative value if it should not, and zero on a tie. Tie-breaking (random or +// otherwise) is left to the callers. +// +// In the XDPoS v2 region every block has difficulty one, so the total +// difficulty degenerates to the chain length and the fork choice reduces to a +// height comparison. In the v1 region the original heaviest-chain rule is +// retained. A missing total difficulty (legacy chaindata that predates the TD +// index) falls back to a height comparison: it is the exact rule for v2 blocks +// and a safe approximation for v1 blocks, where the total difficulty can no +// longer be reconstructed without walking the chain. +func forkChoiceCmp(cfg *params.ChainConfig, number uint64, td *big.Int, currentNumber uint64, currentTd *big.Int) int { + // The v2 boundary lives in params.XDPoSConfig.IsV2Block, the single + // source of truth shared with block validation. When both sides are in + // the v2 region, where every difficulty is one, the fork choice reduces + // to a height comparison. + if cfg != nil && cfg.XDPoS != nil && cfg.XDPoS.IsV2Block(number) && cfg.XDPoS.IsV2Block(currentNumber) { + return cmpHeight(number, currentNumber) + } + // v1 region (or chains without an XDPoS config): heaviest chain wins. + // Missing TDs fall back to a height comparison. + // + // A mixed-region comparison (candidate and current head on opposite sides + // of the switch block) also lands here and keeps the pure TD rule. That + // is safe in practice: a canonical v2 head extends the TD-heaviest chain + // through the switch block, so its TD dominates the TD of any v1 + // candidate that the v1 fork choice never preferred. The domination + // relies on chaindata consistency (the canonical switch block is + // TD-maximal among all v1 blocks), not on this function, so it holds for + // every chain a node can actually observe. + if td != nil && currentTd != nil { + return td.Cmp(currentTd) + } + return cmpHeight(number, currentNumber) +} + +// cmpHeight compares two block numbers as a fork-choice weight: positive when +// number should replace currentNumber, negative when it should not, and zero +// on a tie. Tie-breaking is left to the callers. +func cmpHeight(number, currentNumber uint64) int { + switch { + case number > currentNumber: + return 1 + case number < currentNumber: + return -1 + default: + return 0 + } +} diff --git a/core/forkchoice_test.go b/core/forkchoice_test.go new file mode 100644 index 000000000000..f35d4dece1eb --- /dev/null +++ b/core/forkchoice_test.go @@ -0,0 +1,125 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package core + +import ( + "math/big" + "testing" + + "github.com/XinFinOrg/XDPoSChain/params" +) + +// v2TestConfig returns a chain config whose XDPoS consensus switches to v2 +// for blocks strictly above switchBlock. +func v2TestConfig(switchBlock int64) *params.ChainConfig { + return ¶ms.ChainConfig{ + XDPoS: ¶ms.XDPoSConfig{ + V2: ¶ms.V2{SwitchBlock: big.NewInt(switchBlock)}, + }, + } +} + +// TestForkChoiceCmpV2Height verifies that in the v2 region the fork choice is +// decided by height only, regardless of the (degenerate) total difficulties. +func TestForkChoiceCmpV2Height(t *testing.T) { + cfg := v2TestConfig(900) + + // Higher number wins even when the candidate TD looks smaller. + if got := forkChoiceCmp(cfg, 1000, big.NewInt(5), 1001, big.NewInt(1000000)); got >= 0 { + t.Fatalf("expected lower candidate height to lose, got %d", got) + } + if got := forkChoiceCmp(cfg, 1002, big.NewInt(5), 1001, big.NewInt(1000000)); got <= 0 { + t.Fatalf("expected higher candidate height to win, got %d", got) + } + // Equal height ties regardless of the TDs. + if got := forkChoiceCmp(cfg, 1000, big.NewInt(5), 1000, big.NewInt(7)); got != 0 { + t.Fatalf("expected tie, got %d", got) + } + // Missing TDs fall back to height. + if got := forkChoiceCmp(cfg, 1001, nil, 1000, nil); got <= 0 { + t.Fatalf("expected higher candidate to win with missing TDs, got %d", got) + } + if got := forkChoiceCmp(cfg, 1000, nil, 1001, nil); got >= 0 { + t.Fatalf("expected lower candidate to lose with missing TDs, got %d", got) + } +} + +// TestForkChoiceCmpV1Td verifies that below the v2 transition the original +// heaviest-chain (total difficulty) rule is retained. +func TestForkChoiceCmpV1Td(t *testing.T) { + cfg := v2TestConfig(900) + + // Both sides below the transition: TD decides. + if got := forkChoiceCmp(cfg, 100, big.NewInt(1000), 101, big.NewInt(999)); got <= 0 { + t.Fatalf("expected heavier candidate to win, got %d", got) + } + if got := forkChoiceCmp(cfg, 100, big.NewInt(999), 101, big.NewInt(1000)); got >= 0 { + t.Fatalf("expected lighter candidate to lose, got %d", got) + } + // Equal TD ties even at different heights (pre-existing semantics). + if got := forkChoiceCmp(cfg, 100, big.NewInt(1000), 101, big.NewInt(1000)); got != 0 { + t.Fatalf("expected tie, got %d", got) + } + // A missing TD falls back to height comparison. + if got := forkChoiceCmp(cfg, 101, nil, 100, big.NewInt(1000000)); got <= 0 { + t.Fatalf("expected higher candidate to win with missing candidate TD, got %d", got) + } + if got := forkChoiceCmp(cfg, 100, big.NewInt(1000000), 101, nil); got >= 0 { + t.Fatalf("expected lower candidate to lose with missing current TD, got %d", got) + } +} + +// TestForkChoiceCmpMixedRegion verifies the transition edge: a v2 candidate +// always beats a v1 head, with or without TDs. +func TestForkChoiceCmpMixedRegion(t *testing.T) { + cfg := v2TestConfig(900) + + // v2 candidate extends the v1 head: heavier TD wins. + if got := forkChoiceCmp(cfg, 901, big.NewInt(1000001), 900, big.NewInt(1000000)); got <= 0 { + t.Fatalf("expected v2 candidate to win via TD, got %d", got) + } + // Missing candidate TD falls back to height, still wins. + if got := forkChoiceCmp(cfg, 901, nil, 900, big.NewInt(1000000)); got <= 0 { + t.Fatalf("expected v2 candidate to win via height, got %d", got) + } + // A v1 candidate never beats a v2 head. + if got := forkChoiceCmp(cfg, 900, big.NewInt(1000000), 901, nil); got >= 0 { + t.Fatalf("expected v1 candidate to lose, got %d", got) + } + // With both TDs present the mixed region keeps the pure TD rule. The + // safety of the comparison in real chains comes from the invariant that + // a canonical v2 head extends the TD-heaviest chain through the switch + // block, so the losing direction below is the only one that can occur in + // practice. Both directions are pinned to document the contract. + if got := forkChoiceCmp(cfg, 900, big.NewInt(999), 901, big.NewInt(1000)); got >= 0 { + t.Fatalf("expected lighter v1 candidate to lose via TD, got %d", got) + } + if got := forkChoiceCmp(cfg, 900, big.NewInt(1000000), 901, big.NewInt(999)); got <= 0 { + t.Fatalf("expected heavier v1 candidate to win via TD, got %d", got) + } +} + +// TestForkChoiceCmpNoXDPoS verifies that chains without an XDPoS config keep +// the original heaviest-chain rule. +func TestForkChoiceCmpNoXDPoS(t *testing.T) { + if got := forkChoiceCmp(nil, 100, big.NewInt(10), 101, big.NewInt(9)); got <= 0 { + t.Fatalf("expected heavier candidate to win, got %d", got) + } + if got := forkChoiceCmp(nil, 100, nil, 101, nil); got >= 0 { + t.Fatalf("expected higher candidate to win with missing TDs, got %d", got) + } +} diff --git a/core/headerchain.go b/core/headerchain.go index 2fa0a4d635d4..e84ac6280ec1 100644 --- a/core/headerchain.go +++ b/core/headerchain.go @@ -141,28 +141,44 @@ func (hc *HeaderChain) WriteHeader(header *types.Header) (status WriteStatus, er hash = header.Hash() number = header.Number.Uint64() ) - // Calculate the total difficulty of the header - ptd := hc.GetTd(header.ParentHash, number-1) - if ptd == nil { + // The parent header must exist for the chain to be importable, but its + // total difficulty may be unknown on legacy XDPoS chaindata that predates + // the TD index. A missing parent TD no longer blocks the insertion: the + // header is stored without a TD entry and the fork choice falls back to a + // height comparison. + if hc.GetHeader(header.ParentHash, number-1) == nil { return NonStatTy, consensus.ErrUnknownAncestor } + ptd := hc.GetTd(header.ParentHash, number-1) localTd := hc.GetTd(hc.currentHeaderHash, hc.CurrentHeader().Number.Uint64()) - externTd := new(big.Int).Add(header.Difficulty, ptd) + var externTd *big.Int + if ptd != nil { + externTd = new(big.Int).Add(header.Difficulty, ptd) + } - // Irrelevant of the canonical status, write the td and header to the database + // Irrelevant of the canonical status, write the header to the database. The + // TD entry is written atomically with it whenever it can be computed. // // Note all the components of header(td, hash->number index and header) should // be written atomically. headerBatch := hc.chainDb.NewBatch() - rawdb.WriteTd(headerBatch, hash, number, externTd) + if externTd != nil { + rawdb.WriteTd(headerBatch, hash, number, externTd) + } rawdb.WriteHeader(headerBatch, header) if err := headerBatch.Write(); err != nil { log.Crit("Failed to write header into disk", "err", err) } - // If the total difficulty is higher than our known, add it to the canonical chain + // If the fork-choice weight is higher than our known, add it to the canonical chain // Second clause in the if statement reduces the vulnerability to selfish mining. // Please refer to http://www.cs.cornell.edu/~ie53/publications/btcProcFC.pdf - if externTd.Cmp(localTd) > 0 || (externTd.Cmp(localTd) == 0 && mrand.Float64() < 0.5) { + // + // Equal fork-choice weight triggers the anti-selfish-mining coin flip, + // but only when both total difficulties are known: on legacy chaindata a + // tie derived from the height fallback must not randomly reorganise the + // chain, so the header deterministically stays on the side chain. + cmp := forkChoiceCmp(hc.config, number, externTd, hc.CurrentHeader().Number.Uint64(), localTd) + if cmp > 0 || (cmp == 0 && externTd != nil && localTd != nil && mrand.Float64() < 0.5) { // If the header can be added into canonical chain, adjust the // header chain markers(canonical indexes and head header flag). // @@ -206,7 +222,9 @@ func (hc *HeaderChain) WriteHeader(header *types.Header) (status WriteStatus, er } else { status = SideStatTy } - hc.tdCache.Add(hash, externTd) + if externTd != nil { + hc.tdCache.Add(hash, externTd) + } hc.headerCache.Add(hash, header) hc.numberCache.Add(hash, number) return diff --git a/eth/downloader/downloader.go b/eth/downloader/downloader.go index 1ae7f9d8c57f..20cf44d5d97f 100644 --- a/eth/downloader/downloader.go +++ b/eth/downloader/downloader.go @@ -537,7 +537,7 @@ func (d *Downloader) syncWithPeer(p *peerConnection, hash common.Hash, td *big.I func() error { return d.fetchHeaders(p, origin+1, pivot) }, // Headers are always retrieved func() error { return d.fetchBodies(origin + 1) }, // Bodies are retrieved during normal and fast sync func() error { return d.fetchReceipts(origin + 1) }, // Receipts are retrieved during fast sync - func() error { return d.processHeaders(origin+1, pivot, td) }, + func() error { return d.processHeaders(origin+1, pivot, td, height) }, } if mode == FastSync { fetchers = append(fetchers, func() error { return d.processFastSyncContent(latest) }) @@ -1355,7 +1355,7 @@ func (d *Downloader) fetchParts(deliveryCh chan dataPack, deliver func(dataPack) // processHeaders takes batches of retrieved headers from an input channel and // keeps processing and scheduling them into the header chain and downloader's // queue until the stream ends or a failure occurs. -func (d *Downloader) processHeaders(origin uint64, pivot uint64, td *big.Int) error { +func (d *Downloader) processHeaders(origin uint64, pivot uint64, td *big.Int, height uint64) error { // Keep a count of uncertain headers to roll back var ( rollback []*types.Header @@ -1425,8 +1425,17 @@ func (d *Downloader) processHeaders(origin uint64, pivot uint64, td *big.Int) er // R: Nothing to give if mode != LightSync { head := d.blockchain.CurrentBlock() - if !gotHeaders && td.Cmp(d.blockchain.GetTd(head.Hash(), head.Number.Uint64())) > 0 { - return errStallingPeer + if !gotHeaders { + // The peer delivered nothing: verify it against the promise. + // A missing local TD (legacy XDPoS chaindata) makes the TD + // comparison impossible, fall back to a height comparison. + if localTd := d.blockchain.GetTd(head.Hash(), head.Number.Uint64()); localTd != nil { + if td.Cmp(localTd) > 0 { + return errStallingPeer + } + } else if height > head.Number.Uint64() { + return errStallingPeer + } } } // If fast or light syncing, ensure promised headers are indeed delivered. This is @@ -1441,7 +1450,13 @@ func (d *Downloader) processHeaders(origin uint64, pivot uint64, td *big.Int) er if lastInserted != nil && lastInserted.Number.Uint64() > head.Number.Uint64() { head = lastInserted } - if td.Cmp(d.lightchain.GetTd(head.Hash(), head.Number.Uint64())) > 0 { + // A missing local TD (legacy XDPoS chaindata) makes the TD + // comparison impossible, fall back to a height comparison. + if deliveredTd := d.lightchain.GetTd(head.Hash(), head.Number.Uint64()); deliveredTd != nil { + if td.Cmp(deliveredTd) > 0 { + return errStallingPeer + } + } else if height > head.Number.Uint64() { return errStallingPeer } } diff --git a/eth/downloader/downloader_test.go b/eth/downloader/downloader_test.go index c872c27ac393..fb1ae8ffcbc3 100644 --- a/eth/downloader/downloader_test.go +++ b/eth/downloader/downloader_test.go @@ -294,8 +294,12 @@ func (dl *downloadTester) InsertHeaderChain(headers []*types.Header, checkFreq i dl.ownHashes = append(dl.ownHashes, hash) dl.ownHeaders[hash] = header - td := dl.getTd(header.ParentHash) - dl.ownChainTd[hash] = new(big.Int).Add(td, header.Difficulty) + // Mirror the production behaviour on legacy chaindata: a header whose + // parent TD is missing is stored without a TD entry, keeping the + // nil-TD fork-choice fallbacks exercisable by tests. + if td := dl.getTd(header.ParentHash); td != nil { + dl.ownChainTd[hash] = new(big.Int).Add(td, header.Difficulty) + } } return len(headers), nil } @@ -317,7 +321,11 @@ func (dl *downloadTester) InsertChain(blocks types.Blocks) (i int, err error) { } dl.ownBlocks[block.Hash()] = block dl.stateDb.Put(block.Root().Bytes(), []byte{0x00}) - dl.ownChainTd[block.Hash()] = new(big.Int).Add(dl.ownChainTd[block.ParentHash()], block.Difficulty()) + // Mirror the production behaviour on legacy chaindata: skip the TD + // entry when the parent TD is missing (see InsertHeaderChain). + if td := dl.ownChainTd[block.ParentHash()]; td != nil { + dl.ownChainTd[block.Hash()] = new(big.Int).Add(td, block.Difficulty()) + } } return len(blocks), nil } diff --git a/eth/downloader/td_test.go b/eth/downloader/td_test.go new file mode 100644 index 000000000000..3fdca0e73d0c --- /dev/null +++ b/eth/downloader/td_test.go @@ -0,0 +1,101 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package downloader + +import ( + "math/big" + "testing" +) + +// Tests that a missing local TD for the chain head neither crashes the +// stalling-peer checks nor blocks synchronisation. GetTd returns nil when the +// TD is absent from the database (legacy XDPoS chaindata predating the TD +// index). The checks fall back to a height comparison, so a peer whose head +// height is not ahead of ours simply completes the cycle without progress. +func TestMissingLocalTd100Full(t *testing.T) { testMissingLocalTd(t, xdc100, FullSync) } +func TestMissingLocalTd100Fast(t *testing.T) { testMissingLocalTd(t, xdc100, FastSync) } +func TestMissingLocalTd164Full(t *testing.T) { testMissingLocalTd(t, xdc164, FullSync) } +func TestMissingLocalTd164Fast(t *testing.T) { testMissingLocalTd(t, xdc164, FastSync) } +func TestMissingLocalTd164Light(t *testing.T) { testMissingLocalTd(t, xdc164, LightSync) } +func TestMissingLocalTd165Full(t *testing.T) { testMissingLocalTd(t, xdc165, FullSync) } +func TestMissingLocalTd165Fast(t *testing.T) { testMissingLocalTd(t, xdc165, FastSync) } +func TestMissingLocalTd165Light(t *testing.T) { testMissingLocalTd(t, xdc165, LightSync) } + +func testMissingLocalTd(t *testing.T, protocol int, mode SyncMode) { + t.Parallel() + + tester := newTester() + defer tester.terminate() + + // Drop the TD of the genesis head, simulating chaindata without a TD entry + // for the header the stalling-peer checks resolve to. The peer chain must + // stay genesis-only: the harness derives TDs from the parent header on + // insertion, so any delivered header would make InsertHeaderChain panic on + // the missing parent TD. + delete(tester.ownChainTd, tester.genesis.Hash()) + + chain := testChainBase.shorten(1) + tester.newPeer("peer", protocol, chain) + // The promised TD is set far above the local head, but the promised height + // (genesis) is not ahead of ours, so the height fallback must not flag the + // peer as stalling. Sync twice: the second cycle repeats the missing-TD + // path and must not change the outcome. + for i := 0; i < 2; i++ { + if err := tester.sync("peer", big.NewInt(1000000), mode); err != nil { + t.Fatalf("Synchronisation error mismatch (cycle %d): have %v, want nil", i, err) + } + } +} + +// Tests that the deliveredTd fallback of the fast/light-sync stalling check is +// exercised: the local genesis TD is missing, so every header the peer delivers +// is stored without a TD entry and the terminator check must fall back to the +// promised height. A healthy peer delivering its full chain must not be flagged +// as stalling, and the local head must still carry no TD entry afterwards. +func TestMissingDeliveredTd100Fast(t *testing.T) { testMissingDeliveredTd(t, xdc100, FastSync) } +func TestMissingDeliveredTd164Fast(t *testing.T) { testMissingDeliveredTd(t, xdc164, FastSync) } +func TestMissingDeliveredTd164Light(t *testing.T) { testMissingDeliveredTd(t, xdc164, LightSync) } +func TestMissingDeliveredTd165Fast(t *testing.T) { testMissingDeliveredTd(t, xdc165, FastSync) } +func TestMissingDeliveredTd165Light(t *testing.T) { testMissingDeliveredTd(t, xdc165, LightSync) } + +func testMissingDeliveredTd(t *testing.T, protocol int, mode SyncMode) { + t.Parallel() + + tester := newTester() + defer tester.terminate() + + // Drop the TD of the genesis head. The harness mirrors production and + // stores delivered headers without a TD entry when the parent TD is + // missing, so the deliveredTd == nil branch of the stalling check runs + // for every header the peer delivers. + delete(tester.ownChainTd, tester.genesis.Hash()) + + chain := testChainBase.shorten(100) + tester.newPeer("peer", protocol, chain) + if err := tester.sync("peer", nil, mode); err != nil { + t.Fatalf("Synchronisation error mismatch: have %v, want nil", err) + } + // The peer delivered its full chain, so the local head must be at the + // promised height and still carry no TD entry. + head := chain.headBlock() + if current := tester.CurrentHeader(); current.Hash() != head.Hash() { + t.Fatalf("Head hash mismatch: have %x, want %x", current.Hash(), head.Hash()) + } + if td := tester.GetTd(head.Hash(), head.NumberU64()); td != nil { + t.Fatalf("Head TD mismatch: have %v, want nil", td) + } +} diff --git a/eth/handler.go b/eth/handler.go index 1d2b63c17931..94781b0b43f9 100644 --- a/eth/handler.go +++ b/eth/handler.go @@ -539,10 +539,15 @@ func (pm *ProtocolManager) handleMsg(p *peer) error { verifyDAO := true // If we already have a DAO header, we can check the peer's TD against it. If - // the peer's ahead of this, it too must have a reply to the DAO check + // the peer's ahead of this, it too must have a reply to the DAO check. A + // missing local TD (legacy XDPoS chaindata predating the TD index) leaves + // the comparison unverifiable: keep the drop timer armed and let the peer + // deliver the fork header or time out. if daoHeader := pm.blockchain.GetHeaderByNumber(pm.blockchain.Config().DAOForkBlock.Uint64()); daoHeader != nil { - if _, td := p.Head(); td.Cmp(pm.blockchain.GetTd(daoHeader.Hash(), daoHeader.Number.Uint64())) >= 0 { - verifyDAO = false + if localTd := pm.blockchain.GetTd(daoHeader.Hash(), daoHeader.Number.Uint64()); localTd != nil { + if _, td := p.Head(); td.Cmp(localTd) >= 0 { + verifyDAO = false + } } } // If we're seemingly on the same chain, disable the drop timer @@ -778,19 +783,34 @@ func (pm *ProtocolManager) handleMsg(p *peer) error { trueHead = request.Block.ParentHash() trueTD = new(big.Int).Sub(request.TD, request.Block.Difficulty()) ) - // Update the peers total difficulty if better than the previous - if _, td := p.Head(); trueTD.Cmp(td) > 0 { + // A zero announcement TD is the wire sentinel for a missing TD on legacy + // nodes, which makes the derived trueTD negative and unusable as a peer + // head claim. Meaningful TDs update the peer head as before; unknown + // TDs advance the advertised head monotonically by the announced + // block's height so that the sync probe scheduled below targets the + // peer's latest head instead of the stale handshake head. + if _, td := p.Head(); trueTD.Sign() > 0 && trueTD.Cmp(td) > 0 { p.SetHead(trueHead, trueTD) - - // Schedule a sync if above ours. Note, this will not fire a sync for a gap of - // a singe block (as the true TD is below the propagated block), however this - // scenario should easily be covered by the fetcher. - currentBlock := pm.blockchain.CurrentBlock() - if trueTD.Cmp(pm.blockchain.GetTd(currentBlock.Hash(), currentBlock.Number.Uint64())) > 0 { - go pm.synchronise(p) + } else if trueTD.Sign() <= 0 && request.Block.NumberU64() > 0 { + if parentNum := request.Block.NumberU64() - 1; parentNum > p.HeadNum() { + p.SetHeadByNumber(trueHead, new(big.Int), parentNum) } } + // Schedule a sync if above ours. Note, this will not fire a sync for a gap of + // a singe block (as the true TD is below the propagated block), however this + // scenario should easily be covered by the fetcher. + currentBlock := pm.blockchain.CurrentBlock() + // A missing local TD (legacy XDPoS chaindata) or a zero peer TD makes + // the comparison unverifiable: schedule the sync anyway and let the + // downloader decide. While a cycle is already running, every + // announcement would spawn a probe that the downloader rejects as + // busy, so skip it: the running cycle and the periodic syncer + // re-evaluate peers. + if localTd := pm.blockchain.GetTd(currentBlock.Hash(), currentBlock.Number.Uint64()); (localTd == nil || trueTD.Sign() <= 0 || trueTD.Cmp(localTd) > 0) && !pm.downloader.Synchronising() { + go pm.synchronise(p) + } + case msg.Code == NewPooledTransactionHashesMsg && p.version >= xdc165: // New transaction announcement arrived, make sure we have // a valid and fresh chain to handle them @@ -995,7 +1015,11 @@ func (pm *ProtocolManager) BroadcastBlock(block *types.Block, propagate bool) { // Calculate the TD of the block (it's not imported yet, so block.Td is not valid) var td *big.Int if parent := pm.blockchain.GetBlock(block.ParentHash(), block.NumberU64()-1); parent != nil { - td = new(big.Int).Add(block.Difficulty(), pm.blockchain.GetTd(block.ParentHash(), block.NumberU64()-1)) + // The parent TD may be missing on legacy XDPoS chaindata; propagate + // the block with a zero TD in that case (the wire format has no nil). + if ptd := pm.blockchain.GetTd(block.ParentHash(), block.NumberU64()-1); ptd != nil { + td = new(big.Int).Add(block.Difficulty(), ptd) + } } else { log.Error("Propagating dangling block", "number", block.Number(), "hash", hash) return diff --git a/eth/handler_td_test.go b/eth/handler_td_test.go new file mode 100644 index 000000000000..a0e28fb3f882 --- /dev/null +++ b/eth/handler_td_test.go @@ -0,0 +1,228 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package eth + +import ( + "crypto/rand" + "math/big" + "sync/atomic" + "testing" + "time" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/consensus/ethash" + "github.com/XinFinOrg/XDPoSChain/core" + "github.com/XinFinOrg/XDPoSChain/core/rawdb" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/core/vm" + "github.com/XinFinOrg/XDPoSChain/eth/downloader" + "github.com/XinFinOrg/XDPoSChain/eth/ethconfig" + "github.com/XinFinOrg/XDPoSChain/event" + "github.com/XinFinOrg/XDPoSChain/p2p" + "github.com/XinFinOrg/XDPoSChain/p2p/enode" + "github.com/XinFinOrg/XDPoSChain/params" +) + +// TestBroadcastBlockMissingParentTd verifies that propagating a block whose +// parent TD is missing (legacy chaindata) no longer panics: the block is sent +// with a zero TD, which is the wire representation of an unknown value. +func TestBroadcastBlockMissingParentTd(t *testing.T) { + var ( + evmux = new(event.TypeMux) + pow = ethash.NewFaker() + db = rawdb.NewMemoryDatabase() + config = params.TestChainConfig.Clone() + gspec = &core.Genesis{Config: config} + genesis = gspec.MustCommit(db) + ) + blockchain, err := core.NewBlockChain(db, nil, gspec, pow, vm.Config{}) + if err != nil { + t.Fatalf("failed to create new blockchain: %v", err) + } + chain, _ := core.GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 1, nil) + + // Drop the genesis TD (the parent of chain[0]) and reopen the chain with a + // fresh TD cache so the deletion becomes visible. + blockchain.Stop() + rawdb.DeleteTd(db, genesis.Hash(), genesis.NumberU64()) + + blockchain, err = core.NewBlockChain(db, nil, gspec, pow, vm.Config{}) + if err != nil { + t.Fatalf("failed to reopen blockchain: %v", err) + } + pm, err := NewProtocolManager(config, downloader.FullSync, ethconfig.Defaults.NetworkId, evmux, &testTxPool{pool: make(map[common.Hash]*types.Transaction)}, pow, blockchain, db) + if err != nil { + t.Fatalf("failed to start test protocol manager: %v", err) + } + pm.Start(1000) + defer pm.Stop() + + // Register a pipe peer so the propagation path actually encodes and sends + // the block with its TD on the wire. + app, net := p2p.MsgPipe() + defer app.Close() + var id enode.ID + rand.Read(id[:]) + peer := pm.newPeer(xdc164, p2p.NewPeer(id, "test", nil), net, pm.txpool.Get) + // Mimic a completed handshake: the syncer goroutine reads Head() of every + // registered peer on its periodic tick and panics on a nil TD. + peer.td = new(big.Int) + peer.head = chain[0].ParentHash() + pm.peers.Register(peer) + + // Must not panic on the nil parent TD; the block is sent to the pipe peer + // with a zero TD, which is the wire representation of an unknown value. + // MsgPipe is synchronous and WriteMsg only returns once the receiver has + // consumed the payload, so read and decode concurrently with the write. + type result struct { + code uint64 + packet newBlockData + err error + } + resCh := make(chan result, 1) + go func() { + msg, err := app.ReadMsg() + if err != nil { + resCh <- result{err: err} + return + } + var packet newBlockData + if err := msg.Decode(&packet); err != nil { + resCh <- result{err: err} + return + } + resCh <- result{code: msg.Code, packet: packet} + }() + pm.BroadcastBlock(chain[0], true /*propagate*/) + + res := <-resCh + if res.err != nil { + t.Fatalf("failed to read broadcast: %v", res.err) + } + if res.code != NewBlockMsg { + t.Fatalf("message code mismatch: have %d, want %d", res.code, NewBlockMsg) + } + if res.packet.Block == nil || res.packet.Block.Hash() != chain[0].Hash() { + t.Fatalf("broadcast block mismatch: have %v, want %x", res.packet.Block, chain[0].Hash()) + } + if res.packet.TD == nil || res.packet.TD.Sign() != 0 { + t.Fatalf("broadcast TD mismatch: have %v, want 0", res.packet.TD) + } +} + +// TestNewBlockZeroTdTriggersSync verifies that a NewBlock announcement carrying +// the zero TD wire sentinel (a legacy peer without a TD index) schedules a +// sync probe on a healthy local node and advances the peer's advertised head +// by the announced block's height. The peer advertises block 10 at handshake +// time; the announcement of block 12 (whose parent is block 11) must move the +// tracked head to block 11 so the probe syncs through it. A subsequent stale +// announcement must not regress the head. +func TestNewBlockZeroTdTriggersSync(t *testing.T) { + for _, protocol := range []int{xdc100, xdc164, xdc165} { + t.Run(protocolTestName(protocol), func(t *testing.T) { + var ( + evmux = new(event.TypeMux) + engine = ethash.NewFaker() + db = rawdb.NewMemoryDatabase() + gspec = &core.Genesis{Config: params.TestChainConfig} + genesis = gspec.MustCommit(db) + ) + blockchain, err := core.NewBlockChain(db, nil, gspec, engine, vm.Config{}) + if err != nil { + t.Fatalf("failed to create new blockchain: %v", err) + } + // Insert nine blocks: next (block 10) is the peer's advertised + // head, target (block 11) is where the announcement must advance + // it, and announced (block 12) has target as its parent. + chain, _ := core.GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 13, nil) + if _, err := blockchain.InsertChain(chain[:9]); err != nil { + t.Fatalf("failed to seed chain: %v", err) + } + next := chain[9] + target := chain[10] + announced := chain[11] + + pm, err := NewProtocolManager(gspec.Config, downloader.FullSync, ethconfig.Defaults.NetworkId, evmux, newTestTxPool(), engine, blockchain, db) + if err != nil { + t.Fatalf("failed to create protocol manager: %v", err) + } + pm.Start(1000) + defer pm.Stop() + + peer, _ := newTestPeer("peer", protocol, pm, false) + defer peer.app.Close() + testHandshake(t, pm, peer, protocol) + + // The legacy peer advertises the unimported block 10 as its head + // with the zero TD sentinel. + peer.peer.SetHead(next.Hash(), new(big.Int)) + + // Announce block 12 (parent block 11). The zero TD carries no + // head claim, so the receiver must advance the advertised head by + // height: from block 10 to block 11. + if err := p2p.Send(peer.app, NewBlockMsg, &newBlockData{Block: announced, TD: new(big.Int)}); err != nil { + t.Fatalf("failed to send new block announcement: %v", err) + } + testServeChain(pm, peer.app, chain, target, false) + + // The announced block's parent advances the tracked head. + deadline := time.After(5 * time.Second) + for { + if hash, _ := peer.peer.Head(); hash == target.Hash() { + break + } + select { + case <-deadline: + hash, _ := peer.peer.Head() + t.Fatalf("zero-TD announcement did not advance the peer head: have %x, want %x", hash, target.Hash()) + default: + time.Sleep(time.Millisecond) + } + } + + // The probe targets block 11 and must import through it: only a + // sync cycle can import block 10, so a completed cycle proves the + // announcement triggered the probe. The fetcher may additionally + // import block 12 afterwards, so require at least block 11. + probeDeadline := time.After(5 * time.Second) + for atomic.LoadUint32(&pm.acceptTxs) != 1 { + select { + case <-probeDeadline: + t.Fatalf("sync probe did not complete a sync cycle: acceptTxs=%d, want 1", atomic.LoadUint32(&pm.acceptTxs)) + default: + time.Sleep(time.Millisecond) + } + } + if got := pm.blockchain.CurrentBlock(); got.Number.Uint64() < target.NumberU64() { + t.Fatalf("head number mismatch after announcement: have %d, want >= %d", got.Number.Uint64(), target.NumberU64()) + } + + // Regression: a stale announcement below the tracked height must + // not regress the advertised head. Wait long enough for the + // handler to process the message; the head would regress + // immediately if the height guard were missing. + stale := chain[4] // block 5, parent block 4 + if err := p2p.Send(peer.app, NewBlockMsg, &newBlockData{Block: stale, TD: new(big.Int)}); err != nil { + t.Fatalf("failed to send stale announcement: %v", err) + } + time.Sleep(200 * time.Millisecond) + if hash, _ := peer.peer.Head(); hash != target.Hash() { + t.Fatalf("stale announcement regressed the peer head: have %x, want %x", hash, target.Hash()) + } + }) + } +} diff --git a/eth/peer.go b/eth/peer.go index daf97d0b23e0..ef40e4222cb5 100644 --- a/eth/peer.go +++ b/eth/peer.go @@ -93,7 +93,16 @@ type peer struct { head common.Hash td *big.Int - lock sync.RWMutex + + // headNum is the height of the advertised head, tracked only for peers + // whose TD is unknown (the zero wire sentinel of legacy nodes). The + // handshake assigns head and td directly, so headNum starts at zero for + // every peer and is advanced monotonically by SetHeadByNumber from block + // announcements. SetHead deliberately does not reset it: a peer uses a + // single announcement format in practice, and resetting would let a stale + // zero-TD announcement overwrite a real head in the mixed-format case. + headNum uint64 + lock sync.RWMutex knownBlocks mapset.Set[common.Hash] // Set of block hashes known to be known by this peer queuedBlocks chan *propEvent // Queue of blocks to broadcast to the peer @@ -320,6 +329,28 @@ func (p *peer) SetHead(hash common.Hash, td *big.Int) { p.td.Set(td) } +// HeadNum returns the height of the peer's advertised head. It is only +// meaningful for peers whose TD is unknown (the zero wire sentinel); it is +// zero for handshake heads, which carry no height. +func (p *peer) HeadNum() uint64 { + p.lock.RLock() + defer p.lock.RUnlock() + + return p.headNum +} + +// SetHeadByNumber updates the head hash, total difficulty and head height of +// the peer in one atomic step. It is used to advance the advertised head of +// unknown-TD peers monotonically from block announcements. +func (p *peer) SetHeadByNumber(hash common.Hash, td *big.Int, number uint64) { + p.lock.Lock() + defer p.lock.Unlock() + + copy(p.head[:], hash[:]) + p.td.Set(td) + p.headNum = number +} + // MarkBlock marks a block as known for the peer, ensuring that the block will // never be propagated to this particular peer. func (p *peer) MarkBlock(hash common.Hash) { diff --git a/eth/sync.go b/eth/sync.go index faa410d6491b..3252319d55d2 100644 --- a/eth/sync.go +++ b/eth/sync.go @@ -229,11 +229,28 @@ func (pm *ProtocolManager) synchronise(peer *peer) { if peer == nil { return } - // Make sure the peer's TD is higher than our own + // Make sure the peer's TD is higher than our own. A missing local TD + // (legacy XDPoS chaindata predating the TD index) or a zero peer TD (the + // wire sentinel for a missing TD, which legacy nodes advertise) makes the + // threshold unverifiable: fall back to a height comparison on the known + // peer head, and otherwise proceed and let the downloader's height-based + // stalling checks reject peers that fail to deliver. When both sides have + // a TD the comparison is equivalent to a height comparison in the v2 + // region. currentBlock := pm.blockchain.CurrentBlock() td := pm.blockchain.GetTd(currentBlock.Hash(), currentBlock.Number.Uint64()) pHead, pTd := peer.Head() - if pTd.Cmp(td) <= 0 { + if td != nil && pTd.Sign() > 0 { + if pTd.Cmp(td) <= 0 { + return + } + } else if head := pm.blockchain.GetHeaderByHash(pHead); head != nil && head.Number.Uint64() <= currentBlock.Number.Uint64() { + // Without a verifiable TD on either side the fork choice falls back + // to a height comparison, so a peer whose known head is not strictly + // above ours can never be preferred. Skip the sync cycle instead of + // running an ancestor probe that cannot make progress; legacy nodes + // would otherwise re-probe on every block announcement. Unknown heads + // still trigger a full cycle. return } // Otherwise try to sync with the downloader @@ -253,7 +270,10 @@ func (pm *ProtocolManager) synchronise(peer *peer) { if mode == downloader.FastSync { // Make sure the peer's total difficulty we are synchronizing is higher. - if pm.blockchain.GetTdByHash(pm.blockchain.CurrentSnapBlock().Hash()).Cmp(pTd) >= 0 { + // A missing local snap TD or a zero peer TD (the wire sentinel for a + // missing TD) follows the same unverifiable-promise policy as the head + // TD above. + if snapTd := pm.blockchain.GetTdByHash(pm.blockchain.CurrentSnapBlock().Hash()); snapTd != nil && pTd.Sign() > 0 && snapTd.Cmp(pTd) >= 0 { return } } diff --git a/eth/sync_td_test.go b/eth/sync_td_test.go new file mode 100644 index 000000000000..47af48cc870f --- /dev/null +++ b/eth/sync_td_test.go @@ -0,0 +1,549 @@ +// Copyright 2025 The XDC Authors +// This file is part of the XDC library. +// +// The XDC 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 XDC 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 XDC library. If not, see . + +package eth + +import ( + "crypto/rand" + "math/big" + "sync/atomic" + "testing" + "time" + + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/consensus/ethash" + "github.com/XinFinOrg/XDPoSChain/core" + "github.com/XinFinOrg/XDPoSChain/core/rawdb" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/core/vm" + "github.com/XinFinOrg/XDPoSChain/eth/downloader" + "github.com/XinFinOrg/XDPoSChain/eth/ethconfig" + "github.com/XinFinOrg/XDPoSChain/event" + "github.com/XinFinOrg/XDPoSChain/p2p" + "github.com/XinFinOrg/XDPoSChain/p2p/enode" + "github.com/XinFinOrg/XDPoSChain/params" + "github.com/XinFinOrg/XDPoSChain/rlp" +) + +// TestSynchroniseMissingLocalTd verifies that a node whose head has no TD entry +// (legacy chaindata) neither panics in the sync threshold comparison nor runs +// pointless sync probes. A peer whose known head is not strictly above the +// local head is skipped via the height fallback, and a peer advertising one +// block ahead completes a full sync cycle that sets acceptTxs. +func TestSynchroniseMissingLocalTd(t *testing.T) { + for _, protocol := range []int{xdc100, xdc164, xdc165} { + t.Run(func() string { + switch protocol { + case xdc100: + return "protocol100" + case xdc164: + return "protocol164" + default: + return "protocol165" + } + }(), func(t *testing.T) { + var ( + evmux = new(event.TypeMux) + engine = ethash.NewFaker() + db = rawdb.NewMemoryDatabase() + gspec = &core.Genesis{ + Alloc: types.GenesisAlloc{testBank: {Balance: new(big.Int).SetUint64(10000000000000000000)}}, + Config: params.TestChainConfig, + } + genesis = gspec.MustCommit(db) + ) + blockchain, _ := core.NewBlockChain(db, nil, gspec, engine, vm.Config{}) + chain, _ := core.GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 11, nil) + if _, err := blockchain.InsertChain(chain[:10]); err != nil { + t.Fatalf("failed to seed chain: %v", err) + } + next := chain[10] + // Drop the head's TD entry to simulate legacy chaindata and reopen + // the chain with a fresh TD cache so the deletion is visible. + head := blockchain.CurrentBlock() + blockchain.Stop() + rawdb.DeleteTd(db, head.Hash(), head.Number.Uint64()) + + blockchain, err := core.NewBlockChain(db, nil, gspec, engine, vm.Config{}) + if err != nil { + t.Fatalf("failed to reopen chain: %v", err) + } + pm, err := NewProtocolManager(gspec.Config, downloader.FullSync, ethconfig.Defaults.NetworkId, evmux, newTestTxPool(), engine, blockchain, db) + if err != nil { + t.Fatalf("failed to create protocol manager: %v", err) + } + pm.Start(1000) + defer pm.Stop() + + app, net := p2p.MsgPipe() + defer app.Close() + var id enode.ID + rand.Read(id[:]) + fakePeer := pm.newPeer(protocol, p2p.NewPeer(id, "test", nil), net, pm.txpool.Get) + fakePeer.td = big.NewInt(1000000) + fakePeer.head = head.Hash() + + // The old pTd.Cmp(nil) comparison panicked here. With the local TD + // missing, the height fallback now short-circuits first: the fake + // peer's head is known and not above ours, so synchronise returns + // before touching either TD and no cycle is started. + pm.synchronise(fakePeer) + if atomic.LoadUint32(&pm.acceptTxs) != 0 { + t.Fatalf("synchronise with a non-ahead peer must not complete a sync cycle: acceptTxs=%d, want 0", atomic.LoadUint32(&pm.acceptTxs)) + } + + // Positive path: a fully handshaken peer advertising one block ahead + // of the legacy local head. The missing local TD makes the threshold + // unverifiable, so the downloader must run a complete sync cycle: + // the height fallback must not flag the delivering peer as stalling + // and acceptTxs must be set once the cycle completes. + peer, _ := newTestPeer("peer", protocol, pm, false) + defer peer.app.Close() + + // The local head TD is nil, so complete the handshake manually by + // echoing the remote status back, letting handle() register the peer. + msg, err := peer.app.ReadMsg() + if err != nil { + t.Fatalf("failed to read handshake: %v", err) + } + if msg.Code != StatusMsg { + t.Fatalf("handshake message code mismatch: have %d, want %d", msg.Code, StatusMsg) + } + switch protocol { + case xdc100: + var status statusData100 + if err := msg.Decode(&status); err != nil { + t.Fatalf("failed to decode status: %v", err) + } + if err := p2p.Send(peer.app, StatusMsg, &status); err != nil { + t.Fatalf("failed to send status: %v", err) + } + case xdc164, xdc165: + var status statusData + if err := msg.Decode(&status); err != nil { + t.Fatalf("failed to decode status: %v", err) + } + if err := p2p.Send(peer.app, StatusMsg, &status); err != nil { + t.Fatalf("failed to send status: %v", err) + } + } + + // Serve the local chain plus the advertised next block to the + // downloader: single-header requests get the matching header + // (local or the next one), everything beyond is answered empty, + // terminating the header phase. Body requests for the next block + // are answered so the full sync import can finish. + go func() { + for { + msg, err := peer.app.ReadMsg() + if err != nil { + return + } + switch msg.Code { + case GetBlockBodiesMsg: + var hashes []common.Hash + if err := msg.Decode(&hashes); err != nil { + return + } + bodies := make([]*blockBody, len(hashes)) + for i, hash := range hashes { + if hash == next.Hash() { + bodies[i] = &blockBody{Transactions: next.Transactions(), Uncles: next.Uncles()} + } + } + if err := p2p.Send(peer.app, BlockBodiesMsg, bodies); err != nil { + return + } + case GetBlockHeadersMsg: + var req getBlockHeadersData + if err := msg.Decode(&req); err != nil { + return + } + var headers []*types.Header + if req.Origin.Hash != (common.Hash{}) { + if header := pm.blockchain.GetHeaderByHash(req.Origin.Hash); header != nil { + headers = []*types.Header{header} + } else if req.Origin.Hash == next.Hash() { + headers = []*types.Header{next.Header()} + } + } else { + for i, number := 0, req.Origin.Number; i < int(req.Amount); i++ { + var header *types.Header + if number == next.NumberU64() { + header = next.Header() + } else { + header = pm.blockchain.GetHeaderByNumber(number) + } + if header == nil { + break + } + headers = append(headers, header) + number += req.Skip + 1 + } + } + if err := p2p.Send(peer.app, BlockHeadersMsg, headers); err != nil { + return + } + default: + msg.Discard() + } + } + }() + // Wait for the handshake goroutine to finish registering the peer. + deadline := time.After(5 * time.Second) + for pm.peers.Len() == 0 { + select { + case <-deadline: + t.Fatalf("peer never registered") + default: + time.Sleep(time.Millisecond) + } + } + // The handshake advertised the local head: it is known and not + // above ours, so the height-based short-circuit must skip the + // cycle entirely without probing the peer. + pm.synchronise(peer.peer) + if atomic.LoadUint32(&pm.acceptTxs) != 0 { + t.Fatalf("synchronise with a same-height peer must not complete a sync cycle: acceptTxs=%d, want 0", atomic.LoadUint32(&pm.acceptTxs)) + } + // Advertise the peer one block ahead of the local head so the + // short-circuit lets the sync cycle through. + peer.peer.SetHead(next.Hash(), new(big.Int).SetUint64(next.NumberU64())) + pm.synchronise(peer.peer) + if atomic.LoadUint32(&pm.acceptTxs) != 1 { + t.Fatalf("synchronise did not complete the sync cycle: acceptTxs=%d, want 1", atomic.LoadUint32(&pm.acceptTxs)) + } + if head := pm.blockchain.CurrentBlock(); head.Hash() != next.Hash() { + t.Fatalf("head hash mismatch after sync: have %x, want %x", head.Hash(), next.Hash()) + } + }) + } +} + +// protocolTestName maps a protocol version to a human-readable subtest name. +func protocolTestName(protocol int) string { + switch protocol { + case xdc100: + return "protocol100" + case xdc164: + return "protocol164" + default: + return "protocol165" + } +} + +// testHandshake completes the status handshake for a newTestPeer created with +// shake=false by echoing the remote status back, then waits until handle() +// registers the peer in the protocol manager. +func testHandshake(t *testing.T, pm *ProtocolManager, peer *testPeer, protocol int) { + t.Helper() + msg, err := peer.app.ReadMsg() + if err != nil { + t.Fatalf("failed to read handshake: %v", err) + } + if msg.Code != StatusMsg { + t.Fatalf("handshake message code mismatch: have %d, want %d", msg.Code, StatusMsg) + } + switch protocol { + case xdc100: + var status statusData100 + if err := msg.Decode(&status); err != nil { + t.Fatalf("failed to decode status: %v", err) + } + if err := p2p.Send(peer.app, StatusMsg, &status); err != nil { + t.Fatalf("failed to send status: %v", err) + } + case xdc164, xdc165: + var status statusData + if err := msg.Decode(&status); err != nil { + t.Fatalf("failed to decode status: %v", err) + } + if err := p2p.Send(peer.app, StatusMsg, &status); err != nil { + t.Fatalf("failed to send status: %v", err) + } + } + deadline := time.After(5 * time.Second) + for pm.peers.Len() == 0 { + select { + case <-deadline: + t.Fatalf("peer never registered") + default: + time.Sleep(time.Millisecond) + } + } +} + +// testServeChain answers header, body, receipt and node-data requests on app +// for a generated chain whose blocks carry no transactions. The responder only +// serves blocks up to next, the head the simulated peer advertises; requests +// beyond it are answered empty so the header phase terminates. All generated +// blocks have empty transaction lists, so their receipts are empty and their +// state roots equal the genesis root. +func testServeChain(pm *ProtocolManager, app *p2p.MsgPipeRW, chain []*types.Block, next *types.Block, serveReceipts bool) { + headNum := next.NumberU64() + byHash := make(map[common.Hash]*types.Block, headNum+1) + for _, block := range chain { + if block.NumberU64() > headNum { + break + } + byHash[block.Hash()] = block + } + byHash[next.Hash()] = next + go func() { + for { + msg, err := app.ReadMsg() + if err != nil { + return + } + switch msg.Code { + case GetBlockBodiesMsg: + var hashes []common.Hash + if err := msg.Decode(&hashes); err != nil { + return + } + bodies := make([]*blockBody, len(hashes)) + for i, hash := range hashes { + if block := byHash[hash]; block != nil { + bodies[i] = &blockBody{Transactions: block.Transactions(), Uncles: block.Uncles()} + } + } + if err := p2p.Send(app, BlockBodiesMsg, bodies); err != nil { + return + } + case GetBlockHeadersMsg: + var req getBlockHeadersData + if err := msg.Decode(&req); err != nil { + return + } + var headers []*types.Header + if req.Origin.Hash != (common.Hash{}) { + if block := byHash[req.Origin.Hash]; block != nil { + headers = []*types.Header{block.Header()} + } else if header := pm.blockchain.GetHeaderByHash(req.Origin.Hash); header != nil { + headers = []*types.Header{header} + } + } else { + for i, number := 0, req.Origin.Number; i < int(req.Amount); i++ { + var header *types.Header + if number >= 1 && number <= headNum { + header = chain[number-1].Header() + } else { + header = pm.blockchain.GetHeaderByNumber(number) + } + if header == nil { + break + } + headers = append(headers, header) + number += req.Skip + 1 + } + } + if err := p2p.Send(app, BlockHeadersMsg, headers); err != nil { + return + } + case GetReceiptsMsg: + if !serveReceipts { + msg.Discard() + break + } + var hashes []common.Hash + if err := msg.Decode(&hashes); err != nil { + return + } + receipts := make([]rlp.RawValue, 0, len(hashes)) + for _, hash := range hashes { + if _, ok := byHash[hash]; ok { + // Generated blocks carry no transactions. + encoded, err := rlp.EncodeToBytes(types.Receipts{}) + if err != nil { + return + } + receipts = append(receipts, encoded) + continue + } + results := pm.blockchain.GetReceiptsByHash(hash) + if results == nil { + if header := pm.blockchain.GetHeaderByHash(hash); header == nil || header.ReceiptHash != types.EmptyRootHash { + continue + } + } + encoded, err := rlp.EncodeToBytes(results) + if err != nil { + return + } + receipts = append(receipts, encoded) + } + if err := p2p.Send(app, ReceiptsMsg, receipts); err != nil { + return + } + case GetNodeDataMsg: + // The generated chain has no transactions, so every state root + // equals the genesis root, which is fully present locally. The + // state sync normally issues no requests; serve defensively in + // case it does. + msgStream := rlp.NewStream(msg.Payload, uint64(msg.Size)) + if _, err := msgStream.List(); err != nil { + return + } + var ( + hash common.Hash + data [][]byte + ) + for { + if err := msgStream.Decode(&hash); err == rlp.EOL { + break + } else if err != nil { + return + } + if entry, _ := pm.blockchain.TrieNode(hash); len(entry) > 0 { + data = append(data, entry) + } else if entry, _ = pm.blockchain.ContractCodeWithPrefix(hash); len(entry) > 0 { + data = append(data, entry) + } + } + if err := p2p.Send(app, NodeDataMsg, data); err != nil { + return + } + default: + msg.Discard() + } + } + }() +} + +// TestSynchroniseZeroPeerTd verifies that a peer advertising the zero TD wire +// sentinel (a legacy node without a TD index) is not treated as having a real +// total difficulty. A healthy local node must skip such a peer when its known +// head is not strictly above ours, and must otherwise complete a full sync +// cycle with it. +func TestSynchroniseZeroPeerTd(t *testing.T) { + for _, protocol := range []int{xdc100, xdc164, xdc165} { + t.Run(protocolTestName(protocol), func(t *testing.T) { + var ( + evmux = new(event.TypeMux) + engine = ethash.NewFaker() + db = rawdb.NewMemoryDatabase() + gspec = &core.Genesis{ + Alloc: types.GenesisAlloc{testBank: {Balance: new(big.Int).SetUint64(10000000000000000000)}}, + Config: params.TestChainConfig, + } + genesis = gspec.MustCommit(db) + ) + blockchain, _ := core.NewBlockChain(db, nil, gspec, engine, vm.Config{}) + chain, _ := core.GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 11, nil) + if _, err := blockchain.InsertChain(chain[:10]); err != nil { + t.Fatalf("failed to seed chain: %v", err) + } + next := chain[10] + head := blockchain.CurrentBlock() + + pm, err := NewProtocolManager(gspec.Config, downloader.FullSync, ethconfig.Defaults.NetworkId, evmux, newTestTxPool(), engine, blockchain, db) + if err != nil { + t.Fatalf("failed to create protocol manager: %v", err) + } + pm.Start(1000) + defer pm.Stop() + + // Skip path: a zero-TD peer whose head is known and not strictly + // above ours is skipped via the height fallback even though the + // local TD exists. + app, net := p2p.MsgPipe() + defer app.Close() + var id enode.ID + rand.Read(id[:]) + fakePeer := pm.newPeer(protocol, p2p.NewPeer(id, "test", nil), net, pm.txpool.Get) + fakePeer.td = new(big.Int) // zero TD wire sentinel of a legacy peer + fakePeer.head = head.Hash() + pm.synchronise(fakePeer) + if atomic.LoadUint32(&pm.acceptTxs) != 0 { + t.Fatalf("synchronise with a non-ahead zero-TD peer must not complete a sync cycle: acceptTxs=%d, want 0", atomic.LoadUint32(&pm.acceptTxs)) + } + + // Positive path: a fully handshaken peer advertising one block + // ahead with the zero TD sentinel. The peer TD is unverifiable, + // so the downloader must run a complete sync cycle and acceptTxs + // must be set once it completes. + peer, _ := newTestPeer("peer", protocol, pm, false) + defer peer.app.Close() + testHandshake(t, pm, peer, protocol) + testServeChain(pm, peer.app, chain, next, false) + + peer.peer.SetHead(next.Hash(), new(big.Int)) + pm.synchronise(peer.peer) + if atomic.LoadUint32(&pm.acceptTxs) != 1 { + t.Fatalf("synchronise did not complete the sync cycle: acceptTxs=%d, want 1", atomic.LoadUint32(&pm.acceptTxs)) + } + if got := pm.blockchain.CurrentBlock(); got.Hash() != next.Hash() { + t.Fatalf("head hash mismatch after sync: have %x, want %x", got.Hash(), next.Hash()) + } + }) + } +} + +// TestSynchroniseZeroPeerTdFastSync verifies the fast sync guard against the +// zero TD wire sentinel: a healthy node running fast sync must not reject a +// zero-TD peer merely because the local snap TD exists. The peer serves the +// entire generated chain from genesis and the sync cycle must complete. +func TestSynchroniseZeroPeerTdFastSync(t *testing.T) { + for _, protocol := range []int{xdc100, xdc164, xdc165} { + t.Run(protocolTestName(protocol), func(t *testing.T) { + var ( + evmux = new(event.TypeMux) + engine = ethash.NewFaker() + db = rawdb.NewMemoryDatabase() + gspec = &core.Genesis{ + Alloc: types.GenesisAlloc{testBank: {Balance: new(big.Int).SetUint64(10000000000000000000)}}, + Config: params.TestChainConfig, + } + genesis = gspec.MustCommit(db) + ) + // Fast sync is only enabled on an empty blockchain, so the local + // chain stays at genesis and the peer serves the entire chain. + blockchain, _ := core.NewBlockChain(db, nil, gspec, engine, vm.Config{}) + chain, _ := core.GenerateChain(gspec.Config, genesis, ethash.NewFaker(), db, 11, nil) + next := chain[10] + + pm, err := NewProtocolManager(gspec.Config, downloader.FastSync, ethconfig.Defaults.NetworkId, evmux, newTestTxPool(), engine, blockchain, db) + if err != nil { + t.Fatalf("failed to create protocol manager: %v", err) + } + if atomic.LoadUint32(&pm.snapSync) != 1 { + t.Fatalf("snap sync not enabled on pristine blockchain") + } + pm.Start(1000) + defer pm.Stop() + + peer, _ := newTestPeer("peer", protocol, pm, false) + defer peer.app.Close() + testHandshake(t, pm, peer, protocol) + testServeChain(pm, peer.app, chain, next, true) + + // Advertise the peer one block ahead with the zero TD sentinel. + // Neither the head TD threshold nor the snap TD guard may reject + // the unverifiable peer. + peer.peer.SetHead(next.Hash(), new(big.Int)) + pm.synchronise(peer.peer) + if atomic.LoadUint32(&pm.acceptTxs) != 1 { + t.Fatalf("synchronise did not complete the sync cycle: acceptTxs=%d, want 1", atomic.LoadUint32(&pm.acceptTxs)) + } + if got := pm.blockchain.CurrentBlock(); got.Hash() != next.Hash() { + t.Fatalf("head hash mismatch after sync: have %x, want %x", got.Hash(), next.Hash()) + } + if atomic.LoadUint32(&pm.snapSync) != 0 { + t.Fatalf("snap sync not disabled after sync cycle") + } + }) + } +} diff --git a/ethstats/ethstats.go b/ethstats/ethstats.go index 6ab9085fdbed..d76b8043c02f 100644 --- a/ethstats/ethstats.go +++ b/ethstats/ethstats.go @@ -742,6 +742,13 @@ func (s *Service) assembleBlockStats(block *types.Block) *blockStats { td = s.backend.GetTd(context.Background(), header.Hash()) txs = []txStats{} } + // Legacy chaindata predating the TD index can leave the head without a TD + // entry: report a zero value rather than the literal "" string that + // big.Int.String returns for a nil receiver, which the stats server + // cannot parse as a numeric difficulty. + if td == nil { + td = common.Big0 + } // Assemble and return the block stats author, err := s.engine.Author(header) if err != nil && header.Number.Sign() != 0 { diff --git a/params/config_xdpos.go b/params/config_xdpos.go index ae36144be3fe..3580ccaa1fc5 100644 --- a/params/config_xdpos.go +++ b/params/config_xdpos.go @@ -623,8 +623,30 @@ func (c ExpTimeoutConfig) String() string { return fmt.Sprintf("ExpTimeoutConfig{Base: %v, MaxExponent: %v}", c.Base, c.MaxExponent) } +// isV2Number reports whether the consensus for the given block number is +// XDPoS v2, i.e. the block is strictly above the v2 switch block. It is the +// single source of truth for the v2 boundary and must stay in sync with the +// uint64 fast path of IsV2Block. +func (c *XDPoSConfig) isV2Number(num *big.Int) bool { + return c.V2 != nil && c.V2.SwitchBlock != nil && num.Cmp(c.V2.SwitchBlock) > 0 +} + +// IsV2Block reports whether the consensus for the given block number is XDPoS +// v2, i.e. the block is strictly above the v2 switch block. It is the +// allocation-free fast path of isV2Number for uint64 numbers and is used on +// fork-choice hot paths; it must stay in sync with isV2Number. +func (c *XDPoSConfig) IsV2Block(number uint64) bool { + if c.V2 == nil || c.V2.SwitchBlock == nil { + return false + } + if c.V2.SwitchBlock.IsUint64() { + return number > c.V2.SwitchBlock.Uint64() + } + return c.isV2Number(new(big.Int).SetUint64(number)) +} + func (c *XDPoSConfig) BlockConsensusVersion(num *big.Int) string { - if c.V2 != nil && c.V2.SwitchBlock != nil && num.Cmp(c.V2.SwitchBlock) > 0 { + if c.isV2Number(num) { return ConsensusEngineVersion2 } return ConsensusEngineVersion1 diff --git a/params/config_xdpos_test.go b/params/config_xdpos_test.go index a4cbf50fc7f5..02d71763de69 100644 --- a/params/config_xdpos_test.go +++ b/params/config_xdpos_test.go @@ -322,3 +322,41 @@ func TestV2UnmarshalSwitchEpochVariants(t *testing.T) { assert.Equal(t, uint64(111), v2.SwitchEpoch) assert.Equal(t, big.NewInt(456), v2.SwitchBlock) } + +// TestIsV2Block verifies the uint64 fast path of the v2 consensus boundary: a +// block is v2 strictly above the switch block, and the result must stay in +// sync with BlockConsensusVersion. +func TestIsV2Block(t *testing.T) { + // Without a v2 config or a switch block nothing is v2. + if (&XDPoSConfig{}).IsV2Block(0) { + t.Fatalf("expected v1 without a v2 config") + } + if (&XDPoSConfig{V2: &V2{}}).IsV2Block(0) { + t.Fatalf("expected v1 without a switch block") + } + // Strictly above the switch block, both for a mid-chain and a genesis + // switch. + sw900 := &XDPoSConfig{V2: &V2{SwitchBlock: big.NewInt(900)}} + if sw900.IsV2Block(899) || sw900.IsV2Block(900) || !sw900.IsV2Block(901) { + t.Fatalf("unexpected v2 boundary at switch block 900") + } + sw0 := &XDPoSConfig{V2: &V2{SwitchBlock: big.NewInt(0)}} + if sw0.IsV2Block(0) || !sw0.IsV2Block(1) { + t.Fatalf("unexpected v2 boundary at switch block 0") + } + // A switch block beyond uint64 takes the big.Int fallback: no uint64 + // number can be above it, so every uint64 block is v1. + huge := new(big.Int).Lsh(big.NewInt(1), 64) + swHuge := &XDPoSConfig{V2: &V2{SwitchBlock: huge}} + if swHuge.IsV2Block(^uint64(0)) { + t.Fatalf("expected v1 below a huge switch block") + } + // Consistency with the big.Int definition across the boundary. + for _, number := range []uint64{0, 1, 899, 900, 901, 1 << 40} { + got := sw900.IsV2Block(number) + want := sw900.BlockConsensusVersion(new(big.Int).SetUint64(number)) == ConsensusEngineVersion2 + if got != want { + t.Fatalf("IsV2Block(%d) diverges from BlockConsensusVersion: have %v, want %v", number, got, want) + } + } +}