diff --git a/cmd/config/config.go b/cmd/config/config.go index d01215a7b3..2148c4cbe9 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -51,7 +51,13 @@ func validateHarmonyConfig(config harmonyconfig.HarmonyConfig) error { return fmt.Errorf("flag --run.offline must have p2p IP be %v", nodeconfig.DefaultLocalListenIP) } - if !config.Sync.Client && !config.DNSSync.Client { + // Recovery maintenance must be able to open a stopped database without + // starting either downloader. The mainnet validator exception is paired + // with a fail-closed runtime checkpoint/sync guard in cmd/harmony. + recoveryValidator := config.Network.NetworkType == nodeconfig.Mainnet && + config.General.NodeType == NodeTypeValidator + if !config.Sync.Client && !config.DNSSync.Client && + !config.General.IsOffline && !recoveryValidator { // There is no module up for sync return errors.New("either --sync.client or --sync.legacy.client shall be enabled") } diff --git a/cmd/config/recovery_config_test.go b/cmd/config/recovery_config_test.go new file mode 100644 index 0000000000..f60f9ec28b --- /dev/null +++ b/cmd/config/recovery_config_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "testing" + + nodeconfig "github.com/harmony-one/harmony/internal/configs/node" + "github.com/stretchr/testify/require" +) + +func TestRecoveryConfigAllowsNoSyncClientOnlyInNarrowCases(t *testing.T) { + offline := GetDefaultHmyConfigCopy(nodeconfig.Mainnet) + offline.General.IsOffline = true + offline.P2P.IP = nodeconfig.DefaultLocalListenIP + offline.Sync.Client = false + offline.DNSSync.Client = false + require.NoError(t, validateHarmonyConfig(offline)) + + recoveryValidator := GetDefaultHmyConfigCopy(nodeconfig.Mainnet) + recoveryValidator.Sync.Client = false + recoveryValidator.DNSSync.Client = false + require.NoError(t, validateHarmonyConfig(recoveryValidator)) + + testnet := GetDefaultHmyConfigCopy(nodeconfig.Testnet) + testnet.Sync.Client = false + testnet.DNSSync.Client = false + require.Error(t, validateHarmonyConfig(testnet)) +} diff --git a/cmd/harmony/main.go b/cmd/harmony/main.go index 791bf21816..379c354f08 100644 --- a/cmd/harmony/main.go +++ b/cmd/harmony/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "math/big" _ "net/http/pprof" @@ -28,6 +29,10 @@ import ( "github.com/harmony-one/harmony/consensus" "github.com/harmony-one/harmony/consensus/quorum" "github.com/harmony-one/harmony/core" + "github.com/harmony-one/harmony/core/rawdb" + corestate "github.com/harmony-one/harmony/core/state" + coretypes "github.com/harmony-one/harmony/core/types" + harmonybls "github.com/harmony-one/harmony/crypto/bls" "github.com/harmony-one/harmony/internal/chain" "github.com/harmony-one/harmony/internal/cli" "github.com/harmony-one/harmony/internal/common" @@ -162,27 +167,283 @@ func setupNodeLog(config harmonyconfig.HarmonyConfig) { } } -func revert(chain core.BlockChain, hc harmonyconfig.HarmonyConfig) { +func ensureRevertTargetCommitSig(chain core.BlockChain, target uint64, targetBlock *coretypes.Block) error { + if targetBlock == nil || targetBlock.NumberU64() != target { + return errors.Errorf("cannot verify target certificate for block %d", target) + } + + existing, readErr := rawdb.ReadBlockCommitSigExact(chain.ChainDb(), target) + child := chain.GetBlockByNumber(target + 1) + if child != nil && child.ParentHash() == targetBlock.Hash() { + lastSig := child.Header().LastCommitSignature() + expected := append(lastSig[:], child.Header().LastCommitBitmap()...) + if err := verifyRevertTargetCommitSig(chain, targetBlock, expected); err != nil { + return err + } + // Write unconditionally so a legacy fallback read cannot masquerade as + // the exact block-sig key after an interrupted earlier rollback. + if err := chain.WriteCommitSig(target, expected); err != nil { + return errors.Wrapf(err, "write commit certificate for target block %d", target) + } + readBack, err := rawdb.ReadBlockCommitSigExact(chain.ChainDb(), target) + if err != nil { + return errors.Wrapf(err, "read back commit certificate for target block %d", target) + } + if !bytes.Equal(readBack, expected) { + return errors.Errorf("commit certificate read-back mismatch for target block %d", target) + } + return verifyRevertTargetCommitSig(chain, targetBlock, readBack) + } + if readErr != nil { + return errors.Wrapf(readErr, "read commit certificate for target block %d", target) + } + if len(existing) <= harmonybls.BLSSignatureSizeInBytes { + return errors.Errorf("target block %d has no usable commit certificate or child witness", target) + } + return verifyRevertTargetCommitSig(chain, targetBlock, existing) +} + +func verifyRevertTargetCommitSig(chain core.BlockChain, target *coretypes.Block, certificate []byte) error { + if target == nil || target.Header() == nil || len(certificate) <= harmonybls.BLSSignatureSizeInBytes { + return errors.New("invalid target commit certificate input") + } + if chain.Engine() == nil { + return errors.New("consensus engine unavailable for target certificate verification") + } + var signature harmonybls.SerializedSignature + copy(signature[:], certificate[:harmonybls.BLSSignatureSizeInBytes]) + if err := chain.Engine().VerifyHeaderSignature( + chain, target.Header(), signature, certificate[harmonybls.BLSSignatureSizeInBytes:], + ); err != nil { + return errors.Wrap(err, "target commit certificate cryptographic verification failed") + } + return nil +} + +func verifyRevertTargetState(chain core.BlockChain, target uint64) error { + if target != consensus.EmergencyRecoveryRetainedBlock { + return errors.Errorf("recovery binary only permits retained block %d, got %d", + consensus.EmergencyRecoveryRetainedBlock, target) + } + targetHash, targetRoot, err := consensus.EmergencyRecoveryCheckpoint() + if err != nil { + return err + } + block := chain.GetBlockByNumber(target) + if block == nil || block.Header() == nil { + return errors.Errorf("target block %d is unavailable", target) + } + if block.Hash() != targetHash || block.Root() != targetRoot { + return errors.Errorf("target block tuple mismatch: got hash %s root %s", + block.Hash().Hex(), block.Root().Hex()) + } + stateDB, err := chain.StateAt(targetRoot) + if err != nil { + return errors.Wrap(err, "open target state") + } + iterator := corestate.NewNodeIterator(stateDB) + var nodes uint64 + for iterator.Next() { + nodes++ + if nodes%1_000_000 == 0 { + fmt.Printf("Target state traversal progress: %d entries\n", nodes) + } + } + if iterator.Error != nil { + return errors.Wrap(iterator.Error, "target state traversal failed") + } + fmt.Printf("Target state traversal complete: %d entries\n", nodes) + return nil +} + +func verifyRevertValidatorList(chain core.BlockChain, target uint64, applyRepair bool) error { + block := chain.GetBlockByNumber(target) + if block == nil { + return errors.Errorf("target block %d is unavailable for validator-list verification", target) + } + stateDB, err := chain.StateAt(block.Root()) + if err != nil { + return errors.Wrap(err, "open target state for validator-list verification") + } + validators, err := chain.ReadValidatorList() + if err != nil { + return errors.Wrap(err, "read validator list") + } + seen := make(map[ethCommon.Address]struct{}, len(validators)) + filtered := make([]ethCommon.Address, 0, len(validators)) + for i, address := range validators { + if _, duplicate := seen[address]; duplicate { + return errors.Errorf("validator list contains duplicate address %s at index %d", address.Hex(), i) + } + seen[address] = struct{}{} + if !stateDB.IsValidator(address) { + // Validator-list is append-only in ordinary operation. An address + // absent from the pinned target state was therefore created on the + // discarded branch (or left by an interrupted legacy rollback). + continue + } + wrapper, err := stateDB.ValidatorWrapper(address, true, false) + if err != nil { + return errors.Wrapf(err, "load target validator %s", address.Hex()) + } + if wrapper.Address != address { + return errors.Errorf("target validator wrapper address mismatch: key %s wrapper %s", + address.Hex(), wrapper.Address.Hex()) + } + if wrapper.CreationHeight == nil || wrapper.CreationHeight.Sign() < 0 || + !wrapper.CreationHeight.IsUint64() || wrapper.CreationHeight.Uint64() > target { + return errors.Errorf("target validator %s has invalid creation height %v", + address.Hex(), wrapper.CreationHeight) + } + if err := wrapper.SanityCheck(); err != nil { + return errors.Wrapf(err, "target validator %s failed sanity check", address.Hex()) + } + filtered = append(filtered, address) + } + // Validate the complete ordered result before writing anything. This both + // detects missing pre-target validators and prevents a local DB from choosing + // its own candidate set. + count, digest, err := consensus.EmergencyRecoveryValidatorListManifest(filtered) + if err != nil { + return errors.Wrap(err, "compute target validator-list manifest") + } + fmt.Printf("Target validator list candidate: %d entries sha256 %s\n", count, digest) + if err := consensus.ValidateEmergencyRecoveryValidatorList(filtered); err != nil { + return err + } + if applyRepair && len(filtered) != len(validators) { + if err := chain.WriteValidatorList(chain.ChainDb(), filtered); err != nil { + return errors.Wrap(err, "write repaired target validator list") + } + readBack, err := chain.ReadValidatorList() + if err != nil { + return errors.Wrap(err, "read back repaired target validator list") + } + if len(readBack) != len(filtered) { + return errors.New("validator-list repair read-back count mismatch") + } + for i := range filtered { + if readBack[i] != filtered[i] { + return errors.Errorf("validator-list repair read-back mismatch at index %d", i) + } + } + fmt.Printf("Target validator list repair removed: %d entries\n", len(validators)-len(filtered)) + } + fmt.Printf("Target validator list verification complete: %d entries\n", len(filtered)) + return nil +} + +func rollbackEmergencyRecoveryChain( + chain core.BlockChain, target uint64, expectedTargetHash ethCommon.Hash, prepare func() error, +) error { + current := chain.CurrentBlock() + if current == nil { + return errors.New("rollback current block is unavailable") + } + if current.NumberU64() < target { + return errors.Errorf("rollback head %d is below target %d", current.NumberU64(), target) + } + + // Prove every parent needed by Rollback before changing the first head or + // commit-certificate key. This avoids a partially rewound DB when an + // intermediate historical block is missing. + cursor := current + for cursor.NumberU64() > target { + parentNumber := cursor.NumberU64() - 1 + parent := chain.GetBlock(cursor.ParentHash(), parentNumber) + if parent == nil { + return errors.Errorf("rollback ancestry is incomplete at block %d: parent %s is unavailable", + cursor.NumberU64(), cursor.ParentHash().Hex()) + } + cursor = parent + } + if cursor.Hash() != expectedTargetHash { + return errors.Errorf("rollback ancestry does not reach expected target %s: got %s at block %d", + expectedTargetHash.Hex(), cursor.Hash().Hex(), cursor.NumberU64()) + } + if prepare != nil { + if err := prepare(); err != nil { + return errors.Wrap(err, "prepare recovery rollback") + } + } + + for chain.CurrentBlock().NumberU64() > target { + before := chain.CurrentBlock() + if err := chain.Rollback([]ethCommon.Hash{before.Hash()}); err != nil { + return errors.Wrap(err, "revert rollback failed") + } + after := chain.CurrentBlock() + if after == nil { + return errors.Errorf("rollback of block %d left no current head", before.NumberU64()) + } + if after.Hash() == before.Hash() && after.NumberU64() == before.NumberU64() { + return errors.Errorf("rollback of block %d made no progress", before.NumberU64()) + } + if after.NumberU64()+1 != before.NumberU64() || after.Hash() != before.ParentHash() { + return errors.Errorf("rollback of block %d moved to unexpected head %d (%s), want %d (%s)", + before.NumberU64(), after.NumberU64(), after.Hash().Hex(), + before.NumberU64()-1, before.ParentHash().Hex()) + } + } + return nil +} + +func revert(chain core.BlockChain, hc harmonyconfig.HarmonyConfig) (bool, error) { curNum := chain.CurrentBlock().NumberU64() + target := uint64(hc.Revert.RevertTo) - 1 + // Prove the exact target's complete account/storage/code trie before changing + // a single head pointer. Missing historical state therefore leaves the node + // stopped on its original database instead of partially rewound. + if err := verifyRevertTargetState(chain, target); err != nil { + return false, err + } + targetBlock := chain.GetBlockByNumber(target) + if targetBlock == nil { + return false, errors.Errorf("target block %d disappeared after state verification", target) + } + expectedTargetHash, _, err := consensus.EmergencyRecoveryCheckpoint() + if err != nil { + return false, err + } + // Validate the exact target candidate list before changing a single head or + // metadata key. The second call below applies the same verified transform. + if err := verifyRevertValidatorList(chain, target, false); err != nil { + return false, err + } + if curNum == target { + if err := ensureRevertTargetCommitSig(chain, target, targetBlock); err != nil { + return false, err + } + if err := verifyRevertValidatorList(chain, target, true); err != nil { + return false, err + } + if err := consensus.ValidateEmergencyRecoveryCheckpoint(chain); err != nil { + return false, errors.Wrap(err, "verify recovery postconditions") + } + fmt.Printf("Revert finished. Current block: %v\n", chain.CurrentBlock().NumberU64()) + return true, nil + } if curNum < uint64(hc.Revert.RevertBefore) && curNum >= uint64(hc.Revert.RevertTo) { // Remove invalid blocks - for chain.CurrentBlock().NumberU64() >= uint64(hc.Revert.RevertTo) { - curBlock := chain.CurrentBlock() - rollbacks := []ethCommon.Hash{curBlock.Hash()} - if err := chain.Rollback(rollbacks); err != nil { - fmt.Printf("Revert failed: %v\n", err) - os.Exit(1) - } - lastSig := curBlock.Header().LastCommitSignature() - sigAndBitMap := append(lastSig[:], curBlock.Header().LastCommitBitmap()...) - chain.WriteCommitSig(curBlock.NumberU64()-1, sigAndBitMap) + prepare := func() error { + return ensureRevertTargetCommitSig(chain, target, targetBlock) + } + if err := rollbackEmergencyRecoveryChain(chain, target, expectedTargetHash, prepare); err != nil { + return false, err + } + if err := verifyRevertValidatorList(chain, target, true); err != nil { + return false, err + } + if err := consensus.ValidateEmergencyRecoveryCheckpoint(chain); err != nil { + return false, errors.Wrap(err, "verify recovery postconditions") } fmt.Printf("Revert finished. Current block: %v\n", chain.CurrentBlock().NumberU64()) utils.Logger().Warn(). Uint64("Current Block", chain.CurrentBlock().NumberU64()). Msg("Revert finished.") - os.Exit(1) + return true, nil } + return false, nil } func setupNodeAndRun(hc harmonyconfig.HarmonyConfig) { @@ -221,6 +482,12 @@ func setupNodeAndRun(hc harmonyconfig.HarmonyConfig) { initialAccount.ShardID = uint32(hc.General.ShardID) } } + // This one-off recovery release never permits a shard-0 head jump. Enforce + // isolation in the binary so a validator's pre-existing TOML cannot silently + // re-enable a sync path during coordinated activation. + if shardID, ok := emergencyRecoveryShardID(hc, initialAccounts); ok { + applyEmergencyRecoveryNetworkIsolation(&hc, shardID) + } nodeConfig, err := createGlobalConfig(hc) if err != nil { @@ -244,9 +511,13 @@ func setupNodeAndRun(hc harmonyconfig.HarmonyConfig) { nodeconfig.GetDefaultConfig().ShardID = nodeConfig.ShardID nodeconfig.GetDefaultConfig().IsOffline = nodeConfig.IsOffline nodeconfig.GetDefaultConfig().SyncClient = nodeConfig.SyncClient + if err := validateEmergencyRecoveryStartup(hc, currentNode.Blockchain()); err != nil { + utils.Logger().Panic().Err(err). + Msg("refusing unsafe emergency-recovery startup") + } // It skips the time accuracy check on the localnet since all nodes are running on the same machine - if hc.Network.NetworkType != nodeconfig.Localnet { + if hc.Network.NetworkType != nodeconfig.Localnet && !hc.General.IsOffline { clockAccuracyResp, err := ntp.CheckLocalTimeAccurate(nodeConfig.NtpServer) if !clockAccuracyResp.IsAccurate() { if clockAccuracyResp.AllNtpServersTimedOut() { @@ -277,7 +548,16 @@ func setupNodeAndRun(hc harmonyconfig.HarmonyConfig) { if hc.Revert.RevertBeacon { chain = currentNode.Beaconchain() } - revert(chain, hc) + didRevert, err := revert(chain, hc) + if err != nil { + fmt.Fprintf(os.Stderr, "Revert failed: %v\n", err) + currentNode.ShutDownWithExitCode(1) + } + if didRevert { + // Close LevelDB and flush the selected head before reporting process + // success. ShutDown exits zero after the close completes. + currentNode.ShutDown() + } } //// code to handle pre-image export, import and generation @@ -434,6 +714,115 @@ func setupNodeAndRun(hc harmonyconfig.HarmonyConfig) { select {} } +// validateEmergencyRecoveryStartup permits offline maintenance (including the +// one-shot --revert invocation), but makes every networked mainnet shard-0 +// start prove the exact recovery ancestry and disable head-jump sync paths. +func validateEmergencyRecoveryStartup(hc harmonyconfig.HarmonyConfig, blockchain core.BlockChain) error { + if hc.General.IsOffline { + return nil + } + if blockchain == nil { + return errors.New("nil blockchain during startup validation") + } + if !consensus.IsEmergencyRecoveryMainnetShard0(blockchain.Config(), blockchain.ShardID()) { + return nil + } + if hc.General.RunElasticMode { + return errors.New("TiKV elastic mode is disabled during emergency recovery") + } + if hc.Sync.Enabled || hc.Sync.Client || hc.DNSSync.Client || hc.DNSSync.Server { + return errors.New("all stream and legacy sync clients/servers must be disabled during emergency recovery") + } + if err := consensus.ValidateEmergencyRecoveryCheckpoint(blockchain); err != nil { + return err + } + return validateEmergencyRecoveryStartupValidatorList(blockchain) +} + +// validateEmergencyRecoveryStartupValidatorList checks the raw, unversioned +// list exactly as persisted. Unlike the offline repair candidate, startup must +// not filter or normalize it: any leftover abandoned-branch address means the +// recovery cut did not complete and the node must remain stopped. +func validateEmergencyRecoveryStartupValidatorList(blockchain core.BlockChain) error { + return validateEmergencyRecoveryStartupValidatorListWith( + blockchain, consensus.ValidateEmergencyRecoveryValidatorList, + ) +} + +func validateEmergencyRecoveryStartupValidatorListWith( + blockchain core.BlockChain, + validateManifest func([]ethCommon.Address) error, +) error { + if blockchain == nil || blockchain.CurrentBlock() == nil { + return errors.New("cannot validate recovery validator list without a chain head") + } + validators, err := blockchain.ReadValidatorList() + if err != nil { + return errors.Wrap(err, "read recovery validator list at startup") + } + if validateManifest == nil { + return errors.New("nil recovery validator-list manifest validator") + } + if err := validateManifest(validators); err != nil { + return err + } + stateDB, err := blockchain.StateAt(blockchain.CurrentBlock().Root()) + if err != nil { + return errors.Wrap(err, "open current state for recovery validator-list startup check") + } + seen := make(map[ethCommon.Address]struct{}, len(validators)) + for i, address := range validators { + if _, duplicate := seen[address]; duplicate { + return errors.Errorf("recovery validator list contains duplicate %s at index %d", address.Hex(), i) + } + seen[address] = struct{}{} + if !stateDB.IsValidator(address) { + return errors.Errorf("recovery validator list address %s is absent from current state", address.Hex()) + } + wrapper, err := stateDB.ValidatorWrapper(address, true, false) + if err != nil { + return errors.Wrapf(err, "load recovery validator %s at startup", address.Hex()) + } + if wrapper.Address != address { + return errors.Errorf("recovery validator wrapper address mismatch: key %s wrapper %s", + address.Hex(), wrapper.Address.Hex()) + } + if wrapper.CreationHeight == nil || wrapper.CreationHeight.Sign() < 0 || + !wrapper.CreationHeight.IsUint64() || + wrapper.CreationHeight.Uint64() > consensus.EmergencyRecoveryRetainedBlock { + return errors.Errorf("recovery validator %s has invalid creation height %v", + address.Hex(), wrapper.CreationHeight) + } + if err := wrapper.SanityCheck(); err != nil { + return errors.Wrapf(err, "recovery validator %s failed startup sanity check", address.Hex()) + } + } + return nil +} + +func applyEmergencyRecoveryNetworkIsolation(hc *harmonyconfig.HarmonyConfig, shardID uint32) bool { + if hc == nil || hc.Network.NetworkType != nodeconfig.Mainnet || shardID != shard.BeaconChainShardID { + return false + } + hc.Sync.Enabled = false + hc.Sync.Client = false + hc.DNSSync.Client = false + hc.DNSSync.Server = false + return true +} + +func emergencyRecoveryShardID( + hc harmonyconfig.HarmonyConfig, accounts []*genesis.DeployAccount, +) (uint32, bool) { + if len(accounts) != 0 { + return accounts[0].ShardID, true + } + if hc.General.ShardID < 0 { + return 0, false + } + return uint32(hc.General.ShardID), true +} + func nodeconfigSetShardSchedule(config harmonyconfig.HarmonyConfig) { switch config.Network.NetworkType { case nodeconfig.Mainnet: @@ -783,16 +1172,41 @@ func setupConsensusAndNode(hc harmonyconfig.HarmonyConfig, nodeConfig *nodeconfi Msg("InitConsensusWithMembers failed") } - // Set the consensus ID to be the current block number + // Offline maintenance never starts consensus signing, so it must remain + // usable for preflight and rollback even before release constants are filled. + // Every networked validator installs the floor before any signing service. + if !hc.General.IsOffline { + if err := currentConsensus.ConfigureEmergencyRecoveryViewIDFloor(); err != nil { + utils.Logger().Panic().Err(err). + Msg("refusing to start without a valid emergency recovery ViewID floor") + } + } + + // Set the consensus ID to the checked successor of the retained head's view. viewID := currentNode.Blockchain().CurrentBlock().Header().ViewID().Uint64() - currentConsensus.SetViewIDs(viewID + 1) + nextViewID, err := consensus.CheckedNextViewID(viewID) + if err != nil { + utils.Logger().Panic().Err(err). + Uint64("headViewID", viewID). + Msg("refusing to start because the head ViewID cannot advance") + } + currentConsensus.SetViewIDs(nextViewID) utils.Logger().Info(). - Uint64("viewID", viewID). + Uint64("headViewID", viewID). + Uint64("consensusViewID", currentConsensus.GetCurBlockViewID()). Msg("Init Blockchain") currentNode.Consensus.Registry().SetNodeConfig(currentNode.NodeConfig) - // update consensus information based on the blockchain - currentConsensus.SetMode(currentConsensus.UpdateConsensusInformation("setupConsensusAndNode")) + // Update the committee first, then derive the leader from the exact recovery + // view gap before StartChannel can trigger a proposal. + mode := currentConsensus.UpdateConsensusInformation("setupConsensusAndNode") + if !hc.General.IsOffline { + if err := currentConsensus.InitializeEmergencyRecoveryLeader(); err != nil { + utils.Logger().Panic().Err(err). + Msg("refusing to start without a deterministic emergency recovery leader") + } + } + currentConsensus.SetMode(mode) currentConsensus.NextBlockDue = time.Now() return currentNode } diff --git a/cmd/harmony/recovery_rollback_test.go b/cmd/harmony/recovery_rollback_test.go new file mode 100644 index 0000000000..c973d4d78c --- /dev/null +++ b/cmd/harmony/recovery_rollback_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/core" + "github.com/harmony-one/harmony/core/types" + "github.com/stretchr/testify/require" +) + +type recoveryRollbackTestChain struct { + core.Stub + current *types.Block + blocks map[common.Hash]*types.Block + rollbackErr error + rollbackNoop bool + commitWriteErr error + commitWrites []uint64 + rollbackCalls int +} + +func (c *recoveryRollbackTestChain) CurrentBlock() *types.Block { return c.current } +func (c *recoveryRollbackTestChain) GetBlock(hash common.Hash, number uint64) *types.Block { + block := c.blocks[hash] + if block == nil || block.NumberU64() != number { + return nil + } + return block +} +func (c *recoveryRollbackTestChain) Rollback(hashes []common.Hash) error { + c.rollbackCalls++ + if c.rollbackErr != nil { + return c.rollbackErr + } + if c.rollbackNoop { + return nil + } + if len(hashes) != 1 || c.current == nil || hashes[0] != c.current.Hash() { + return errors.New("unexpected rollback request") + } + c.current = c.blocks[c.current.ParentHash()] + return nil +} +func (c *recoveryRollbackTestChain) WriteCommitSig(number uint64, _ []byte) error { + if c.commitWriteErr != nil { + return c.commitWriteErr + } + c.commitWrites = append(c.commitWrites, number) + return nil +} + +func recoveryRollbackTestBlock(number uint64, parent common.Hash) *types.Block { + header := blockfactory.ForMainnet.NewHeader(big.NewInt(3002)) + header.SetNumber(new(big.Int).SetUint64(number)) + header.SetParentHash(parent) + return types.NewBlockWithHeader(header) +} + +func TestRollbackEmergencyRecoveryChainRejectsNoProgress(t *testing.T) { + target := recoveryRollbackTestBlock(10, common.HexToHash("0x01")) + current := recoveryRollbackTestBlock(11, target.Hash()) + chain := &recoveryRollbackTestChain{ + current: current, + blocks: map[common.Hash]*types.Block{target.Hash(): target}, + rollbackNoop: true, + } + + err := rollbackEmergencyRecoveryChain(chain, target.NumberU64(), target.Hash(), nil) + require.ErrorContains(t, err, "made no progress") + require.Equal(t, 1, chain.rollbackCalls) + require.Empty(t, chain.commitWrites) +} + +func TestRollbackEmergencyRecoveryChainPreflightsAncestry(t *testing.T) { + target := recoveryRollbackTestBlock(10, common.HexToHash("0x01")) + current := recoveryRollbackTestBlock(12, common.HexToHash("0xmissing")) + chain := &recoveryRollbackTestChain{ + current: current, + blocks: map[common.Hash]*types.Block{target.Hash(): target}, + } + + err := rollbackEmergencyRecoveryChain(chain, target.NumberU64(), target.Hash(), nil) + require.ErrorContains(t, err, "rollback ancestry is incomplete") + require.Zero(t, chain.rollbackCalls) + require.Empty(t, chain.commitWrites) +} + +func TestRollbackEmergencyRecoveryChainMovesToTarget(t *testing.T) { + target := recoveryRollbackTestBlock(10, common.HexToHash("0x01")) + middle := recoveryRollbackTestBlock(11, target.Hash()) + current := recoveryRollbackTestBlock(12, middle.Hash()) + chain := &recoveryRollbackTestChain{ + current: current, + blocks: map[common.Hash]*types.Block{ + target.Hash(): target, + middle.Hash(): middle, + }, + } + + require.NoError(t, rollbackEmergencyRecoveryChain(chain, target.NumberU64(), target.Hash(), nil)) + require.Equal(t, target.Hash(), chain.current.Hash()) + require.Equal(t, 2, chain.rollbackCalls) + require.Empty(t, chain.commitWrites) +} + +func TestRollbackEmergencyRecoveryChainRejectsWrongTargetAncestry(t *testing.T) { + expectedTarget := recoveryRollbackTestBlock(10, common.HexToHash("0x01")) + wrongTarget := recoveryRollbackTestBlock(10, common.HexToHash("0x02")) + middle := recoveryRollbackTestBlock(11, wrongTarget.Hash()) + current := recoveryRollbackTestBlock(12, middle.Hash()) + chain := &recoveryRollbackTestChain{ + current: current, + blocks: map[common.Hash]*types.Block{ + wrongTarget.Hash(): wrongTarget, + middle.Hash(): middle, + }, + } + + err := rollbackEmergencyRecoveryChain(chain, expectedTarget.NumberU64(), expectedTarget.Hash(), nil) + require.ErrorContains(t, err, "does not reach expected target") + require.Zero(t, chain.rollbackCalls) + require.Empty(t, chain.commitWrites) + require.Equal(t, current.Hash(), chain.current.Hash()) +} + +func TestRollbackEmergencyRecoveryChainPreparesBeforeMovingHead(t *testing.T) { + target := recoveryRollbackTestBlock(10, common.HexToHash("0x01")) + current := recoveryRollbackTestBlock(11, target.Hash()) + chain := &recoveryRollbackTestChain{ + current: current, + blocks: map[common.Hash]*types.Block{target.Hash(): target}, + commitWriteErr: errors.New("certificate write failed"), + } + prepareCalls := 0 + prepare := func() error { + prepareCalls++ + return chain.WriteCommitSig(target.NumberU64(), nil) + } + + err := rollbackEmergencyRecoveryChain(chain, target.NumberU64(), target.Hash(), prepare) + require.ErrorContains(t, err, "certificate write failed") + require.Equal(t, 1, prepareCalls) + require.Zero(t, chain.rollbackCalls) + require.Empty(t, chain.commitWrites) + require.Equal(t, current.Hash(), chain.current.Hash()) +} diff --git a/cmd/harmony/recovery_startup_test.go b/cmd/harmony/recovery_startup_test.go new file mode 100644 index 0000000000..423bd6b207 --- /dev/null +++ b/cmd/harmony/recovery_startup_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/core" + "github.com/harmony-one/harmony/core/rawdb" + corestate "github.com/harmony-one/harmony/core/state" + "github.com/harmony-one/harmony/core/types" + harmonyconfig "github.com/harmony-one/harmony/internal/configs/harmony" + "github.com/harmony-one/harmony/internal/genesis" + "github.com/harmony-one/harmony/internal/params" + staketest "github.com/harmony-one/harmony/staking/types/test" + "github.com/stretchr/testify/require" +) + +type recoveryStartupTestChain struct { + core.Stub + config *params.ChainConfig + shardID uint32 + current *types.Block + validators []common.Address + stateDB *corestate.DB +} + +func (c *recoveryStartupTestChain) Config() *params.ChainConfig { return c.config } +func (c *recoveryStartupTestChain) ShardID() uint32 { return c.shardID } +func (c *recoveryStartupTestChain) CurrentBlock() *types.Block { return c.current } +func (c *recoveryStartupTestChain) ReadValidatorList() ([]common.Address, error) { + return c.validators, nil +} +func (c *recoveryStartupTestChain) StateAt(common.Hash) (*corestate.DB, error) { + return c.stateDB, nil +} + +func TestEmergencyRecoveryStartupSettings(t *testing.T) { + mainnet := &recoveryStartupTestChain{config: params.MainnetChainConfig, shardID: 0} + + require.NoError(t, validateEmergencyRecoveryStartup( + harmonyconfig.HarmonyConfig{General: harmonyconfig.GeneralConfig{IsOffline: true}}, nil, + )) + require.NoError(t, validateEmergencyRecoveryStartup( + harmonyconfig.HarmonyConfig{}, + &recoveryStartupTestChain{config: params.TestnetChainConfig, shardID: 0}, + )) + require.NoError(t, validateEmergencyRecoveryStartup( + harmonyconfig.HarmonyConfig{}, + &recoveryStartupTestChain{config: params.MainnetChainConfig, shardID: 1}, + )) + + tests := []struct { + name string + config harmonyconfig.HarmonyConfig + }{ + {name: "stream service", config: harmonyconfig.HarmonyConfig{Sync: harmonyconfig.SyncConfig{Enabled: true}}}, + {name: "stream client", config: harmonyconfig.HarmonyConfig{Sync: harmonyconfig.SyncConfig{Client: true}}}, + {name: "legacy client", config: harmonyconfig.HarmonyConfig{DNSSync: harmonyconfig.DnsSync{Client: true}}}, + {name: "legacy server", config: harmonyconfig.HarmonyConfig{DNSSync: harmonyconfig.DnsSync{Server: true}}}, + {name: "elastic mode", config: harmonyconfig.HarmonyConfig{General: harmonyconfig.GeneralConfig{RunElasticMode: true}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Error(t, validateEmergencyRecoveryStartup(test.config, mainnet)) + }) + } + + // With all jump/import modes disabled, a structurally empty chain still + // fails the compiled checkpoint. This assertion remains valid after release + // constants replace the development placeholders. + require.Error(t, validateEmergencyRecoveryStartup(harmonyconfig.HarmonyConfig{}, mainnet)) +} + +func TestEmergencyRecoveryStartupRejectsUntrustedRawValidatorList(t *testing.T) { + address := common.HexToAddress("0x0000000000000000000000000000000000000001") + stateDB, err := corestate.New(common.Hash{}, corestate.NewDatabase(rawdb.NewMemoryDatabase()), nil) + require.NoError(t, err) + wrapper := staketest.GetDefaultValidatorWrapperWithAddr(address, nil) + wrapper.SlotPubKeys = staketest.GetDefaultValidatorWrapper().SlotPubKeys + wrapper.CreationHeight = big.NewInt(1) + require.NoError(t, stateDB.UpdateValidatorWrapper(address, &wrapper)) + stateDB.SetValidatorFlag(address) + + chain := &recoveryStartupTestChain{ + config: params.MainnetChainConfig, + shardID: 0, + current: types.NewBlockWithHeader(blockfactory.NewTestHeader()), + validators: []common.Address{address}, + stateDB: stateDB, + } + acceptManifest := func([]common.Address) error { return nil } + require.NoError(t, validateEmergencyRecoveryStartupValidatorListWith(chain, acceptManifest)) + + missingState, err := corestate.New(common.Hash{}, corestate.NewDatabase(rawdb.NewMemoryDatabase()), nil) + require.NoError(t, err) + chain.stateDB = missingState + require.Error(t, validateEmergencyRecoveryStartupValidatorListWith(chain, acceptManifest)) +} + +func TestEmergencyRecoveryNetworkIsolationIsForcedForMainnetShardZero(t *testing.T) { + config := harmonyconfig.HarmonyConfig{ + Network: harmonyconfig.NetworkConfig{NetworkType: "mainnet"}, + Sync: harmonyconfig.SyncConfig{Enabled: true, Client: true}, + DNSSync: harmonyconfig.DnsSync{Client: true, Server: true}, + } + require.True(t, applyEmergencyRecoveryNetworkIsolation(&config, 0)) + require.False(t, config.Sync.Enabled) + require.False(t, config.Sync.Client) + require.False(t, config.DNSSync.Client) + require.False(t, config.DNSSync.Server) + + testnet := harmonyconfig.HarmonyConfig{ + Network: harmonyconfig.NetworkConfig{NetworkType: "testnet"}, + Sync: harmonyconfig.SyncConfig{Enabled: true, Client: true}, + DNSSync: harmonyconfig.DnsSync{Client: true, Server: true}, + } + require.False(t, applyEmergencyRecoveryNetworkIsolation(&testnet, 0)) + require.True(t, testnet.Sync.Enabled) + + shardOne := harmonyconfig.HarmonyConfig{ + Network: harmonyconfig.NetworkConfig{NetworkType: "mainnet"}, + Sync: harmonyconfig.SyncConfig{Enabled: true, Client: true}, + DNSSync: harmonyconfig.DnsSync{Client: true, Server: true}, + } + require.False(t, applyEmergencyRecoveryNetworkIsolation(&shardOne, 1)) + require.True(t, shardOne.Sync.Enabled) +} + +func TestEmergencyRecoveryShardIDHandlesEmptyInitialAccounts(t *testing.T) { + hc := harmonyconfig.HarmonyConfig{General: harmonyconfig.GeneralConfig{ShardID: 1}} + shardID, ok := emergencyRecoveryShardID(hc, nil) + require.True(t, ok) + require.Equal(t, uint32(1), shardID) + + hc.General.ShardID = -1 + _, ok = emergencyRecoveryShardID(hc, nil) + require.False(t, ok) + + accounts := []*genesis.DeployAccount{{ShardID: 0}} + shardID, ok = emergencyRecoveryShardID(hc, accounts) + require.True(t, ok) + require.Equal(t, uint32(0), shardID) +} diff --git a/consensus/checks.go b/consensus/checks.go index 739c19dd6c..bc9467ea28 100644 --- a/consensus/checks.go +++ b/consensus/checks.go @@ -142,6 +142,12 @@ func (consensus *Consensus) newBlockSanityChecks( // TODO: leo: move the sanity check to p2p message validation func (consensus *Consensus) onViewChangeSanityCheck(recvMsg *FBFTMessage) bool { + if err := consensus.assertEmergencyRecoveryViewID(recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgViewChangingID", recvMsg.ViewID). + Msg("[onViewChangeSanityCheck] rejected ViewID below recovery floor") + return false + } // TODO: if difference is only one, new leader can still propose the same committed block to avoid another view change // TODO: new leader catchup without ignore view change message @@ -194,6 +200,12 @@ func (consensus *Consensus) onViewChangeSanityCheck(recvMsg *FBFTMessage) bool { // TODO: leo: move the sanity check to p2p message validation func (consensus *Consensus) onNewViewSanityCheck(recvMsg *FBFTMessage) bool { + if err := consensus.assertEmergencyRecoveryViewID(recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgViewChangingID", recvMsg.ViewID). + Msg("[onNewView] rejected ViewID below recovery floor") + return false + } if recvMsg.ViewID < consensus.getCurBlockViewID() { consensus.getLogger().Warn(). Uint64("LastSuccessfulConsensusViewID", consensus.getCurBlockViewID()). diff --git a/consensus/consensus_block_proposing.go b/consensus/consensus_block_proposing.go index 64200f93b1..0c165389e5 100644 --- a/consensus/consensus_block_proposing.go +++ b/consensus/consensus_block_proposing.go @@ -23,6 +23,9 @@ const ( // ProposeNewBlock proposes a new block... func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byte) (*types.Block, error) { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getCurBlockViewID()); err != nil { + return nil, errors.Wrap(err, "refusing to propose with unsafe ViewID") + } var ( currentHeader = consensus.Blockchain().CurrentHeader() nowEpoch = currentHeader.Epoch() @@ -40,6 +43,9 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt return nil, errors.Wrap(err, "failed to update worker") } header := env.CurrentHeader() + recoveryFeatureFreeze := core.IsEmergencyRecoveryFeatureFreeze( + consensus.Blockchain().Config(), consensus.ShardID, header.Number().Uint64(), + ) shardState, err := consensus.Blockchain().ReadShardState(header.Epoch()) if err != nil { return nil, errors.WithMessage(err, "failed to read shard") @@ -99,7 +105,7 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt plainTxsPerAcc = append(plainTxsPerAcc, plainTx) } else if stakingTx, ok := tx.(*staking.StakingTransaction); ok { // Only process staking transactions after pre-staking epoch happened. - if consensus.Blockchain().Config().IsPreStaking(worker.GetCurrentHeader().Epoch()) { + if !recoveryFeatureFreeze && consensus.Blockchain().Config().IsPreStaking(worker.GetCurrentHeader().Epoch()) { pendingStakingTxs = append(pendingStakingTxs, stakingTx) } } else { @@ -131,7 +137,10 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt // being a significant problem, the source shards will stop // accepting txs destined to the shards which are shutting down // one epoch prior the shut down - receiptsList := consensus.proposeReceiptsProof() + var receiptsList []*types.CXReceiptsProof + if !recoveryFeatureFreeze { + receiptsList = consensus.proposeReceiptsProof() + } if len(receiptsList) != 0 { if err := worker.CommitReceipts(receiptsList); err != nil { @@ -148,7 +157,7 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt utils.AnalysisStart("proposeNewBlockVerifyCrossLinks") // Prepare cross links and slashing messages var crossLinksToPropose types.CrossLinks - if isBeaconchainInCrossLinkEra { + if isBeaconchainInCrossLinkEra && !recoveryFeatureFreeze { allPending, err := consensus.Blockchain().ReadPendingCrossLinks() invalidToDelete := []types.CrossLink{} if err == nil { @@ -211,7 +220,7 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt } utils.AnalysisEnd("proposeNewBlockVerifyCrossLinks") - if isBeaconchainInStakingEra { + if isBeaconchainInStakingEra && !recoveryFeatureFreeze { // this will set a meaningful w.current.slashes if err := worker.CollectVerifiedSlashes(); err != nil { return nil, err @@ -238,6 +247,9 @@ func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byt consensus.GetLogger().Error().Err(err).Msg("[ProposeNewBlock] Failed finalizing the new block") return nil, err } + if err := core.ValidateEmergencyRecoveryBlockPolicy(consensus.Blockchain().Config(), finalizedBlock); err != nil { + return nil, errors.Wrap(err, "proposed block contains an emergency-frozen payload") + } consensus.GetLogger().Info().Msg("[ProposeNewBlock] verifying the new block header") err = core.NewBlockValidator(consensus.Blockchain()).ValidateHeader(finalizedBlock, true) diff --git a/consensus/consensus_service.go b/consensus/consensus_service.go index 0cc54f8829..fb5a342f97 100644 --- a/consensus/consensus_service.go +++ b/consensus/consensus_service.go @@ -268,6 +268,9 @@ func (consensus *Consensus) RegisterRndChannel(rndChannel chan [548]byte) { // Check viewID, caller's responsibility to hold lock when change ignoreViewIDCheck func (consensus *Consensus) checkViewID(msg *FBFTMessage) error { + if err := consensus.assertEmergencyRecoveryViewID(msg.ViewID); err != nil { + return err + } // just ignore consensus check for the first time when node join if consensus.IgnoreViewIDCheck.IsSet() { //in syncing mode, node accepts incoming messages without viewID/leaderKey checking @@ -494,8 +497,9 @@ func (consensus *Consensus) updateConsensusInformation(reason string) Mode { } // If the leader changed and I myself become the leader - if (oldLeader != nil && consensus.getLeaderPubKey() != nil && - !consensus.getLeaderPubKey().Object.IsEqual(oldLeader.Object)) && consensus.isLeader() { + if consensus.current.GetViewIDFloor() == 0 && + (oldLeader != nil && consensus.getLeaderPubKey() != nil && + !consensus.getLeaderPubKey().Object.IsEqual(oldLeader.Object)) && consensus.isLeader() { go func() { consensus.GetLogger().Info(). Str("myKey", myPubKeys.SerializeToHexStr()). @@ -619,6 +623,9 @@ func (consensus *Consensus) selfCommit(payload []byte) error { if block == nil { return errGetPreparedBlock } + if err := consensus.assertEmergencyRecoveryBlockViewID(block.Header().ViewID().Uint64()); err != nil { + return err + } aggSig, mask, err := readSignatureBitmapPayload(payload, 32, consensus.decider().Participants()) if err != nil { diff --git a/consensus/consensus_v2.go b/consensus/consensus_v2.go index 728a964687..5869268a5b 100644 --- a/consensus/consensus_v2.go +++ b/consensus/consensus_v2.go @@ -377,8 +377,16 @@ func (consensus *Consensus) StartChannel() { func (consensus *Consensus) syncReadyChan(reason string) { consensus.getLogger().Info().Msgf("[ConsensusMainLoop] syncReadyChan %s", reason) if consensus.getBlockNum() < consensus.Blockchain().CurrentHeader().Number().Uint64()+1 { - consensus.setBlockNum(consensus.Blockchain().CurrentHeader().Number().Uint64() + 1) - consensus.setViewIDs(consensus.Blockchain().CurrentHeader().ViewID().Uint64() + 1) + currentHeader := consensus.Blockchain().CurrentHeader() + nextViewID, err := checkedNextViewID(currentHeader.ViewID().Uint64()) + if err != nil { + consensus.getLogger().Error().Err(err). + Uint64("headViewID", currentHeader.ViewID().Uint64()). + Msg("[syncReadyChan] refusing to reset to an exhausted ViewID") + return + } + consensus.setBlockNum(currentHeader.Number().Uint64() + 1) + consensus.setViewIDs(nextViewID) mode := consensus.updateConsensusInformation(reason) consensus.current.SetMode(mode) consensus.getLogger().Info().Msg("[syncReadyChan] Start consensus timer") @@ -715,7 +723,9 @@ func (consensus *Consensus) commitBlock(blk *types.Block, committedMsg *FBFTMess consensus.FinishFinalityCount() consensus.postConsensusProcessing(blk) - consensus.setupForNewConsensus(blk, committedMsg) + if err := consensus.setupForNewConsensus(blk, committedMsg); err != nil { + return err + } consensus.getLogger().Info().Uint64("blockNum", blk.NumberU64()). Str("hash", blk.Header().Hash().Hex()). Msg("Added New Block to Blockchain!!!") @@ -840,9 +850,13 @@ func (consensus *Consensus) rotateLeader(epoch *big.Int, defaultKey *bls.PublicK } // SetupForNewConsensus sets the state for new consensus -func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg *FBFTMessage) { +func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg *FBFTMessage) error { + nextViewID, err := checkedNextViewID(committedMsg.ViewID) + if err != nil { + return errors.Wrap(err, "cannot advance consensus ViewID") + } consensus.setBlockNum(blk.NumberU64() + 1) - consensus.setCurBlockViewID(committedMsg.ViewID + 1) + consensus.setCurBlockViewID(nextViewID) var epoch *big.Int if blk.IsLastBlockInEpoch() { epoch = new(big.Int).Add(blk.Epoch(), common.Big1) @@ -897,6 +911,7 @@ func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg consensus.fBFTLog.PruneCacheBeforeBlock(blk.NumberU64()) consensus.resetState() consensus.sendLastSignPower() + return nil } func (consensus *Consensus) postCatchup(initBN uint64) { diff --git a/consensus/construct.go b/consensus/construct.go index dab4026918..98be4a817b 100644 --- a/consensus/construct.go +++ b/consensus/construct.go @@ -58,6 +58,9 @@ func (pm *State) populateMessageFieldsAndSender( func (consensus *Consensus) construct( p msg_pb.MessageType, payloadForSign []byte, priKeys []*bls.PrivateKeyWrapper, ) (*NetworkMessage, error) { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getCurBlockViewID()); err != nil { + return nil, err + } if len(priKeys) == 0 { return nil, errors.New("no elected bls keys provided") } diff --git a/consensus/engine/rejected_block.go b/consensus/engine/rejected_block.go new file mode 100644 index 0000000000..bb745ac4a6 --- /dev/null +++ b/consensus/engine/rejected_block.go @@ -0,0 +1,30 @@ +package engine + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +// ErrRejectedBlock is returned for a block that consensus must never accept. +var ErrRejectedBlock = errors.New("block rejected by hash") + +var rejectedBlockHashes = map[common.Hash]struct{}{ + // Shard 0 retains block 92,730,034. Reject its original child so a dirty + // rolled-back database cannot reattach the abandoned branch. + common.HexToHash("0x5de06979a333f20afb8b245a8cf44472dc5bfc7383a57ddee48e1809bcee7c5d"): {}, + // Keep the first confirmed malicious shard-0 block rejected as defense in + // depth, including for embedded block references. + common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"): {}, + common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"): {}, +} + +// ValidateBlockHash rejects an abandoned chain anchor. Descendants cannot +// attach once their anchor is rejected. +func ValidateBlockHash(hash common.Hash) error { + if _, rejected := rejectedBlockHashes[hash]; rejected { + return fmt.Errorf("%w: %s", ErrRejectedBlock, hash.Hex()) + } + return nil +} diff --git a/consensus/engine/rejected_block_test.go b/consensus/engine/rejected_block_test.go new file mode 100644 index 0000000000..9c37413538 --- /dev/null +++ b/consensus/engine/rejected_block_test.go @@ -0,0 +1,42 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestValidateBlockHashRejectsAbandonedChainAnchors(t *testing.T) { + tests := []struct { + name string + hash common.Hash + want error + }{ + { + name: "reject shard 0 first abandoned child at block 92730035", + hash: common.HexToHash("0x5de06979a333f20afb8b245a8cf44472dc5bfc7383a57ddee48e1809bcee7c5d"), + want: ErrRejectedBlock, + }, + { + name: "reject shard 0 first confirmed malicious block at 92730036", + hash: common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"), + want: ErrRejectedBlock, + }, + { + name: "reject shard 1 abandoned chain anchor", + hash: common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"), + want: ErrRejectedBlock, + }, + {name: "allow replacement hash", hash: common.HexToHash("0x01")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateBlockHash(test.hash) + if !errors.Is(err, test.want) { + t.Fatalf("ValidateBlockHash(%s) error = %v, want %v", test.hash.Hex(), err, test.want) + } + }) + } +} diff --git a/consensus/leader.go b/consensus/leader.go index af7e39a0d7..61239393a0 100644 --- a/consensus/leader.go +++ b/consensus/leader.go @@ -18,6 +18,10 @@ import ( // announce fires leader func (consensus *Consensus) announce(block *types.Block) { + if err := consensus.assertEmergencyRecoveryBlockViewID(block.Header().ViewID().Uint64()); err != nil { + consensus.getLogger().Error().Err(err).Msg("[Announce] unsafe recovery ViewID") + return + } blockHash := block.Hash() // prepare message and broadcast to validators diff --git a/consensus/recovery_checkpoint.go b/consensus/recovery_checkpoint.go new file mode 100644 index 0000000000..27e7c72c22 --- /dev/null +++ b/consensus/recovery_checkpoint.go @@ -0,0 +1,208 @@ +package consensus + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethdb" + consensusengine "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core" + "github.com/harmony-one/harmony/core/rawdb" + "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/internal/params" + "github.com/harmony-one/harmony/shard" +) + +const ( + // These values pin the independently verified block-92730034 tuple used by + // the shard-0 emergency recovery release. + EmergencyRecoveryRetainedHashHex = "0x30c35d2f2291e4b27debe7862956cf7a0cc7abefc044273d6823567335086d8d" + EmergencyRecoveryRetainedRootHex = "0x39e72dc20835abe61f69966bec2cc4766bb9e893c4168e117154dd539f2fc728" +) + +var ( + ErrEmergencyRecoveryCheckpointUnset = errors.New("emergency recovery checkpoint is unset") + ErrEmergencyRecoveryCheckpointMismatch = errors.New("emergency recovery checkpoint mismatch") +) + +// IsEmergencyRecoveryMainnetShard0 scopes the one-off startup invariant. It is +// intentionally independent of the local head height: a head below the target +// must fail rather than bypass recovery checks. +func IsEmergencyRecoveryMainnetShard0(config *params.ChainConfig, shardID uint32) bool { + return config != nil && config.ChainID != nil && + config.ChainID.Cmp(params.MainnetChainID) == 0 && + shardID == shard.BeaconChainShardID +} + +func parseRecoveryHash(name, value string) (common.Hash, error) { + if len(value) != 66 || !strings.HasPrefix(value, "0x") { + return common.Hash{}, fmt.Errorf("%w: %s", ErrEmergencyRecoveryCheckpointUnset, name) + } + raw, err := hex.DecodeString(value[2:]) + if err != nil || len(raw) != common.HashLength { + return common.Hash{}, fmt.Errorf("%w: %s", ErrEmergencyRecoveryCheckpointUnset, name) + } + var result common.Hash + copy(result[:], raw) + if result == (common.Hash{}) { + return common.Hash{}, fmt.Errorf("%w: %s is zero", ErrEmergencyRecoveryCheckpointUnset, name) + } + return result, nil +} + +// EmergencyRecoveryCheckpoint returns the release-pinned retained block tuple. +// It fails closed while either value is still a release placeholder. +func EmergencyRecoveryCheckpoint() (common.Hash, common.Hash, error) { + targetHash, err := parseRecoveryHash("target block hash", EmergencyRecoveryRetainedHashHex) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + targetRoot, err := parseRecoveryHash("target state root", EmergencyRecoveryRetainedRootHex) + if err != nil { + return common.Hash{}, common.Hash{}, err + } + return targetHash, targetRoot, nil +} + +// ValidateEmergencyRecoveryCheckpoint rejects a stale or partially rewound DB +// before networking, services, or validator signing start. +func ValidateEmergencyRecoveryCheckpoint(blockchain core.BlockChain) error { + if blockchain == nil { + return errors.New("nil blockchain") + } + if !IsEmergencyRecoveryMainnetShard0(blockchain.Config(), blockchain.ShardID()) { + return nil + } + targetHash, targetRoot, err := EmergencyRecoveryCheckpoint() + if err != nil { + return err + } + if err := validateEmergencyRecoveryCheckpointWith( + blockchain, targetHash, targetRoot, consensusengine.ValidateBlockHash, + ); err != nil { + return err + } + if err := validateEmergencyRecoveryPersistedHeads( + blockchain.ChainDb(), blockchain.CurrentBlock().Hash(), + ); err != nil { + return err + } + return validateEmergencyRecoveryTargetCertificate(blockchain, targetHash) +} + +func validateEmergencyRecoveryPersistedHeads(db ethdb.KeyValueReader, expected common.Hash) error { + if db == nil { + return fmt.Errorf("%w: chain database is unavailable", ErrEmergencyRecoveryCheckpointMismatch) + } + header := rawdb.ReadHeadHeaderHash(db) + fast := rawdb.ReadHeadFastBlockHash(db) + full := rawdb.ReadHeadBlockHash(db) + if header != expected || fast != expected || full != expected { + return fmt.Errorf( + "%w: persisted heads differ from expected %s (header %s fast %s full %s)", + ErrEmergencyRecoveryCheckpointMismatch, + expected.Hex(), header.Hex(), fast.Hex(), full.Hex(), + ) + } + return nil +} + +func validateEmergencyRecoveryTargetCertificate(blockchain core.BlockChain, targetHash common.Hash) error { + target := blockchain.GetBlock(targetHash, EmergencyRecoveryRetainedBlock) + if target == nil || target.Header() == nil { + return fmt.Errorf("%w: target block missing for certificate validation", + ErrEmergencyRecoveryCheckpointMismatch) + } + certificate, err := rawdb.ReadBlockCommitSigExact( + blockchain.ChainDb(), EmergencyRecoveryRetainedBlock, + ) + if err != nil { + return fmt.Errorf("%w: target commit certificate unavailable: %v", + ErrEmergencyRecoveryCheckpointMismatch, err) + } + if len(certificate) <= bls.BLSSignatureSizeInBytes { + return fmt.Errorf("%w: target commit certificate is truncated", + ErrEmergencyRecoveryCheckpointMismatch) + } + var signature bls.SerializedSignature + copy(signature[:], certificate[:bls.BLSSignatureSizeInBytes]) + bitmap := certificate[bls.BLSSignatureSizeInBytes:] + if blockchain.Engine() == nil { + return fmt.Errorf("%w: consensus engine unavailable", + ErrEmergencyRecoveryCheckpointMismatch) + } + if err := blockchain.Engine().VerifyHeaderSignature(blockchain, target.Header(), signature, bitmap); err != nil { + return fmt.Errorf("%w: target commit certificate is invalid: %v", + ErrEmergencyRecoveryCheckpointMismatch, err) + } + + return nil +} + +func validateEmergencyRecoveryCheckpointWith( + blockchain core.BlockChain, + targetHash common.Hash, + targetRoot common.Hash, + validateHash func(common.Hash) error, +) error { + current := blockchain.CurrentBlock() + fast := blockchain.CurrentFastBlock() + header := blockchain.CurrentHeader() + if current == nil || fast == nil || header == nil || current.Header() == nil { + return fmt.Errorf("%w: a chain head is missing", ErrEmergencyRecoveryCheckpointMismatch) + } + headNumber := current.NumberU64() + if headNumber < EmergencyRecoveryRetainedBlock { + return fmt.Errorf("%w: head %d is below target %d", + ErrEmergencyRecoveryCheckpointMismatch, headNumber, EmergencyRecoveryRetainedBlock) + } + if current.ShardID() != shard.BeaconChainShardID || + header.Number().Uint64() != headNumber || header.Hash() != current.Hash() || + fast.NumberU64() != headNumber || fast.Hash() != current.Hash() { + return fmt.Errorf("%w: full, header, and fast heads are not identical", + ErrEmergencyRecoveryCheckpointMismatch) + } + if blockchain.GetCanonicalHash(headNumber) != current.Hash() { + return fmt.Errorf("%w: current head is not canonical", ErrEmergencyRecoveryCheckpointMismatch) + } + if blockchain.GetCanonicalHash(EmergencyRecoveryRetainedBlock) != targetHash { + return fmt.Errorf("%w: target canonical hash", ErrEmergencyRecoveryCheckpointMismatch) + } + + walk := current + for { + if walk == nil || walk.Header() == nil || walk.NumberU64() > headNumber || + walk.ShardID() != shard.BeaconChainShardID { + return fmt.Errorf("%w: broken ancestry", ErrEmergencyRecoveryCheckpointMismatch) + } + if err := validateHash(walk.Hash()); err != nil { + return err + } + if walk.NumberU64() == EmergencyRecoveryRetainedBlock { + break + } + parentNumber := walk.NumberU64() - 1 + parent := blockchain.GetBlock(walk.ParentHash(), parentNumber) + if parent == nil || parent.Hash() != walk.ParentHash() || parent.NumberU64() != parentNumber { + return fmt.Errorf("%w: missing parent at %d", + ErrEmergencyRecoveryCheckpointMismatch, parentNumber) + } + walk = parent + } + + if walk.Hash() != targetHash || walk.Root() != targetRoot { + return fmt.Errorf("%w: target hash or state root", ErrEmergencyRecoveryCheckpointMismatch) + } + if blockchain.GetBlock(targetHash, EmergencyRecoveryRetainedBlock) == nil { + return fmt.Errorf("%w: target block body missing", ErrEmergencyRecoveryCheckpointMismatch) + } + stateDB, err := blockchain.StateAt(current.Root()) + if err != nil || stateDB == nil { + return fmt.Errorf("%w: current state unavailable: %v", + ErrEmergencyRecoveryCheckpointMismatch, err) + } + return nil +} diff --git a/consensus/recovery_checkpoint_test.go b/consensus/recovery_checkpoint_test.go new file mode 100644 index 0000000000..5f4387f628 --- /dev/null +++ b/consensus/recovery_checkpoint_test.go @@ -0,0 +1,178 @@ +package consensus + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/block" + blockfactory "github.com/harmony-one/harmony/block/factory" + consensusengine "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core" + "github.com/harmony-one/harmony/core/rawdb" + "github.com/harmony-one/harmony/core/state" + "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/internal/params" + "github.com/stretchr/testify/require" +) + +type recoveryCheckpointTestChain struct { + core.Stub + current *types.Block + fast *types.Block + blocks map[common.Hash]*types.Block + canonical map[uint64]common.Hash + stateErr error +} + +func (c *recoveryCheckpointTestChain) ShardID() uint32 { return 0 } +func (c *recoveryCheckpointTestChain) Config() *params.ChainConfig { + return params.MainnetChainConfig +} +func (c *recoveryCheckpointTestChain) CurrentBlock() *types.Block { return c.current } +func (c *recoveryCheckpointTestChain) CurrentFastBlock() *types.Block { + return c.fast +} +func (c *recoveryCheckpointTestChain) CurrentHeader() *block.Header { + if c.current == nil { + return nil + } + return c.current.Header() +} +func (c *recoveryCheckpointTestChain) GetCanonicalHash(number uint64) common.Hash { + return c.canonical[number] +} +func (c *recoveryCheckpointTestChain) GetBlock(hash common.Hash, number uint64) *types.Block { + result := c.blocks[hash] + if result == nil || result.NumberU64() != number { + return nil + } + return result +} +func (c *recoveryCheckpointTestChain) StateAt(common.Hash) (*state.DB, error) { + if c.stateErr != nil { + return nil, c.stateErr + } + return new(state.DB), nil +} + +func recoveryCheckpointTestBlock(number uint64, parent, root common.Hash) *types.Block { + header := blockfactory.ForMainnet.NewHeader(big.NewInt(3002)) + header.SetNumber(new(big.Int).SetUint64(number)) + header.SetShardID(0) + header.SetParentHash(parent) + header.SetRoot(root) + header.SetViewID(new(big.Int).SetUint64(number + 100)) + return types.NewBlockWithHeader(header) +} + +func newRecoveryCheckpointTestChain() (*recoveryCheckpointTestChain, *types.Block, *types.Block) { + targetRoot := common.HexToHash("0x1234") + target := recoveryCheckpointTestBlock(EmergencyRecoveryRetainedBlock, common.HexToHash("0xabcd"), targetRoot) + descendant := recoveryCheckpointTestBlock(EmergencyRecoveryRetainedBlock+1, target.Hash(), common.HexToHash("0x5678")) + chain := &recoveryCheckpointTestChain{ + current: descendant, + fast: descendant, + blocks: map[common.Hash]*types.Block{ + target.Hash(): target, + descendant.Hash(): descendant, + }, + canonical: map[uint64]common.Hash{ + EmergencyRecoveryRetainedBlock: target.Hash(), + EmergencyRecoveryRetainedBlock + 1: descendant.Hash(), + }, + } + return chain, target, descendant +} + +func TestEmergencyRecoveryCheckpointReleaseTuple(t *testing.T) { + hash, root, err := EmergencyRecoveryCheckpoint() + require.NoError(t, err) + require.Equal(t, + common.HexToHash("0x30c35d2f2291e4b27debe7862956cf7a0cc7abefc044273d6823567335086d8d"), + hash, + ) + require.Equal(t, + common.HexToHash("0x39e72dc20835abe61f69966bec2cc4766bb9e893c4168e117154dd539f2fc728"), + root, + ) +} + +func TestEmergencyRecoveryCheckpointAcceptsPinnedAncestry(t *testing.T) { + chain, target, _ := newRecoveryCheckpointTestChain() + require.NoError(t, validateEmergencyRecoveryCheckpointWith( + chain, target.Hash(), target.Root(), func(common.Hash) error { return nil }, + )) +} + +func TestEmergencyRecoveryCheckpointValidatesPersistedHeads(t *testing.T) { + db := rawdb.NewMemoryDatabase() + expected := common.HexToHash("0x1234") + require.NoError(t, rawdb.WriteHeadHeaderHash(db, expected)) + require.NoError(t, rawdb.WriteHeadFastBlockHash(db, expected)) + require.NoError(t, rawdb.WriteHeadBlockHash(db, expected)) + require.NoError(t, validateEmergencyRecoveryPersistedHeads(db, expected)) + + require.NoError(t, rawdb.WriteHeadFastBlockHash(db, common.HexToHash("0xdead"))) + require.ErrorIs(t, + validateEmergencyRecoveryPersistedHeads(db, expected), + ErrEmergencyRecoveryCheckpointMismatch, + ) +} + +func TestEmergencyRecoveryCheckpointFailsClosed(t *testing.T) { + chain, target, descendant := newRecoveryCheckpointTestChain() + + t.Run("divergent fast head", func(t *testing.T) { + copyChain := *chain + copyChain.fast = target + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + ©Chain, target.Hash(), target.Root(), func(common.Hash) error { return nil }, + ), ErrEmergencyRecoveryCheckpointMismatch) + }) + + t.Run("wrong target canonical hash", func(t *testing.T) { + copyChain := *chain + copyChain.canonical = map[uint64]common.Hash{ + EmergencyRecoveryRetainedBlock: common.HexToHash("0xdead"), + EmergencyRecoveryRetainedBlock + 1: descendant.Hash(), + } + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + ©Chain, target.Hash(), target.Root(), func(common.Hash) error { return nil }, + ), ErrEmergencyRecoveryCheckpointMismatch) + }) + + t.Run("missing parent", func(t *testing.T) { + copyChain := *chain + copyChain.blocks = map[common.Hash]*types.Block{descendant.Hash(): descendant} + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + ©Chain, target.Hash(), target.Root(), func(common.Hash) error { return nil }, + ), ErrEmergencyRecoveryCheckpointMismatch) + }) + + t.Run("wrong target root", func(t *testing.T) { + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + chain, target.Hash(), common.HexToHash("0xbad"), func(common.Hash) error { return nil }, + ), ErrEmergencyRecoveryCheckpointMismatch) + }) + + t.Run("unavailable current state", func(t *testing.T) { + copyChain := *chain + copyChain.stateErr = errors.New("missing trie node") + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + ©Chain, target.Hash(), target.Root(), func(common.Hash) error { return nil }, + ), ErrEmergencyRecoveryCheckpointMismatch) + }) + + t.Run("rejected descendant", func(t *testing.T) { + require.ErrorIs(t, validateEmergencyRecoveryCheckpointWith( + chain, target.Hash(), target.Root(), func(hash common.Hash) error { + if hash == descendant.Hash() { + return consensusengine.ErrRejectedBlock + } + return nil + }, + ), consensusengine.ErrRejectedBlock) + }) +} diff --git a/consensus/recovery_validator_list.go b/consensus/recovery_validator_list.go new file mode 100644 index 0000000000..ced9e5cbed --- /dev/null +++ b/consensus/recovery_validator_list.go @@ -0,0 +1,98 @@ +package consensus + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" +) + +const ( + // These values pin the independently audited validator list at block + // 92730034. The digest is SHA-256 over the RLP encoding of []common.Address, + // exactly matching the canonical encoding stored by rawdb.WriteValidatorList. + EmergencyRecoveryValidatorListCount uint64 = 771 + EmergencyRecoveryValidatorListSHA256Hex = "0xf5dc6b4879ed956818c19d7e68b41044be251284d37b09735d896cc3d657050d" +) + +var ( + ErrEmergencyRecoveryValidatorListManifestUnset = errors.New("emergency recovery validator-list manifest is unset") + ErrEmergencyRecoveryValidatorListMismatch = errors.New("emergency recovery validator-list mismatch") +) + +// ValidateEmergencyRecoveryValidatorList verifies the exact ordered validator +// list against the release manifest. It fails closed while either release +// value is left as a placeholder. +func ValidateEmergencyRecoveryValidatorList(validators []common.Address) error { + return validateEmergencyRecoveryValidatorListWith( + validators, + EmergencyRecoveryValidatorListCount, + EmergencyRecoveryValidatorListSHA256Hex, + ) +} + +// EmergencyRecoveryValidatorListManifest computes the release-manifest values +// from the exact ordered list. It is exposed so offline recovery preflight can +// report the independently reproducible candidate before any database write. +func EmergencyRecoveryValidatorListManifest(validators []common.Address) (uint64, string, error) { + encoded, err := rlp.EncodeToBytes(validators) + if err != nil { + return 0, "", err + } + digest := sha256.Sum256(encoded) + return uint64(len(validators)), "0x" + hex.EncodeToString(digest[:]), nil +} + +func validateEmergencyRecoveryValidatorListWith( + validators []common.Address, + expectedCount uint64, + expectedSHA256Hex string, +) error { + if expectedCount == 0 { + return fmt.Errorf("%w: validator count", ErrEmergencyRecoveryValidatorListManifestUnset) + } + expectedDigest, err := parseEmergencyRecoveryValidatorListDigest(expectedSHA256Hex) + if err != nil { + return err + } + if uint64(len(validators)) != expectedCount { + return fmt.Errorf("%w: count got %d want %d", + ErrEmergencyRecoveryValidatorListMismatch, len(validators), expectedCount) + } + + encoded, err := rlp.EncodeToBytes(validators) + if err != nil { + return fmt.Errorf("%w: RLP encoding failed: %v", + ErrEmergencyRecoveryValidatorListMismatch, err) + } + digest := sha256.Sum256(encoded) + if digest != expectedDigest { + return fmt.Errorf("%w: SHA-256 got 0x%s want 0x%s", + ErrEmergencyRecoveryValidatorListMismatch, + hex.EncodeToString(digest[:]), hex.EncodeToString(expectedDigest[:])) + } + return nil +} + +func parseEmergencyRecoveryValidatorListDigest(value string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + if len(value) != 2+sha256.Size*2 || !strings.HasPrefix(value, "0x") { + return digest, fmt.Errorf("%w: validator-list SHA-256", + ErrEmergencyRecoveryValidatorListManifestUnset) + } + raw, err := hex.DecodeString(value[2:]) + if err != nil || len(raw) != sha256.Size { + return digest, fmt.Errorf("%w: validator-list SHA-256", + ErrEmergencyRecoveryValidatorListManifestUnset) + } + copy(digest[:], raw) + if digest == ([sha256.Size]byte{}) { + return digest, fmt.Errorf("%w: validator-list SHA-256 is zero", + ErrEmergencyRecoveryValidatorListManifestUnset) + } + return digest, nil +} diff --git a/consensus/recovery_validator_list_test.go b/consensus/recovery_validator_list_test.go new file mode 100644 index 0000000000..db2342d537 --- /dev/null +++ b/consensus/recovery_validator_list_test.go @@ -0,0 +1,103 @@ +package consensus + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + "github.com/stretchr/testify/require" +) + +func recoveryValidatorListManifestForTest( + t *testing.T, validators []common.Address, +) (uint64, string) { + t.Helper() + encoded, err := rlp.EncodeToBytes(validators) + require.NoError(t, err) + digest := sha256.Sum256(encoded) + return uint64(len(validators)), "0x" + hex.EncodeToString(digest[:]) +} + +func TestEmergencyRecoveryValidatorListReleaseManifest(t *testing.T) { + require.Equal(t, uint64(771), EmergencyRecoveryValidatorListCount) + require.Equal(t, + "0xf5dc6b4879ed956818c19d7e68b41044be251284d37b09735d896cc3d657050d", + EmergencyRecoveryValidatorListSHA256Hex, + ) + _, err := parseEmergencyRecoveryValidatorListDigest(EmergencyRecoveryValidatorListSHA256Hex) + require.NoError(t, err) +} + +func TestEmergencyRecoveryValidatorListManifestAcceptsExactOrderedList(t *testing.T) { + validators := []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000001"), + common.HexToAddress("0x0000000000000000000000000000000000000002"), + } + count, digest := recoveryValidatorListManifestForTest(t, validators) + computedCount, computedDigest, err := EmergencyRecoveryValidatorListManifest(validators) + require.NoError(t, err) + require.Equal(t, count, computedCount) + require.Equal(t, digest, computedDigest) + require.NoError(t, validateEmergencyRecoveryValidatorListWith(validators, count, digest)) +} + +func TestEmergencyRecoveryValidatorListManifestRejectsMismatch(t *testing.T) { + validators := []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000001"), + common.HexToAddress("0x0000000000000000000000000000000000000002"), + } + count, digest := recoveryValidatorListManifestForTest(t, validators) + + t.Run("count", func(t *testing.T) { + require.ErrorIs(t, + validateEmergencyRecoveryValidatorListWith(validators[:1], count, digest), + ErrEmergencyRecoveryValidatorListMismatch, + ) + }) + + t.Run("order", func(t *testing.T) { + reordered := []common.Address{validators[1], validators[0]} + require.ErrorIs(t, + validateEmergencyRecoveryValidatorListWith(reordered, count, digest), + ErrEmergencyRecoveryValidatorListMismatch, + ) + }) + + t.Run("address", func(t *testing.T) { + changed := append([]common.Address(nil), validators...) + changed[1] = common.HexToAddress("0x0000000000000000000000000000000000000003") + require.ErrorIs(t, + validateEmergencyRecoveryValidatorListWith(changed, count, digest), + ErrEmergencyRecoveryValidatorListMismatch, + ) + }) +} + +func TestEmergencyRecoveryValidatorListManifestFailsClosedWhenUnset(t *testing.T) { + validators := []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000001"), + } + _, digest := recoveryValidatorListManifestForTest(t, validators) + zeroDigest := "0x" + hex.EncodeToString(make([]byte, sha256.Size)) + + tests := []struct { + name string + count uint64 + digest string + }{ + {name: "zero count", count: 0, digest: digest}, + {name: "placeholder digest", count: 1, digest: "REPLACE_WITH_TARGET_VALIDATOR_LIST_SHA256"}, + {name: "malformed digest", count: 1, digest: "0xnot-hex"}, + {name: "zero digest", count: 1, digest: zeroDigest}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.ErrorIs(t, + validateEmergencyRecoveryValidatorListWith(validators, test.count, test.digest), + ErrEmergencyRecoveryValidatorListManifestUnset, + ) + }) + } +} diff --git a/consensus/recovery_view_id.go b/consensus/recovery_view_id.go new file mode 100644 index 0000000000..a501bfe158 --- /dev/null +++ b/consensus/recovery_view_id.go @@ -0,0 +1,217 @@ +package consensus + +import ( + "errors" + "fmt" + "math" + "sync/atomic" + + "github.com/harmony-one/harmony/internal/params" + "github.com/harmony-one/harmony/shard" +) + +const ( + // EmergencyRecoveryRetainedBlock is the last retained shard-0 block for the + // August 2026 mainnet recovery. + EmergencyRecoveryRetainedBlock uint64 = params.EmergencyRecoveryRetainedBlock + + // EmergencyRecoveryViewIDFloor is the recovery release's signed activation + // floor for mainnet shard 0. + EmergencyRecoveryViewIDFloor uint64 = params.EmergencyRecoveryViewIDFloor +) + +var ( + ErrEmergencyRecoveryViewIDFloorUnset = errors.New("emergency recovery ViewID floor is unset") + ErrEmergencyRecoveryViewIDBelowFloor = errors.New("ViewID is below emergency recovery floor") + ErrViewIDExhausted = errors.New("ViewID exhausted") +) + +// checkedNextViewID returns viewID+1 without permitting uint64 wraparound. +func checkedNextViewID(viewID uint64) (uint64, error) { + if viewID == math.MaxUint64 { + return 0, ErrViewIDExhausted + } + return viewID + 1, nil +} + +// CheckedNextViewID is the startup-safe form of viewID+1. +func CheckedNextViewID(viewID uint64) (uint64, error) { + return checkedNextViewID(viewID) +} + +func checkedAddViewID(a, b uint64) (uint64, error) { + if math.MaxUint64-a < b { + return 0, ErrViewIDExhausted + } + return a + b, nil +} + +func checkedLeaderViewGap(viewID, lastBlockViewID uint64) (int, error) { + firstStuckView, err := checkedNextViewID(lastBlockViewID) + if err != nil { + return 0, err + } + if viewID < firstStuckView || viewID-firstStuckView > uint64(^uint(0)>>1) { + return 0, errors.New("invalid recovery leader ViewID gap") + } + return int(viewID - firstStuckView), nil +} + +// emergencyRecoveryViewIDFloorFor scopes the one-off rule to mainnet shard 0 +// at and after the retained recovery block. The bool reports whether the rule +// applies even when its required release value is still unset. +func emergencyRecoveryViewIDFloorFor( + config *params.ChainConfig, shardID uint32, headHeight uint64, +) (floor uint64, applies bool, err error) { + if config == nil || config.ChainID == nil || + config.ChainID.Cmp(params.MainnetChainID) != 0 || + shardID != shard.BeaconChainShardID || + headHeight < EmergencyRecoveryRetainedBlock { + return 0, false, nil + } + if EmergencyRecoveryViewIDFloor == 0 { + return 0, true, ErrEmergencyRecoveryViewIDFloorUnset + } + if EmergencyRecoveryViewIDFloor == math.MaxUint64 { + return 0, true, fmt.Errorf("%w: emergency recovery floor is max uint64", ErrViewIDExhausted) + } + return EmergencyRecoveryViewIDFloor, true, nil +} + +// ConfigureEmergencyRecoveryViewIDFloor must run before networking or any BLS +// signing. It intentionally fails closed for an applicable build whose audited +// floor has not been filled in. +func (consensus *Consensus) ConfigureEmergencyRecoveryViewIDFloor() error { + blockchain := consensus.Blockchain() + if blockchain == nil { + return errors.New("cannot configure emergency recovery ViewID floor without blockchain") + } + header := blockchain.CurrentHeader() + if header == nil { + return errors.New("cannot configure emergency recovery ViewID floor without current header") + } + floor, applies, err := emergencyRecoveryViewIDFloorFor( + blockchain.Config(), blockchain.ShardID(), header.Number().Uint64(), + ) + if err != nil { + return err + } + if !applies { + return nil + } + + consensus.current.SetViewIDFloor(floor) + consensus.getLogger().Warn(). + Uint64("viewIDFloor", floor). + Uint64("headHeight", header.Number().Uint64()). + Msg("emergency recovery ViewID floor enabled") + return nil +} + +// InitializeEmergencyRecoveryLeader deterministically derives the leader for +// the exact effective recovery view. This prevents different nodes from +// retaining the old head's leader after a large ViewID jump. +func (consensus *Consensus) InitializeEmergencyRecoveryLeader() error { + floor := consensus.current.GetViewIDFloor() + if floor == 0 { + return nil + } + blockchain := consensus.Blockchain() + if blockchain == nil || blockchain.CurrentHeader() == nil { + return errors.New("cannot initialize emergency recovery leader without blockchain head") + } + viewID := consensus.current.GetCurBlockViewID() + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return err + } + shardState, err := blockchain.ReadShardState(blockchain.CurrentHeader().Epoch()) + if err != nil { + return fmt.Errorf("read recovery shard state: %w", err) + } + committee, err := shardState.FindCommitteeByID(consensus.ShardID) + if err != nil { + return fmt.Errorf("find recovery committee: %w", err) + } + leader := consensus.current.getNextLeaderKey( + blockchain, consensus.decider(), viewID, committee, + ) + if leader == nil { + return errors.New("cannot derive emergency recovery leader") + } + consensus.setLeaderPubKey(leader) + consensus.IgnoreViewIDCheck.UnSet() + consensus.getLogger().Warn(). + Uint64("viewID", viewID). + Str("leader", leader.Bytes.Hex()). + Msg("emergency recovery leader initialized") + return nil +} + +func atomicMaxUint64(target *uint64, candidate uint64) uint64 { + for { + current := atomic.LoadUint64(target) + if candidate <= current { + return current + } + if atomic.CompareAndSwapUint64(target, current, candidate) { + return candidate + } + } +} + +// SetViewIDFloor raises (and can never lower) the process-local ViewID floor. +// It immediately raises both mutable ViewIDs so no signing path observes a +// value below the floor after this method returns. +func (pm *State) SetViewIDFloor(floor uint64) { + atomicMaxUint64(&pm.viewIDFloor, floor) + atomicMaxUint64(&pm.blockViewID, floor) + atomicMaxUint64(&pm.viewChangingID, floor) +} + +func (pm *State) GetViewIDFloor() uint64 { + return atomic.LoadUint64(&pm.viewIDFloor) +} + +func (pm *State) clampViewID(viewID uint64) uint64 { + if floor := pm.GetViewIDFloor(); viewID < floor { + return floor + } + return viewID +} + +// nextViewID clamps a calculated next view against both the recovery floor and +// current+1. This is the single normalization used by view-change and leader +// selection. +func (pm *State) nextViewID(calculated uint64) (uint64, error) { + nextCurrent, err := checkedNextViewID(pm.GetCurBlockViewID()) + if err != nil { + return 0, err + } + next := pm.clampViewID(calculated) + if next < nextCurrent { + next = nextCurrent + } + if next == math.MaxUint64 { + return 0, ErrViewIDExhausted + } + return next, nil +} + +func (consensus *Consensus) assertEmergencyRecoveryViewID(viewID uint64) error { + if floor := consensus.current.GetViewIDFloor(); floor != 0 { + if viewID < floor { + return fmt.Errorf("%w: got %d, floor %d", ErrEmergencyRecoveryViewIDBelowFloor, viewID, floor) + } + if viewID == math.MaxUint64 { + return ErrViewIDExhausted + } + } + return nil +} + +func (consensus *Consensus) assertEmergencyRecoveryBlockViewID(viewID uint64) error { + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return fmt.Errorf("refusing to sign block: %w", err) + } + return nil +} diff --git a/consensus/recovery_view_id_test.go b/consensus/recovery_view_id_test.go new file mode 100644 index 0000000000..17bcb51a2b --- /dev/null +++ b/consensus/recovery_view_id_test.go @@ -0,0 +1,167 @@ +package consensus + +import ( + "math" + "math/big" + "testing" + + "github.com/harmony-one/abool" + msg_pb "github.com/harmony-one/harmony/api/proto/message" + "github.com/harmony-one/harmony/consensus/quorum" + "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/internal/params" + "github.com/harmony-one/harmony/shard" + "github.com/stretchr/testify/require" +) + +func TestEmergencyRecoveryViewIDFloorScopeFailsClosed(t *testing.T) { + require.Equal(t, uint64(1_000_000_000), EmergencyRecoveryViewIDFloor) + + mainnet := ¶ms.ChainConfig{ChainID: new(big.Int).Set(params.MainnetChainID)} + testnet := ¶ms.ChainConfig{ChainID: new(big.Int).Set(params.TestnetChainID)} + + tests := []struct { + name string + config *params.ChainConfig + shardID uint32 + headHeight uint64 + applies bool + wantErr error + }{ + {name: "nil config", shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryRetainedBlock}, + {name: "testnet", config: testnet, shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryRetainedBlock}, + {name: "shard one", config: mainnet, shardID: 1, headHeight: EmergencyRecoveryRetainedBlock}, + {name: "before retained block", config: mainnet, shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryRetainedBlock - 1}, + { + name: "applicable mainnet shard zero build requires audited floor", + config: mainnet, + shardID: shard.BeaconChainShardID, + headHeight: EmergencyRecoveryRetainedBlock, + applies: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + floor, applies, err := emergencyRecoveryViewIDFloorFor(test.config, test.shardID, test.headHeight) + require.Equal(t, test.applies, applies) + if test.applies && EmergencyRecoveryViewIDFloor == 0 { + require.ErrorIs(t, err, ErrEmergencyRecoveryViewIDFloorUnset) + } else { + require.ErrorIs(t, err, test.wantErr) + if test.applies { + require.Equal(t, EmergencyRecoveryViewIDFloor, floor) + } + } + }) + } +} + +func TestRecoveryViewIDSettersAreFlooredAndMonotonic(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetCurBlockViewID(10) + state.SetViewChangingID(11) + state.SetViewIDFloor(100) + + require.Equal(t, uint64(100), state.GetViewIDFloor()) + require.Equal(t, uint64(100), state.GetCurBlockViewID()) + require.Equal(t, uint64(100), state.GetViewChangingID()) + + state.SetCurBlockViewID(1) + state.SetViewChangingID(2) + require.Equal(t, uint64(100), state.GetCurBlockViewID()) + require.Equal(t, uint64(100), state.GetViewChangingID()) + + state.SetCurBlockViewID(105) + state.SetViewChangingID(3) + require.Equal(t, uint64(105), state.GetCurBlockViewID()) + require.Equal(t, uint64(105), state.GetViewChangingID()) + + state.SetViewIDFloor(50) + require.Equal(t, uint64(100), state.GetViewIDFloor()) +} + +func TestOrdinaryViewIDSettersMayLowerWithoutRecoveryFloor(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetCurBlockViewID(10) + state.SetViewChangingID(11) + state.SetCurBlockViewID(3) + state.SetViewChangingID(4) + + require.Equal(t, uint64(3), state.GetCurBlockViewID()) + require.Equal(t, uint64(4), state.GetViewChangingID()) +} + +func TestRecoveryNextViewIDUsesFloorAndStrictSuccessor(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetViewIDFloor(100) + + next, err := state.nextViewID(1) + require.NoError(t, err) + require.Equal(t, uint64(101), next) + + next, _, err = state.getNextViewID(nil, nil) + require.NoError(t, err) + require.Equal(t, uint64(101), next) + + state.blockViewID = math.MaxUint64 + _, err = state.nextViewID(1) + require.ErrorIs(t, err, ErrViewIDExhausted) + _, err = checkedNextViewID(math.MaxUint64) + require.ErrorIs(t, err, ErrViewIDExhausted) + _, err = checkedAddViewID(math.MaxUint64, 1) + require.ErrorIs(t, err, ErrViewIDExhausted) + + gap, err := checkedLeaderViewGap(100, 90) + require.NoError(t, err) + require.Equal(t, 9, gap) + _, err = checkedLeaderViewGap(89, 90) + require.Error(t, err) +} + +func TestRecoveryInboundMessagesCannotLowerViewID(t *testing.T) { + consensus := &Consensus{ + current: NewState(Normal, shard.BeaconChainShardID), + IgnoreViewIDCheck: abool.NewBool(true), + } + consensus.current.SetViewIDFloor(100) + message := &FBFTMessage{ViewID: 99} + + require.ErrorIs(t, consensus.checkViewID(message), ErrEmergencyRecoveryViewIDBelowFloor) + require.False(t, consensus.onViewChangeSanityCheck(message)) + require.False(t, consensus.onNewViewSanityCheck(message)) + require.True(t, consensus.IgnoreViewIDCheck.IsSet()) +} + +func TestRecoveryConstructRefusesCorruptViewBelowFloor(t *testing.T) { + consensus := &Consensus{current: NewState(Normal, shard.BeaconChainShardID)} + consensus.current.SetViewIDFloor(100) + // Emulate memory corruption or a future call site bypassing the setter. + consensus.current.blockViewID = 99 + + _, err := consensus.construct(msg_pb.MessageType_PREPARE, nil, nil) + require.ErrorIs(t, err, ErrEmergencyRecoveryViewIDBelowFloor) +} + +func TestRecoveryLeaderSelectionUsesClampedNextViewID(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetViewIDFloor(100) + decider := quorum.NewDecider(quorum.SuperMajorityVote, shard.BeaconChainShardID) + + wrappedKeys := make([]bls.PublicKeyWrapper, 0, 3) + for range 3 { + privateKey := bls.RandPrivateKey() + publicKey := privateKey.GetPublicKey() + serialized := bls.SerializedPublicKey{} + serialized.FromLibBLSPublicKey(publicKey) + wrappedKeys = append(wrappedKeys, bls.PublicKeyWrapper{Object: publicKey, Bytes: serialized}) + } + decider.UpdateParticipants(wrappedKeys, []bls.PublicKeyWrapper{}) + state.setLeaderPubKey(&wrappedKeys[0]) + + // Without a chain header, leader selection advances once relative to the + // current leader while still clamping the supplied view to the exact floor. + next := state.getNextLeaderKey(nil, decider, 1, nil) + require.NotNil(t, next) + require.True(t, next.Object.IsEqual(wrappedKeys[1].Object)) +} diff --git a/consensus/rejected_block_test.go b/consensus/rejected_block_test.go new file mode 100644 index 0000000000..a7fe4123ea --- /dev/null +++ b/consensus/rejected_block_test.go @@ -0,0 +1,21 @@ +package consensus + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + consensusengine "github.com/harmony-one/harmony/consensus/engine" +) + +func TestValidateNewBlockRejectsAbandonedChainAnchorBeforeVerifiedCache(t *testing.T) { + hash := common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8") + log := NewFBFTLog() + log.verifiedBlocks[hash] = struct{}{} + consensus := &Consensus{fBFTLog: log} + + _, err := consensus.validateNewBlock(&FBFTMessage{BlockHash: hash}) + if !errors.Is(err, consensusengine.ErrRejectedBlock) { + t.Fatalf("validateNewBlock() error = %v, want %v", err, consensusengine.ErrRejectedBlock) + } +} diff --git a/consensus/state.go b/consensus/state.go index 170de3f3b3..e5a5f2dbe4 100644 --- a/consensus/state.go +++ b/consensus/state.go @@ -25,6 +25,10 @@ type State struct { // it is the next view id viewChangingID uint64 + // viewIDFloor is a process-local, monotonically increasing lower bound used + // by the one-off mainnet shard-0 recovery. + viewIDFloor uint64 + // the publickey of leader leaderPubKey unsafe.Pointer //*bls.PublicKeyWrapper diff --git a/consensus/threshold.go b/consensus/threshold.go index e83442b3be..d800c959c4 100644 --- a/consensus/threshold.go +++ b/consensus/threshold.go @@ -45,6 +45,9 @@ func (consensus *Consensus) didReachPrepareQuorum() error { Msg("[didReachPrepareQuorum] Unparseable block data") return err } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + return err + } commitPayload := signature.ConstructCommitPayload(consensus.Blockchain().Config(), blockObj.Epoch(), blockObj.Hash(), blockObj.NumberU64(), blockObj.Header().ViewID().Uint64()) diff --git a/consensus/validator.go b/consensus/validator.go index 874edb0ef8..75ba86c712 100644 --- a/consensus/validator.go +++ b/consensus/validator.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/rlp" msg_pb "github.com/harmony-one/harmony/api/proto/message" + consensusengine "github.com/harmony-one/harmony/consensus/engine" "github.com/harmony-one/harmony/consensus/signature" "github.com/harmony-one/harmony/core/types" "github.com/harmony-one/harmony/crypto/bls" @@ -19,13 +20,22 @@ import ( func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { recvMsg, err := consensus.parseFBFTMessage(msg) - if err != nil { + if err != nil || recvMsg == nil { consensus.getLogger().Error(). Err(err). - Uint64("MsgBlockNum", recvMsg.BlockNum). Msg("[OnAnnounce] Unparseable leader message") return } + // Reject an abandoned block before it can enter the FBFT log or cause this + // validator to sign PREPARE. validateNewBlock repeats this check for every + // other entry point. + if err := consensusengine.ValidateBlockHash(recvMsg.BlockHash); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Str("MsgBlockHash", recvMsg.BlockHash.Hex()). + Msg("[OnAnnounce] Rejected block") + return + } // NOTE let it handle its own logs if !consensus.onAnnounceSanityChecks(recvMsg) { @@ -40,16 +50,7 @@ func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { } return } - consensus.StartFinalityCount() - - consensus.getLogger().Info(). - Uint64("MsgViewID", recvMsg.ViewID). - Uint64("MsgBlockNum", recvMsg.BlockNum). - Msg("[OnAnnounce] Announce message Added") - consensus.fBFTLog.AddVerifiedMessage(recvMsg) - consensus.current.blockHash = recvMsg.BlockHash - // we have already added message and block, skip check viewID - // and send prepare message if is in ViewChanging mode + // Do not cache or sign an announce while view change is in progress. if consensus.isViewChangingMode() { consensus.getLogger().Debug(). Msg("[OnAnnounce] Still in ViewChanging Mode, Exiting !!") @@ -65,19 +66,29 @@ func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { } return } + // Announce must carry the block. Signing a hash before decoding and fully + // validating its block is unsafe during recovery. + if len(recvMsg.Block) == 0 { + consensus.getLogger().Warn(). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Announce has no block payload") + return + } + if _, err := consensus.validateNewBlock(recvMsg); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Block validation failed before PREPARE") + return + } + + consensus.StartFinalityCount() + consensus.current.blockHash = recvMsg.BlockHash + consensus.getLogger().Info(). + Uint64("MsgViewID", recvMsg.ViewID). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Validated announce added") consensus.prepare() consensus.switchPhase("Announce", FBFTPrepare) - - if len(recvMsg.Block) > 0 { - go func() { - // Best effort check, no need to error out. - _, err := consensus.ValidateNewBlock(recvMsg) - if err == nil { - consensus.GetLogger().Info(). - Msgf("[Announce] Block verified %d", recvMsg.BlockNum) - } - }() - } } func (consensus *Consensus) ValidateNewBlock(recvMsg *FBFTMessage) (*types.Block, error) { @@ -86,6 +97,9 @@ func (consensus *Consensus) ValidateNewBlock(recvMsg *FBFTMessage) (*types.Block return consensus.validateNewBlock(recvMsg) } func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block, error) { + if err := consensusengine.ValidateBlockHash(recvMsg.BlockHash); err != nil { + return nil, err + } if consensus.fBFTLog.IsBlockVerified(recvMsg.BlockHash) { var blockObj *types.Block @@ -101,6 +115,15 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block } blockObj = &blockObj2 } + if blockObj == nil || blockObj.Header() == nil { + return nil, errors.New("verified block is missing its header") + } + if blockObj.Header().ViewID().Uint64() != recvMsg.ViewID { + return nil, errors.New("verified block ViewID does not match announce ViewID") + } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + return nil, err + } consensus.getLogger().Info(). Msg("[validateNewBlock] Block Already verified") return blockObj, nil @@ -115,14 +138,23 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block return nil, errors.New("Failed parsing new block") } - consensus.fBFTLog.AddBlock(&blockObj) - // let this handle it own logs if !consensus.newBlockSanityChecks(&blockObj, recvMsg) { return nil, errors.New("new block failed sanity checks") } + if blockObj.Header().ViewID().Uint64() != recvMsg.ViewID { + return nil, errors.New("block ViewID does not match announce ViewID") + } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + return nil, err + } + if err := consensus.verifyBlock(&blockObj); err != nil { + consensus.getLogger().Error().Err(err).Msg("[validateNewBlock] Block verification failed") + return nil, errors.Errorf("Block verification failed: %s", err.Error()) + } - // add block field + // Only cache the block and message after complete validation succeeds. + consensus.fBFTLog.AddBlock(&blockObj) blockPayload := make([]byte, len(recvMsg.Block)) copy(blockPayload[:], recvMsg.Block[:]) consensus.current.block = blockPayload @@ -133,11 +165,6 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block Uint64("MsgBlockNum", recvMsg.BlockNum). Hex("blockHash", recvMsg.BlockHash[:]). Msg("[validateNewBlock] Prepared message and block added") - - if err := consensus.verifyBlock(&blockObj); err != nil { - consensus.getLogger().Error().Err(err).Msg("[validateNewBlock] Block verification failed") - return nil, errors.Errorf("Block verification failed: %s", err.Error()) - } return &blockObj, nil } @@ -168,6 +195,10 @@ func (consensus *Consensus) sendCommitMessages(blockObj *types.Block) { if consensus.isBackup || blockObj == nil { return } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + consensus.getLogger().Error().Err(err).Msg("[sendCommitMessages] unsafe recovery ViewID") + return + } priKeys, err := consensus.getPriKeysInCommittee() if err != nil { @@ -241,6 +272,7 @@ func (consensus *Consensus) onPrepared(recvMsg *FBFTMessage) { Uint64("MsgBlockNum", recvMsg.BlockNum). Uint64("MsgViewID", recvMsg.ViewID). Msg("[OnPrepared] failed to verify new block") + return } if consensus.checkViewID(recvMsg) != nil { diff --git a/consensus/view_change.go b/consensus/view_change.go index cc0fbd0d49..20aef48085 100644 --- a/consensus/view_change.go +++ b/consensus/view_change.go @@ -1,6 +1,7 @@ package consensus import ( + "math" "math/big" "sync/atomic" "time" @@ -41,8 +42,11 @@ func (pm *State) GetCurBlockViewID() uint64 { // SetCurBlockViewID sets the current view id func (pm *State) SetCurBlockViewID(viewID uint64) uint64 { - atomic.StoreUint64(&pm.blockViewID, viewID) - return viewID + if pm.GetViewIDFloor() == 0 { + atomic.StoreUint64(&pm.blockViewID, viewID) + return viewID + } + return atomicMaxUint64(&pm.blockViewID, pm.clampViewID(viewID)) } // GetViewChangingID return the current view changing id @@ -54,7 +58,15 @@ func (pm *State) GetViewChangingID() uint64 { // SetViewChangingID set the current view changing id // It is meaningful during view change mode func (pm *State) SetViewChangingID(id uint64) { - atomic.StoreUint64(&pm.viewChangingID, id) + if pm.GetViewIDFloor() == 0 { + atomic.StoreUint64(&pm.viewChangingID, id) + return + } + id = pm.clampViewID(id) + if current := pm.GetCurBlockViewID(); id < current { + id = current + } + atomicMaxUint64(&pm.viewChangingID, id) } // GetViewChangeDuraion return the duration of the current view change @@ -66,15 +78,23 @@ func (pm *State) GetViewChangeDuraion() time.Duration { // fallbackNextViewID return the next view ID and duration when there is an exception // to calculate the time-based viewId -func (pm *State) fallbackNextViewID() (uint64, time.Duration) { - diff := int64(pm.GetViewChangingID() + 1 - pm.GetCurBlockViewID()) +func (pm *State) fallbackNextViewID() (uint64, time.Duration, error) { + calculated, err := checkedNextViewID(pm.GetViewChangingID()) + if err != nil { + return 0, 0, err + } + nextViewID, err := pm.nextViewID(calculated) + if err != nil { + return 0, 0, err + } + diff := int64(nextViewID - pm.GetCurBlockViewID()) if diff <= 0 { diff = int64(1) } pm.getLogger().Error(). Int64("diff", diff). Msg("[fallbackNextViewID] use legacy viewID algorithm") - return pm.GetViewChangingID() + 1, time.Duration(diff * diff * int64(viewChangeDuration)) + return nextViewID, time.Duration(diff * diff * int64(viewChangeDuration)), nil } // effectiveViewChangeTimestamp returns the wall time used to advance view ID. @@ -108,12 +128,15 @@ func viewChangeTimestampDiff(curTimestamp, blockTimestamp int64, timestampValida // The view change duration is a fixed duration now to avoid stuck into offline nodes during // the view change. // viewID is only used as the fallback mechansim to determine the nextViewID -func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.ChainConfig) (uint64, time.Duration) { +func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.ChainConfig) (uint64, time.Duration, error) { if curHeader == nil { return pm.fallbackNextViewID() } blockTimestamp := curHeader.Time().Int64() - stuckBlockViewID := curHeader.ViewID().Uint64() + 1 + stuckBlockViewID, err := checkedNextViewID(curHeader.ViewID().Uint64()) + if err != nil { + return 0, 0, err + } curTimestamp := time.Now().Unix() timestampValidation := chainConfig != nil && chainConfig.IsTimestampValidation(curHeader.Epoch()) @@ -125,7 +148,14 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai Msg("[getNextViewID] timestamp of block too high") return pm.fallbackNextViewID() } - nextViewID := diff + stuckBlockViewID + calculated, err := checkedAddViewID(diff, stuckBlockViewID) + if err != nil { + return 0, 0, err + } + nextViewID, err := pm.nextViewID(calculated) + if err != nil { + return 0, 0, err + } pm.getLogger().Info(). Int64("curTimestamp", curTimestamp). @@ -136,7 +166,7 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai Msg("[getNextViewID]") // duration is always the fixed view change duration for synchronous view change - return nextViewID, viewChangeDuration + return nextViewID, viewChangeDuration, nil } // getNextLeaderKey uniquely determine who is the leader for given viewID @@ -144,11 +174,21 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai // the next leader based on the gap of the viewID of the view change and the last // know view id of the block. func (pm *State) getNextLeaderKey(blockchain engine.ChainReader, decider quorum.Decider, viewID uint64, committee *shard.Committee) *bls.PublicKeyWrapper { + viewID = pm.clampViewID(viewID) + if viewID == math.MaxUint64 { + pm.getLogger().Error().Msg("[getNextLeaderKey] exhausted ViewID") + return nil + } gap := 1 cur := pm.GetCurBlockViewID() if viewID > cur { - gap = int(viewID - cur) + delta := viewID - cur + if delta > uint64(^uint(0)>>1) { + pm.getLogger().Error().Msg("[getNextLeaderKey] ViewID gap overflows int") + return nil + } + gap = int(delta) } var lastLeaderPubKey *bls.PublicKeyWrapper var err error @@ -162,8 +202,11 @@ func (pm *State) getNextLeaderKey(blockchain engine.ChainReader, decider quorum. pm.getLogger().Error().Msg("[getNextLeaderKey] Failed to get current header from blockchain") lastLeaderPubKey = pm.getLeaderPubKey() } else { - stuckBlockViewID := curHeader.ViewID().Uint64() + 1 - gap = int(viewID - stuckBlockViewID) + gap, err = checkedLeaderViewGap(viewID, curHeader.ViewID().Uint64()) + if err != nil { + pm.getLogger().Error().Err(err).Msg("[getNextLeaderKey] invalid ViewID gap") + return nil + } // this is the truth of the leader based on blockchain blocks lastLeaderPubKey, err = chain.GetLeaderPubKeyFromCoinbase(blockchain, curHeader) if err != nil || lastLeaderPubKey == nil { @@ -247,7 +290,11 @@ func (consensus *Consensus) startViewChange() { consensus.consensusTimeout[timeoutBootstrap].Stop() consensus.current.SetMode(ViewChanging) curHeader := consensus.Blockchain().CurrentHeader() - nextViewID, duration := consensus.current.getNextViewID(curHeader, consensus.Blockchain().Config()) + nextViewID, duration, err := consensus.current.getNextViewID(curHeader, consensus.Blockchain().Config()) + if err != nil { + consensus.getLogger().Error().Err(err).Msg("[startViewChange] cannot advance ViewID") + return + } consensus.setViewChangingID(nextViewID) epoch := curHeader.Epoch() ss, err := consensus.Blockchain().ReadShardState(epoch) @@ -266,8 +313,12 @@ func (consensus *Consensus) startViewChange() { // aganist the consensus.LeaderPubKey variable. // Ideally, we shall use another variable to keep track of the // leader pubkey in viewchange mode - consensus.setLeaderPubKey( - consensus.current.getNextLeaderKey(consensus.Blockchain(), consensus.decider(), nextViewID, committee)) + nextLeader := consensus.current.getNextLeaderKey(consensus.Blockchain(), consensus.decider(), nextViewID, committee) + if nextLeader == nil { + consensus.getLogger().Error().Msg("[startViewChange] cannot select leader for recovery-safe ViewID") + return + } + consensus.setLeaderPubKey(nextLeader) consensus.getLogger().Warn(). Uint64("nextViewID", nextViewID). @@ -303,6 +354,10 @@ func (consensus *Consensus) startViewChange() { continue } msgToSend := consensus.constructViewChangeMessage(&key) + if msgToSend == nil { + consensus.getLogger().Error().Msg("[startViewChange] refused to construct unsafe ViewChange message") + continue + } if err := consensus.msgSender.SendWithRetry( consensus.getBlockNum(), msg_pb.MessageType_VIEWCHANGE, @@ -321,6 +376,9 @@ func (consensus *Consensus) startNewView(viewID uint64, newLeaderPriKey *bls.Pri if !consensus.isViewChangingMode() { return errors.New("not in view changing mode anymore") } + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return err + } msgToSend := consensus.constructNewViewMessage( viewID, newLeaderPriKey, diff --git a/consensus/view_change_msg.go b/consensus/view_change_msg.go index 1ba9c76063..375990a092 100644 --- a/consensus/view_change_msg.go +++ b/consensus/view_change_msg.go @@ -18,6 +18,11 @@ import ( // construct the view change message func (consensus *Consensus) constructViewChangeMessage(priKey *bls.PrivateKeyWrapper) []byte { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getViewChangingID()); err != nil { + consensus.getLogger().Error().Err(err). + Msg("[constructViewChangeMessage] unsafe recovery ViewID") + return nil + } message := &msg_pb.Message{ ServiceType: msg_pb.ServiceType_CONSENSUS, Type: msg_pb.MessageType_VIEWCHANGE, @@ -101,6 +106,11 @@ func (consensus *Consensus) constructViewChangeMessage(priKey *bls.PrivateKeyWra // new leader construct newview message func (consensus *Consensus) constructNewViewMessage(viewID uint64, priKey *bls.PrivateKeyWrapper) []byte { + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + consensus.getLogger().Error().Err(err). + Msg("[constructNewViewMessage] unsafe recovery ViewID") + return nil + } message := &msg_pb.Message{ ServiceType: msg_pb.ServiceType_CONSENSUS, Type: msg_pb.MessageType_NEWVIEW, diff --git a/core/block_validator.go b/core/block_validator.go index cfe6c118eb..98947d0886 100644 --- a/core/block_validator.go +++ b/core/block_validator.go @@ -54,6 +54,12 @@ func NewBlockValidator(blockchain BlockChain) *BlockValidator { // ValidateBody verifies the block header's transaction root. // The headers are assumed to be already validated at this point. func (v *BlockValidator) ValidateBody(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } + if err := ValidateEmergencyRecoveryBlockPolicy(v.bc.Config(), block); err != nil { + return err + } // Check whether the block's known, and if not, that it's linkable if v.bc.HasBlockAndState(block.Hash(), block.NumberU64()) { return errors.WithMessage(ErrKnownBlock, "validate body: has block and state") @@ -126,6 +132,9 @@ func (v *BlockValidator) ValidateHeader(block *types.Block, seal bool) error { if block == nil { return errors.New("block is nil") } + if err := ValidateEmergencyRecoveryBlockPolicy(v.bc.Config(), block); err != nil { + return err + } if h := block.Header(); h != nil { return v.bc.Engine().VerifyHeader(v.bc, h, true) } diff --git a/core/blockchain_impl.go b/core/blockchain_impl.go index 8a93304418..d48ee2abf8 100644 --- a/core/blockchain_impl.go +++ b/core/blockchain_impl.go @@ -499,6 +499,9 @@ func (bc *BlockChainImpl) ValidateNewBlock(block *types.Block, beaconChain Block if block == nil || block.Header() == nil { return errors.New("nil header or block asked to verify") } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } if block.ShardID() != bc.ShardID() { utils.Logger().Error(). @@ -951,11 +954,20 @@ func (bc *BlockChainImpl) ExportN(w io.Writer, first uint64, last uint64) error } func (bc *BlockChainImpl) WriteHeadBlock(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } return bc.writeHeadBlock(block) } // writeHeadBlock writes a new head block func (bc *BlockChainImpl) writeHeadBlock(block *types.Block) error { + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } // If the block is on a side chain or an unknown one, force other heads onto it too updateHeads := bc.GetCanonicalHash(block.NumberU64()) != block.Hash() @@ -1008,6 +1020,12 @@ func (bc *BlockChainImpl) writeHeadBlock(block *types.Block) error { // tikvFastForward writes a new head block in tikv mode, used for reader node or follower writer node func (bc *BlockChainImpl) tikvFastForward(block *types.Block, logs []*types.Log) error { + if err := validateBlockHashes(block); err != nil { + return err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } bc.currentBlock.Store(block) headBlockGauge.Update(int64(block.NumberU64())) @@ -1322,19 +1340,21 @@ func (bc *BlockChainImpl) Rollback(chain []common.Hash) error { if currentFastBlock := bc.CurrentFastBlock(); currentFastBlock != nil && currentFastBlock.Hash() == hash { newFastBlock := bc.GetBlock(currentFastBlock.ParentHash(), currentFastBlock.NumberU64()-1) if newFastBlock != nil { + if err := rawdb.WriteHeadFastBlockHash(bc.db, newFastBlock.Hash()); err != nil { + return errors.Wrap(err, "write fast head during rollback") + } bc.currentFastBlock.Store(newFastBlock) headFastBlockGauge.Update(int64(newFastBlock.NumberU64())) - rawdb.WriteHeadFastBlockHash(bc.db, newFastBlock.Hash()) } } if currentBlock := bc.CurrentBlock(); currentBlock != nil && currentBlock.Hash() == hash { newBlock := bc.GetBlock(currentBlock.ParentHash(), currentBlock.NumberU64()-1) if newBlock != nil { - bc.currentBlock.Store(newBlock) - headBlockGauge.Update(int64(newBlock.NumberU64())) if err := rawdb.WriteHeadBlockHash(bc.db, newBlock.Hash()); err != nil { - return err + return errors.Wrap(err, "write full head during rollback") } + bc.currentBlock.Store(newBlock) + headBlockGauge.Update(int64(newBlock.NumberU64())) for _, stkTxn := range currentBlock.StakingTransactions() { if stkTxn.StakingType() == staking.DirectiveCreateValidator { @@ -1439,6 +1459,12 @@ func (bc *BlockChainImpl) InsertReceiptChain(blockChain types.Blocks, receiptCha batch = bc.db.NewBatch() ) for i, block := range blockChain { + if err := validateBlockHashes(block); err != nil { + return i, err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return i, err + } receipts := receiptChain[i] // Short circuit insertion if shutting down or processing failed if atomic.LoadInt32(&bc.procInterrupt) == 1 { @@ -1523,6 +1549,12 @@ func (bc *BlockChainImpl) InsertReceiptChain(blockChain types.Blocks, receiptCha var lastWrite uint64 func (bc *BlockChainImpl) WriteBlockWithoutState(block *types.Block) (err error) { + if err := validateBlockHashes(block); err != nil { + return err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } bc.chainmu.Lock() defer bc.chainmu.Unlock() @@ -1540,6 +1572,15 @@ func (bc *BlockChainImpl) WriteBlockWithState( paid reward.Reader, state *state.DB, ) (status WriteStatus, err error) { + if err := validateBlockHashes(block); err != nil { + return NonStatTy, err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return NonStatTy, err + } + if err := validateEmergencyRecoveryDerivedStaking(bc.chainConfig, block, len(stakeMsgs)); err != nil { + return NonStatTy, err + } currentBlock := bc.CurrentBlock() if currentBlock == nil { return NonStatTy, errors.New("Current block is nil") @@ -1688,6 +1729,15 @@ func (bc *BlockChainImpl) GetMaxGarbageCollectedBlockNumber() int64 { } func (bc *BlockChainImpl) InsertChain(chain types.Blocks, verifyHeaders bool) (int, error) { + for i, block := range chain { + if err := validateBlockHashes(block); err != nil { + return i, err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return i, err + } + } + // if in tikv mode, writer node need preempt master or come be a follower if bc.isInitTiKV() && !bc.tikvPreemptMaster(bc.rangeBlock(chain)) { return len(chain), nil @@ -1700,6 +1750,16 @@ func (bc *BlockChainImpl) InsertChain(chain types.Blocks, verifyHeaders bool) (i } } + // InsertChain is used by sync/import paths that can bypass the live + // consensus validator. Apply the incoming-receipt checks after known-block + // handling, because replaying an already-canonical block may legitimately + // find its receipt-spent markers already set. + for i, block := range chain { + if err := VerifyIncomingReceipts(bc, block); err != nil { + return i, err + } + } + prevHash := bc.CurrentBlock().Hash() bc.chainmu.Lock() defer bc.chainmu.Unlock() diff --git a/core/epochchain.go b/core/epochchain.go index 2dab284713..c7b00361bb 100644 --- a/core/epochchain.go +++ b/core/epochchain.go @@ -123,6 +123,12 @@ func (bc *EpochChain) InsertChain(blocks types.Blocks, _ bool) (int, error) { <-bc.mu }() for i, block := range blocks { + if err := validateBlockHashes(block); err != nil { + return i, err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return i, err + } if !block.IsLastBlockInEpoch() { return i, ErrNotLastBlockInEpoch } @@ -282,6 +288,12 @@ func (bc *EpochChain) writeShardStateBytes(db rawdb.DatabaseWriter, // WriteHeadBlock writes a new head block. func (bc *EpochChain) WriteHeadBlock(block *types.Block) error { + if err := validateBlockHashes(block); err != nil { + return err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return err + } batch := bc.db.NewBatch() se, err := bc.writeHeadBlock(batch, block) if err != nil { diff --git a/core/headerchain.go b/core/headerchain.go index 4f5e8a066c..3b5a70824f 100644 --- a/core/headerchain.go +++ b/core/headerchain.go @@ -137,6 +137,9 @@ func (hc *HeaderChain) GetBlockNumber(hash common.Hash) *uint64 { // in two scenarios: pure-header mode of operation (light clients), or properly // separated header/block phases (non-archive clients). func (hc *HeaderChain) WriteHeader(header *block.Header) (status WriteStatus, err error) { + if err := ValidateEmergencyRecoveryBlockPolicy(hc.config, types.NewBlockWithHeader(header)); err != nil { + return NonStatTy, err + } // Cache some values to prevent constant recalculation var ( hash = header.Hash() diff --git a/core/offchain.go b/core/offchain.go index a469477607..4e706b4018 100644 --- a/core/offchain.go +++ b/core/offchain.go @@ -31,6 +31,15 @@ func (bc *BlockChainImpl) CommitOffChainData( payout reward.Reader, state *state.DB, ) (status WriteStatus, err error) { + if err := validateBlockHashes(block); err != nil { + return NonStatTy, err + } + if err := ValidateEmergencyRecoveryBlockPolicy(bc.chainConfig, block); err != nil { + return NonStatTy, err + } + if err := validateEmergencyRecoveryDerivedStaking(bc.chainConfig, block, len(stakeMsgs)); err != nil { + return NonStatTy, err + } // Write receipts of the block if err := rawdb.WriteReceipts(batch, block.Hash(), block.NumberU64(), receipts); err != nil { return NonStatTy, err @@ -196,7 +205,7 @@ func (bc *BlockChainImpl) CommitOffChainData( utils.Logger().Debug().Msgf(msg, len(*crossLinks), num) } - if isBeaconChain && bc.Config().IsCrossLink(bc.CurrentBlock().Epoch()) { + if bc.shouldRollUpLatestCrossLinks(block, isBeaconChain) { // Roll up latest crosslinks for i, c := uint32(0), shard.Schedule.InstanceForEpoch( epoch, @@ -287,6 +296,15 @@ func (bc *BlockChainImpl) CommitOffChainData( return CanonStatTy, nil } +// shouldRollUpLatestCrossLinks prevents stock Rollback's abandoned crosslink +// indexes from advancing last-continuous markers while recovery keeps +// crosslinks frozen. Other networks and retained mainnet history are unchanged. +func (bc *BlockChainImpl) shouldRollUpLatestCrossLinks(block *types.Block, isBeaconChain bool) bool { + return isBeaconChain && + !isEmergencyRecoveryBlock(bc.chainConfig, block) && + bc.Config().IsCrossLink(bc.CurrentBlock().Epoch()) +} + func (bc *BlockChainImpl) writeValidatorStats( tempValidatorStats map[common.Address]*staking.ValidatorStats, batch rawdb.DatabaseWriter, diff --git a/core/rawdb/accessors_offchain.go b/core/rawdb/accessors_offchain.go index 9e9e83941d..6e4d1d9bd3 100644 --- a/core/rawdb/accessors_offchain.go +++ b/core/rawdb/accessors_offchain.go @@ -406,6 +406,14 @@ func ReadBlockCommitSig(db DatabaseReader, blockNum uint64) ([]byte, error) { return data, nil } +// ReadBlockCommitSigExact retrieves only the height-keyed certificate. Unlike +// ReadBlockCommitSig, it never falls back to the legacy global LastCommits key. +// Recovery verification must use this accessor so a stale fallback cannot +// masquerade as a durably persisted target certificate. +func ReadBlockCommitSigExact(db DatabaseReader, blockNum uint64) ([]byte, error) { + return db.Get(blockCommitSigKey(blockNum)) +} + // WriteBlockCommitSig .. func WriteBlockCommitSig(db DatabaseWriter, blockNum uint64, sigAndBitmap []byte) error { return db.Put(blockCommitSigKey(blockNum), sigAndBitmap) diff --git a/core/rawdb/accessors_offchain_test.go b/core/rawdb/accessors_offchain_test.go index 836fa93024..d4691f4ecf 100644 --- a/core/rawdb/accessors_offchain_test.go +++ b/core/rawdb/accessors_offchain_test.go @@ -1,12 +1,34 @@ package rawdb import ( + "bytes" "math/big" "testing" "github.com/harmony-one/harmony/core/types" ) +func TestReadBlockCommitSigExactNeverUsesLegacyFallback(t *testing.T) { + db := NewMemoryDatabase() + legacy := []byte("legacy-global-certificate") + if err := db.Put(lastCommitsKey, legacy); err != nil { + t.Fatal(err) + } + if got, err := ReadBlockCommitSig(db, 92730034); err != nil || !bytes.Equal(got, legacy) { + t.Fatalf("compatibility reader got %x, %v", got, err) + } + if got, err := ReadBlockCommitSigExact(db, 92730034); err == nil { + t.Fatalf("exact reader accepted fallback bytes %x", got) + } + exact := []byte("height-keyed-certificate") + if err := WriteBlockCommitSig(db, 92730034, exact); err != nil { + t.Fatal(err) + } + if got, err := ReadBlockCommitSigExact(db, 92730034); err != nil || !bytes.Equal(got, exact) { + t.Fatalf("exact reader got %x, %v", got, err) + } +} + func TestWriteCXReceiptsProofSpentUsesMerkleProofIdentity(t *testing.T) { db := NewMemoryDatabase() diff --git a/core/recovery_crosslink_rollup_test.go b/core/recovery_crosslink_rollup_test.go new file mode 100644 index 0000000000..18abda6eec --- /dev/null +++ b/core/recovery_crosslink_rollup_test.go @@ -0,0 +1,41 @@ +package core + +import ( + "math/big" + "testing" + + "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/internal/params" +) + +func TestEmergencyRecoverySkipsLatestCrossLinkRollup(t *testing.T) { + activeHeader := emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock, 0) + activeHeader.SetEpoch(new(big.Int).Set(params.MainnetChainConfig.CrossLinkEpoch)) + activeHead := types.NewBlockWithHeader(activeHeader) + + mainnet := &BlockChainImpl{chainConfig: params.MainnetChainConfig} + mainnet.currentBlock.Store(activeHead) + if mainnet.shouldRollUpLatestCrossLinks( + emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 0), true, + ) { + t.Fatal("post-target mainnet recovery block must not roll up stale crosslinks") + } + if !mainnet.shouldRollUpLatestCrossLinks( + emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock, 0), true, + ) { + t.Fatal("retained mainnet history unexpectedly skips crosslink rollup") + } + + testnet := &BlockChainImpl{chainConfig: params.TestnetChainConfig} + testnet.currentBlock.Store(activeHead) + if !testnet.shouldRollUpLatestCrossLinks( + emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 0), true, + ) { + t.Fatal("testnet unexpectedly skips crosslink rollup at mainnet recovery height") + } + if mainnet.shouldRollUpLatestCrossLinks( + emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock, 0), false, + ) { + t.Fatal("non-beacon chain unexpectedly rolls up crosslinks") + } +} diff --git a/core/recovery_freeze.go b/core/recovery_freeze.go new file mode 100644 index 0000000000..580c8f8fd5 --- /dev/null +++ b/core/recovery_freeze.go @@ -0,0 +1,91 @@ +package core + +import ( + "errors" + "fmt" + "math" + + "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/internal/params" +) + +var ( + ErrEmergencyRecoveryFeatureFrozen = errors.New("feature is frozen during emergency recovery") + ErrEmergencyRecoveryViewIDFloorUnset = errors.New("emergency recovery ViewID floor is unset") + ErrEmergencyRecoveryViewIDBelowFloor = errors.New("block ViewID is below emergency recovery floor") + ErrEmergencyRecoveryViewIDInvalid = errors.New("invalid emergency recovery block ViewID") +) + +func isEmergencyRecoveryBlock(config *params.ChainConfig, block *types.Block) bool { + return block != nil && block.Header() != nil && + params.IsEmergencyRecoveryFeatureFreeze(config, block.ShardID(), block.NumberU64()) +} + +// ValidateEmergencyRecoveryFrozenPayload blocks all auxiliary data whose +// post-target LevelDB indexes are intentionally left dirty by stock Rollback. +// This release keeps the freeze permanent; a later metadata-cleanup release is +// required before these features can be re-enabled. +func ValidateEmergencyRecoveryFrozenPayload(config *params.ChainConfig, block *types.Block) error { + if !isEmergencyRecoveryBlock(config, block) { + return nil + } + header := block.Header() + switch { + case len(block.StakingTransactions()) != 0: + return fmt.Errorf("%w: staking transactions", ErrEmergencyRecoveryFeatureFrozen) + case len(block.IncomingReceipts()) != 0: + return fmt.Errorf("%w: incoming receipts", ErrEmergencyRecoveryFeatureFrozen) + case header.IncomingReceiptHash() != types.EmptyRootHash: + return fmt.Errorf("%w: incoming receipt commitment", ErrEmergencyRecoveryFeatureFrozen) + case len(header.CrossLinks()) != 0: + return fmt.Errorf("%w: crosslinks", ErrEmergencyRecoveryFeatureFrozen) + case len(header.Slashes()) != 0: + return fmt.Errorf("%w: slashes", ErrEmergencyRecoveryFeatureFrozen) + } + return nil +} + +func validateEmergencyRecoveryViewIDWithFloor(config *params.ChainConfig, block *types.Block, floor uint64) error { + if !isEmergencyRecoveryBlock(config, block) { + return nil + } + if floor == 0 { + return ErrEmergencyRecoveryViewIDFloorUnset + } + if floor == math.MaxUint64 { + return fmt.Errorf("%w: floor exhausts uint64", ErrEmergencyRecoveryViewIDInvalid) + } + viewID := block.Header().ViewID() + if viewID == nil || viewID.Sign() < 0 || viewID.BitLen() > 64 { + return ErrEmergencyRecoveryViewIDInvalid + } + if viewID.Uint64() == math.MaxUint64 { + return fmt.Errorf("%w: block ViewID exhausts uint64", ErrEmergencyRecoveryViewIDInvalid) + } + if viewID.Uint64() < floor { + return fmt.Errorf("%w: got %s, floor %d", ErrEmergencyRecoveryViewIDBelowFloor, viewID, floor) + } + return nil +} + +// ValidateEmergencyRecoveryBlockPolicy enforces the complete post-target +// recovery policy before any known-block or database-write shortcut. +func ValidateEmergencyRecoveryBlockPolicy(config *params.ChainConfig, block *types.Block) error { + if err := ValidateEmergencyRecoveryFrozenPayload(config, block); err != nil { + return err + } + return validateEmergencyRecoveryViewIDWithFloor(config, block, params.EmergencyRecoveryViewIDFloor) +} + +func validateEmergencyRecoveryDerivedStaking(config *params.ChainConfig, block *types.Block, stakeMessageCount int) error { + if isEmergencyRecoveryBlock(config, block) && stakeMessageCount != 0 { + return fmt.Errorf("%w: EVM-derived staking messages", ErrEmergencyRecoveryFeatureFrozen) + } + return nil +} + +// IsEmergencyRecoveryFeatureFreeze reports whether proposal-side staking/CX/ +// crosslink/slash sources must remain empty. +func IsEmergencyRecoveryFeatureFreeze(config *params.ChainConfig, shardID uint32, blockNumber uint64) bool { + return params.IsEmergencyRecoveryFeatureFreeze(config, shardID, blockNumber) +} diff --git a/core/recovery_freeze_test.go b/core/recovery_freeze_test.go new file mode 100644 index 0000000000..1df7d42048 --- /dev/null +++ b/core/recovery_freeze_test.go @@ -0,0 +1,195 @@ +package core + +import ( + "errors" + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/block" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/internal/params" + staking "github.com/harmony-one/harmony/staking/types" +) + +func TestValidateEmergencyRecoveryFrozenPayload(t *testing.T) { + tests := []struct { + name string + block *types.Block + }{ + {name: "staking transaction", block: emergencyRecoveryBlockWithStaking(t)}, + {name: "incoming receipt", block: emergencyRecoveryBlockWithIncomingReceipt()}, + {name: "incoming receipt commitment", block: emergencyRecoveryBlockWithIncomingReceiptCommitment()}, + {name: "crosslink", block: emergencyRecoveryBlockWithCrossLink()}, + {name: "slash", block: emergencyRecoveryBlockWithSlash()}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := ValidateEmergencyRecoveryFrozenPayload(params.MainnetChainConfig, test.block); !errors.Is(err, ErrEmergencyRecoveryFeatureFrozen) { + t.Fatalf("ValidateEmergencyRecoveryFrozenPayload() error = %v, want %v", err, ErrEmergencyRecoveryFeatureFrozen) + } + }) + } +} + +func TestEmergencyRecoveryFeatureFreezeScope(t *testing.T) { + bad := emergencyRecoveryBlockWithSlash() + if err := ValidateEmergencyRecoveryFrozenPayload(params.MainnetChainConfig, emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock, 0)); err != nil { + t.Fatalf("target block unexpectedly frozen: %v", err) + } + if err := ValidateEmergencyRecoveryFrozenPayload(params.MainnetChainConfig, emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 1)); err != nil { + t.Fatalf("shard 1 unexpectedly frozen: %v", err) + } + if err := ValidateEmergencyRecoveryFrozenPayload(params.TestnetChainConfig, bad); err != nil { + t.Fatalf("testnet unexpectedly frozen: %v", err) + } + if err := ValidateEmergencyRecoveryFrozenPayload(params.MainnetChainConfig, emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 0)); err != nil { + t.Fatalf("empty recovery block rejected: %v", err) + } +} + +func TestEmergencyRecoveryDerivedStakingMessagesFrozen(t *testing.T) { + block := emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 0) + if err := validateEmergencyRecoveryDerivedStaking(params.MainnetChainConfig, block, 1); !errors.Is(err, ErrEmergencyRecoveryFeatureFrozen) { + t.Fatalf("validateEmergencyRecoveryDerivedStaking() error = %v, want %v", err, ErrEmergencyRecoveryFeatureFrozen) + } + if err := validateEmergencyRecoveryDerivedStaking(params.MainnetChainConfig, block, 0); err != nil { + t.Fatalf("empty derived staking messages rejected: %v", err) + } +} + +func TestEmergencyRecoveryViewIDPolicy(t *testing.T) { + beforeTarget := emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock, 0) + if err := validateEmergencyRecoveryViewIDWithFloor(params.MainnetChainConfig, beforeTarget, 100); err != nil { + t.Fatalf("target block unexpectedly subject to new-block ViewID policy: %v", err) + } + + header := emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0) + header.SetViewID(big.NewInt(99)) + block := types.NewBlockWithHeader(header) + if err := validateEmergencyRecoveryViewIDWithFloor(params.MainnetChainConfig, block, 100); !errors.Is(err, ErrEmergencyRecoveryViewIDBelowFloor) { + t.Fatalf("below-floor ViewID error = %v, want %v", err, ErrEmergencyRecoveryViewIDBelowFloor) + } + + header.SetViewID(big.NewInt(100)) + if err := validateEmergencyRecoveryViewIDWithFloor(params.MainnetChainConfig, types.NewBlockWithHeader(header), 100); err != nil { + t.Fatalf("floor ViewID rejected: %v", err) + } + if err := validateEmergencyRecoveryViewIDWithFloor(params.MainnetChainConfig, types.NewBlockWithHeader(header), 0); !errors.Is(err, ErrEmergencyRecoveryViewIDFloorUnset) { + t.Fatalf("unset floor error = %v, want %v", err, ErrEmergencyRecoveryViewIDFloorUnset) + } + header.SetViewID(new(big.Int).SetUint64(math.MaxUint64)) + if err := validateEmergencyRecoveryViewIDWithFloor(params.MainnetChainConfig, types.NewBlockWithHeader(header), 100); !errors.Is(err, ErrEmergencyRecoveryViewIDInvalid) { + t.Fatalf("exhausted ViewID error = %v, want %v", err, ErrEmergencyRecoveryViewIDInvalid) + } +} + +func TestEmergencyRecoveryViewIDPolicyRunsBeforeKnownAndWriteShortcuts(t *testing.T) { + block := emergencyRecoveryEmptyBlock(params.EmergencyRecoveryRetainedBlock+1, 0) + want := ErrEmergencyRecoveryViewIDBelowFloor + if params.EmergencyRecoveryViewIDFloor == 0 { + want = ErrEmergencyRecoveryViewIDFloorUnset + } + + chain := emergencyRecoveryKnownChain{config: params.MainnetChainConfig} + if err := NewBlockValidator(chain).ValidateBody(block); !errors.Is(err, want) { + t.Fatalf("ValidateBody() error = %v, want %v before known-block shortcut", err, want) + } + + directChain := &BlockChainImpl{chainConfig: params.MainnetChainConfig} + if err := directChain.WriteBlockWithoutState(block); !errors.Is(err, want) { + t.Fatalf("WriteBlockWithoutState() error = %v, want %v before database access", err, want) + } + + headerChain := &HeaderChain{config: params.MainnetChainConfig} + if _, err := headerChain.WriteHeader(block.Header()); !errors.Is(err, want) { + t.Fatalf("WriteHeader() error = %v, want %v before database access", err, want) + } +} + +type emergencyRecoveryKnownChain struct { + Stub + config *params.ChainConfig +} + +func (c emergencyRecoveryKnownChain) Config() *params.ChainConfig { return c.config } + +func (emergencyRecoveryKnownChain) HasBlockAndState(common.Hash, uint64) bool { return true } + +func TestEmergencyRecoveryFreezeRunsBeforeKnownBlockShortcut(t *testing.T) { + chain := emergencyRecoveryKnownChain{config: params.MainnetChainConfig} + err := NewBlockValidator(chain).ValidateBody(emergencyRecoveryBlockWithSlash()) + if !errors.Is(err, ErrEmergencyRecoveryFeatureFrozen) { + t.Fatalf("ValidateBody() error = %v, want freeze error before known-block shortcut", err) + } +} + +func TestEmergencyRecoveryFreezeRunsBeforeDirectDatabaseWrites(t *testing.T) { + chain := &BlockChainImpl{chainConfig: params.MainnetChainConfig} + block := emergencyRecoveryBlockWithSlash() + + if err := chain.WriteBlockWithoutState(block); !errors.Is(err, ErrEmergencyRecoveryFeatureFrozen) { + t.Fatalf("WriteBlockWithoutState() error = %v, want freeze error", err) + } + if _, err := chain.InsertChain(types.Blocks{block}, false); !errors.Is(err, ErrEmergencyRecoveryFeatureFrozen) { + t.Fatalf("InsertChain() error = %v, want freeze error", err) + } +} + +func emergencyRecoveryHeader(number uint64, shardID uint32) *block.Header { + return blockfactory.NewTestHeader().With(). + Number(new(big.Int).SetUint64(number)). + ShardID(shardID). + Header() +} + +func emergencyRecoveryEmptyBlock(number uint64, shardID uint32) *types.Block { + return types.NewBlock(emergencyRecoveryHeader(number, shardID), nil, nil, nil, nil, nil) +} + +func emergencyRecoveryBlockWithStaking(t *testing.T) *types.Block { + t.Helper() + stx, err := staking.NewStakingTransaction(0, 21000, big.NewInt(1), func() (staking.Directive, interface{}) { + return staking.DirectiveCollectRewards, staking.CollectRewards{} + }) + if err != nil { + t.Fatal(err) + } + return types.NewBlock( + emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0), + nil, []*types.Receipt{types.NewReceipt(nil, false, 0)}, nil, nil, + []*staking.StakingTransaction{stx}, + ) +} + +func emergencyRecoveryBlockWithIncomingReceipt() *types.Block { + proof := &types.CXReceiptsProof{ + MerkleProof: &types.CXMerkleProof{BlockNum: big.NewInt(1)}, + Header: blockfactory.NewTestHeader(), + } + return types.NewBlock( + emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0), + nil, nil, nil, []*types.CXReceiptsProof{proof}, nil, + ) +} + +func emergencyRecoveryBlockWithIncomingReceiptCommitment() *types.Block { + header := emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0) + header.SetIncomingReceiptHash(common.HexToHash("0x01")) + return types.NewBlockWithHeader(header) +} + +func emergencyRecoveryBlockWithCrossLink() *types.Block { + header := emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0) + header.SetCrossLinks([]byte{1}) + return types.NewBlock(header, nil, nil, nil, nil, nil) +} + +func emergencyRecoveryBlockWithSlash() *types.Block { + header := emergencyRecoveryHeader(params.EmergencyRecoveryRetainedBlock+1, 0) + header.SetSlashes([]byte{1}) + return types.NewBlock(header, nil, nil, nil, nil, nil) +} diff --git a/core/rejected_block.go b/core/rejected_block.go new file mode 100644 index 0000000000..71268382f7 --- /dev/null +++ b/core/rejected_block.go @@ -0,0 +1,41 @@ +package core + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + blockv0 "github.com/harmony-one/harmony/block/v0" + consensus_engine "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +func validateBlockHashes(block *types.Block) error { + return validateBlockHashesWith(block, consensus_engine.ValidateBlockHash) +} + +func validateBlockHashesWith(block *types.Block, validateHash func(common.Hash) error) error { + if err := validateHash(block.Hash()); err != nil { + return err + } + + _, isV0 := block.Header().Header.(*blockv0.Header) + if !isV0 { + encoded := block.Header().CrossLinks() + var crossLinks types.CrossLinks + if len(encoded) > 0 && rlp.DecodeBytes(encoded, &crossLinks) == nil { + for i := range crossLinks { + if err := validateHash(crossLinks[i].Hash()); err != nil { + return err + } + } + } + } + + for _, proof := range block.IncomingReceipts() { + if proof != nil && proof.Header != nil { + if err := validateHash(proof.Header.Hash()); err != nil { + return err + } + } + } + return nil +} diff --git a/core/rejected_block_test.go b/core/rejected_block_test.go new file mode 100644 index 0000000000..f94492a404 --- /dev/null +++ b/core/rejected_block_test.go @@ -0,0 +1,76 @@ +package core + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rlp" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +const rejectedShard1BlockHash = "0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878" + +func TestValidateBlockHashesRejectsEmbeddedCrossLink(t *testing.T) { + block := blockWithRejectedCrossLink(t) + + if err := validateBlockHashes(block); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("validateBlockHashes() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func TestWriteBlockWithoutStateRejectsEmbeddedCrossLinkBeforeDatabaseWrite(t *testing.T) { + block := blockWithRejectedCrossLink(t) + var chain *BlockChainImpl + + if err := chain.WriteBlockWithoutState(block); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("WriteBlockWithoutState() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func blockWithRejectedCrossLink(t *testing.T) *types.Block { + t.Helper() + crossLinks := types.CrossLinks{{ + ShardIDF: 1, + BlockNumberF: big.NewInt(94978279), + ViewIDF: new(big.Int), + HashF: common.HexToHash(rejectedShard1BlockHash), + EpochF: new(big.Int), + }} + encoded, err := rlp.EncodeToBytes(crossLinks) + if err != nil { + t.Fatal(err) + } + header := blockfactory.NewTestHeader().With().CrossLinks(encoded).Header() + return types.NewBlock(header, nil, nil, nil, nil, nil) +} + +func TestValidateBlockHashesRejectsIncomingReceiptSource(t *testing.T) { + rejectedHeader := blockfactory.NewTestHeader().With().Extra([]byte("rejected source header")).Header() + block := types.NewBlock( + blockfactory.NewTestHeader(), nil, nil, nil, + []*types.CXReceiptsProof{{Header: rejectedHeader}}, nil, + ) + + validateHash := func(hash common.Hash) error { + if hash == rejectedHeader.Hash() { + return engine.ErrRejectedBlock + } + return nil + } + if err := validateBlockHashesWith(block, validateHash); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("validateBlockHashes() error = %v, want %v", err, engine.ErrRejectedBlock) + } +} + +func TestValidateBlockHashesLeavesMalformedCrossLinksToSemanticValidation(t *testing.T) { + header := blockfactory.NewTestHeader().With().CrossLinks([]byte("not rlp")).Header() + block := types.NewBlock(header, nil, nil, nil, nil, nil) + + if err := validateBlockHashes(block); err != nil { + t.Fatalf("validateBlockHashes() error = %v, want nil", err) + } +} diff --git a/core/state/iterator.go b/core/state/iterator.go index 8bc26332dd..74d122b9a9 100644 --- a/core/state/iterator.go +++ b/core/state/iterator.go @@ -22,6 +22,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/trie" ) @@ -115,21 +116,26 @@ func (it *NodeIterator) step() error { } it.dataIt = dataTrie.NodeIterator(nil) if !it.dataIt.Next(true) { + if err := it.dataIt.Error(); err != nil { + return err + } it.dataIt = nil } if !bytes.Equal(account.CodeHash, types.EmptyCodeHash.Bytes()) { it.codeHash = common.BytesToHash(account.CodeHash) addrHash := common.BytesToHash(it.stateIt.LeafKey()) - it.code, err = it.state.db.ContractCode(addrHash, common.BytesToHash(account.CodeHash)) - if err != nil { - return fmt.Errorf("code %x: %v", account.CodeHash, err) - } - if it.code == nil || len(it.code) == 0 { - it.code, err = it.state.db.ValidatorCode(addrHash, common.BytesToHash(account.CodeHash)) - if err != nil { - return fmt.Errorf("code %x: %v", account.CodeHash, err) + it.code, err = it.state.db.ContractCode(addrHash, it.codeHash) + if err != nil || len(it.code) == 0 { + contractErr := err + it.code, err = it.state.db.ValidatorCode(addrHash, it.codeHash) + if err != nil || len(it.code) == 0 { + return fmt.Errorf("code %x unavailable (contract: %v, validator: %v)", + account.CodeHash, contractErr, err) } } + if got := crypto.Keccak256Hash(it.code); got != it.codeHash { + return fmt.Errorf("code %x hash mismatch: got %x", account.CodeHash, got) + } } it.accountHash = it.stateIt.Parent() return nil diff --git a/core/state/iterator_test.go b/core/state/iterator_test.go index 8333dc8bce..6d475034cf 100644 --- a/core/state/iterator_test.go +++ b/core/state/iterator_test.go @@ -130,6 +130,56 @@ func TestNodeIteratorCoverage(t *testing.T) { } } +func TestNodeIteratorTraversesValidatorCodeAndChecksHash(t *testing.T) { + db := rawdb.NewMemoryDatabase() + sdb := NewDatabase(db) + statedb, err := New(common.Hash{}, sdb, nil) + if err != nil { + t.Fatal(err) + } + address := common.HexToAddress("0x1234") + code := []byte("validator-wrapper") + codeHash := crypto.Keccak256Hash(code) + object := statedb.GetOrNewStateObject(address) + object.SetCode(codeHash, code, true) + statedb.updateStateObject(object) + root, err := statedb.Commit(false) + if err != nil { + t.Fatal(err) + } + if err := sdb.TrieDB().Commit(root, false); err != nil { + t.Fatal(err) + } + + check, err := New(root, NewDatabase(db), nil) + if err != nil { + t.Fatal(err) + } + iterator := NewNodeIterator(check) + found := false + for iterator.Next() { + found = found || iterator.Hash == codeHash + } + if iterator.Error != nil { + t.Fatalf("validator code traversal failed: %v", iterator.Error) + } + if !found { + t.Fatal("validator code hash was not traversed") + } + + rawdb.WriteValidatorCode(db, codeHash, []byte("corrupt-validator-wrapper")) + check, err = New(root, NewDatabase(db), nil) + if err != nil { + t.Fatal(err) + } + iterator = NewNodeIterator(check) + for iterator.Next() { + } + if iterator.Error == nil { + t.Fatal("corrupt validator code passed traversal") + } +} + // isTrieNode is a helper function which reports if the provided // database entry belongs to a trie node or not. func isTrieNode(scheme string, key, val []byte) (bool, common.Hash) { diff --git a/core/vm/emergency_recovery.go b/core/vm/emergency_recovery.go new file mode 100644 index 0000000000..cc106dd9a3 --- /dev/null +++ b/core/vm/emergency_recovery.go @@ -0,0 +1,43 @@ +package vm + +import ( + "errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/internal/params" +) + +var ( + emergencyRecoveryStakingPrecompileAddress = common.BytesToAddress([]byte{252}) + // ErrEmergencyRecoveryStakingFrozen is returned by calls to the staking + // precompile while post-target validator metadata is quarantined. + ErrEmergencyRecoveryStakingFrozen = errors.New("staking precompile is frozen during emergency recovery") + emergencyRecoveryFrozenStaking = &emergencyRecoveryFrozenStakingPrecompile{} +) + +func isEmergencyRecoveryStakingPrecompileFrozen(evm *EVM, addr common.Address) bool { + if evm == nil || evm.Context.BlockNumber == nil || evm.Context.BlockNumber.Sign() < 0 || + evm.Context.BlockNumber.BitLen() > 64 || addr != emergencyRecoveryStakingPrecompileAddress { + return false + } + return params.IsEmergencyRecoveryFeatureFreeze( + evm.ChainConfig(), evm.Context.ShardID, evm.Context.BlockNumber.Uint64(), + ) +} + +// emergencyRecoveryFrozenStakingPrecompile preserves 0xfc as a precompile but +// makes every write attempt fail before parsing input or invoking any staking +// callback. The EVM reverts the call snapshot and consumes the call gas. +type emergencyRecoveryFrozenStakingPrecompile struct{} + +var _ WriteCapablePrecompiledContract = (*emergencyRecoveryFrozenStakingPrecompile)(nil) + +func (*emergencyRecoveryFrozenStakingPrecompile) IsWrite() bool { return true } + +func (*emergencyRecoveryFrozenStakingPrecompile) RequiredGas(*EVM, *Contract, []byte) (uint64, error) { + return 0, ErrEmergencyRecoveryStakingFrozen +} + +func (*emergencyRecoveryFrozenStakingPrecompile) RunWriteCapable(*EVM, *Contract, []byte) ([]byte, error) { + return nil, ErrEmergencyRecoveryStakingFrozen +} diff --git a/core/vm/emergency_recovery_test.go b/core/vm/emergency_recovery_test.go new file mode 100644 index 0000000000..35585ea0f0 --- /dev/null +++ b/core/vm/emergency_recovery_test.go @@ -0,0 +1,73 @@ +package vm + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/internal/params" + stakingTypes "github.com/harmony-one/harmony/staking/types" +) + +func TestEmergencyRecoveryStakingPrecompileSelection(t *testing.T) { + address := common.BytesToAddress([]byte{252}) + tests := []struct { + name string + config *params.ChainConfig + shardID uint32 + blockNumber uint64 + wantFrozen bool + }{ + {name: "mainnet shard 0 after target", config: params.MainnetChainConfig, shardID: 0, blockNumber: params.EmergencyRecoveryRetainedBlock + 1, wantFrozen: true}, + {name: "target block", config: params.MainnetChainConfig, shardID: 0, blockNumber: params.EmergencyRecoveryRetainedBlock}, + {name: "other shard", config: params.MainnetChainConfig, shardID: 1, blockNumber: params.EmergencyRecoveryRetainedBlock + 1}, + {name: "other network", config: params.TestnetChainConfig, shardID: 0, blockNumber: params.EmergencyRecoveryRetainedBlock + 1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + evm := NewEVM(BlockContext{ + BlockNumber: new(big.Int).SetUint64(test.blockNumber), + EpochNumber: big.NewInt(3002), + ShardID: test.shardID, + }, TxContext{}, nil, test.config, Config{}) + precompile, ok := evm.precompile(address) + if !ok { + t.Fatal("staking precompile not selected") + } + _, frozen := precompile.(*emergencyRecoveryFrozenStakingPrecompile) + if frozen != test.wantFrozen { + t.Fatalf("frozen precompile selected = %v, want %v", frozen, test.wantFrozen) + } + }) + } +} + +func TestEmergencyRecoveryStakingPrecompileFailsBeforeCallback(t *testing.T) { + callbackCalled := false + evm := NewEVM(BlockContext{ + BlockNumber: new(big.Int).SetUint64(params.EmergencyRecoveryRetainedBlock + 1), + EpochNumber: big.NewInt(3002), + ShardID: 0, + Delegate: func(StateDB, RosettaTracer, *stakingTypes.Delegate) error { + callbackCalled = true + return nil + }, + }, TxContext{}, nil, params.MainnetChainConfig, Config{}) + + precompile, ok := evm.precompile(emergencyRecoveryStakingPrecompileAddress) + if !ok { + t.Fatal("staking precompile not selected") + } + contract := NewContract(AccountRef(common.Address{}), AccountRef(emergencyRecoveryStakingPrecompileAddress), new(big.Int), 1) + if _, _, err := RunPrecompiledContract(precompile, evm, contract, []byte{1, 2, 3, 4}, 1, false); !errors.Is(err, ErrEmergencyRecoveryStakingFrozen) { + t.Fatalf("RunPrecompiledContract() error = %v, want %v", err, ErrEmergencyRecoveryStakingFrozen) + } + if callbackCalled { + t.Fatal("staking callback was invoked") + } + if len(evm.StakeMsgs) != 0 { + t.Fatalf("staking messages mutated: %d", len(evm.StakeMsgs)) + } +} diff --git a/core/vm/evm.go b/core/vm/evm.go index 9f64457ab0..1d20a44666 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -87,6 +87,9 @@ func ActivePrecompiles(rules params.Rules) []common.Address { } func (evm *EVM) precompile(addr common.Address) (WriteCapablePrecompiledContract, bool) { + if isEmergencyRecoveryStakingPrecompileFrozen(evm, addr) { + return emergencyRecoveryFrozenStaking, true + } precompiles := PrecompiledContractsHomestead var writeCapablePrecompiles map[common.Address]WriteCapablePrecompiledContract if evm.ChainConfig().IsS3(evm.Context.EpochNumber) { diff --git a/internal/chain/engine.go b/internal/chain/engine.go index 8b701a3dce..1c4c413e60 100644 --- a/internal/chain/engine.go +++ b/internal/chain/engine.go @@ -66,6 +66,9 @@ func NewEngine() *engineImpl { // VerifyHeader checks whether a header conforms to the consensus rules of the bft engine. // Note that each block header contains the bls signature of the parent block func (e *engineImpl) VerifyHeader(chain engine.ChainReader, header *block.Header, seal bool) error { + if err := engine.ValidateBlockHash(header.Hash()); err != nil { + return err + } parentHeader := chain.GetHeader(header.ParentHash(), header.Number().Uint64()-1) if parentHeader == nil { return engine.ErrUnknownAncestor @@ -636,6 +639,9 @@ func applySlashes( // i.e. this header verification api is more flexible since the caller specifies which commit signature and bitmap to use // for verifying the block header, which is necessary for cross-shard block header verification. Example of such is cross-shard transaction. func (e *engineImpl) VerifyHeaderSignature(chain engine.ChainReader, header *block.Header, commitSig bls_cosi.SerializedSignature, commitBitmap []byte) error { + if err := engine.ValidateBlockHash(header.Hash()); err != nil { + return err + } if chain.CurrentHeader().Number().Uint64() <= uint64(1) { return nil } @@ -647,6 +653,9 @@ func (e *engineImpl) VerifyHeaderSignature(chain engine.ChainReader, header *blo // VerifyCrossLink verifies the signature of the given CrossLink. func (e *engineImpl) VerifyCrossLink(chain engine.ChainReader, cl types.CrossLink) error { + if err := engine.ValidateBlockHash(cl.Hash()); err != nil { + return err + } if cl.BlockNum() <= 1 { return errors.New("crossLink BlockNumber should greater than 1") } diff --git a/internal/chain/rejected_block_test.go b/internal/chain/rejected_block_test.go new file mode 100644 index 0000000000..bf02ea96fc --- /dev/null +++ b/internal/chain/rejected_block_test.go @@ -0,0 +1,32 @@ +package chain + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/harmony-one/harmony/consensus/engine" + "github.com/harmony-one/harmony/core/types" +) + +func TestVerifyCrossLinkRejectsAbandonedChainAnchors(t *testing.T) { + tests := []types.CrossLink{ + { + ShardIDF: 0, + BlockNumberF: big.NewInt(92730036), + HashF: common.HexToHash("0x890473cdb9aa8dc5c0bbd54cf20b6d8d84bda60d3dcb2273443d34432d8539e8"), + }, + { + ShardIDF: 1, + BlockNumberF: big.NewInt(94978279), + HashF: common.HexToHash("0xc936581d391b74a620bf6636519834b14a9a2d4e9a5154867c8407f219d8a878"), + }, + } + + for _, crossLink := range tests { + if err := NewEngine().VerifyCrossLink(nil, crossLink); !errors.Is(err, engine.ErrRejectedBlock) { + t.Fatalf("VerifyCrossLink() error = %v, want %v", err, engine.ErrRejectedBlock) + } + } +} diff --git a/internal/params/emergency_recovery.go b/internal/params/emergency_recovery.go new file mode 100644 index 0000000000..87898030db --- /dev/null +++ b/internal/params/emergency_recovery.go @@ -0,0 +1,19 @@ +package params + +// EmergencyRecoveryRetainedBlock is the last canonical shard-0 block retained +// by the emergency recovery release. +const EmergencyRecoveryRetainedBlock uint64 = 92730034 + +// EmergencyRecoveryViewIDFloor is the recovery release's signed activation +// floor for mainnet shard 0. +const EmergencyRecoveryViewIDFloor uint64 = 1_000_000_000 + +// IsEmergencyRecoveryFeatureFreeze reports whether features backed by +// post-target auxiliary metadata must remain disabled. Shard 0 is the beacon +// chain; keeping this helper in params avoids duplicating the activation height +// between core block validation and the EVM staking precompile. +func IsEmergencyRecoveryFeatureFreeze(config *ChainConfig, shardID uint32, blockNumber uint64) bool { + return config != nil && config.ChainID != nil && + config.ChainID.Cmp(MainnetChainID) == 0 && + shardID == 0 && blockNumber > EmergencyRecoveryRetainedBlock +} diff --git a/node/harmony/node.go b/node/harmony/node.go index fb10bde8ba..85475cd9e6 100644 --- a/node/harmony/node.go +++ b/node/harmony/node.go @@ -1200,6 +1200,16 @@ func (node *Node) ServiceManager() *service.Manager { // ShutDown gracefully shut down the node server and dump the in-memory blockchain state into DB. func (node *Node) ShutDown() { + node.shutDown(0) +} + +// ShutDownWithExitCode performs the same graceful close while preserving a +// maintenance command's failure status. +func (node *Node) ShutDownWithExitCode(exitCode int) { + node.shutDown(exitCode) +} + +func (node *Node) shutDown(exitCode int) { if err := node.StopRPC(); err != nil { utils.Logger().Error().Err(err).Msg("failed to stop RPC") } @@ -1241,7 +1251,7 @@ func (node *Node) ShutDown() { const msg = "Successfully shut down!\n" utils.Logger().Print(msg) fmt.Print(msg) - os.Exit(0) + os.Exit(exitCode) } // IsRunningBeaconChain returns whether the node is running on beacon chain.