diff --git a/cmd/XDC/chaincmd.go b/cmd/XDC/chaincmd.go index 1e767eae3497..a2b8e478f6b0 100644 --- a/cmd/XDC/chaincmd.go +++ b/cmd/XDC/chaincmd.go @@ -219,7 +219,7 @@ func importChain(ctx *cli.Context) error { // Start metrics export if enabled utils.SetupMetrics(&cfg.Metrics) - chain, db := utils.MakeChain(ctx, stack, false) + chain, db := utils.MakeChain(ctx, stack, false, cfg.Eth.ChainConfigMismatchPolicy) defer db.Close() // Start periodically gathering memory profiles @@ -291,10 +291,10 @@ func exportChain(ctx *cli.Context) error { utils.Fatalf("This command requires an argument.") } - stack, _ := makeConfigNode(ctx) + stack, cfg := makeConfigNode(ctx) defer stack.Close() - chain, db := utils.MakeChain(ctx, stack, true) + chain, db := utils.MakeChain(ctx, stack, true, cfg.Eth.ChainConfigMismatchPolicy) defer db.Close() start := time.Now() diff --git a/cmd/XDC/genesis_startup_test.go b/cmd/XDC/genesis_startup_test.go index e8aa4b3c10c9..20d2238e1ddc 100644 --- a/cmd/XDC/genesis_startup_test.go +++ b/cmd/XDC/genesis_startup_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + cmdutils "github.com/XinFinOrg/XDPoSChain/cmd/utils" "github.com/XinFinOrg/XDPoSChain/common" "github.com/XinFinOrg/XDPoSChain/core" "github.com/XinFinOrg/XDPoSChain/core/rawdb" @@ -117,13 +118,17 @@ func assertCommandSucceeds(t *testing.T, cmd *testXDC) { } } -func startTestnetConsole(t *testing.T, datadir string) { +func startTestnetConsole(t *testing.T, datadir string, extraArgs ...string) { t.Helper() - startupCmd := runXDC(t, + args := []string{ "--testnet", "console", "--port", "0", "--maxpeers", "0", "--nodiscover", "--nat", "none", "--ipcdisable", - "--datadir", datadir, "--exec", "2+2", - ) + "--datadir", datadir, + } + args = append(args, extraArgs...) + args = append(args, "--exec", "2+2") + + startupCmd := runXDC(t, args...) assertCommandSucceeds(t, startupCmd) } @@ -855,7 +860,12 @@ func TestOfflineExportFailsReadonlyConfigRewindWithoutMutation(t *testing.T) { injectedHeadHash := injectCanonicalHeadBlock(t, datadir, genesisHash, 101) exportCmd := runXDC(t, "--datadir", datadir, "--testnet", "export", exportFile) - assertCommandFailsWithChainConfigError(t, exportCmd, "Can't open blockchain in readonly mode: the local chain configuration requires rewind. Use the correct --networkid/--datadir combination, or reopen the database in writable mode so the chain can rewind, then retry.") + assertCommandFailsWithChainConfigErrors( + t, + exportCmd, + "Can't open blockchain: mismatching", + cmdutils.ChainConfigMismatchPolicyExitHint, + ) if _, err := os.Stat(exportFile); err == nil { t.Fatalf("expected export to fail without creating %s", exportFile) } else if !os.IsNotExist(err) { @@ -929,7 +939,12 @@ func TestOfflineExportFailsReadonlyConfigRewindToZeroWithoutMutation(t *testing. injectedHeadHash := injectCanonicalHeadBlock(t, datadir, genesisHash, 1) exportCmd := runXDC(t, "--datadir", datadir, "--testnet", "export", exportFile) - assertCommandFailsWithChainConfigError(t, exportCmd, "Can't open blockchain in readonly mode: the local chain configuration requires rewind. Use the correct --networkid/--datadir combination, or reopen the database in writable mode so the chain can rewind, then retry.") + assertCommandFailsWithChainConfigErrors( + t, + exportCmd, + "Can't open blockchain: mismatching", + cmdutils.ChainConfigMismatchPolicyExitHint, + ) if _, err := os.Stat(exportFile); err == nil { t.Fatalf("expected export to fail without creating %s", exportFile) } else if !os.IsNotExist(err) { @@ -967,7 +982,7 @@ func TestStartupRewindsStoredBuiltInHistoricalForkDrift(t *testing.T) { genesisHash := overwriteStoredBerlinBlock(t, datadir, big.NewInt(100)) injectedHeadHash := injectCanonicalHeadBlock(t, datadir, genesisHash, 101) - startTestnetConsole(t, datadir) + startTestnetConsole(t, datadir, "--chain-config-mismatch-policy", "rewind-and-update") path := filepath.Join(datadir, "XDC", "chaindata") db := openTestChainDB(t, path) @@ -993,12 +1008,12 @@ func TestStartupRewindsStoredBuiltInHistoricalForkDrift(t *testing.T) { func TestStartupRewindsStoredBuiltInConfigDriftToZero(t *testing.T) { datadir := t.TempDir() - startTestnetConsole(t, datadir) + startTestnetConsole(t, datadir, "--chain-config-mismatch-policy", "rewind-and-update") genesisHash := overwriteStoredEIP150Block(t, datadir, big.NewInt(1)) injectedHeadHash := injectCanonicalHeadBlock(t, datadir, genesisHash, 1) - startTestnetConsole(t, datadir) + startTestnetConsole(t, datadir, "--chain-config-mismatch-policy", "rewind-and-update") path := filepath.Join(datadir, "XDC", "chaindata") db := openTestChainDB(t, path) diff --git a/cmd/XDC/main.go b/cmd/XDC/main.go index c10bd371ade9..8003b78ba4ae 100644 --- a/cmd/XDC/main.go +++ b/cmd/XDC/main.go @@ -122,6 +122,7 @@ var ( utils.VMTraceJsonConfigFlag, utils.NetworkIdFlag, utils.AllowBuiltInConfigOverrideFlag, + utils.ChainConfigMismatchPolicyFlag, utils.HTTPCORSDomainFlag, utils.AuthListenFlag, utils.AuthPortFlag, diff --git a/cmd/utils/cmd.go b/cmd/utils/cmd.go index 9d93c0d01729..47f265978385 100644 --- a/cmd/utils/cmd.go +++ b/cmd/utils/cmd.go @@ -43,6 +43,8 @@ import ( const ( importBatchSize = 2500 + + ChainConfigMismatchPolicyExitHint = "Hint: Use --chain-config-mismatch-policy to recover; Or restart with a matching XDC binary and the same network/genesis settings." ) // Fatalf formats a message to standard error and exits the program. @@ -72,6 +74,22 @@ func FormatChainConfigError(err error) string { return "" } message := err.Error() + if errors.Is(err, core.ErrConfigMismatchPolicyExit) { + exitText := core.ErrConfigMismatchPolicyExit.Error() + guidance := ChainConfigMismatchPolicyExitHint + prefix := exitText + ": " + if after, ok := strings.CutPrefix(message, prefix); ok { + details := strings.TrimSpace(after) + if details == "" { + return guidance + } + return details + ".\n" + guidance + } + if message == exitText { + return guidance + } + return message + ". " + ChainConfigMismatchPolicyExitHint + } if !errors.Is(err, params.ErrMissingForkSwitch) { return message } diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 40d071af47e2..e882d5461a18 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -131,6 +131,12 @@ var ( Usage: "Allow same-hash custom overrides on built-in IDs to use custom chain config", Category: flags.EthCategory, } + ChainConfigMismatchPolicyFlag = &cli.StringFlag{ + Name: "chain-config-mismatch-policy", + Usage: "Startup policy when chain config mismatches stored config: exit|rewind-and-update|update-config-only|ignore-mismatch (warning: update-config-only/ignore-mismatch may cause state/consensus divergence; expert use only)", + Value: core.DefaultChainConfigMismatchPolicy.String(), + Category: flags.EthCategory, + } // Dev mode DeveloperFlag = &cli.BoolFlag{ @@ -1520,6 +1526,7 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *ethconfig.Config) { setMiner(ctx, &cfg.Miner) setLes(ctx, cfg) cfg.AllowBuiltInCustomRecovery = ctx.Bool(AllowBuiltInConfigOverrideFlag.Name) + cfg.ChainConfigMismatchPolicy = resolveChainConfigMismatchPolicyOrFatal(ctx, cfg.ChainConfigMismatchPolicy) // Cap the cache allowance and tune the garbage collector mem, err := gopsutil.VirtualMemory() @@ -1850,6 +1857,8 @@ func formatBlockChainOpenError(err error, readonly bool) string { return fmt.Sprintf("Can't create BlockChain: %v", err) } switch { + case errors.Is(err, core.ErrConfigMismatchPolicyExit): + return "Can't open blockchain: " + FormatChainConfigError(err) case errors.Is(err, core.ErrReadOnlyGenesisStateRecovery): return "Can't open blockchain in readonly mode: genesis state is missing and requires recovery. Reopen the database in writable mode to recover the missing genesis state, then retry." case errors.Is(err, core.ErrReadOnlyHeadStateRepair): @@ -1857,7 +1866,9 @@ func formatBlockChainOpenError(err error, readonly bool) string { case errors.Is(err, core.ErrReadOnlyBadHashRewind): return "Can't open blockchain in readonly mode: the local chain contains a denylisted hash and requires rewind. Reopen the database in writable mode so the chain can rewind past the denylisted hash, then retry." case errors.Is(err, core.ErrReadOnlyConfigRewind): - return "Can't open blockchain in readonly mode: the local chain configuration requires rewind. Use the correct --networkid/--datadir combination, or reopen the database in writable mode so the chain can rewind, then retry." + return "Can't open blockchain in readonly mode: the selected chain-config mismatch policy requires rewind. Reopen in writable mode, or use --chain-config-mismatch-policy=ignore-mismatch to avoid rewind in readonly mode." + case errors.Is(err, core.ErrReadOnlyConfigUpdate): + return "Can't open blockchain in readonly mode: the selected chain-config mismatch policy requires writing chain config. Reopen in writable mode, or use --chain-config-mismatch-policy=ignore-mismatch in readonly mode." default: return fmt.Sprintf("Can't create BlockChain: %v", err) } @@ -1866,14 +1877,15 @@ func formatBlockChainOpenError(err error, readonly bool) string { var makeChainFatalf = Fatalf // MakeChain creates a chain manager from set command line flags. -func MakeChain(ctx *cli.Context, stack *node.Node, readonly bool) (*core.BlockChain, ethdb.Database) { +func MakeChain(ctx *cli.Context, stack *node.Node, readonly bool, configuredCompatPolicy string) (*core.BlockChain, ethdb.Database) { var ( - gspec = MakeGenesis(ctx) - chainDb = MakeChainDatabase(ctx, stack, readonly) - config *params.ChainConfig - ghash common.Hash - compatErr *params.ConfigCompatError - err error + gspec = MakeGenesis(ctx) + chainDb = MakeChainDatabase(ctx, stack, readonly) + config *params.ChainConfig + ghash common.Hash + compatErr *params.ConfigCompatError + compatPolicy = core.ChainConfigMismatchPolicy(resolveChainConfigMismatchPolicyOrFatal(ctx, configuredCompatPolicy)) + err error ) if readonly { // Readonly startup still needs compatibility metadata so chain open can @@ -1933,9 +1945,9 @@ func MakeChain(ctx *cli.Context, stack *node.Node, readonly bool) (*core.BlockCh // Disable transaction indexing/unindexing by default. var chain *core.BlockChain if readonly { - chain, err = core.NewBlockChainReadOnlyResolved(chainDb, cache, gspec, engine, vmcfg, config, ghash, compatErr) + chain, err = core.NewBlockChainReadOnlyResolved(chainDb, cache, gspec, engine, vmcfg, config, ghash, compatErr, compatPolicy) } else { - chain, err = core.NewBlockChainResolved(chainDb, cache, gspec, engine, vmcfg, config, ghash, compatErr) + chain, err = core.NewBlockChainResolved(chainDb, cache, gspec, engine, vmcfg, config, ghash, compatErr, compatPolicy) } if err != nil { makeChainFatalf("%s", formatBlockChainOpenError(err, readonly)) @@ -1944,6 +1956,29 @@ func MakeChain(ctx *cli.Context, stack *node.Node, readonly bool) (*core.BlockCh return chain, chainDb } +func resolveChainConfigMismatchPolicy(ctx *cli.Context, configured string) (string, error) { + raw := configured + errPrefix := "invalid ChainConfigMismatchPolicy in config" + if ctx.IsSet(ChainConfigMismatchPolicyFlag.Name) { + raw = ctx.String(ChainConfigMismatchPolicyFlag.Name) + errPrefix = fmt.Sprintf("invalid --%s flag", ChainConfigMismatchPolicyFlag.Name) + } + policy, err := core.ParseChainConfigMismatchPolicy(raw) + if err != nil { + return "", fmt.Errorf("%s: %w", errPrefix, err) + } + log.Info("Resolved chain config mismatch policy", "value", policy.String()) + return policy.String(), nil +} + +func resolveChainConfigMismatchPolicyOrFatal(ctx *cli.Context, configured string) string { + policy, err := resolveChainConfigMismatchPolicy(ctx, configured) + if err != nil { + makeChainFatalf("%v", err) + } + return policy +} + // MakeConsolePreloads retrieves the absolute paths for the console JavaScript // scripts to preload before starting. func MakeConsolePreloads(ctx *cli.Context) []string { diff --git a/cmd/utils/flags_test.go b/cmd/utils/flags_test.go index ae966d132029..dcd51006c6e9 100644 --- a/cmd/utils/flags_test.go +++ b/cmd/utils/flags_test.go @@ -26,6 +26,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "github.com/XinFinOrg/XDPoSChain/consensus/ethash" @@ -195,10 +196,11 @@ func TestMakeChainWriteModePassesCompatRewindToCore(t *testing.T) { } ctx := newMakeChainTestCLIContext(t, map[string]string{ - TestnetFlag.Name: "true", - GCModeFlag.Name: "full", + TestnetFlag.Name: "true", + GCModeFlag.Name: "full", + ChainConfigMismatchPolicyFlag.Name: core.MismatchRewindAndUpdate.String(), }) - chain, reopenedDb := MakeChain(ctx, stack, false) + chain, reopenedDb := MakeChain(ctx, stack, false, "") defer chain.Stop() defer reopenedDb.Close() @@ -269,7 +271,7 @@ func TestMakeChainReadOnlyModeSurfacesCompatRewind(t *testing.T) { t.Fatal("expected compatibility error") } - chain, err := core.NewBlockChainReadOnlyResolved(readonlyDb, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr) + chain, err := core.NewBlockChainReadOnlyResolved(readonlyDb, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr, core.MismatchRewindAndUpdate) if chain != nil { chain.Stop() t.Fatal("expected readonly blockchain open to fail") @@ -317,8 +319,9 @@ func TestMakeChainReadOnlyModeFormatsCompatRewindForOperators(t *testing.T) { } ctx := newMakeChainTestCLIContext(t, map[string]string{ - TestnetFlag.Name: "true", - GCModeFlag.Name: "full", + TestnetFlag.Name: "true", + GCModeFlag.Name: "full", + ChainConfigMismatchPolicyFlag.Name: core.MismatchRewindAndUpdate.String(), }) const sentinel = "fatal called" @@ -334,13 +337,13 @@ func TestMakeChainReadOnlyModeFormatsCompatRewindForOperators(t *testing.T) { if recovered := recover(); recovered != sentinel { t.Fatalf("expected sentinel panic, have %v", recovered) } - want := "Can't open blockchain in readonly mode: the local chain configuration requires rewind. Use the correct --networkid/--datadir combination, or reopen the database in writable mode so the chain can rewind, then retry." + want := "Can't open blockchain in readonly mode: the selected chain-config mismatch policy requires rewind. Reopen in writable mode, or use --chain-config-mismatch-policy=ignore-mismatch to avoid rewind in readonly mode." if got != want { t.Fatalf("unexpected fatal message: have %q want %q", got, want) } }() - MakeChain(ctx, stack, true) + MakeChain(ctx, stack, true, "") t.Fatal("expected MakeChain to terminate via fatal hook") } @@ -371,7 +374,17 @@ func TestFormatBlockChainOpenErrorReadOnly(t *testing.T) { { name: "config rewind", err: core.ErrReadOnlyConfigRewind, - want: "Can't open blockchain in readonly mode: the local chain configuration requires rewind. Use the correct --networkid/--datadir combination, or reopen the database in writable mode so the chain can rewind, then retry.", + want: "Can't open blockchain in readonly mode: the selected chain-config mismatch policy requires rewind. Reopen in writable mode, or use --chain-config-mismatch-policy=ignore-mismatch to avoid rewind in readonly mode.", + }, + { + name: "config update", + err: core.ErrReadOnlyConfigUpdate, + want: "Can't open blockchain in readonly mode: the selected chain-config mismatch policy requires writing chain config. Reopen in writable mode, or use --chain-config-mismatch-policy=ignore-mismatch in readonly mode.", + }, + { + name: "config exit", + err: core.ErrConfigMismatchPolicyExit, + want: "Can't open blockchain: " + ChainConfigMismatchPolicyExitHint, }, } @@ -394,12 +407,65 @@ func TestFormatBlockChainOpenErrorReadOnly(t *testing.T) { } } +func TestResolveChainConfigMismatchPolicyHonorsConfiguredWhenFlagNotSet(t *testing.T) { + t.Parallel() + + set := flag.NewFlagSet("resolve-policy-test", flag.ContinueOnError) + set.String(ChainConfigMismatchPolicyFlag.Name, core.DefaultChainConfigMismatchPolicy.String(), "") + ctx := cli.NewContext(cli.NewApp(), set, nil) + + got, err := resolveChainConfigMismatchPolicy(ctx, core.MismatchIgnoreMismatch.String()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != core.MismatchIgnoreMismatch.String() { + t.Fatalf("unexpected policy: have %q want %q", got, core.MismatchIgnoreMismatch.String()) + } +} + +func TestResolveChainConfigMismatchPolicyFlagOverridesConfigured(t *testing.T) { + t.Parallel() + + set := flag.NewFlagSet("resolve-policy-override-test", flag.ContinueOnError) + set.String(ChainConfigMismatchPolicyFlag.Name, core.DefaultChainConfigMismatchPolicy.String(), "") + if err := set.Set(ChainConfigMismatchPolicyFlag.Name, core.MismatchUpdateConfigOnly.String()); err != nil { + t.Fatalf("failed to set policy flag: %v", err) + } + ctx := cli.NewContext(cli.NewApp(), set, nil) + + got, err := resolveChainConfigMismatchPolicy(ctx, core.MismatchIgnoreMismatch.String()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != core.MismatchUpdateConfigOnly.String() { + t.Fatalf("unexpected policy: have %q want %q", got, core.MismatchUpdateConfigOnly.String()) + } +} + +func TestResolveChainConfigMismatchPolicyRejectsInvalidConfiguredValue(t *testing.T) { + t.Parallel() + + set := flag.NewFlagSet("resolve-policy-invalid-test", flag.ContinueOnError) + set.String(ChainConfigMismatchPolicyFlag.Name, core.DefaultChainConfigMismatchPolicy.String(), "") + ctx := cli.NewContext(cli.NewApp(), set, nil) + + _, err := resolveChainConfigMismatchPolicy(ctx, "not-a-policy") + if err == nil { + t.Fatal("expected error for invalid configured policy") + } + const want = "invalid ChainConfigMismatchPolicy in config" + if !strings.HasPrefix(err.Error(), want) { + t.Fatalf("unexpected error: %v", err) + } +} + // newMakeChainTestCLIContext builds a minimal CLI context for MakeChain tests. func newMakeChainTestCLIContext(t *testing.T, values map[string]string) *cli.Context { t.Helper() set := flag.NewFlagSet("make-chain-test", flag.ContinueOnError) set.Bool(TestnetFlag.Name, false, "") set.Bool(AllowBuiltInConfigOverrideFlag.Name, false, "") + set.String(ChainConfigMismatchPolicyFlag.Name, core.DefaultChainConfigMismatchPolicy.String(), "") set.String(GCModeFlag.Name, "full", "") for name, value := range values { if err := set.Set(name, value); err != nil { diff --git a/core/blockchain.go b/core/blockchain.go index daffceb42984..bd06c0069304 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -234,6 +234,7 @@ type blockchainOpenConfig struct { chainConfig *params.ChainConfig genesisHash common.Hash compatErr *params.ConfigCompatError + compatPolicy ChainConfigMismatchPolicy recoveryGenesis *Genesis } @@ -287,13 +288,15 @@ var ( ErrReadOnlyHeadStateRepair = errors.New("readonly blockchain open requires head state repair") ErrReadOnlyBadHashRewind = errors.New("readonly blockchain open requires bad-hash rewind") ErrReadOnlyConfigRewind = errors.New("readonly blockchain open requires config rewind") + ErrReadOnlyConfigUpdate = errors.New("readonly blockchain open requires config update") + ErrConfigMismatchPolicyExit = errors.New("chain config mismatch policy is exit") errBlockChainOpenMissingGenesisHash = errors.New("blockchain open options require genesis hash when chain config is provided") ) // NewBlockChain returns a fully initialised writable block chain using startup // metadata resolved from the database via SetupGenesisBlock. func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, genesis *Genesis, engine consensus.Engine, vmConfig vm.Config) (*BlockChain, error) { - resolvedCfg, err := resolveBlockChainOpenConfig(db, genesis, false) + resolvedCfg, err := resolveBlockChainOpenConfig(db, genesis, false, DefaultChainConfigMismatchPolicy) if err != nil { return nil, err } @@ -303,7 +306,7 @@ func NewBlockChain(db ethdb.Database, cacheConfig *CacheConfig, genesis *Genesis // NewBlockChainReadOnly returns a fully initialised readonly block chain using // startup metadata resolved from the database via LoadChainConfigWithCompat. func NewBlockChainReadOnly(db ethdb.Database, cacheConfig *CacheConfig, genesis *Genesis, engine consensus.Engine, vmConfig vm.Config) (*BlockChain, error) { - resolvedCfg, err := resolveBlockChainOpenConfig(db, genesis, true) + resolvedCfg, err := resolveBlockChainOpenConfig(db, genesis, true, DefaultChainConfigMismatchPolicy) if err != nil { return nil, err } @@ -312,8 +315,8 @@ func NewBlockChainReadOnly(db ethdb.Database, cacheConfig *CacheConfig, genesis // NewBlockChainResolved opens a writable block chain from caller-supplied // startup metadata. -func NewBlockChainResolved(db ethdb.Database, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError) (*BlockChain, error) { - resolvedCfg, err := newResolvedBlockChainOpenConfig(false, recoveryGenesis, chainConfig, genesisHash, compatErr) +func NewBlockChainResolved(db ethdb.Database, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError, compatPolicy ChainConfigMismatchPolicy) (*BlockChain, error) { + resolvedCfg, err := newResolvedBlockChainOpenConfig(false, recoveryGenesis, chainConfig, genesisHash, compatErr, compatPolicy) if err != nil { return nil, err } @@ -322,8 +325,8 @@ func NewBlockChainResolved(db ethdb.Database, cacheConfig *CacheConfig, recovery // NewBlockChainReadOnlyResolved opens a readonly block chain from caller- // supplied startup metadata. -func NewBlockChainReadOnlyResolved(db ethdb.Database, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError) (*BlockChain, error) { - resolvedCfg, err := newResolvedBlockChainOpenConfig(true, recoveryGenesis, chainConfig, genesisHash, compatErr) +func NewBlockChainReadOnlyResolved(db ethdb.Database, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError, compatPolicy ChainConfigMismatchPolicy) (*BlockChain, error) { + resolvedCfg, err := newResolvedBlockChainOpenConfig(true, recoveryGenesis, chainConfig, genesisHash, compatErr, compatPolicy) if err != nil { return nil, err } @@ -332,7 +335,7 @@ func NewBlockChainReadOnlyResolved(db ethdb.Database, cacheConfig *CacheConfig, // resolveBlockChainOpenConfig resolves startup metadata and normalizes // recovery inputs for writable or readonly opens. -func resolveBlockChainOpenConfig(db ethdb.Database, genesis *Genesis, readOnly bool) (blockchainOpenConfig, error) { +func resolveBlockChainOpenConfig(db ethdb.Database, genesis *Genesis, readOnly bool, compatPolicy ChainConfigMismatchPolicy) (blockchainOpenConfig, error) { resolveStartup := SetupGenesisBlock if readOnly { resolveStartup = LoadChainConfigWithCompat @@ -341,11 +344,19 @@ func resolveBlockChainOpenConfig(db ethdb.Database, genesis *Genesis, readOnly b if err != nil { return blockchainOpenConfig{}, err } + normalizedCompatPolicy, err := ValidateAndNormalizeCompatPolicy(compatPolicy) + if err != nil { + return blockchainOpenConfig{}, err + } + if compatErr != nil && normalizedCompatPolicy == MismatchExit { + return blockchainOpenConfig{}, fmt.Errorf("%w: %v", ErrConfigMismatchPolicyExit, compatErr) + } resolved := blockchainOpenConfig{ readOnly: readOnly, chainConfig: chainConfig, genesisHash: genesisHash, compatErr: compatErr, + compatPolicy: normalizedCompatPolicy, recoveryGenesis: genesis, } resolved.recoveryGenesis, err = normalizedRecoveryGenesis(resolved.recoveryGenesis, chainConfig) @@ -355,10 +366,17 @@ func resolveBlockChainOpenConfig(db ethdb.Database, genesis *Genesis, readOnly b return resolved, nil } -func newResolvedBlockChainOpenConfig(readOnly bool, recoveryGenesis *Genesis, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError) (blockchainOpenConfig, error) { +func newResolvedBlockChainOpenConfig(readOnly bool, recoveryGenesis *Genesis, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError, compatPolicy ChainConfigMismatchPolicy) (blockchainOpenConfig, error) { if genesisHash == (common.Hash{}) { return blockchainOpenConfig{}, errBlockChainOpenMissingGenesisHash } + normalizedCompatPolicy, err := ValidateAndNormalizeCompatPolicy(compatPolicy) + if err != nil { + return blockchainOpenConfig{}, err + } + if compatErr != nil && normalizedCompatPolicy == MismatchExit { + return blockchainOpenConfig{}, fmt.Errorf("%w: %v", ErrConfigMismatchPolicyExit, compatErr) + } normalizedGenesis, err := normalizedRecoveryGenesis(recoveryGenesis, chainConfig) if err != nil { return blockchainOpenConfig{}, err @@ -368,6 +386,7 @@ func newResolvedBlockChainOpenConfig(readOnly bool, recoveryGenesis *Genesis, ch chainConfig: chainConfig, genesisHash: genesisHash, compatErr: compatErr, + compatPolicy: normalizedCompatPolicy, recoveryGenesis: normalizedGenesis, }, nil } @@ -382,6 +401,7 @@ func newBlockChain(db ethdb.Database, cacheConfig *CacheConfig, engine consensus } genesisHash := cfg.genesisHash compatErr := cfg.compatErr + compatPolicy := cfg.compatPolicy log.Info(strings.Repeat("-", 153)) for line := range strings.SplitSeq(chainConfig.Description(), "\n") { log.Info(line) @@ -536,13 +556,31 @@ func newBlockChain(db ethdb.Database, cacheConfig *CacheConfig, engine consensus } // Rewind the chain in case of an incompatible config upgrade. + // NOTE: MismatchExit is handled in resolveBlockChainOpenConfig / + // newResolvedBlockChainOpenConfig before any chain state is touched, + // so it never reaches here. if compatErr != nil { - if cfg.readOnly { - return nil, fmt.Errorf("%w: %v", ErrReadOnlyConfigRewind, compatErr) + switch compatPolicy { + case MismatchRewindAndUpdate: + if cfg.readOnly { + return nil, fmt.Errorf("%w: %v", ErrReadOnlyConfigRewind, compatErr) + } + log.Warn("Applying chain config mismatch policy", "policy", compatPolicy, "rewind_to", compatErr.RewindTo, "update_config", true, "err", compatErr) + if err := bc.SetHead(compatErr.RewindTo); err != nil { + return nil, fmt.Errorf("failed to rewind chain: %w", err) + } + rawdb.WriteChainConfig(db, genesisHash, chainConfig) + case MismatchUpdateConfigOnly: + if cfg.readOnly { + return nil, fmt.Errorf("%w: %v", ErrReadOnlyConfigUpdate, compatErr) + } + log.Warn("Applying chain config mismatch policy", "policy", compatPolicy, "rewind", false, "update_config", true, "err", compatErr, "risk", "may cause state/consensus divergence; expert use only") + rawdb.WriteChainConfig(db, genesisHash, chainConfig) + case MismatchIgnoreMismatch: + log.Warn("Applying chain config mismatch policy", "policy", compatPolicy, "rewind", false, "update_config", false, "err", compatErr, "risk", "may cause state/consensus divergence; expert use only") + default: + return nil, fmt.Errorf("invalid chain config mismatch policy %q", compatPolicy) } - log.Warn("Rewinding chain to upgrade configuration", "err", compatErr) - bc.SetHead(compatErr.RewindTo) - rawdb.WriteChainConfig(db, genesisHash, chainConfig) } // Start future block processor. @@ -577,8 +615,8 @@ func NewBlockChainExReadOnly(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cache // NewBlockChainExResolved opens a writable XDCx-aware blockchain from caller- // supplied startup metadata. -func NewBlockChainExResolved(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError) (*BlockChain, error) { - blockchain, err := NewBlockChainResolved(db, cacheConfig, recoveryGenesis, engine, vmConfig, chainConfig, genesisHash, compatErr) +func NewBlockChainExResolved(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError, compatPolicy ChainConfigMismatchPolicy) (*BlockChain, error) { + blockchain, err := NewBlockChainResolved(db, cacheConfig, recoveryGenesis, engine, vmConfig, chainConfig, genesisHash, compatErr, compatPolicy) if err != nil { return nil, err } @@ -590,8 +628,8 @@ func NewBlockChainExResolved(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cache // NewBlockChainExReadOnlyResolved opens a readonly XDCx-aware blockchain from // caller-supplied startup metadata. -func NewBlockChainExReadOnlyResolved(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError) (*BlockChain, error) { - blockchain, err := NewBlockChainReadOnlyResolved(db, cacheConfig, recoveryGenesis, engine, vmConfig, chainConfig, genesisHash, compatErr) +func NewBlockChainExReadOnlyResolved(db ethdb.Database, XDCxDb ethdb.XDCxDatabase, cacheConfig *CacheConfig, recoveryGenesis *Genesis, engine consensus.Engine, vmConfig vm.Config, chainConfig *params.ChainConfig, genesisHash common.Hash, compatErr *params.ConfigCompatError, compatPolicy ChainConfigMismatchPolicy) (*BlockChain, error) { + blockchain, err := NewBlockChainReadOnlyResolved(db, cacheConfig, recoveryGenesis, engine, vmConfig, chainConfig, genesisHash, compatErr, compatPolicy) if err != nil { return nil, err } diff --git a/core/blockchain_test.go b/core/blockchain_test.go index a62836d18eb4..aad58ea6c73b 100644 --- a/core/blockchain_test.go +++ b/core/blockchain_test.go @@ -229,7 +229,7 @@ func TestNewBlockChainReadOnlyFailsGenesisStateRecovery(t *testing.T) { rawdb.DeleteLegacyTrieNode(db, genesisBlock.Root()) - chain, err := NewBlockChainReadOnlyResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, genesis.Config, genesisBlock.Hash(), nil) + chain, err := NewBlockChainReadOnlyResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, genesis.Config, genesisBlock.Hash(), nil, DefaultChainConfigMismatchPolicy) if err == nil { chain.Stop() t.Fatal("expected readonly open to fail when genesis state restoration would be required") @@ -283,7 +283,7 @@ func TestNewBlockChainRecoversMissingCustomGenesisState(t *testing.T) { t.Fatal("expected genesis state to be missing before reopen") } - chain, err := NewBlockChainResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr) + chain, err := NewBlockChainResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr, MismatchRewindAndUpdate) if err != nil { t.Fatalf("failed to recover missing custom genesis state: %v", err) } @@ -342,7 +342,7 @@ func TestNewBlockChainRecoversMissingSparseGenesisState(t *testing.T) { t.Fatal("expected genesis state to be missing before reopen") } - chain, err := NewBlockChainResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr) + chain, err := NewBlockChainResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr, DefaultChainConfigMismatchPolicy) if err != nil { t.Fatalf("failed to recover missing sparse genesis state: %v", err) } @@ -394,7 +394,7 @@ func TestNewBlockChainReadOnlyFailsCustomGenesisStateRecovery(t *testing.T) { t.Fatalf("failed to delete persisted genesis alloc: %v", err) } - chain, err := NewBlockChainReadOnlyResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr) + chain, err := NewBlockChainReadOnlyResolved(db, nil, genesis, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr, DefaultChainConfigMismatchPolicy) if err == nil { chain.Stop() t.Fatal("expected readonly open to fail when custom genesis recovery would be required") @@ -448,7 +448,7 @@ func TestNewBlockChainLiveTracerDoesNotRecoverCustomGenesisAlloc(t *testing.T) { OnGenesisBlock: func(block *types.Block, alloc types.GenesisAlloc) { called = true }, - }}, config, ghash, compatErr) + }}, config, ghash, compatErr, DefaultChainConfigMismatchPolicy) if err == nil { chain.Stop() t.Fatal("expected live tracer open to fail when custom genesis alloc would require recovery") @@ -601,7 +601,7 @@ func TestNewBlockChainReadOnlyFailsHeadStateRepair(t *testing.T) { rawdb.DeleteLegacyTrieNode(db, head.Root()) - reopened, err := NewBlockChainReadOnlyResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil) + reopened, err := NewBlockChainReadOnlyResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil, DefaultChainConfigMismatchPolicy) if err == nil { reopened.Stop() t.Fatal("expected readonly open to fail when head state repair would be required") @@ -647,7 +647,7 @@ func TestNewBlockChainExReadOnlyResolvedHonorsReadOnly(t *testing.T) { rawdb.DeleteLegacyTrieNode(db, head.Root()) - reopened, err := NewBlockChainExReadOnlyResolved(db, nil, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil) + reopened, err := NewBlockChainExReadOnlyResolved(db, nil, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil, DefaultChainConfigMismatchPolicy) if err == nil { reopened.Stop() t.Fatal("expected readonly open to fail when head state repair would be required") @@ -745,7 +745,7 @@ func TestNewBlockChainRewindsIncompatibleHead(t *testing.T) { t.Fatal("expected compatibility error") } - reopened, err := NewBlockChainResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr) + reopened, err := NewBlockChainResolved(db, nil, nil, ethash.NewFaker(), vm.Config{}, config, ghash, compatErr, MismatchRewindAndUpdate) if err != nil { t.Fatalf("failed to reopen rewound chain: %v", err) } @@ -841,7 +841,7 @@ func TestNewBlockChainFailsReadonlyConfigRewind(t *testing.T) { t.Fatal("expected compatibility error") } - resolvedCfg, err := newResolvedBlockChainOpenConfig(true, nil, config, ghash, compatErr) + resolvedCfg, err := newResolvedBlockChainOpenConfig(true, nil, config, ghash, compatErr, MismatchRewindAndUpdate) if err != nil { t.Fatalf("failed to build readonly startup config: %v", err) } @@ -876,7 +876,7 @@ func TestNewBlockChainReadOnlyFailsBadHashRewind(t *testing.T) { defer func() { delete(BadHashes, blocks[3].Hash()) }() blockchain.Stop() - reopened, err := NewBlockChainReadOnlyResolved(genDb, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil) + reopened, err := NewBlockChainReadOnlyResolved(genDb, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil, DefaultChainConfigMismatchPolicy) if err == nil { reopened.Stop() t.Fatal("expected readonly open to fail when bad-hash rewind would be required") @@ -907,7 +907,7 @@ func TestNewBlockChainRewindsBadHashOnWritableOpen(t *testing.T) { defer func() { delete(BadHashes, blocks[3].Hash()) }() blockchain.Stop() - reopened, err := NewBlockChainResolved(genDb, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil) + reopened, err := NewBlockChainResolved(genDb, nil, nil, ethash.NewFaker(), vm.Config{}, gspec.Config, gspec.ToBlock().Hash(), nil, DefaultChainConfigMismatchPolicy) if err != nil { t.Fatalf("failed to reopen rewound chain: %v", err) } @@ -1007,12 +1007,44 @@ func TestNewBlockChainReadOnlyDoesNotRepairMissingChainConfig(t *testing.T) { // TestNewBlockChainResolvedRejectsMissingGenesisHash tests the // resolved-config constructor rejects an empty genesis hash. func TestNewBlockChainResolvedRejectsMissingGenesisHash(t *testing.T) { - _, err := NewBlockChainResolved(rawdb.NewMemoryDatabase(), nil, nil, ethash.NewFaker(), vm.Config{}, params.AllEthashProtocolChanges, common.Hash{}, nil) + _, err := NewBlockChainResolved(rawdb.NewMemoryDatabase(), nil, nil, ethash.NewFaker(), vm.Config{}, params.AllEthashProtocolChanges, common.Hash{}, nil, DefaultChainConfigMismatchPolicy) if !errors.Is(err, errBlockChainOpenMissingGenesisHash) { t.Fatalf("unexpected error for missing genesis hash: %v", err) } } +func TestNewBlockChainResolvedRejectsInvalidCompatPolicy(t *testing.T) { + t.Parallel() + + db := rawdb.NewMemoryDatabase() + genesis := DefaultGenesisBlock() + if _, _, _, err := SetupGenesisBlock(db, genesis); err != nil { + t.Fatalf("failed to setup genesis: %v", err) + } + + chain, err := NewBlockChainResolved( + db, + nil, + genesis, + ethash.NewFaker(), + vm.Config{}, + genesis.Config, + genesis.ToBlock().Hash(), + nil, + ChainConfigMismatchPolicy("not-a-policy"), + ) + if chain != nil { + chain.Stop() + t.Fatal("expected blockchain open to fail for invalid compat policy") + } + if err == nil { + t.Fatal("expected error for invalid compat policy") + } + if !strings.Contains(err.Error(), "invalid chain config mismatch policy") { + t.Fatalf("unexpected error: %v", err) + } +} + // TestRecoveryGenesisConfigMismatch reports whether a caller-provided recovery // genesis config would be ignored in favor of the resolved chain config. func TestRecoveryGenesisConfigMismatch(t *testing.T) { diff --git a/core/chain_config_mismatch_policy.go b/core/chain_config_mismatch_policy.go new file mode 100644 index 000000000000..6fffd12276de --- /dev/null +++ b/core/chain_config_mismatch_policy.go @@ -0,0 +1,61 @@ +package core + +import ( + "fmt" + "strings" +) + +// ChainConfigMismatchPolicy controls startup behavior when the resolved runtime +// chain config is incompatible with the stored chain config. +type ChainConfigMismatchPolicy string + +const ( + MismatchExit ChainConfigMismatchPolicy = "exit" + MismatchRewindAndUpdate ChainConfigMismatchPolicy = "rewind-and-update" + MismatchUpdateConfigOnly ChainConfigMismatchPolicy = "update-config-only" + MismatchIgnoreMismatch ChainConfigMismatchPolicy = "ignore-mismatch" +) + +const DefaultChainConfigMismatchPolicy = MismatchExit + +func (p ChainConfigMismatchPolicy) String() string { + if p == "" { + return string(DefaultChainConfigMismatchPolicy) + } + return string(p) +} + +// NormalizeChainConfigMismatchPolicy converts empty policy values to default. +func NormalizeChainConfigMismatchPolicy(policy ChainConfigMismatchPolicy) ChainConfigMismatchPolicy { + if policy == "" { + return DefaultChainConfigMismatchPolicy + } + return policy +} + +// ParseChainConfigMismatchPolicy parses and validates a startup mismatch policy. +func ParseChainConfigMismatchPolicy(input string) (ChainConfigMismatchPolicy, error) { + policy := NormalizeChainConfigMismatchPolicy(ChainConfigMismatchPolicy(strings.TrimSpace(input))) + switch policy { + case MismatchExit, + MismatchRewindAndUpdate, + MismatchUpdateConfigOnly, + MismatchIgnoreMismatch: + return policy, nil + default: + return "", fmt.Errorf("invalid chain config mismatch policy %q (supported: %q, %q, %q, %q)", input, + MismatchExit, + MismatchRewindAndUpdate, + MismatchUpdateConfigOnly, + MismatchIgnoreMismatch, + ) + } +} + +// ValidateAndNormalizeCompatPolicy normalizes and validates a mismatch policy. +// It converts empty policy values to the default and rejects unknown values. +// NOTE: enforcing the "exit" behavior when compatErr is non-nil is handled +// by the blockchain open path. +func ValidateAndNormalizeCompatPolicy(policy ChainConfigMismatchPolicy) (ChainConfigMismatchPolicy, error) { + return ParseChainConfigMismatchPolicy(string(policy)) +} diff --git a/core/chain_config_mismatch_policy_test.go b/core/chain_config_mismatch_policy_test.go new file mode 100644 index 000000000000..0f3e3f27f5ef --- /dev/null +++ b/core/chain_config_mismatch_policy_test.go @@ -0,0 +1,113 @@ +package core + +import ( + "strings" + "testing" +) + +func TestNormalizeChainConfigMismatchPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input ChainConfigMismatchPolicy + want ChainConfigMismatchPolicy + }{ + { + name: "empty defaults to exit", + input: "", + want: DefaultChainConfigMismatchPolicy, + }, + { + name: "non-empty preserved", + input: MismatchIgnoreMismatch, + want: MismatchIgnoreMismatch, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := NormalizeChainConfigMismatchPolicy(tt.input) + if got != tt.want { + t.Fatalf("unexpected normalized policy: have %q want %q", got, tt.want) + } + }) + } +} + +func TestParseChainConfigMismatchPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want ChainConfigMismatchPolicy + wantError bool + }{ + { + name: "empty defaults to exit", + input: "", + want: DefaultChainConfigMismatchPolicy, + }, + { + name: "whitespace defaults to exit", + input: " \t\n", + want: DefaultChainConfigMismatchPolicy, + }, + { + name: "trimmed rewind-and-update", + input: " rewind-and-update ", + want: MismatchRewindAndUpdate, + }, + { + name: "exit", + input: "exit", + want: MismatchExit, + }, + { + name: "update-config-only", + input: "update-config-only", + want: MismatchUpdateConfigOnly, + }, + { + name: "ignore-mismatch", + input: "ignore-mismatch", + want: MismatchIgnoreMismatch, + }, + { + name: "invalid value", + input: "invalid", + wantError: true, + }, + { + name: "invalid mixed case", + input: "Continue", + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ParseChainConfigMismatchPolicy(tt.input) + if tt.wantError { + if err == nil { + t.Fatalf("expected error for input %q", tt.input) + } + if !strings.Contains(err.Error(), "invalid chain config mismatch policy") { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("unexpected parsed policy: have %q want %q", got, tt.want) + } + }) + } +} diff --git a/docs/upgrade.md b/docs/upgrade.md index bed39b00745a..1dacb3f8343b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -7,6 +7,7 @@ This document summarizes the current startup rules for `genesis` and - how the node distinguishes built-in networks, Localnet, and custom networks - which missing fields can be backfilled for each class - when a resolved `ChainConfig` is written back to the database +- how the node reacts to an incompatible stored config via `--chain-config-mismatch-policy` - how to upgrade `ChainConfig` to add a future fork without changing the canonical genesis block ## Operator Migration: Single Binary, Runtime Network Selection @@ -212,6 +213,67 @@ Additional notes: - For same-hash custom chains, first explicit initialization can also persist a chain-config override marker for that data directory. +## Chain Config Mismatch Policy (`--chain-config-mismatch-policy`) + +When startup resolves a runtime `ChainConfig` that is incompatible with the +stored config, `SetupGenesisBlock` / `LoadChainConfigWithCompat` return a +non-nil `ConfigCompatError`. How the node reacts to that error is now controlled +by an explicit startup policy instead of an unconditional rewind. + +Set the policy with the `--chain-config-mismatch-policy` flag (or the +`ChainConfigMismatchPolicy` field in the TOML config). Supported values: + +- `exit` (default) + - Behavior on mismatch: abort startup with an error; the database is not modified. + - Writable mode: startup fails, no rewind, no write. + - Readonly mode: startup fails, no write. + +- `rewind-and-update` + - Behavior on mismatch: rewind the head to `compatErr.RewindTo`, then persist the new chain config. + - Writable mode: rewinds and writes the resolved config. + - Readonly mode: refuses to open (`readonly blockchain open requires config rewind`). + +- `update-config-only` + - Risk level: high risk (`may cause state/consensus divergence; expert use only`). + - Behavior on mismatch: persist the new chain config without rewinding the head. + - Writable mode: writes the resolved config, no rewind. + - Readonly mode: refuses to open (`readonly blockchain open requires config update`). + +- `ignore-mismatch` + - Risk level: high risk (`may cause state/consensus divergence; expert use only`). + - Behavior on mismatch: keep running with the in-memory config; do not rewind and do not write. + - Writable mode: runs; mismatch recurs on next restart. + - Readonly mode: runs read-only with no database writes. + +Behavior change relative to older startup logic: + +- The previous behavior was equivalent to `rewind-and-update` and ran + unconditionally on writable startup. The default is now `exit`, so an + incompatible config makes the node stop and hand the decision to the operator + instead of silently rewinding the chain and rewriting stored config. +- To preserve the old automatic-rewind behavior, start with + `--chain-config-mismatch-policy=rewind-and-update`. + +Operational guidance: + +- Prefer `exit` (the default) and investigate why the resolved config disagrees + with the stored config before choosing a recovery mode. A mismatch usually + means the wrong `--networkid`/`--datadir`/`genesis.json` combination, not a + routine upgrade. +- Use `rewind-and-update` only when you intend to roll the head back to the + fork boundary and reprocess blocks under the new rules. This is the only + policy that keeps the head consistent with the new config. +- `update-config-only` and `ignore-mismatch` are advanced escape hatches and **high-risk options**. They do **not** + reprocess blocks that were already imported past the changed fork boundary, + so the running head can diverge in state/consensus from a node that rewound. + `update-config-only` additionally marks the mismatch as resolved on disk, so the + warning will not recur even though no reprocessing happened. Treat both as + `may cause state/consensus divergence; expert use only` and use them only + when you fully understand the consensus implications. +- `rewind-and-update` and `update-config-only` require a writable open. In readonly + mode they refuse to start so the database is never mutated; reopen in writable + mode, or use `exit`/`ignore-mismatch`, to proceed without writes. + ## Startup API Semantics The startup helpers now have distinct writable vs. readonly roles: @@ -533,7 +595,9 @@ persisted config or external `genesis.json` carries the required values. Do not treat a readonly compatibility warning as a harmless cosmetic diff. It means the writable path would need to repair metadata or require an explicit -rewind decision before startup should proceed. +rewind decision before startup should proceed. The recovery mode for that +rewind decision is selected with `--chain-config-mismatch-policy` (see +[Chain Config Mismatch Policy](#chain-config-mismatch-policy---chain-config-mismatch-policy)). ## Same-Hash Custom Chains on Built-In IDs (`50` / `51` / `5551`) diff --git a/eth/backend.go b/eth/backend.go index bc4b0e95dab3..cb7eb53dc9e8 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -236,7 +236,19 @@ func New(stack *node.Node, config *ethconfig.Config, XDCXServ *XDCx.XDCX, lendin return eth.Lending } } - eth.blockchain, err = core.NewBlockChainExResolved(chainDb, XDCXServ.GetLevelDB(), cacheConfig, config.Genesis, eth.engine, vmConfig, chainConfig, genesisHash, compatErr) + compatPolicy := core.ChainConfigMismatchPolicy(config.ChainConfigMismatchPolicy) + eth.blockchain, err = core.NewBlockChainExResolved( + chainDb, + XDCXServ.GetLevelDB(), + cacheConfig, + config.Genesis, + eth.engine, + vmConfig, + chainConfig, + genesisHash, + compatErr, + compatPolicy, + ) if err != nil { return nil, err } diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go index 1042c1541d50..865f9dd61c54 100644 --- a/eth/ethconfig/config.go +++ b/eth/ethconfig/config.go @@ -40,22 +40,23 @@ var FullNodeGPO = gasprice.Config{ // Defaults contains default settings for use on the Ethereum main net. var Defaults = Config{ - SyncMode: downloader.FullSync, - NetworkId: 0, // enable auto configuration of networkID == chainID - LightPeers: 100, - DatabaseCache: 768, - TrieCleanCache: 256, - TrieDirtyCache: 256, - TrieTimeout: 5 * time.Minute, - FilterLogCacheSize: 32, - Miner: miner.DefaultConfig, - LogQueryLimit: 1000, - TxPool: legacypool.DefaultConfig, - RPCGasCap: 50000000, - RPCEVMTimeout: 5 * time.Second, - GPO: FullNodeGPO, - RPCTxFeeCap: 1, // 1 ether - RangeLimit: 5000, + SyncMode: downloader.FullSync, + NetworkId: 0, // enable auto configuration of networkID == chainID + LightPeers: 100, + DatabaseCache: 768, + TrieCleanCache: 256, + TrieDirtyCache: 256, + TrieTimeout: 5 * time.Minute, + FilterLogCacheSize: 32, + Miner: miner.DefaultConfig, + LogQueryLimit: 1000, + TxPool: legacypool.DefaultConfig, + RPCGasCap: 50000000, + RPCEVMTimeout: 5 * time.Second, + GPO: FullNodeGPO, + RPCTxFeeCap: 1, // 1 ether + RangeLimit: 5000, + ChainConfigMismatchPolicy: core.DefaultChainConfigMismatchPolicy.String(), } //go:generate go run github.com/fjl/gencodec -type Config -formats toml -out gen_config.go @@ -66,6 +67,7 @@ type Config struct { // If nil, the Ethereum main net block is used. Genesis *core.Genesis `toml:",omitempty"` AllowBuiltInCustomRecovery bool `toml:",omitempty"` + ChainConfigMismatchPolicy string `toml:",omitempty"` // Network ID separates blockchains on the peer-to-peer networking level. When left // zero, the chain ID is used as network ID. diff --git a/eth/ethconfig/gen_config.go b/eth/ethconfig/gen_config.go index d69445e9b3d5..6d7fae422dc3 100644 --- a/eth/ethconfig/gen_config.go +++ b/eth/ethconfig/gen_config.go @@ -18,6 +18,7 @@ func (c Config) MarshalTOML() (interface{}, error) { type Config struct { Genesis *core.Genesis `toml:",omitempty"` AllowBuiltInCustomRecovery bool `toml:",omitempty"` + ChainConfigMismatchPolicy string `toml:",omitempty"` NetworkId uint64 SyncMode downloader.SyncMode FastSyncPivotNumber uint64 @@ -51,6 +52,7 @@ func (c Config) MarshalTOML() (interface{}, error) { var enc Config enc.Genesis = c.Genesis enc.AllowBuiltInCustomRecovery = c.AllowBuiltInCustomRecovery + enc.ChainConfigMismatchPolicy = c.ChainConfigMismatchPolicy enc.NetworkId = c.NetworkId enc.SyncMode = c.SyncMode enc.FastSyncPivotNumber = c.FastSyncPivotNumber @@ -88,6 +90,7 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error { type Config struct { Genesis *core.Genesis `toml:",omitempty"` AllowBuiltInCustomRecovery *bool `toml:",omitempty"` + ChainConfigMismatchPolicy *string `toml:",omitempty"` NetworkId *uint64 SyncMode *downloader.SyncMode FastSyncPivotNumber *uint64 @@ -128,6 +131,9 @@ func (c *Config) UnmarshalTOML(unmarshal func(interface{}) error) error { if dec.AllowBuiltInCustomRecovery != nil { c.AllowBuiltInCustomRecovery = *dec.AllowBuiltInCustomRecovery } + if dec.ChainConfigMismatchPolicy != nil { + c.ChainConfigMismatchPolicy = *dec.ChainConfigMismatchPolicy + } if dec.NetworkId != nil { c.NetworkId = *dec.NetworkId }