diff --git a/app/params/config.go b/app/params/config.go index 0fb57adb01..0b5fd2be40 100644 --- a/app/params/config.go +++ b/app/params/config.go @@ -1,6 +1,7 @@ package params import ( + "github.com/sei-protocol/sei-chain/config/registry" evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" "github.com/sei-protocol/sei-chain/sei-cosmos/types/address" @@ -94,8 +95,11 @@ const ( ) // IsFullnodeType returns true if the node is a fullnode-like node (full or archive) +// +// The rule itself lives in the configuration registry, because a section's own package needs the same +// fact and cannot import this one. func (m NodeMode) IsFullnodeType() bool { - return m == NodeModeFull || m == NodeModeArchive + return registry.IsFullnodeMode(registry.Mode(m)) } // setValidatorTypeTendermintConfig sets common Tendermint config for validator-like nodes diff --git a/config/registry/detach_test.go b/config/registry/detach_test.go new file mode 100644 index 0000000000..1e3a8205c6 --- /dev/null +++ b/config/registry/detach_test.go @@ -0,0 +1,65 @@ +package registry_test + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// listBearing is a probe whose default is a package-level variable, which is the usual shape. +type listBearing struct { + Allowed []string `mapstructure:"allowed"` + Labels map[string]string `mapstructure:"labels"` + Absent []string `mapstructure:"absent"` +} + +var listBearingDefault = listBearing{ + Allowed: []string{"callTracer", "prestateTracer"}, + Labels: map[string]string{"chain": "pacific-1"}, +} + +// TestAResolvedListIsTheCallersToWriteInto covers what a caller may do with a resolved value. +// +// A section's default is usually a package-level variable, so handing out its slice hands out the array +// that variable holds. A caller sorting or de-duplicating a resolved list in place, which is what a caller +// producing deterministic output does, would rewrite that variable for the whole process: every later +// resolution and every reader that copies the same struct. Two of the lists this reaches in practice are +// deny lists, so the rewrite is silent and it is a security control. +func TestAResolvedListIsTheCallersToWriteInto(t *testing.T) { + registry.Reset() + registry.RegisterSection("probe", &listBearing{}, func(registry.Mode) any { return listBearingDefault }) + for _, d := range registry.Defects() { + t.Fatalf("the probe was refused: %v", d.Err) + } + + resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + resolved.Values["probe.allowed"].([]string)[0] = "written-by-the-caller" + resolved.Values["probe.labels"].(map[string]string)["chain"] = "written-by-the-caller" + + if got := listBearingDefault.Allowed[0]; got != "callTracer" { + t.Errorf("writing into the resolved list changed the section's own default to %q, so every later "+ + "resolution and every reader copying that struct carries the caller's value", got) + } + if got := listBearingDefault.Labels["chain"]; got != "pacific-1" { + t.Errorf("writing into the resolved map changed the section's own default to %q", got) + } + + again, err := registry.Resolve(registry.ModeFull, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got := again.Values["probe.allowed"]; !reflect.DeepEqual(got, []string{"callTracer", "prestateTracer"}) { + t.Errorf("a later resolution carries %v, so one caller's edit reached another's answer", got) + } + + // A nil list stays nil rather than becoming an empty one, because absent and empty are different + // answers to a reader that checks length. + if got := again.Values["probe.absent"]; got == nil || !reflect.ValueOf(got).IsNil() { + t.Errorf("an unset list resolved to %#v, want a nil slice of its own type", got) + } +} diff --git a/config/registry/registry.go b/config/registry/registry.go index 008738680c..cee2e7666c 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -26,6 +26,17 @@ const ( // Modes returns every mode a default is asked for, in a fixed order. func Modes() []Mode { return []Mode{ModeValidator, ModeFull, ModeSeed, ModeArchive} } +// IsFullnodeMode reports whether a node of this kind serves queries to callers other than itself. +// +// Stated here because more than one package needs it and they sit on opposite sides of an import edge. +// The package that owns the node a binary was started as also owns the type that describes it, and a +// section's own package needs the same fact to state a default that varies on it while being imported by +// that package rather than importing it. +// +// An archive node counts. It serves queries, which is the property this names, and it is the mode most +// easily forgotten when the rule is written out by hand. +func IsFullnodeMode(mode Mode) bool { return mode == ModeFull || mode == ModeArchive } + // Section is one registered configuration section. type Section struct { // Name is the section's own segment, and the first segment of every key it declares. diff --git a/config/registry/resolve.go b/config/registry/resolve.go index dfcca8f9e3..b64a00b90d 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -10,6 +10,15 @@ import ( // Resolved is every declared key's value, plus what a caller has to be told about how it got there. type Resolved struct { // Values carries one value per declared key. + // + // A key's Go type depends on which source answered it, and a caller that type-asserts has to expect + // all three. A default arrives as the field's own type, so a duration is a duration and a list is a + // list. A file arrives as whatever the file format decodes to, so the same duration is text and the + // same list is a list of untyped elements. An environment variable arrives as one string, always. This + // resolves values and does not convert them, so the reader that owns a key remains the thing that + // turns any of the three into what that key means. + // + // A value is the caller's to write into. Nothing here shares storage with a section's own default. Values map[string]any // Overrides are the declared keys something other than this node's defaults supplied, sorted. // @@ -259,11 +268,43 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error { } continue } - out[path] = fv.Interface() + out[path] = detach(fv) } return nil } +// detach returns a field's value with nothing shared with the struct it came from. +// +// A section's default is usually a package-level variable, so a slice or a map field hands out the +// backing array that variable holds. A caller sorting or de-duplicating a resolved list in place, which is +// what a caller producing deterministic output does, would rewrite that variable for the whole process: +// every later resolution, and every reader that copies the same struct. Two of the lists that reach here +// are deny lists, so the rewrite is silent and it is a security control. +// +// Lookup already copies a section's keys for this reason. This is the same guarantee for its values. +func detach(v reflect.Value) any { + switch v.Kind() { + case reflect.Slice: + if v.IsNil() { + return v.Interface() + } + out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) + reflect.Copy(out, v) + return out.Interface() + case reflect.Map: + if v.IsNil() { + return v.Interface() + } + out := reflect.MakeMapWithSize(v.Type(), v.Len()) + for _, key := range v.MapKeys() { + out.SetMapIndex(key, v.MapIndex(key)) + } + return out.Interface() + default: + return v.Interface() + } +} + // envValues reads the keys an environment supplies, from the caller's declared set. // // Driven by the declared set rather than by the environment, which is also what makes it complete: diff --git a/evmrpc/config/register.go b/evmrpc/config/register.go new file mode 100644 index 0000000000..30d76881e6 --- /dev/null +++ b/evmrpc/config/register.go @@ -0,0 +1,36 @@ +package config + +import "github.com/sei-protocol/sei-chain/config/registry" + +// SectionName is this section's name in the configuration key space. +const SectionName = "evm" + +// Registration puts this package's configuration section in the registry. +// +// The owning package registers its own section, so the struct, the values and the keys come from one +// place. This section's mapstructure tags already spell the keys its reader resolves, so the registry +// derives what a node reads rather than restating them. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +// +// The two interface toggles answer per kind of node. A full node and an archive node serve queries, which +// is what these interfaces are for; a validator and a seed serve none, and leaving them open would put a +// public request surface on the node that holds a signing key. The rule is read from the registry rather +// than restated, because the package that owns the node mode imports this one and cannot be imported back. +// +// Two values come from the machine rather than from a decision, and they are not one case. The worker pool +// has a portable answer: the pool re-measures whenever the value it is given is not positive, so a file +// carrying zero lets every node size itself, and a caller rendering into a file should write that rather +// than this. The simulation call limit has no portable answer, because zero there is not a request to +// measure but the absence of a limit, and the limit is the only bound on how many simulations a node runs +// at once. Both describe the host that resolved them, so neither travels. +func defaults(mode registry.Mode) any { + cfg := DefaultConfig + serves := registry.IsFullnodeMode(mode) + cfg.HTTPEnabled = serves + cfg.WSEnabled = serves + return cfg +} diff --git a/evmrpc/config/register_test.go b/evmrpc/config/register_test.go new file mode 100644 index 0000000000..23eab42ea5 --- /dev/null +++ b/evmrpc/config/register_test.go @@ -0,0 +1,121 @@ +package config + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived keys against the reader's own constants. +// +// The section registers the struct its reader fills, so a mapstructure tag is the only spelling of these +// keys and there is no second list to fall behind. What remains is the constants ReadConfig looks up, +// which state the same keys again in the same file, and a rename that moves one and not the other +// compiles. +// +// Written out rather than derived from the struct, because a list derived from the same tags would agree +// with itself whatever those tags said. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused, so none of its keys is declared: %v", SectionName, defect.Err) + } + } + want := []string{ + flagHTTPEnabled, flagHTTPPort, flagWSEnabled, flagWSPort, + flagReadTimeout, flagReadHeaderTimeout, flagWriteTimeout, flagIdleTimeout, + flagSimulationGasLimit, flagSimulationEVMTimeout, flagCORSOrigins, flagWSOrigins, + flagFilterTimeout, flagMaxTxPoolTxs, flagCheckTxTimeout, flagSlow, + flagEnableSimulation, flagDenyList, flagMaxLogNoBlock, flagMaxLogBytes, + flagMaxBlocksForLog, flagMaxEstimateGasCalls, flagMaxStateOverrideAccounts, + flagMaxStateOverrideSlots, flagMaxSubscriptionsNewHead, flagMaxSubscriptionsLogs, + flagEnableTestAPI, flagMaxConcurrentTraceCalls, flagMaxConcurrentSimulationCalls, + flagMaxTraceLookbackBlocks, flagTraceTimeout, flagMaxTraceStructLogBytes, + flagTraceAllowedTracers, flagTraceAllowJSTracers, flagEnableParallelizedBlockTrace, + flagRPCStatsInterval, flagWorkerPoolSize, flagWorkerQueueSize, flagEVMLegacySeiApis, + flagTraceBakeEnabled, flagTraceBakeWorkers, flagTraceBakeQueueSize, flagTraceBakeTracers, + flagTraceBakeWindowBlocks, flagTraceBakeUseSnapshot, flagTraceBakeSnapshotWindow, + flagIPRateLimitRPS, flagIPRateLimitBurst, flagRateLimitingEnabled, flagTrustedProxyCIDRs, + flagBatchRequestLimit, flagBatchResponseMaxSize, flagMaxRequestBodyBytes, + flagMaxConcurrentRequestBytes, flagWSAdmissionTimeout, flagMaxOpenConnections, + flagBodyReadIdleTimeout, + } + sort.Strings(want) + + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + declared := map[string]bool{} + for _, key := range section.Keys { + declared[key] = true + } + for _, key := range want { + if !declared[key] { + t.Errorf("the reader resolves %s and no tag declares it", key) + } + delete(declared, key) + } + for key := range declared { + t.Errorf("%s is declared and no constant in this file resolves it", key) + } +} + +// TestEachKindOfNodeResolvesTheInterfacesItIsFor is the mode-varying part of this section. +// +// A full node and an archive node serve queries, which is what these two interfaces are for. A validator +// and a seed serve none, and an open interface on the node that holds a signing key is a public request +// surface on the one node meant to expose the least. The values are written out here rather than taken +// from the same rule the section reads, so a change to that rule fails this and gets looked at. +func TestEachKindOfNodeResolvesTheInterfacesItIsFor(t *testing.T) { + serving := map[registry.Mode]bool{ + registry.ModeValidator: false, + registry.ModeSeed: false, + registry.ModeFull: true, + registry.ModeArchive: true, + } + for _, mode := range registry.Modes() { + want, named := serving[mode] + if !named { + t.Fatalf("mode %q has no expectation here, so a mode was added and this was not revisited", mode) + } + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + for _, key := range []string{flagHTTPEnabled, flagWSEnabled} { + if got := resolved.Values[key]; got != want { + t.Errorf("mode %q: %s resolves to %v, want %v", mode, key, got, want) + } + } + } +} + +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. +// +// Resolving carries the key a tag produced together with the value that tag's field held. Comparing the +// defaults struct against itself does not: two tags on each other's fields leave the key set identical and +// every field still holding the value it always did, so a list and a URL change places unnoticed. +func TestEachKeyResolvesToTheValueItsFieldHolds(t *testing.T) { + resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{}) + if err != nil { + t.Fatalf("%v", err) + } + for key, want := range map[string]any{ + flagCORSOrigins: DefaultConfig.CORSOrigins, + flagDenyList: DefaultConfig.DenyList, + flagTraceAllowedTracers: DefaultConfig.TraceAllowedTracers, + flagEVMLegacySeiApis: DefaultConfig.EnabledLegacySeiApis, + flagTrustedProxyCIDRs: DefaultConfig.TrustedProxyCIDRs, + flagReadTimeout: DefaultConfig.ReadTimeout, + flagHTTPPort: DefaultConfig.HTTPPort, + flagIPRateLimitRPS: DefaultConfig.IPRateLimitRPS, + flagMaxLogBytes: DefaultConfig.MaxLogBytes, + } { + if got := resolved.Values[key]; !reflect.DeepEqual(got, want) { + t.Errorf("%s resolves to %#v (%T), want %#v (%T)", key, got, got, want, want) + } + } +} diff --git a/x/evm/blocktest/register.go b/x/evm/blocktest/register.go new file mode 100644 index 0000000000..293cd89ea5 --- /dev/null +++ b/x/evm/blocktest/register.go @@ -0,0 +1,24 @@ +package blocktest + +import "github.com/sei-protocol/sei-chain/config/registry" + +// SectionName is this section's name in the configuration key space. +const SectionName = "eth_blocktest" + +// Registration puts this package's configuration section in the registry. +// +// The owning package registers its own section, so the struct, the values and the keys come from one +// place. This section's mapstructure tags already spell the keys its reader resolves, so the registry +// derives what a node reads rather than restating them. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +// +// The same values for every mode. This section drives a harness against recorded block data, which is +// not something any kind of node does while serving a chain. +// +// The data path is a tilde path, and it resolves as written. Whoever opens it expands the tilde, so a +// caller that renders this value into a file writes the same text an operator would. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/x/evm/blocktest/register_test.go b/x/evm/blocktest/register_test.go new file mode 100644 index 0000000000..035a267866 --- /dev/null +++ b/x/evm/blocktest/register_test.go @@ -0,0 +1,60 @@ +package blocktest + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived keys against the reader's own constants. +// +// The section registers the struct its reader fills, so a mapstructure tag is the only spelling of these +// keys. What remains is the constants ReadConfig passes to Get, which state the same keys again in the same +// file, and a rename that moves one and not the other compiles. +// +// The section name is passed to the registry rather than derived, which is what keeps this section reachable +// at all: the struct that carries it in the generated file is tagged with a different spelling, and a +// registry that took the section name from a tag would declare a section no operator writes. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused, so none of its keys is declared: %v", SectionName, defect.Err) + } + } + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + want := []string{flagEnabled, flagTestDataPath} + sort.Strings(want) + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. +// +// These two fields carry different types, so a tag on the wrong field changes what a key resolves to +// without changing the key set at all. +func TestEachKeyResolvesToTheValueItsFieldHolds(t *testing.T) { + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + for key, want := range map[string]any{ + flagEnabled: DefaultConfig.Enabled, + flagTestDataPath: DefaultConfig.TestDataPath, + } { + if got := resolved.Values[key]; !reflect.DeepEqual(got, want) { + t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", mode, key, got, got, want, want) + } + } + if resolved.Values[flagEnabled] == true { + t.Errorf("mode %q resolves the block-test harness on, which replays recorded data instead of "+ + "following the chain", mode) + } + } +} diff --git a/x/evm/querier/register.go b/x/evm/querier/register.go new file mode 100644 index 0000000000..532f1f3530 --- /dev/null +++ b/x/evm/querier/register.go @@ -0,0 +1,21 @@ +package querier + +import "github.com/sei-protocol/sei-chain/config/registry" + +// SectionName is this section's name in the configuration key space. +const SectionName = "evm_query" + +// Registration puts this package's configuration section in the registry. +// +// The owning package registers its own section, so the struct, the values and the keys come from one +// place. This section's mapstructure tags already spell the key its reader resolves, so the registry +// derives what a node reads rather than restating it. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +// +// The same value for every mode. The limit bounds the work a contract can ask the EVM to do inside a +// query, and every node answers the same queries. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/x/evm/querier/register_test.go b/x/evm/querier/register_test.go new file mode 100644 index 0000000000..4721542400 --- /dev/null +++ b/x/evm/querier/register_test.go @@ -0,0 +1,48 @@ +package querier + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived key against the reader's own constant. +// +// The section registers the struct its reader fills, so a mapstructure tag is the only spelling of its key. +// What remains is the constant ReadConfig passes to Get, which states the same key again a few lines away, +// and a rename that moves one and not the other compiles. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused, so none of its keys is declared: %v", SectionName, defect.Err) + } + } + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + want := []string{flagGasLimit} + sort.Strings(want) + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. +// +// Resolving carries the key a tag produced together with the value that tag's field held, so this notices a +// tag sitting on the wrong field. Comparing the defaults struct against itself does not: the key set stays +// the same and every field still holds the value it always did. +func TestEachKeyResolvesToTheValueItsFieldHolds(t *testing.T) { + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + if got, want := resolved.Values[flagGasLimit], DefaultConfig.GasLimit; !reflect.DeepEqual(got, want) { + t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", mode, flagGasLimit, got, got, want, want) + } + } +} diff --git a/x/evm/replay/register.go b/x/evm/replay/register.go new file mode 100644 index 0000000000..e6672b4ad2 --- /dev/null +++ b/x/evm/replay/register.go @@ -0,0 +1,27 @@ +package replay + +import "github.com/sei-protocol/sei-chain/config/registry" + +// SectionName is this section's name in the configuration key space. +const SectionName = "eth_replay" + +// Registration puts this package's configuration section in the registry. +// +// The owning package registers its own section, so the struct, the values and the keys come from one +// place. This section's mapstructure tags already spell the keys its reader resolves, so the registry +// derives what a node reads rather than restating them. +// +// One of the four keys is written into app.toml under a name nothing reads. The template renders +// eth_replay_contract_state_checks and the reader looks up contract_state_checks, so the declared key is +// the one a value reaches a reader through. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +// +// The same values for every mode, and replay off. Turning it on makes a node replay recorded chain data +// from an endpoint instead of following the chain, and the endpoint is a fixed third-party address, so no +// kind of node implies it. Construction opens a client for that address without reaching it, which is why +// an unreachable endpoint surfaces during replay rather than at startup. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/x/evm/replay/register_test.go b/x/evm/replay/register_test.go new file mode 100644 index 0000000000..2cb3231650 --- /dev/null +++ b/x/evm/replay/register_test.go @@ -0,0 +1,58 @@ +package replay + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived keys against the reader's own constants. +// +// Three of the four keys carry the name the template writes and one does not: the template renders +// eth_replay_contract_state_checks and the reader looks up contract_state_checks. The declared key is the +// one a value reaches a reader through, and the exact comparison below is what keeps the other out. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused, so none of its keys is declared: %v", SectionName, defect.Err) + } + } + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + want := []string{flagEnabled, flagEthRPC, flagEthDataDir, flagContractStateChecks} + sort.Strings(want) + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. +// +// Two of these fields are strings holding an endpoint and a directory. A tag on the wrong field leaves the +// key set identical and resolves a filesystem path where a reader expects a URL. +func TestEachKeyResolvesToTheValueItsFieldHolds(t *testing.T) { + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + for key, want := range map[string]any{ + flagEnabled: DefaultConfig.Enabled, + flagEthRPC: DefaultConfig.EthRPC, + flagEthDataDir: DefaultConfig.EthDataDir, + flagContractStateChecks: DefaultConfig.ContractStateChecks, + } { + if got := resolved.Values[key]; !reflect.DeepEqual(got, want) { + t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", mode, key, got, got, want, want) + } + } + if resolved.Values[flagEnabled] == true { + t.Errorf("mode %q resolves replay on, so those nodes would replay recorded data from %v instead "+ + "of following the chain", mode, resolved.Values[flagEthRPC]) + } + } +}