From 001bd7ef3ba7f5f726600b802cbc6e76b7fca24c Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Sat, 21 Mar 2026 15:26:54 +0800 Subject: [PATCH] fix(eth/hooks,contracts): use parent-state randomize data for checkpoint validator verification (#2186) Read randomize values at the checkpoint parent height during v1 HookValidator and HookVerifyMNs instead of the latest chain state. This fixes a sync-time race where historical checkpoint headers could be rejected with ErrInvalidCheckpointValidators and then accepted later after local state advanced. The accompanying regression test now derives expected validators independently from HookValidator and the M2 generation path uses a local RNG source, removing CI-only nondeterminism during repeated verification. --- ...checkpoint_sync_invalid_validators_test.go | 214 ++++++++++++++++++ contracts/utils.go | 12 +- eth/hooks/engine_v1_hooks.go | 15 +- 3 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 consensus/tests/engine_v1_tests/checkpoint_sync_invalid_validators_test.go diff --git a/consensus/tests/engine_v1_tests/checkpoint_sync_invalid_validators_test.go b/consensus/tests/engine_v1_tests/checkpoint_sync_invalid_validators_test.go new file mode 100644 index 0000000000..3c6dce5989 --- /dev/null +++ b/consensus/tests/engine_v1_tests/checkpoint_sync_invalid_validators_test.go @@ -0,0 +1,214 @@ +package engine_v1_tests + +import ( + "context" + "math/big" + "math/rand" + "strconv" + "strings" + "testing" + + ethereum "github.com/XinFinOrg/XDPoSChain" + "github.com/XinFinOrg/XDPoSChain/accounts/abi" + "github.com/XinFinOrg/XDPoSChain/accounts/abi/bind" + "github.com/XinFinOrg/XDPoSChain/accounts/abi/bind/backends" + "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS" + "github.com/XinFinOrg/XDPoSChain/consensus/XDPoS/utils" + "github.com/XinFinOrg/XDPoSChain/contracts" + randomizeContract "github.com/XinFinOrg/XDPoSChain/contracts/randomize/contract" + "github.com/XinFinOrg/XDPoSChain/core/types" + "github.com/XinFinOrg/XDPoSChain/eth/hooks" + "github.com/XinFinOrg/XDPoSChain/params" + "github.com/stretchr/testify/require" +) + +type randomizeBackendMock struct { + *backends.SimulatedBackend + apiABI abi.ABI + opening [32]byte + latest map[common.Address]int64 + byBlock map[uint64]map[common.Address]int64 +} + +func newRandomizeBackendMock(t *testing.T, backend *backends.SimulatedBackend) *randomizeBackendMock { + t.Helper() + parsed, err := abi.JSON(strings.NewReader(randomizeContract.XDCRandomizeABI)) + require.NoError(t, err) + + var opening [32]byte + copy(opening[:], []byte("checkpoint-sync-randomize-key-000")) // 32 bytes prefix, deterministic + + return &randomizeBackendMock{ + SimulatedBackend: backend, + apiABI: parsed, + opening: opening, + latest: make(map[common.Address]int64), + byBlock: make(map[uint64]map[common.Address]int64), + } +} + +func (m *randomizeBackendMock) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) { + if contract == common.RandomizeSMCBinary { + return []byte{1}, nil + } + return m.SimulatedBackend.CodeAt(ctx, contract, blockNumber) +} + +func (m *randomizeBackendMock) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + if call.To == nil || *call.To != common.RandomizeSMCBinary || len(call.Data) < 4 { + return m.SimulatedBackend.CallContract(ctx, call, blockNumber) + } + method, err := m.apiABI.MethodById(call.Data[:4]) + if err != nil { + return nil, err + } + inputs, err := method.Inputs.Unpack(call.Data[4:]) + if err != nil { + return nil, err + } + var addr common.Address + if len(inputs) > 0 { + addr = inputs[0].(common.Address) + } + + switch method.Name { + case "getSecret": + random := m.lookupRandom(addr, blockNumber) + encrypted := contracts.Encrypt(m.opening[:], strconv.FormatInt(random, 10)) + var secret [32]byte + copy(secret[:], common.LeftPadBytes([]byte(encrypted), 32)) + return method.Outputs.Pack([][32]byte{secret}) + case "getOpening": + return method.Outputs.Pack(m.opening) + default: + return m.SimulatedBackend.CallContract(ctx, call, blockNumber) + } +} + +func (m *randomizeBackendMock) lookupRandom(addr common.Address, blockNumber *big.Int) int64 { + if blockNumber == nil { + return m.latest[addr] + } + if vals, ok := m.byBlock[blockNumber.Uint64()]; ok { + if random, ok := vals[addr]; ok { + return random + } + } + return m.latest[addr] +} + +// Regression test for sync-time checkpoint verification. +// +// Scenario: +// 1) Build chain up to block 899. +// 2) Build checkpoint header #900 and precompute its validators from parent(#899) state. +// 3) Advance canonical chain with #900/#901 that mutate randomize contract state. +// 4) Re-verify old checkpoint header #900 against the updated chain head. +// +// Before the fix, HookVerifyMNs used latest-state randomize reads and could return +// ErrInvalidCheckpointValidators for step (4). After the fix, verification is pinned +// to parent block state and should pass. +func TestCheckpointSyncValidatorVerificationUsesParentState(t *testing.T) { + const checkpointNumber = uint64(900) + + blockchain, backend, parentBlock, _, _ := PrepareXDCTestBlockChain(t, int(checkpointNumber-1), params.TestXDPoSMockChainConfig) + require.Equal(t, checkpointNumber-1, parentBlock.NumberU64()) + + engine := blockchain.Engine().(*XDPoS.XDPoS) + hooks.AttachConsensusV1Hooks(engine, blockchain, blockchain.Config()) + masternodes, err := engine.EngineV1.GetAuthorisedSignersFromSnapshot(blockchain, parentBlock.Header()) + require.NoError(t, err) + require.NotEmpty(t, masternodes) + + mockBackend := newRandomizeBackendMock(t, backend) + parentRandoms := make(map[common.Address]int64) + latestRandoms := make(map[common.Address]int64) + for i, addr := range masternodes { + parentRandoms[addr] = int64(i + 1) + latestRandoms[addr] = int64(len(masternodes) - i + 100) + } + mockBackend.byBlock[checkpointNumber-1] = parentRandoms + mockBackend.latest = latestRandoms + blockchain.Client = mockBackend + + checkpointHeader := &types.Header{ + Root: common.HexToHash("0xea465415b60d88429f181fec9fae67c0f19cbf5a4fa10971d96d4faa57d96ffa"), + Number: new(big.Int).SetUint64(checkpointNumber), + ParentHash: parentBlock.Hash(), + Coinbase: common.HexToAddress("0xaaa0000000000000000000000000000000000900"), + } + + // Build expected validators from parent-state randomize values directly, + // independent from HookValidator implementation. + validatorsAtParent, err := validatorsFromRandomizeAtNumber(blockchain.Client, masternodes, new(big.Int).SetUint64(checkpointNumber-1)) + require.NoError(t, err) + validatorsAtLatest, err := validatorsFromRandomizeAtNumber(blockchain.Client, masternodes, nil) + require.NoError(t, err) + require.NotEqual(t, validatorsAtLatest, validatorsAtParent) + checkpointHeader.Validators = validatorsAtParent + + // Re-verify checkpoint header while latest randomize differs from parent block. + err = engine.EngineV1.HookVerifyMNs(checkpointHeader, masternodes) + require.NoError(t, err) + + // Sanity: latest randomize view has diverged from parent-state view for at least + // one masternode, proving this test exercises the historical-state requirement. + var diverged bool + for _, addr := range masternodes { + latest, lerr := contracts.GetRandomizeFromContractAtNumber(blockchain.Client, addr, nil) + require.NoError(t, lerr) + atParent, perr := contracts.GetRandomizeFromContractAtNumber(blockchain.Client, addr, new(big.Int).SetUint64(checkpointNumber-1)) + require.NoError(t, perr) + if latest != atParent { + diverged = true + break + } + } + require.True(t, diverged) + + // Keep explicit guard for the historical failure signature. + require.NotEqual(t, utils.ErrInvalidCheckpointValidators, err) +} + +func validatorsFromRandomizeAtNumber(client bind.ContractBackend, masternodes []common.Address, blockNumber *big.Int) ([]byte, error) { + randoms := make([]int64, 0, len(masternodes)) + for _, addr := range masternodes { + random, err := contracts.GetRandomizeFromContractAtNumber(client, addr, blockNumber) + if err != nil { + return nil, err + } + randoms = append(randoms, random) + } + m2 := deterministicM2FromRandomize(randoms, int64(len(masternodes))) + return contracts.BuildValidatorFromM2(m2), nil +} + +// deterministicM2FromRandomize mirrors contracts.GenM2FromRandomize but uses a +// local RNG source so the test does not depend on global math/rand state. +func deterministicM2FromRandomize(randomizes []int64, lenSigners int64) []int64 { + blockValidator := make([]int64, lenSigners) + for i := int64(0); i < lenSigners; i++ { + blockValidator[i] = i + } + randIndexs := make([]int64, lenSigners) + total := int64(0) + for _, v := range randomizes { + total += v + } + rng := rand.New(rand.NewSource(total)) + + for i := len(blockValidator) - 1; i >= 0; i-- { + blockLength := len(blockValidator) - 1 + if blockLength <= 1 { + blockLength = 1 + } + randomIndex := rng.Intn(blockLength) + temp := blockValidator[randomIndex] + blockValidator[randomIndex] = blockValidator[i] + blockValidator[i] = temp + blockValidator = append(blockValidator[:i], blockValidator[i+1:]...) + randIndexs[i] = temp + } + return randIndexs +} diff --git a/contracts/utils.go b/contracts/utils.go index 9bdba358a0..c0e336df47 100644 --- a/contracts/utils.go +++ b/contracts/utils.go @@ -231,11 +231,17 @@ func GetSignersByExecutingEVM(addrBlockSigner common.Address, client bind.Contra // Get random from randomize contract. func GetRandomizeFromContract(client bind.ContractBackend, addrMasternode common.Address) (int64, error) { + return GetRandomizeFromContractAtNumber(client, addrMasternode, nil) +} + +// GetRandomizeFromContractAtNumber reads randomize data at a specific block height. +// If blockNumber is nil, the latest state is used. +func GetRandomizeFromContractAtNumber(client bind.ContractBackend, addrMasternode common.Address, blockNumber *big.Int) (int64, error) { randomize, err := randomizeContract.NewXDCRandomize(common.RandomizeSMCBinary, client) if err != nil { log.Error("Fail to get instance of randomize", "error", err) } - opts := new(bind.CallOpts) + opts := &bind.CallOpts{BlockNumber: blockNumber} secrets, err := randomize.GetSecret(opts, addrMasternode) if err != nil { log.Error("Fail get secrets from randomize", "error", err) @@ -256,13 +262,13 @@ func GenM2FromRandomize(randomizes []int64, lenSigners int64) ([]int64, error) { for _, j := range randomizes { total += j } - rand.Seed(total) + rng := rand.New(rand.NewSource(total)) for i := len(blockValidator) - 1; i >= 0; i-- { blockLength := len(blockValidator) - 1 if blockLength <= 1 { blockLength = 1 } - randomIndex := int64(rand.Intn(blockLength)) + randomIndex := int64(rng.Intn(blockLength)) temp := blockValidator[randomIndex] blockValidator[randomIndex] = blockValidator[i] blockValidator[i] = temp diff --git a/eth/hooks/engine_v1_hooks.go b/eth/hooks/engine_v1_hooks.go index c33e2eeec3..d089b147dc 100644 --- a/eth/hooks/engine_v1_hooks.go +++ b/eth/hooks/engine_v1_hooks.go @@ -179,7 +179,7 @@ func AttachConsensusV1Hooks(adaptor *XDPoS.XDPoS, bc *core.BlockChain, chainConf // Hook prepares validators M2 for the current epoch at checkpoint block adaptor.EngineV1.HookValidator = func(header *types.Header, signers []common.Address) ([]byte, error) { start := time.Now() - validators, err := getValidators(bc, signers) + validators, err := getValidatorsAtNumber(bc, signers, parentBlockNumber(header)) if err != nil { return []byte{}, err } @@ -193,7 +193,7 @@ func AttachConsensusV1Hooks(adaptor *XDPoS.XDPoS, bc *core.BlockChain, chainConf number := header.Number.Int64() if number > 0 && number%common.EpocBlockRandomize == 0 { start := time.Now() - validators, err := getValidators(bc, signers) + validators, err := getValidatorsAtNumber(bc, signers, parentBlockNumber(header)) log.Debug("Time Calculated HookVerifyMNs ", "block", header.Number.Uint64(), "time", common.PrettyDuration(time.Since(start))) if err != nil { return err @@ -307,7 +307,7 @@ func AttachConsensusV1Hooks(adaptor *XDPoS.XDPoS, bc *core.BlockChain, chainConf } } -func getValidators(bc *core.BlockChain, masternodes []common.Address) ([]byte, error) { +func getValidatorsAtNumber(bc *core.BlockChain, masternodes []common.Address, blockNumber *big.Int) ([]byte, error) { if bc.Config().XDPoS == nil { return nil, core.ErrNotXDPoS } @@ -322,7 +322,7 @@ func getValidators(bc *core.BlockChain, masternodes []common.Address) ([]byte, e lenSigners := int64(len(masternodes)) if lenSigners > 0 { for _, addr := range masternodes { - random, err := contracts.GetRandomizeFromContract(client, addr) + random, err := contracts.GetRandomizeFromContractAtNumber(client, addr, blockNumber) if err != nil { return nil, err } @@ -337,3 +337,10 @@ func getValidators(bc *core.BlockChain, masternodes []common.Address) ([]byte, e } return nil, core.ErrNotFoundM1 } + +func parentBlockNumber(header *types.Header) *big.Int { + if header == nil || header.Number == nil || header.Number.Sign() == 0 { + return nil + } + return new(big.Int).Sub(header.Number, common.Big1) +}