From 1da0d72ff8cc75af4c5f28af38098f45bbe85ca4 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 14:02:34 -0700 Subject: [PATCH 01/25] config: the first four sections enter the registry Four sections, each registered by the package that owns its struct, so the struct, the values and the keys come from one place and cannot drift apart. The keys derive from the mapstructure tags, which is what makes the registry's spelling and each reader's own constants the same strings, and each package's test holds the two against each other rather than against a written-out list. admin_server 2 keys giga_executor 2 keys receipt-store 6 keys wasm 3 keys Three register their own struct. wasm needs a schema, because the upstream type carries no mapstructure tags at all, so keys derived from it would be field names rather than the ones the module reads. Its simulation gas limit is text because the field it stands for is an optional number and absent is a meaning of its own: unset means the consensus block gas limit applies, which no number can say. Two of that type's settings declare nothing, one having no key any reader resolves and the other written into app.toml by the template and read by nothing. Registering the receipt store needed a distinction the registry did not draw. mapstructure reads a tag of "-" as skip this field, and a configuration struct uses it for a field something else assigns: KeepRecent comes from the global min-retain-blocks flag at the app layer, ExternalPruning from whatever constructs the garbage collector. The registry read that as a missing name and refused the whole section. Such a field now declares no key, which is narrower than declaring one that resolves to a default and safer for the same reason: a declared key is written at override precedence, so a default would land on top of the value that code assigned, and a node with min-retain-blocks set would silently keep nothing. A field with no tag at all stays a defect, because that is the opposite intent, a key nothing names reaching no field. The skip lives in tagOf, which both walks already share, so the declared keys and the rendered defaults describe the same fields. Reverting either walk's skip on its own fails a test. The recorded configuration surface does not move: nothing consumes the registry yet, and no golden changed. 100% of statements in config/registry, race clean. Three mutations each fail a named test: refusing a dash again, skipping an untagged field, and letting the two walks disagree. --- admin/register.go | 19 ++++++++ admin/register_test.go | 37 ++++++++++++++++ config/registry/registry.go | 32 +++++++++----- config/registry/resolve.go | 7 ++- config/registry/spec_test.go | 55 ++++++++++++++++++++++-- giga/executor/config/register.go | 27 ++++++++++++ giga/executor/config/register_test.go | 41 ++++++++++++++++++ sei-db/config/receipt_register.go | 21 +++++++++ sei-db/config/receipt_register_test.go | 55 ++++++++++++++++++++++++ sei-wasmd/x/wasm/config_register.go | 48 +++++++++++++++++++++ sei-wasmd/x/wasm/config_register_test.go | 52 ++++++++++++++++++++++ 11 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 admin/register.go create mode 100644 admin/register_test.go create mode 100644 giga/executor/config/register.go create mode 100644 giga/executor/config/register_test.go create mode 100644 sei-db/config/receipt_register.go create mode 100644 sei-db/config/receipt_register_test.go create mode 100644 sei-wasmd/x/wasm/config_register.go create mode 100644 sei-wasmd/x/wasm/config_register_test.go diff --git a/admin/register.go b/admin/register.go new file mode 100644 index 0000000000..3d4bfba2b6 --- /dev/null +++ b/admin/register.go @@ -0,0 +1,19 @@ +package admin + +import ( + "github.com/sei-protocol/sei-chain/config/registry" +) + +// SectionName is this section's name in the configuration key space. +const SectionName = "admin_server" + +// Registration puts this section in the configuration registry. +// +// The keys derive from the mapstructure tags, so they are admin_server.admin_enabled and +// admin_server.admin_address, which are the strings this package's reader already resolves. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/admin/register_test.go b/admin/register_test.go new file mode 100644 index 0000000000..2085f6498b --- /dev/null +++ b/admin/register_test.go @@ -0,0 +1,37 @@ +package admin + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestTheDeclaredKeysAreTheKeysThisReaderResolves holds the declaration against the reader. +// +// This package names its keys only in its mapstructure tags, so the check is that the registry derives +// exactly the two the reader resolves and no third. +func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + + want := []string{"admin_server.admin_address", "admin_server.admin_enabled"} + if got := section.Keys; !reflect.DeepEqual(got, want) { + t.Errorf("declared keys are %v, want %v", got, want) + } +} + +// TestTheDefaultsAreWhatTheNodeAlreadyRuns keeps the section from restating the values by hand. +func TestTheDefaultsAreWhatTheNodeAlreadyRuns(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want Config", mode, defaults(mode)) + } + if got != DefaultConfig { + t.Errorf("mode %q resolves to %+v, want the package default %+v", mode, got, DefaultConfig) + } + } +} diff --git a/config/registry/registry.go b/config/registry/registry.go index 008738680c..ea15452d0b 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -238,10 +238,13 @@ func walk(t reflect.Type, prefix string, keys *[]string, open map[reflect.Type]b continue } - tag, squash, err := tagOf(f, prefix) + tag, squash, skip, err := tagOf(f, prefix) if err != nil { return err } + if skip { + continue + } ft := f.Type for ft.Kind() == reflect.Ptr { @@ -289,10 +292,10 @@ func walkSubtree(t reflect.Type, path, field string, keys *[]string, open map[re } // tagOf returns a field's mapstructure name, or reports that the field cannot be addressed. -func tagOf(f reflect.StructField, prefix string) (name string, squash bool, err error) { +func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool, err error) { tag, ok := f.Tag.Lookup("mapstructure") if !ok { - return "", false, fmt.Errorf("%s.%s has no mapstructure tag; a key derived from a field "+ + return "", false, false, fmt.Errorf("%s.%s has no mapstructure tag; a key derived from a field "+ "name is a key no operator writes, which is how ninety-two legacy keys became "+ "unreachable through their tags", prefix, f.Name) } @@ -306,25 +309,34 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash bool, err } if squash { if name != "" { - return "", false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other", + return "", false, false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other", prefix, f.Name, name) } - return "", true, nil + return "", true, false, nil + } + if name == "-" { + // The tag mapstructure honours for a field configuration does not reach. Something else in the + // program assigns it: the receipt store's KeepRecent comes from the global min-retain-blocks + // flag at the app layer, and its ExternalPruning from whatever constructs the collector. + // + // So it declares no key rather than declaring one that resolves to a default. A declared key is + // written at override precedence, which would put the default over the value that code assigned. + return "", false, true, nil } - if name == "" || name == "-" { - return "", false, fmt.Errorf("%s.%s has an empty mapstructure name", prefix, f.Name) + if name == "" { + return "", false, false, fmt.Errorf("%s.%s has an empty mapstructure name", prefix, f.Name) } if bad, found := unaddressableChar(name); found { - return "", false, fmt.Errorf("%s.%s names %q, which carries %q. A dot makes the field claim a "+ + return "", false, false, fmt.Errorf("%s.%s names %q, which carries %q. A dot makes the field claim a "+ "subtree the struct does not have, and neither a dot nor a space survives a round trip "+ "through a configuration source", prefix, f.Name, name, bad) } if name != strings.ToLower(name) { - return "", false, fmt.Errorf("%s.%s names %q, which is not lower case; a configuration "+ + return "", false, false, fmt.Errorf("%s.%s names %q, which is not lower case; a configuration "+ "source enumerates lower-cased, so this key would never match a written one", prefix, f.Name, name) } - return name, false, nil + return name, false, false, nil } // unaddressableChar returns the first character in a key segment that no configuration source can diff --git a/config/registry/resolve.go b/config/registry/resolve.go index dfcca8f9e3..ebc44785b2 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -221,10 +221,15 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error { } continue } - tag, squash, err := tagOf(f, prefix) + tag, squash, skip, err := tagOf(f, prefix) if err != nil { return err } + if skip { + // Skipped on the type side too, so the declared keys and the rendered defaults describe the + // same set of fields and matchesDeclaration has nothing to disagree about. + continue + } fv := v.Field(i) for fv.Kind() == reflect.Ptr { diff --git a/config/registry/spec_test.go b/config/registry/spec_test.go index cddce89064..dd88b2693b 100644 --- a/config/registry/spec_test.go +++ b/config/registry/spec_test.go @@ -758,9 +758,6 @@ func TestEveryRefusalIsReportedAsADefect(t *testing.T) { type emptyName struct { N string `mapstructure:""` } - type dashName struct { - N string `mapstructure:"-"` - } type upperName struct { N string `mapstructure:"N"` } @@ -812,7 +809,6 @@ func TestEveryRefusalIsReportedAsADefect(t *testing.T) { }{ {"a squashed field that also names a segment", "s", &squashNamed{}, anyDefault, "one or the other"}, {"an empty mapstructure name", "s", &emptyName{}, anyDefault, "empty mapstructure name"}, - {"a dash mapstructure name", "s", &dashName{}, anyDefault, "empty mapstructure name"}, {"an upper-case key", "s", &upperName{}, anyDefault, "not lower case"}, {"a squashed scalar", "s", &squashScalar{}, anyDefault, "not a struct"}, {"a struct declaring nothing", "s", &noKeys{}, anyDefault, "declares no keys"}, @@ -1353,3 +1349,54 @@ func TestARefusalInsideASquashedBaseIsReported(t *testing.T) { t.Errorf("the refusal reads %q; a squashed field's path is the section's own", msg) } } + +// TestAFieldExcludedFromConfigDeclaresNoKey covers the tag that means "not from configuration". +// +// mapstructure reads "-" as skip this field, and a config struct uses it for a field something else in +// the program assigns: the receipt store's KeepRecent comes from the global min-retain-blocks flag at the +// app layer, and its ExternalPruning from whatever constructs the collector. +// +// Such a field declares no key. Declaring one that resolved to the default would be worse than refusing +// the section: a declared key is written at override precedence, so the default would land on top of the +// value that code assigned, and a node with min-retain-blocks set would silently keep nothing. +// +// An untagged field stays a defect. The two look alike and mean opposite things: one is a field the author +// excluded, the other is a field configuration cannot reach because nothing names it. +func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { + type excluded struct { + Kept string `mapstructure:"kept"` + Assigned int `mapstructure:"-"` + } + + registry.Reset() + registry.RegisterSection("probe", &excluded{}, func(registry.Mode) any { + return &excluded{Kept: "x", Assigned: 42} + }) + for _, d := range registry.Defects() { + t.Fatalf("a field excluded from configuration was reported as a defect: %v", d.Err) + } + + if got, want := registry.Keys(), []string{"probe.kept"}; !reflect.DeepEqual(got, want) { + t.Fatalf("declared keys are %v, want %v. An excluded field declaring a key would have that key "+ + "written at override precedence over whatever assigned the field", got, want) + } + + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) + if err != nil { + t.Fatalf("resolving a section with an excluded field: %v", err) + } + if _, present := resolved.Values["probe.assigned"]; present { + t.Error("the excluded field resolved to a value, so installing it would overwrite what assigns it") + } + + // An untagged field means the opposite and stays a defect. + type untagged struct { + Kept string `mapstructure:"kept"` + Forgotten int + } + registry.Reset() + registry.RegisterSection("probe", &untagged{}, func(registry.Mode) any { return &untagged{} }) + if len(registry.Defects()) == 0 { + t.Error("a field with no tag at all registered cleanly, so a key nothing names reaches no field") + } +} diff --git a/giga/executor/config/register.go b/giga/executor/config/register.go new file mode 100644 index 0000000000..c22802ed11 --- /dev/null +++ b/giga/executor/config/register.go @@ -0,0 +1,27 @@ +package config + +import ( + "github.com/sei-protocol/sei-chain/config/registry" +) + +// SectionName is this section's name in the configuration key space. +// +// The same prefix the flag constants already use, so the derived keys are the keys this package's reader +// resolves rather than a second spelling of them. +const SectionName = "giga_executor" + +// Registration puts this section in the configuration registry. +// +// The owning package registers its own section, so the struct, the values and the keys come from one place +// and cannot drift apart. The dotted keys derive from the mapstructure tags, which is what makes the +// registry's spelling and this package's flag constants the same strings. +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, because that is what a node runs today. A mode-varying default would +// change what an archive node does, which is a decision about how the executor should behave rather than +// a consequence of describing it here. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/giga/executor/config/register_test.go b/giga/executor/config/register_test.go new file mode 100644 index 0000000000..d1e9e5203d --- /dev/null +++ b/giga/executor/config/register_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestTheDeclaredKeysAreTheKeysThisReaderResolves holds the declaration against the reader. +// +// The registry derives a key from the section name and a mapstructure tag; ReadConfig asks for a flag +// constant. Those are two spellings of one key, and a section is only useful if they are the same string. +// Checked against the constants rather than against a written-out list, so a rename of either moves both +// or fails here. +func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + + want := []string{FlagEnabled, FlagOCCEnabled} + if got := section.Keys; !reflect.DeepEqual(got, want) { + t.Errorf("declared keys are %v, want the keys the reader asks for, %v", got, want) + } +} + +// TestTheDefaultsAreWhatTheNodeAlreadyRuns keeps the section from restating the values by hand. +func TestTheDefaultsAreWhatTheNodeAlreadyRuns(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want Config", mode, defaults(mode)) + } + if got != DefaultConfig { + t.Errorf("mode %q resolves to %+v, want the package default %+v. A section states what the "+ + "binary already runs; a different value here is a behaviour change nobody asked for", + mode, got, DefaultConfig) + } + } +} diff --git a/sei-db/config/receipt_register.go b/sei-db/config/receipt_register.go new file mode 100644 index 0000000000..945ae73780 --- /dev/null +++ b/sei-db/config/receipt_register.go @@ -0,0 +1,21 @@ +package config + +import ( + "github.com/sei-protocol/sei-chain/config/registry" +) + +// ReceiptStoreSectionName is this section's name in the configuration key space. +const ReceiptStoreSectionName = "receipt-store" + +// Registration puts this section in the configuration registry. +// +// Two of the struct's fields carry the tag that excludes a field from configuration, so they declare no +// key: KeepRecent is derived from the global min-retain-blocks flag at the app layer, and ExternalPruning +// is set by whatever constructs the garbage collector. Declaring a key for either would put a default over +// the value that code assigns. +func init() { + registry.RegisterSection(ReceiptStoreSectionName, &ReceiptStoreConfig{}, receiptStoreDefaults) +} + +// receiptStoreDefaults is what this section resolves to for a node that has written nothing. +func receiptStoreDefaults(registry.Mode) any { return DefaultReceiptStoreConfig() } diff --git a/sei-db/config/receipt_register_test.go b/sei-db/config/receipt_register_test.go new file mode 100644 index 0000000000..373ce6183b --- /dev/null +++ b/sei-db/config/receipt_register_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestTheDeclaredKeysAreTheKeysThisReaderResolves holds the declaration against the reader. +// +// Two of the struct's fields are excluded from configuration and must declare nothing. KeepRecent is +// derived from the global min-retain-blocks flag at the app layer and ExternalPruning is set by whatever +// constructs the collector, so a key for either would be written at override precedence over the value +// that code assigns. +func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + section, ok := registry.Lookup(ReceiptStoreSectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", ReceiptStoreSectionName) + } + + want := []string{ + "receipt-store.async-write-buffer", + "receipt-store.db-directory", + "receipt-store.enable-read-write-metrics", + "receipt-store.log-filter-parallelism", + "receipt-store.prune-interval-seconds", + "receipt-store.rs-backend", + } + if got := section.Keys; !reflect.DeepEqual(got, want) { + t.Errorf("declared keys are\n %v\nwant\n %v", got, want) + } + for _, excluded := range []string{"receipt-store.keep-recent", "receipt-store.external-pruning"} { + for _, key := range section.Keys { + if key == excluded { + t.Errorf("%s is declared. Nothing sources it from configuration, so installing it would "+ + "put a default over the value the app layer assigns", excluded) + } + } + } +} + +// TestTheDefaultsAreWhatTheNodeAlreadyRuns keeps the section from restating the values by hand. +func TestTheDefaultsAreWhatTheNodeAlreadyRuns(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := receiptStoreDefaults(mode).(ReceiptStoreConfig) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want ReceiptStoreConfig", mode, receiptStoreDefaults(mode)) + } + if !reflect.DeepEqual(got, DefaultReceiptStoreConfig()) { + t.Errorf("mode %q resolves to %+v, want the package default %+v", + mode, got, DefaultReceiptStoreConfig()) + } + } +} diff --git a/sei-wasmd/x/wasm/config_register.go b/sei-wasmd/x/wasm/config_register.go new file mode 100644 index 0000000000..0ca9482380 --- /dev/null +++ b/sei-wasmd/x/wasm/config_register.go @@ -0,0 +1,48 @@ +package wasm + +import ( + "strconv" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" +) + +// SectionName is this section's name in the configuration key space. +const SectionName = "wasm" + +// wasmSchema names the keys this module's reader resolves. +// +// A schema rather than types.WasmConfig itself, which carries no mapstructure tags at all, so registering +// it would derive keys from field names and those are not the keys the reader asks for. The schema states +// the three the module reads, and states them once. +// +// SimulationGasLimit is text because the field it stands for is an optional number, and absent is a +// meaning of its own: unset means the consensus block gas limit applies. A number cannot carry that, and +// the reader already parses this key from text. +type wasmSchema struct { + MemoryCacheSize uint32 `mapstructure:"memory_cache_size"` + QueryGasLimit uint64 `mapstructure:"query_gas_limit"` + SimulationGasLimit string `mapstructure:"simulation_gas_limit"` +} + +// Registration puts this section in the configuration registry. +// +// Three keys, matching the three flag constants above. Two settings of types.WasmConfig are deliberately +// absent: ContractDebugMode has no key any reader resolves, and lru_size is written into app.toml by the +// template and read by nothing, so declaring either would put a key in the space that reaches no field. +func init() { + registry.RegisterSection(SectionName, &wasmSchema{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +func defaults(registry.Mode) any { + live := types.DefaultWasmConfig() + schema := wasmSchema{ + MemoryCacheSize: live.MemoryCacheSize, + QueryGasLimit: live.SmartQueryGasLimit, + } + if live.SimulationGasLimit != nil { + schema.SimulationGasLimit = strconv.FormatUint(*live.SimulationGasLimit, 10) + } + return schema +} diff --git a/sei-wasmd/x/wasm/config_register_test.go b/sei-wasmd/x/wasm/config_register_test.go new file mode 100644 index 0000000000..b0263a01d7 --- /dev/null +++ b/sei-wasmd/x/wasm/config_register_test.go @@ -0,0 +1,52 @@ +package wasm + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" +) + +// TestTheDeclaredKeysAreTheFlagsThisModuleReads holds the schema against the module. +// +// The schema exists because types.WasmConfig carries no mapstructure tags, so the keys cannot be derived +// from it. That makes the schema a second statement of the same key set, and a second statement is only +// safe while something holds it against the first. These are the flag constants the module registers and +// reads. +func TestTheDeclaredKeysAreTheFlagsThisModuleReads(t *testing.T) { + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + + want := []string{flagWasmMemoryCacheSize, flagWasmQueryGasLimit, flagWasmSimulationGasLimit} + if got := section.Keys; !reflect.DeepEqual(got, want) { + t.Errorf("declared keys are %v, want the flags this module reads, %v", got, want) + } +} + +// TestTheDefaultsCarryTheLiveWasmConfig keeps the schema's values from drifting from the real ones. +// +// The schema restates three settings of types.WasmConfig, so nothing stops those values diverging from +// what DefaultWasmConfig returns except this. +func TestTheDefaultsCarryTheLiveWasmConfig(t *testing.T) { + live := types.DefaultWasmConfig() + got, ok := defaults(registry.ModeValidator).(wasmSchema) + if !ok { + t.Fatalf("defaults returned %T, want wasmSchema", defaults(registry.ModeValidator)) + } + + if got.MemoryCacheSize != live.MemoryCacheSize { + t.Errorf("memory_cache_size resolves to %d, want the live %d", got.MemoryCacheSize, live.MemoryCacheSize) + } + if got.QueryGasLimit != live.SmartQueryGasLimit { + t.Errorf("query_gas_limit resolves to %d, want the live %d", got.QueryGasLimit, live.SmartQueryGasLimit) + } + // Absent is a meaning of its own here: unset means the consensus block gas limit applies, so an unset + // live value has to resolve to no text rather than to a zero. + if live.SimulationGasLimit == nil && got.SimulationGasLimit != "" { + t.Errorf("simulation_gas_limit resolves to %q where the live value is unset. A number here claims "+ + "a limit the node does not apply", got.SimulationGasLimit) + } +} From 467ceec26edd5a3e068d5f33c3f52300b042bf23 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 14:19:11 -0700 Subject: [PATCH 02/25] config: four app sections enter the registry Four sections owned by the app package, each registered where its struct lives. genesis 2 keys light_invariance 1 key state-commit 20 keys state-store 12 keys light_invariance registers the type its reader fills. The other three declare a schema, because keys derived from the type the reader fills are not the keys the reader looks up. The genesis import type carries no mapstructure tags at all. State store and state commit both tag their fields with something other than the name resolved from configuration, and state commit nests its settings under three inner structs while the keys are flat names on the section, apart from the one flat key-value setting that has a segment of its own. State commit's write mode is text rather than the reader's named type, because the reader parses a written name into that type itself. Declaring the named type would have one key answer as a named string from these defaults and as a plain one from an operator's file. Each section's test holds its resolved keys and values against the reader's own constants and its own defaults. Resolving is what it compares, rather than the registered struct, because the resolved map carries the key a tag produced and the value that tag's field held: a comparison of struct to struct agrees with itself while two tags sit on the wrong fields, since each field still holds the value the test names for it. Putting the genesis tags on each other's fields leaves the key set identical and fails the test. State store's declared defaults are not what its reader produces for a file missing those keys. It starts from the declared defaults, then assigns eleven of its twelve fields straight from a lookup with no check that the key was present, so an absent key casts to a zero and clobbers the default beside it: the store reads as disabled, with no backend, keeping every version, and committing synchronously. Only the snapshot key is guarded, and its own comment at the read says why. A node whose app.toml predates one of the other keys therefore runs the clobbered value today and the declared default once something installs this section, and guarding the remaining reads is what makes those the same thing. The recorded configuration surface does not move, because nothing consumes the registry on a boot path yet. --- app/config_register.go | 190 ++++++++++++++++++++++++++++++++++++ app/config_register_test.go | 166 +++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 app/config_register.go create mode 100644 app/config_register_test.go diff --git a/app/config_register.go b/app/config_register.go new file mode 100644 index 0000000000..8d17c6a21d --- /dev/null +++ b/app/config_register.go @@ -0,0 +1,190 @@ +package app + +import ( + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-db/config" +) + +// The names these sections have in the configuration key space. +const ( + LightInvarianceSectionName = "light_invariance" + GenesisSectionName = "genesis" + StateStoreSectionName = "state-store" + StateCommitSectionName = "state-commit" +) + +// Registration puts this package's configuration sections in the registry. +// +// The owning package registers its own sections, so the struct, the values and the keys come from one +// place and cannot drift apart. Three of the four declare a schema rather than the type their reader +// fills, and each says why on the schema itself; the keys still derive from mapstructure tags, so a +// section's spelling and its reader's own constants stay the same strings. +func init() { + registry.RegisterSection(LightInvarianceSectionName, &LightInvarianceConfig{}, lightInvarianceDefaults) + registry.RegisterSection(GenesisSectionName, &genesisSchema{}, genesisDefaults) + registry.RegisterSection(StateStoreSectionName, &stateStoreSchema{}, stateStoreDefaults) + registry.RegisterSection(StateCommitSectionName, &stateCommitSchema{}, stateCommitDefaults) +} + +// lightInvarianceDefaults is what this section resolves to for a node that has written nothing. +// +// The same value for every mode, and on. The check compares the bank module's recorded total supply +// against what the store holds, which is a correctness property of every node rather than of one kind, +// so a mode-varying default would stop some nodes noticing that they had diverged. +func lightInvarianceDefaults(registry.Mode) any { return DefaultLightInvarianceConfig } + +// genesisSchema declares the keys the genesis import reader resolves. +// +// A schema and not a transport: nothing decodes into it. The type the reader fills is +// genesistypes.GenesisImportConfig, which carries no mapstructure tags at all, so no key can be derived +// from it. Declaring the spelling here is what lets the registry name the keys the reader looks up, and +// the test holds these tags against the reader's own constants because nothing keeps them together by +// construction. +type genesisSchema struct { + StreamImport bool `mapstructure:"stream-import"` + ImportFile string `mapstructure:"import-file"` +} + +// genesisDefaults is what this section resolves to for a node that has written nothing. +// +// Read out of the reader's own default rather than written again here, so a changed default moves both at +// once and this states only which key carries which setting. The same values for every mode: streaming a +// genesis file is what an operator does to import a chain's existing state, and no node mode implies it. +func genesisDefaults(registry.Mode) any { + return genesisSchema{ + StreamImport: DefaultGenesisConfig.StreamGenesisImport, + ImportFile: DefaultGenesisConfig.GenesisStreamFile, + } +} + +// stateStoreSchema declares the keys parseSSConfigs resolves. +// +// A schema and not a transport: nothing decodes into it. config.StateStoreConfig carries mapstructure +// tags of its own and every one names something other than the key the reader looks up, so deriving from +// that type would declare a set of keys no operator writes. It also holds settings no key reaches, which +// stay at whatever the defaults struct holds; giving them keys would declare settings a written value +// could not change. +type stateStoreSchema struct { + Enable bool `mapstructure:"ss-enable"` + DBDirectory string `mapstructure:"ss-db-directory"` + Backend string `mapstructure:"ss-backend"` + AsyncWriteBuffer int `mapstructure:"ss-async-write-buffer"` + KeepRecent int `mapstructure:"ss-keep-recent"` + PruneIntervalSeconds int `mapstructure:"ss-prune-interval"` + ImportNumWorkers int `mapstructure:"ss-import-num-workers"` + EnableReadWriteMetrics bool `mapstructure:"ss-enable-read-write-metrics"` + SnapshotEnable bool `mapstructure:"ss-snapshot-enable"` + EVMDBDirectory string `mapstructure:"evm-ss-db-directory"` + SeparateEVMSubDBs bool `mapstructure:"evm-ss-separate-dbs"` + EVMSplit bool `mapstructure:"evm-ss-split"` +} + +// stateStoreDefaults is what this section resolves to for a node that has written nothing. +// +// The declared defaults, which is what seid init renders into app.toml, so a generated file reproduces a +// freshly initialised node. +// +// That is not what parseSSConfigs produces for a file missing these keys. It starts from the declared +// defaults and then assigns eleven of its twelve fields straight from a lookup with no check that the key +// was present, so an absent key casts to a zero and clobbers the default beside it: the store reads as +// disabled, with no backend, keeping every version, and committing synchronously. Only ss-snapshot-enable +// is guarded, and its own comment at the read says why. So a node whose app.toml predates one of the other +// keys runs the clobbered value today and the declared default once something installs this section, and +// guarding the remaining reads is what makes those the same thing. +func stateStoreDefaults(registry.Mode) any { + live := config.DefaultStateStoreConfig() + return stateStoreSchema{ + Enable: live.Enable, + DBDirectory: live.DBDirectory, + Backend: live.Backend, + AsyncWriteBuffer: live.AsyncWriteBuffer, + KeepRecent: live.KeepRecent, + PruneIntervalSeconds: live.PruneIntervalSeconds, + ImportNumWorkers: live.ImportNumWorkers, + EnableReadWriteMetrics: live.EnableReadWriteMetrics, + SnapshotEnable: live.SnapshotEnable, + EVMDBDirectory: live.EVMDBDirectory, + SeparateEVMSubDBs: live.SeparateEVMSubDBs, + EVMSplit: live.EVMSplit, + } +} + +// stateCommitFlatKVSchema declares the one flat key-value setting that has a key of its own. +// +// A nested segment, because the key is state-commit.flatkv.enable-read-write-metrics. The rest of the +// flat key-value configuration has no keys: nothing reads them from configuration, so declaring them +// would give an operator settings a written value could not change. +type stateCommitFlatKVSchema struct { + EnableReadWriteMetrics bool `mapstructure:"enable-read-write-metrics"` +} + +// stateCommitSchema declares the keys parseSCConfigs resolves. +// +// A schema and not a transport: nothing decodes into it. config.StateCommitConfig nests its settings +// under MemIAVLConfig, FlatKVConfig and HashLogger, and the keys the reader looks up are flat names on the +// section itself, so no derivation from that type produces them. +// +// The write mode is a plain string rather than the reader's own named type, because the reader parses a +// written name into that type itself. Declaring the named type would have one key answer as a named string +// from these defaults and as a plain one from an operator's file, which is a difference a caller can trip +// over and nothing here needs. +type stateCommitSchema struct { + Enable bool `mapstructure:"sc-enable"` + Directory string `mapstructure:"sc-directory"` + AsyncCommitBuffer int `mapstructure:"sc-async-commit-buffer"` + SnapshotKeepRecent uint32 `mapstructure:"sc-keep-recent"` + SnapshotInterval uint32 `mapstructure:"sc-snapshot-interval"` + SnapshotMinTimeInterval uint32 `mapstructure:"sc-snapshot-min-time-interval"` + SnapshotWriterLimit int `mapstructure:"sc-snapshot-writer-limit"` + SnapshotPrefetchThreshold float64 `mapstructure:"sc-snapshot-prefetch-threshold"` + SnapshotWriteRateMBps int `mapstructure:"sc-snapshot-write-rate-mbps"` + HistoricalProofMaxInFlight int `mapstructure:"sc-historical-proof-max-inflight"` + HistoricalProofRateLimit float64 `mapstructure:"sc-historical-proof-rate-limit"` + HistoricalProofBurst int `mapstructure:"sc-historical-proof-burst"` + WriteMode string `mapstructure:"sc-write-mode"` + WriteModeEnableAuto bool `mapstructure:"sc-write-mode-enable-auto"` + HashLoggerEnable bool `mapstructure:"sc-hash-logger-enable"` + HashLoggerDirectory string `mapstructure:"sc-hash-logger-directory"` + HashLoggerBlocksToRetain uint `mapstructure:"sc-hash-logger-blocks-to-retain"` + HashLoggerTargetFileSize uint `mapstructure:"sc-hash-logger-target-file-size"` + HashLoggerMaxDiskSize uint `mapstructure:"sc-hash-logger-max-disk-size"` + FlatKV stateCommitFlatKVSchema `mapstructure:"flatkv"` +} + +// stateCommitDefaults is what this section resolves to for a node that has written nothing. +// +// The declared defaults, which is what seid init renders into app.toml. Eighteen of parseSCConfigs' twenty +// reads already check that the key was present, so for those the declared default is also what an absent +// key resolves to today. The two that do not are sc-enable and sc-directory, and sc-enable is the one that +// matters: an absent key reads as false, and SetupSeiDB stops a node with state commitment off, so no +// running node has that key missing. Resolving it to true is what every working node already has written. +// +// The same values for every mode. How often a node snapshots and how much proof history it serves are +// decisions about disk and load that an operator writes down. +func stateCommitDefaults(registry.Mode) any { + live := config.DefaultStateCommitConfig() + return stateCommitSchema{ + Enable: live.Enable, + Directory: live.Directory, + AsyncCommitBuffer: live.MemIAVLConfig.AsyncCommitBuffer, + SnapshotKeepRecent: live.MemIAVLConfig.SnapshotKeepRecent, + SnapshotInterval: live.MemIAVLConfig.SnapshotInterval, + SnapshotMinTimeInterval: live.MemIAVLConfig.SnapshotMinTimeInterval, + SnapshotWriterLimit: live.MemIAVLConfig.SnapshotWriterLimit, + SnapshotPrefetchThreshold: live.MemIAVLConfig.SnapshotPrefetchThreshold, + SnapshotWriteRateMBps: live.MemIAVLConfig.SnapshotWriteRateMBps, + HistoricalProofMaxInFlight: live.HistoricalProofMaxInFlight, + HistoricalProofRateLimit: live.HistoricalProofRateLimit, + HistoricalProofBurst: live.HistoricalProofBurst, + WriteMode: string(live.WriteMode), + WriteModeEnableAuto: live.WriteModeEnableAuto, + HashLoggerEnable: live.HashLogger.Enable, + HashLoggerDirectory: live.HashLogger.Directory, + HashLoggerBlocksToRetain: live.HashLogger.BlocksToRetain, + HashLoggerTargetFileSize: live.HashLogger.TargetFileSize, + HashLoggerMaxDiskSize: live.HashLogger.MaxDiskSize, + FlatKV: stateCommitFlatKVSchema{ + EnableReadWriteMetrics: live.FlatKVConfig.EnableReadWriteMetrics, + }, + } +} diff --git a/app/config_register_test.go b/app/config_register_test.go new file mode 100644 index 0000000000..bcac7b8940 --- /dev/null +++ b/app/config_register_test.go @@ -0,0 +1,166 @@ +package app + +import ( + "reflect" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-db/config" +) + +// requireSectionResolves holds one section's resolved keys and values against what its reader asks for. +// +// Resolving is what to compare against rather than the registered struct, because the resolved map is +// what a caller reads: it carries the key a tag produced and the value that tag's field held. A +// comparison of struct to struct agrees with itself while two tags are on the wrong fields, since each +// field still holds the value the test names for it. This one does not, because the swap moves the value +// to the other key. +// +// The values come from the reader's own constants and its own defaults, so a renamed key or a changed +// default fails here rather than being restated correctly in two places and wrongly in a third. +func requireSectionResolves(t *testing.T, mode registry.Mode, section string, want map[string]any) { + t.Helper() + registered, ok := registry.Lookup(section) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", section) + } + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + + declared := make(map[string]bool, len(registered.Keys)) + for _, key := range registered.Keys { + declared[key] = true + expected, named := want[key] + if !named { + t.Errorf("mode %q: %s declares %s and nothing here names a value for it, so either its reader "+ + "resolves the key and this list is short, or no reader does and an operator has a setting "+ + "that changes nothing", mode, section, key) + continue + } + if got := resolved.Values[key]; !reflect.DeepEqual(got, expected) { + t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", mode, key, got, got, expected, expected) + } + } + for key := range want { + if !declared[key] { + t.Errorf("mode %q: %s does not declare %s, which its reader resolves, so that setting stays "+ + "answered by whatever answers it today and nothing reports it", mode, section, key) + } + } +} + +// TestLightInvarianceResolves covers the one section registered as the type its reader fills. +// +// Every mode, because this section's defaults are the same for all of them: the check compares the bank +// module's recorded total supply against what the store holds, which is a property of every node. +func TestLightInvarianceResolves(t *testing.T) { + for _, mode := range registry.Modes() { + requireSectionResolves(t, mode, LightInvarianceSectionName, map[string]any{ + flagSupplyEnabled: DefaultLightInvarianceConfig.SupplyEnabled, + }) + } +} + +// TestGenesisResolves holds the genesis schema against the reader's own constants. +// +// The two keys carry different types, which is what makes the pairing worth asserting: the schema states +// the tags in one place and the reader looks them up in another, and nothing but this holds the two +// together. +func TestGenesisResolves(t *testing.T) { + for _, mode := range registry.Modes() { + requireSectionResolves(t, mode, GenesisSectionName, map[string]any{ + flagGenesisStreamImport: DefaultGenesisConfig.StreamGenesisImport, + flagGenesisImportFile: DefaultGenesisConfig.GenesisStreamFile, + }) + } +} + +// TestStateStoreResolves holds the state store schema against every key parseSSConfigs resolves. +// +// Twelve keys, including ss-snapshot-enable, which is the one read that checks whether the key was +// present. A schema short of a key its reader resolves leaves that setting undeclared, so it keeps +// whatever answers it today and no diagnostic names it. +func TestStateStoreResolves(t *testing.T) { + live := config.DefaultStateStoreConfig() + for _, mode := range registry.Modes() { + requireSectionResolves(t, mode, StateStoreSectionName, map[string]any{ + FlagSSEnable: live.Enable, + FlagSSDirectory: live.DBDirectory, + FlagSSBackend: live.Backend, + FlagSSAsyncWriterBuffer: live.AsyncWriteBuffer, + FlagSSKeepRecent: live.KeepRecent, + FlagSSPruneInterval: live.PruneIntervalSeconds, + FlagSSImportNumWorkers: live.ImportNumWorkers, + FlagSSReadWriteMetrics: live.EnableReadWriteMetrics, + FlagSSSnapshotEnable: live.SnapshotEnable, + FlagEVMSSDirectory: live.EVMDBDirectory, + FlagEVMSSSeparateDBs: live.SeparateEVMSubDBs, + FlagEVMSSSplit: live.EVMSplit, + }) + } +} + +// TestStateCommitResolves holds the state commit schema against every key parseSCConfigs resolves. +// +// Twenty keys, one of them a segment below the section, since the flat key-value read is +// state-commit.flatkv.enable-read-write-metrics. The write mode is a plain string here because the +// reader parses a written name into its own type, and comparing values is what holds it to that: the +// named type carries the same text and is not the same value. +func TestStateCommitResolves(t *testing.T) { + live := config.DefaultStateCommitConfig() + for _, mode := range registry.Modes() { + requireSectionResolves(t, mode, StateCommitSectionName, map[string]any{ + FlagSCEnable: live.Enable, + FlagSCDirectory: live.Directory, + FlagSCAsyncCommitBuffer: live.MemIAVLConfig.AsyncCommitBuffer, + FlagSCSnapshotKeepRecent: live.MemIAVLConfig.SnapshotKeepRecent, + FlagSCSnapshotInterval: live.MemIAVLConfig.SnapshotInterval, + FlagSCSnapshotMinTimeInterval: live.MemIAVLConfig.SnapshotMinTimeInterval, + FlagSCSnapshotWriterLimit: live.MemIAVLConfig.SnapshotWriterLimit, + FlagSCSnapshotPrefetchThreshold: live.MemIAVLConfig.SnapshotPrefetchThreshold, + FlagSCSnapshotWriteRateMBps: live.MemIAVLConfig.SnapshotWriteRateMBps, + FlagSCHistoricalProofMaxInFlight: live.HistoricalProofMaxInFlight, + FlagSCHistoricalProofRateLimit: live.HistoricalProofRateLimit, + FlagSCHistoricalProofBurst: live.HistoricalProofBurst, + FlagSCWriteMode: string(live.WriteMode), + FlagSCWriteModeEnableAuto: live.WriteModeEnableAuto, + FlagSCHashLoggerEnable: live.HashLogger.Enable, + FlagSCHashLoggerDirectory: live.HashLogger.Directory, + FlagSCHashLoggerBlocksToRetain: live.HashLogger.BlocksToRetain, + FlagSCHashLoggerTargetFileSize: live.HashLogger.TargetFileSize, + FlagSCHashLoggerMaxDiskSize: live.HashLogger.MaxDiskSize, + FlagSCFlatKVReadWriteMetrics: live.FlatKVConfig.EnableReadWriteMetrics, + }) + } +} + +// TestStateCommitWriteModeDefaultIsOneTheReaderAccepts covers the one declared value that is parsed text. +// +// Every other declared default is a value its reader uses as it stands. This one is a name the reader +// turns into a mode, so a default nothing parses would put a value in a generated file that stops the +// node it was generated for. +func TestStateCommitWriteModeDefaultIsOneTheReaderAccepts(t *testing.T) { + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) + if err != nil { + t.Fatalf("%v", err) + } + declared, ok := resolved.Values[FlagSCWriteMode].(string) + if !ok { + t.Fatalf("%s resolves to %T, and the reader parses text", FlagSCWriteMode, resolved.Values[FlagSCWriteMode]) + } + if _, err := config.ParseSCWriteMode(declared); err != nil { + t.Errorf("%s resolves to %q, which this binary's own reader refuses: %v", FlagSCWriteMode, declared, err) + } +} + +// TestEverySectionThisPackageRegistersIsWellFormed covers what the registry itself refuses. +// +// A section with a tag the registry cannot read is reported rather than returned, so a defect here is a +// section that registered and declares nothing a caller can resolve. +func TestEverySectionThisPackageRegistersIsWellFormed(t *testing.T) { + for _, defect := range registry.Defects() { + t.Errorf("%s is registered and defective: %v", defect.Section, defect.Err) + } +} From 25fa5f997bde6b2c34cc35db3becb43302cc3fd2 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 14:38:22 -0700 Subject: [PATCH 03/25] config: the EVM sections enter the registry Four sections, each registered by the package that owns its struct. eth_blocktest 2 keys eth_replay 4 keys evm 57 keys evm_query 1 key All four register the struct their reader fills. None needs a schema, because in each of them the mapstructure tags already spell the keys the reader looks up, so the registry derives what a node reads and nothing restates a list of fifty-seven keys. Each package's test holds the derived keys against the reader's own constants, which are the second statement of the same set in the same file: a rename that moves one and not the other compiles. The EVM section resolves the same values for every mode. Nothing consults a node's kind while reading these keys, so a file missing them serves both interfaces whatever kind of node it is, and that is what these resolve to. A node seid init provisioned is the other case and needs nothing here, since that path writes the two interface toggles per mode and a written value is what resolves. Two of that section's values come from the machine rather than from a decision. The simulation call limit is the processor count and the worker pool is twice it, capped, so they describe whichever host resolved them. That is stated where they are declared, because a caller rendering them into a file carries one host's sizing to whatever reads that file next. Replay declares three of its four keys under the name the template writes and one under a different one. The template renders eth_replay_contract_state_checks and the reader looks up contract_state_checks, so every generated file already carries a name nothing resolves. The declared key is the one a value reaches a reader through, and a test refuses the other: declaring it would add a key an operator can set and no reader answers, which is worse than the mismatch, because the value would look as though it applied. The recorded configuration surface does not move, because nothing consumes the registry on a boot path yet. --- evmrpc/config/register.go | 30 +++++++++++ evmrpc/config/register_test.go | 92 ++++++++++++++++++++++++++++++++ x/evm/blocktest/register.go | 24 +++++++++ x/evm/blocktest/register_test.go | 47 ++++++++++++++++ x/evm/querier/register.go | 21 ++++++++ x/evm/querier/register_test.go | 40 ++++++++++++++ x/evm/replay/register.go | 26 +++++++++ x/evm/replay/register_test.go | 66 +++++++++++++++++++++++ 8 files changed, 346 insertions(+) create mode 100644 evmrpc/config/register.go create mode 100644 evmrpc/config/register_test.go create mode 100644 x/evm/blocktest/register.go create mode 100644 x/evm/blocktest/register_test.go create mode 100644 x/evm/querier/register.go create mode 100644 x/evm/querier/register_test.go create mode 100644 x/evm/replay/register.go create mode 100644 x/evm/replay/register_test.go diff --git a/evmrpc/config/register.go b/evmrpc/config/register.go new file mode 100644 index 0000000000..7cb5c2209c --- /dev/null +++ b/evmrpc/config/register.go @@ -0,0 +1,30 @@ +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, all fifty-seven of +// them, so the registry derives what a node reads rather than restating a list this long. +func init() { + registry.RegisterSection(SectionName, &Config{}, defaults) +} + +// defaults is what this section resolves to for a node that has written nothing. +// +// The declared defaults, unchanged by mode, because that is what such a node runs: nothing consults the +// node's kind while reading these keys, so a file missing them serves both interfaces whatever kind of +// node it is. +// +// A node seid init provisioned is a different case and needs no help from here. That path writes the two +// interface toggles per mode, closing them for a validator and a seed, so those nodes carry written values +// and a written value is what resolves. +// +// Two of these values come from the machine rather than from a decision: the simulation call limit is the +// processor count and the worker pool is twice it, capped. They describe the host that asked, so a caller +// that renders them into a file carries one host's sizing to whatever reads that file next. +func defaults(registry.Mode) any { return DefaultConfig } diff --git a/evmrpc/config/register_test.go b/evmrpc/config/register_test.go new file mode 100644 index 0000000000..a2269efa32 --- /dev/null +++ b/evmrpc/config/register_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "reflect" + "runtime" + "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 fifty-seven 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) { + 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) + } + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares %d keys and its reader resolves %d.\ndeclared: %v\nresolved: %v", + SectionName, len(section.Keys), len(want), section.Keys, want) + } +} + +// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// +// Unchanged by mode, which is the decision worth pinning. seid init writes the two interface toggles per +// mode, so a validator it provisioned carries them as written values. These are what a node with nothing +// written runs, and no read of these keys consults the node's kind. +func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + } + if !reflect.DeepEqual(got, DefaultConfig) { + t.Errorf("mode %q resolves to a value other than the reader's own default", mode) + } + if !got.HTTPEnabled || !got.WSEnabled { + t.Errorf("mode %q resolves an interface closed. A node whose file lacks these keys serves "+ + "both, so resolving one closed would take an interface away from a running node", mode) + } + } +} + +// TestTheTwoHostDerivedValuesDescribeThisHost covers the two defaults that are measurements. +// +// Every other value here is a decision someone wrote down and is the same on any machine. These two are +// the processor count and twice it, so they describe whichever host resolved them. Nothing here can make +// that portable, and stating it is what keeps a caller from rendering them into a file as though it were. +func TestTheTwoHostDerivedValuesDescribeThisHost(t *testing.T) { + got, ok := defaults(registry.ModeValidator).(Config) + if !ok { + t.Fatalf("defaults returned %T, want the type its reader fills", defaults(registry.ModeValidator)) + } + if got.MaxConcurrentSimulationCalls != runtime.NumCPU() { + t.Errorf("%s resolves to %d and this host has %d processors", + flagMaxConcurrentSimulationCalls, got.MaxConcurrentSimulationCalls, runtime.NumCPU()) + } + if want := min(MaxWorkerPoolSize, runtime.NumCPU()*2); got.WorkerPoolSize != want { + t.Errorf("%s resolves to %d, want %d on a host with %d processors", + flagWorkerPoolSize, got.WorkerPoolSize, want, runtime.NumCPU()) + } +} 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..d5ef96ed60 --- /dev/null +++ b/x/evm/blocktest/register_test.go @@ -0,0 +1,47 @@ +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 and there is no second list to fall behind. What remains is the constants ReadConfig looks up, +// which state the same keys again a few lines away, and a rename that moves one and not the other +// compiles. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + want := []string{flagEnabled, flagTestDataPath} + sort.Strings(want) + + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// +// Off for every mode, which is the value worth pinning: a mode that resolved this on would have those +// nodes replay recorded data instead of serving the chain. +func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + } + if got != DefaultConfig { + t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + } + if got.Enabled { + t.Errorf("mode %q resolves the block-test harness on", 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..42d2d799a0 --- /dev/null +++ b/x/evm/querier/register_test.go @@ -0,0 +1,40 @@ +package querier + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived keys 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 and there is no second list to fall behind. What remains is the constant ReadConfig looks up, 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) { + want := []string{flagGasLimit} + sort.Strings(want) + + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + } + if got != DefaultConfig { + t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + } + } +} diff --git a/x/evm/replay/register.go b/x/evm/replay/register.go new file mode 100644 index 0000000000..fa3e1a291b --- /dev/null +++ b/x/evm/replay/register.go @@ -0,0 +1,26 @@ +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 application construction dial the +// endpoint and fail when it cannot reach it, so a mode whose defaults turned it on would stop those nodes +// booting. The endpoint itself is a fixed third-party address, which is another reason no mode implies it. +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..49079caaba --- /dev/null +++ b/x/evm/replay/register_test.go @@ -0,0 +1,66 @@ +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. +// +// 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 a few lines away, and a rename that moves one and not the other +// compiles. +func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { + want := []string{flagEnabled, flagEthRPC, flagEthDataDir, flagContractStateChecks} + sort.Strings(want) + + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + if !reflect.DeepEqual(section.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", SectionName, section.Keys, want) + } +} + +// TestTheWrittenSpellingOfTheStateCheckIsNotDeclared covers a name that is written and never read. +// +// The app.toml template renders eth_replay_contract_state_checks and the reader looks up +// contract_state_checks, so every generated file carries a name nothing resolves. Declaring that name +// would add a key an operator can set and no reader answers, which is the one outcome worse than the +// mismatch itself: a value that looks as though it applied. +func TestTheWrittenSpellingOfTheStateCheckIsNotDeclared(t *testing.T) { + section, ok := registry.Lookup(SectionName) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) + } + for _, key := range section.Keys { + if key == SectionName+".eth_replay_contract_state_checks" { + t.Errorf("%s is declared and no reader looks it up", key) + } + } +} + +// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// +// Off for every mode, which is the value worth pinning: turning replay on makes application construction +// dial the endpoint, so a mode that resolved it on would stop those nodes booting. +func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + got, ok := defaults(mode).(Config) + if !ok { + t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + } + if got != DefaultConfig { + t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + } + if got.Enabled { + t.Errorf("mode %q resolves replay on, which makes those nodes dial %q at construction", + mode, got.EthRPC) + } + } +} From 59a387ad0994b59bd6888faac7bdd2a161527b66 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 15:04:18 -0700 Subject: [PATCH 04/25] config: the upstream server sections enter the registry Five sections whose keys belong to the Cosmos server, and the two registry capabilities they need. api 8 keys base 14 keys grpc 11 keys state-sync 3 keys telemetry 7 keys These sections have no owning package here. Their structs and their readers live in sei-cosmos, which this repository vendors rather than authors, so there is nowhere upstream to put a registration this registry would see. Four of the five register the upstream struct directly, because its mapstructure tags already name the keys the reader resolves. A section can now declare keys at the root of the file. The node-wide settings are written at the top of app.toml and read as pruning and halt-height, with no segment in front, so a section carrying a name into every key would rename all fourteen and an operator's existing file would reach none of them. A section therefore has a name it is looked up by and a prefix its keys carry, and for a root section the prefix is empty. Both walks build a key through one function, so a root key gains no separator on either side; reverting either one on its own fails a test, the value walk through the check that a rendered default states one value per declared key. Two keys can now collide where two prefixes never could. A key two sections both declare has one default rendered over the other, and which one depends on the order the sections are walked. And a root key that is also a section's name cannot be written at all, because a file holding both a value for that name and a table under it is not valid TOML, so one of the two is unreachable and nothing says which. Both are refused, in either registration order. A section can now say that an environment variable cannot supply one of its keys. The metric label set is a list of name and value rows and its reader asserts that exact shape rather than casting what it finds, so no single string satisfies it, and the assertion is the first statement of the whole server configuration. A resolved variable would install a value that stops the node; leaving the channel out means the file's value applies and the node runs. The reason is required rather than optional, because an operator whose variable is ignored has to be told why, and a refusal with no reason is itself refused. The metric section is the one here that needs a schema, and for one field's shape rather than for a spelling. Its label set is declared as untyped rows to match what the reader takes. A test holds every other field to the upstream field's name, tag and type, and holds the count of differing types at one, so a second divergence is a failure and a converged upstream type leaves the schema with nothing to justify it. Nothing here varies a default by mode. seid init writes the two interface toggles and the block retention per mode, so a node it provisioned carries those as written values, and these are what a node with nothing written runs. One declared value is not what a running node uses, and it is worth knowing which. The pruning strategy is declared as keeping everything, while the command line registers a flag of the same name defaulting to the standard strategy, and a bound flag is a source of its own below the file. A node started with no pruning key written prunes on the standard schedule. Whoever resolves for a running node has to supply the flag values to get the answer that node uses. The recorded configuration surface does not move, because nothing consumes the registry on a boot path yet. --- config/cosmosbase/cosmosbase.go | 129 +++++++++++++++ config/cosmosbase/cosmosbase_test.go | 224 +++++++++++++++++++++++++++ config/registry/environment.go | 42 +++++ config/registry/registry.go | 118 ++++++++++++-- config/registry/resolve.go | 16 +- config/registry/rootkeys_test.go | 211 +++++++++++++++++++++++++ 6 files changed, 723 insertions(+), 17 deletions(-) create mode 100644 config/cosmosbase/cosmosbase.go create mode 100644 config/cosmosbase/cosmosbase_test.go create mode 100644 config/registry/environment.go create mode 100644 config/registry/rootkeys_test.go diff --git a/config/cosmosbase/cosmosbase.go b/config/cosmosbase/cosmosbase.go new file mode 100644 index 0000000000..9b2312b6e4 --- /dev/null +++ b/config/cosmosbase/cosmosbase.go @@ -0,0 +1,129 @@ +// Package cosmosbase registers the configuration sections whose keys belong to the Cosmos server. +// +// These sections have no owning package inside this repository. Their structs and their readers live in +// sei-cosmos, which this repository vendors rather than authors, so there is nowhere upstream to put a +// registration that this repository's registry would see. A section belongs here only when its keys are +// upstream's; a section this repository owns registers in the package that owns its struct. +package cosmosbase + +import ( + "github.com/sei-protocol/sei-chain/config/registry" + srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" +) + +// The names these sections have in the configuration key space. +// +// BaseSectionName names a section whose keys carry no prefix at all. The name is for lookups and reports +// and is not part of any key, because giving those settings a section would rename every one of them. +const ( + BaseSectionName = "base" + APISectionName = "api" + GRPCSectionName = "grpc" + TelemetrySectionName = "telemetry" + StateSyncSectionName = "state-sync" +) + +// GlobalLabelsKey is the metric label set, which is the one key here no environment variable can supply. +const GlobalLabelsKey = TelemetrySectionName + ".global-labels" + +// Registration puts the upstream server's configuration sections in the registry. +// +// Four of the five register the upstream struct directly, because their mapstructure tags already name the +// keys their reader resolves. That is worth stating rather than assuming: the two SeiDB sections needed a +// schema precisely because their tags name something else. +func init() { + registry.RegisterRootKeys(BaseSectionName, &srvconfig.BaseConfig{}, baseDefaults) + registry.RegisterSection(APISectionName, &srvconfig.APIConfig{}, apiDefaults) + registry.RegisterSection(GRPCSectionName, &srvconfig.GRPCConfig{}, grpcDefaults) + registry.RegisterSection(TelemetrySectionName, &telemetrySchema{}, telemetryDefaults) + registry.RegisterSection(StateSyncSectionName, &srvconfig.StateSyncConfig{}, stateSyncDefaults) + + registry.RefuseFromEnvironment(GlobalLabelsKey, + "the metric label set is a list of name and value rows, and its reader takes that exact shape "+ + "rather than casting what it finds, so no single environment string can supply it. Write it "+ + "in the configuration file instead") +} + +// baseDefaults is what the node-wide settings resolve to for a node that has written nothing. +// +// The upstream defaults, unchanged by mode. Every one of these keys is read with a casting getter and no +// check that the key was present, so an absent key casts to a zero and clobbers the default beside it. +// Five of the fourteen have a non-zero default, and the pruning strategy is the one that matters, because +// an empty strategy is not a strategy. +// +// Three keys elsewhere in this package vary by node mode, and none of them varies here. seid init writes +// the interface toggles and the block retention per mode, so a node it provisioned carries those as +// written values, and a written value is what resolves. These are what a node with nothing written runs. +// +// One value here is not what a running node uses today, and it is worth knowing which. The pruning +// strategy is declared as keeping everything, while the command line registers a flag of the same name +// defaulting to the standard strategy, and a bound flag is a source of its own below the file. So a node +// started with no pruning key written prunes on the standard schedule and this states that it would keep +// everything. Whoever resolves for a running node has to supply the flag values to get the answer that +// node uses. +func baseDefaults(registry.Mode) any { return srvconfig.DefaultConfig().BaseConfig } + +// apiDefaults is what the REST interface settings resolve to for a node that has written nothing. +// +// The interface is off, for every mode. seid init turns it on for a full node and an archive node, so +// those carry it written, and a node whose file lacks the key does not serve REST whatever kind it is. +func apiDefaults(registry.Mode) any { return srvconfig.DefaultConfig().API } + +// grpcDefaults is what the gRPC settings resolve to for a node that has written nothing. +// +// The interface is on, which is the upstream default, and seid init writes it off for a validator and a +// seed. Six of these eleven keys are read only when the key is present, so for those the declared default +// is also what an absent key resolves to today. +// +// The six durations are declared as durations and written into a file as text, which is the shape the +// reader parses back. +func grpcDefaults(registry.Mode) any { return srvconfig.DefaultConfig().GRPC } + +// stateSyncDefaults is what the snapshot settings resolve to for a node that has written nothing. +// +// All three keys are read with a casting getter and no presence check, and the retention is the one that +// inverts: it is declared as keeping two snapshots and an absent key casts to zero, which the file format +// documents as keeping every snapshot. +func stateSyncDefaults(registry.Mode) any { return srvconfig.DefaultConfig().StateSync } + +// telemetrySchema declares the keys the metric settings reader resolves. +// +// A schema rather than the upstream type, and the only one of these five that needs one. The difference is +// a single field's type. The upstream struct declares the label set as a list of string pairs, and the +// reader takes a list of untyped rows: it asserts that exact shape rather than casting what it finds, and +// the struct's own type does not satisfy it, including that type's empty value. Registering the upstream +// type would resolve a default the reader refuses, and it refuses by returning an error that is the first +// statement of the whole server configuration, so the node stops. Every node, not only one that wrote the +// key. +// +// Every other field matches the upstream type, so this is one field's shape and not the section's. +type telemetrySchema struct { + ServiceName string `mapstructure:"service-name"` + Enabled bool `mapstructure:"enabled"` + EnableHostname bool `mapstructure:"enable-hostname"` + EnableHostnameLabel bool `mapstructure:"enable-hostname-label"` + EnableServiceLabel bool `mapstructure:"enable-service-label"` + PrometheusRetentionTime int64 `mapstructure:"prometheus-retention-time"` + GlobalLabels []any `mapstructure:"global-labels"` +} + +// telemetryDefaults is what the metric settings resolve to for a node that has written nothing. +// +// Read out of the upstream defaults rather than written again here, so a changed default moves both at +// once and this states only which key carries which setting. +// +// The label set is empty, which is what the upstream default holds, so there is nothing to convert into +// the untyped rows the reader takes. A test holds that emptiness, because a default that gained rows would +// need converting and would otherwise reach the reader as the shape it refuses. +func telemetryDefaults(registry.Mode) any { + live := srvconfig.DefaultConfig().Telemetry + return telemetrySchema{ + ServiceName: live.ServiceName, + Enabled: live.Enabled, + EnableHostname: live.EnableHostname, + EnableHostnameLabel: live.EnableHostnameLabel, + EnableServiceLabel: live.EnableServiceLabel, + PrometheusRetentionTime: live.PrometheusRetentionTime, + GlobalLabels: []any{}, + } +} diff --git a/config/cosmosbase/cosmosbase_test.go b/config/cosmosbase/cosmosbase_test.go new file mode 100644 index 0000000000..1220db25bd --- /dev/null +++ b/config/cosmosbase/cosmosbase_test.go @@ -0,0 +1,224 @@ +package cosmosbase + +import ( + "reflect" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" + "github.com/sei-protocol/sei-chain/sei-cosmos/telemetry" +) + +// requireDeclares holds one section's declared keys against the keys named for it. +func requireDeclares(t *testing.T, section string, reads []string) registry.Section { + t.Helper() + registered, ok := registry.Lookup(section) + if !ok { + t.Fatalf("%s is not registered, so nothing resolves its keys", section) + } + want := append([]string(nil), reads...) + sort.Strings(want) + if !reflect.DeepEqual(registered.Keys, want) { + t.Errorf("%s declares\n %v\nand its reader resolves\n %v", section, registered.Keys, want) + } + return registered +} + +// TestTheNodeWideKeysAreTheOnesTheirReaderResolves holds the root section against the server's constants. +// +// Fourteen keys and not one of them carries a segment in front. The reader looks these up by the constants +// below, so a prefix here would declare fourteen keys no operator writes and leave the real ones +// undeclared. +func TestTheNodeWideKeysAreTheOnesTheirReaderResolves(t *testing.T) { + section := requireDeclares(t, BaseSectionName, []string{ + server.FlagMinGasPrices, server.FlagPruning, server.FlagPruningKeepRecent, + server.FlagPruningKeepEvery, server.FlagPruningInterval, server.FlagHaltHeight, + server.FlagFreezeHeight, server.FlagHaltTime, server.FlagMinRetainBlocks, + server.FlagInterBlockCache, server.FlagIndexEvents, server.FlagCompactionInterval, + server.FlagConcurrencyWorkers, baseapp.FlagOccEnabled, + }) + if section.Prefix != "" { + t.Errorf("the section carries prefix %q, and one here renames every key it declares", section.Prefix) + } +} + +// TestTheSnapshotKeysAreTheOnesTheirReaderResolves holds the snapshot section against the server's +// constants. +func TestTheSnapshotKeysAreTheOnesTheirReaderResolves(t *testing.T) { + requireDeclares(t, StateSyncSectionName, []string{ + server.FlagStateSyncSnapshotInterval, + server.FlagStateSyncSnapshotKeepRecent, + server.FlagStateSyncSnapshotDir, + }) +} + +// TestTheRESTKeysAreTheOnesItsReaderResolves holds the REST section against the keys its reader looks up. +// +// Written out rather than taken from constants, because this reader has none: it looks each key up as a +// literal string where it reads it. That is the whole reason a comparison is worth making here. +func TestTheRESTKeysAreTheOnesItsReaderResolves(t *testing.T) { + requireDeclares(t, APISectionName, []string{ + "api.enable", "api.swagger", "api.enabled-unsafe-cors", "api.address", + "api.max-open-connections", "api.rpc-read-timeout", "api.rpc-write-timeout", + "api.rpc-max-body-bytes", + }) +} + +// TestTheGRPCKeysAreTheOnesItsReaderResolves holds the gRPC section against the keys its reader looks up. +// +// Written out for the same reason as the REST section: the reader has no constants for these. +func TestTheGRPCKeysAreTheOnesItsReaderResolves(t *testing.T) { + requireDeclares(t, GRPCSectionName, []string{ + "grpc.enable", "grpc.address", "grpc.max-recv-msg-size", "grpc.max-open-connections", + "grpc.max-connection-idle", "grpc.max-connection-age", "grpc.max-connection-age-grace", + "grpc.keepalive-time", "grpc.keepalive-timeout", "grpc.keepalive-min-time", + "grpc.keepalive-permit-without-stream", + }) +} + +// TestTheMetricKeysAreTheOnesItsReaderResolves holds the metric section against the keys its reader looks +// up, the label set among them. +func TestTheMetricKeysAreTheOnesItsReaderResolves(t *testing.T) { + requireDeclares(t, TelemetrySectionName, []string{ + "telemetry.service-name", "telemetry.enabled", "telemetry.enable-hostname", + "telemetry.enable-hostname-label", "telemetry.enable-service-label", + "telemetry.prometheus-retention-time", GlobalLabelsKey, + }) +} + +// TestTheMetricSchemaRestatesTheUpstreamTypeExactlyOnceOver is what a schema costs. +// +// The schema exists for one field's shape, so every other field has to be the upstream field: same name, +// same tag, same type. A field that drifted would declare a key under a spelling the reader does not look +// up, or resolve a value of a type it cannot take, and the section would go on registering cleanly either +// way. +func TestTheMetricSchemaRestatesTheUpstreamTypeExactlyOnceOver(t *testing.T) { + upstream := reflect.TypeOf(telemetry.Config{}) + schema := reflect.TypeOf(telemetrySchema{}) + if schema.NumField() != upstream.NumField() { + t.Fatalf("the schema has %d fields and the upstream type has %d; a field on one side only is "+ + "either a key nothing reads or a setting nothing declares", + schema.NumField(), upstream.NumField()) + } + + differing := 0 + for i := range schema.NumField() { + got, want := schema.Field(i), upstream.Field(i) + if got.Name != want.Name { + t.Errorf("field %d is %s here and %s upstream", i, got.Name, want.Name) + continue + } + if got.Tag != want.Tag { + t.Errorf("%s is tagged %q here and %q upstream, so it declares a key the reader does not "+ + "look up", got.Name, got.Tag, want.Tag) + } + if got.Type == want.Type { + continue + } + differing++ + if got.Name != "GlobalLabels" { + t.Errorf("%s is %s here and %s upstream. The label set is the only field whose shape this "+ + "schema changes, so a second one is a divergence nothing decided", + got.Name, got.Type, want.Type) + } + } + if differing != 1 { + t.Errorf("%d fields differ in type, want exactly one. If the upstream type came to match, this "+ + "schema is a restatement with nothing left to justify it", differing) + } +} + +// TestTheUpstreamDefaultCarriesNoLabels holds the assumption the declared label set is built on. +// +// The declared default is an empty list of rows, which is right only while the upstream default holds no +// labels. A default that gained a pair would need converting into the untyped rows the reader takes, and +// without that it reaches the reader as the shape it refuses. +func TestTheUpstreamDefaultCarriesNoLabels(t *testing.T) { + if got := srvconfig.DefaultConfig().Telemetry.GlobalLabels; len(got) != 0 { + t.Errorf("the upstream default carries %d label rows: %v. They need converting into untyped rows "+ + "here, because the reader asserts that shape rather than casting what it finds", len(got), got) + } +} + +// TestTheLabelSetIsRefusedFromTheEnvironment covers the one key no variable here can supply. +// +// Its reader asserts a list of untyped rows and an environment carries one string, so resolving the +// variable installs a value the reader refuses, and it refuses in the first statement of the whole server +// configuration. The node stops. Leaving the channel out means the file's value applies and the node runs. +func TestTheLabelSetIsRefusedFromTheEnvironment(t *testing.T) { + reason, refused := registry.EnvCannotDeliver()[GlobalLabelsKey] + if !refused { + t.Fatalf("%s is not refused from the environment, so a variable naming it resolves to a string "+ + "and installing that stops the node", GlobalLabelsKey) + } + if reason == "" { + t.Error("the refusal carries no reason, so an operator whose variable is ignored cannot be told why") + } + + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{ + LookupEnv: func(name string) (string, bool) { + if name == registry.EnvName(GlobalLabelsKey) { + return "chain_id=pacific-1", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got := resolved.Values[GlobalLabelsKey]; !reflect.DeepEqual(got, []any{}) { + t.Errorf("%s resolved to %#v (%T), want the declared default it was left to", + GlobalLabelsKey, got, got) + } + for _, key := range resolved.Overrides { + if key == GlobalLabelsKey { + t.Errorf("%s is reported as a value an operator supplied, and the variable did nothing", + GlobalLabelsKey) + } + } +} + +// TestDefaultsAreTheUpstreamOnesForEveryMode covers the value side of all five registrations. +// +// Unchanged by mode, which is the decision worth pinning. seid init writes three of these keys per mode, +// so a node it provisioned carries them as written values; these are what a node with nothing written +// runs. +func TestDefaultsAreTheUpstreamOnesForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + live := srvconfig.DefaultConfig() + for _, c := range []struct { + section string + got any + want any + }{ + {BaseSectionName, baseDefaults(mode), live.BaseConfig}, + {APISectionName, apiDefaults(mode), live.API}, + {GRPCSectionName, grpcDefaults(mode), live.GRPC}, + {StateSyncSectionName, stateSyncDefaults(mode), live.StateSync}, + } { + if !reflect.DeepEqual(c.got, c.want) { + t.Errorf("mode %q: %s resolves to something other than the upstream default", mode, c.section) + } + } + + metrics, ok := telemetryDefaults(mode).(telemetrySchema) + if !ok { + t.Fatalf("mode %q: the metric defaults returned %T, want the schema", mode, telemetryDefaults(mode)) + } + if metrics.Enabled != live.Telemetry.Enabled || + metrics.PrometheusRetentionTime != live.Telemetry.PrometheusRetentionTime || + metrics.ServiceName != live.Telemetry.ServiceName { + t.Errorf("mode %q: the metric defaults are not the upstream ones: %+v", mode, metrics) + } + } +} + +// TestEverySectionHereRegistersCleanly covers what the registry itself refuses. +func TestEverySectionHereRegistersCleanly(t *testing.T) { + for _, defect := range registry.Defects() { + t.Errorf("%s is registered and defective: %v", defect.Section, defect.Err) + } +} diff --git a/config/registry/environment.go b/config/registry/environment.go new file mode 100644 index 0000000000..ef2e013ed3 --- /dev/null +++ b/config/registry/environment.go @@ -0,0 +1,42 @@ +package registry + +import "fmt" + +// envCannotDeliver holds the keys an environment variable cannot supply, with the reason. +var envCannotDeliver = map[string]string{} + +// RefuseFromEnvironment records that an environment variable cannot supply a key. +// +// An environment carries one string per name. Most readers cast that string into whatever the setting +// needs, so the environment works for them. A reader that takes its value's exact type instead cannot be +// handed a string at all, and no spelling of the variable would satisfy it. +// +// Resolving such a key from the environment puts an unusable value at the top of the order, and installing +// it stops the node. Leaving the channel out means the file's value applies and the node runs. That is +// deliberately not what the machinery this replaces does, which resolves the variable and refuses to +// start, so the difference is recorded rather than assumed. A value silently doing nothing is the failure +// this whole surface exists to remove, which is why the reason is required and not optional. +// +// Called from the owning package, beside its registration, so the reason sits with the code that knows it. +func RefuseFromEnvironment(key, reason string) { + mu.Lock() + defer mu.Unlock() + if reason == "" { + defects = append(defects, Defect{Section: key, Err: fmt.Errorf( + "refusing %q from the environment with no reason; an operator whose variable is ignored has "+ + "to be told why", key)}) + return + } + envCannotDeliver[key] = reason +} + +// EnvCannotDeliver returns the keys an environment variable cannot supply, and why. +func EnvCannotDeliver() map[string]string { + mu.RLock() + defer mu.RUnlock() + out := make(map[string]string, len(envCannotDeliver)) + for key, reason := range envCannotDeliver { + out[key] = reason + } + return out +} diff --git a/config/registry/registry.go b/config/registry/registry.go index 008738680c..a9753d270d 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -28,8 +28,16 @@ func Modes() []Mode { return []Mode{ModeValidator, ModeFull, ModeSeed, ModeArchi // 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. + // Name identifies the section. A lookup, a report and a defect are keyed by it, and for most + // sections it is also the first segment of every key. Name string + // Prefix is the first segment of every key this section declares, and is empty for a section whose + // keys sit at the root of the file with no section of their own. + // + // Separate from Name because the two do different jobs. A node-wide setting such as the pruning + // strategy is written at the top of app.toml and read as "pruning", so it has no segment to take a + // name from, and it still needs one to be looked up and reported under. + Prefix string // Keys are the dotted paths this section declares, sorted. Keys []string // Defaults returns the section's default for a mode. @@ -68,7 +76,23 @@ var ( // It never panics. A registration this package cannot use is recorded as a Defect and the // section is not registered. func RegisterSection(name string, prototype any, defaults func(Mode) any) { - keys, err := deriveKeys(name, prototype) + record(name, name, prototype, defaults) +} + +// RegisterRootKeys records a section whose keys sit at the root of the file, with no section of their own. +// +// name identifies the section for lookups and reports and is not part of any key. Everything else matches +// RegisterSection: the keys come from the mapstructure tags, and the tags are the only spelling. +// +// Some settings are node-wide and are written at the top of a file rather than inside a table. Giving them +// a section would rename them, and a renamed key is one an operator's existing file no longer reaches. +func RegisterRootKeys(name string, prototype any, defaults func(Mode) any) { + record(name, "", prototype, defaults) +} + +// record is the one path both registrations take. +func record(name, prefix string, prototype any, defaults func(Mode) any) { + keys, err := deriveKeys(name, prefix, prototype) mu.Lock() defer mu.Unlock() @@ -82,14 +106,73 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) { defects = append(defects, Defect{Section: name, Err: fmt.Errorf("section registered twice")}) return } + if err := refuseOverlap(name, prefix, keys); err != nil { + defects = append(defects, Defect{Section: name, Err: err}) + return + } if err := envNamesAreDistinct(keys); err != nil { defects = append(defects, Defect{Section: name, Err: err}) return } - sections[name] = Section{Name: name, Keys: keys, Defaults: defaults} + sections[name] = Section{Name: name, Prefix: prefix, Keys: keys, Defaults: defaults} } } +// refuseOverlap rejects a registration whose keys cannot coexist with what is already registered. +// Callers hold mu. +// +// Two shapes of overlap, and neither could happen while every key carried its section's name. A key two +// sections both declare has one default rendered over the other, and which one depends on the order the +// sections are walked. And a root key that is also a section's name cannot be written at all: a file +// holding both a value for that name and a table under it is not valid TOML, so one of the two is +// unreachable and nothing says which. +// +// The first shape reaches the environment check below as well, which would refuse it for the wrong +// reason: two spellings of one variable, when the keys are in fact the same key. This names it as itself. +func refuseOverlap(name, prefix string, keys []string) error { + declaredBy := map[string]string{} + sectionNamed := map[string]string{} + for _, s := range sections { + for _, key := range s.Keys { + declaredBy[key] = s.Name + } + if s.Prefix != "" { + sectionNamed[s.Prefix] = s.Name + } + } + + for _, key := range keys { + if owner, taken := declaredBy[key]; taken { + return fmt.Errorf("%s declares %q and so does %s; one default renders over the other and "+ + "which one wins depends on the order the sections are walked", name, key, owner) + } + if prefix != "" { + continue + } + if owner, taken := sectionNamed[key]; taken { + return fmt.Errorf("%s declares %q at the root of the file and %s is a section of that name; "+ + "a file cannot hold both a value for %q and a table under it, so one of them is "+ + "unreachable", name, key, owner, key) + } + } + + if prefix == "" { + return nil + } + for _, s := range sections { + if s.Prefix != "" { + continue + } + for _, key := range s.Keys { + if key == prefix { + return fmt.Errorf("%s is a section named %q and %s declares %q at the root of the file; "+ + "a file cannot hold both a table and a value under that name", name, prefix, s.Name, key) + } + } + } + return nil +} + // envNamesAreDistinct refuses keys that share one environment spelling. Callers hold mu. // // Dots and hyphens both become underscores, so two keys differing only in that punctuation answer to @@ -166,19 +249,19 @@ func Keys() []string { // outside state-commit.flatkv.*. Ninety-two operator-facing keys reach their field only through a // spelling the tags do not produce, and a silent fallback is what made that invisible. Refusing to // guess is what keeps the tag authoritative. -func deriveKeys(section string, prototype any) ([]string, error) { - if section == "" { +func deriveKeys(name, prefix string, prototype any) ([]string, error) { + if name == "" { return nil, fmt.Errorf("section name is empty") } - if section != strings.ToLower(section) { + if name != strings.ToLower(name) { return nil, fmt.Errorf("section name %q is not lower case; configuration sources "+ - "enumerate lower-cased, so a key under it would never match a written one", section) + "enumerate lower-cased, so a key under it would never match a written one", name) } - if bad, found := unaddressableChar(section); found { + if bad, found := unaddressableChar(name); found { return nil, fmt.Errorf("section name %q carries %q, and a section is one segment. A dotted name "+ "declares keys inside another section's subtree, where the two sections' defaults land in "+ "one map and whichever renders last silently wins; a space cannot be written in an "+ - "environment variable name at all", section, bad) + "environment variable name at all", name, bad) } if prototype == nil { return nil, fmt.Errorf("no struct") @@ -192,7 +275,7 @@ func deriveKeys(section string, prototype any) ([]string, error) { } var keys []string - if err := walk(t, section, &keys, map[reflect.Type]bool{}); err != nil { + if err := walk(t, prefix, &keys, map[reflect.Type]bool{}); err != nil { return nil, err } if len(keys) == 0 { @@ -254,15 +337,15 @@ func walk(t reflect.Type, prefix string, keys *[]string, open map[reflect.Type]b if ft.Kind() != reflect.Struct { return fmt.Errorf("%s.%s is squashed but is a %s, not a struct", prefix, f.Name, ft.Kind()) } - if err := walkSubtree(ft, prefix, prefix+"."+f.Name, keys, open); err != nil { + if err := walkSubtree(ft, prefix, join(prefix, f.Name), keys, open); err != nil { return err } continue } - path := prefix + "." + tag + path := join(prefix, tag) if ft.Kind() == reflect.Struct && !isLeaf(ft) { - if err := walkSubtree(ft, path, prefix+"."+f.Name, keys, open); err != nil { + if err := walkSubtree(ft, path, join(prefix, f.Name), keys, open); err != nil { return err } continue @@ -272,6 +355,14 @@ func walk(t reflect.Type, prefix string, keys *[]string, open map[reflect.Type]b return nil } +// join appends a key segment to a prefix, and returns the segment alone when there is no prefix. +func join(prefix, segment string) string { + if prefix == "" { + return segment + } + return prefix + "." + segment +} + // walkSubtree appends the keys a struct-typed field declares, and refuses one that declares none. // // A struct configuration cannot reach is a setting an operator writes into nothing. A defined type @@ -362,6 +453,7 @@ func Reset() { defer mu.Unlock() sections = map[string]Section{} defects = nil + envCannotDeliver = map[string]string{} } // envPrefix is the environment namespace for every derived key. diff --git a/config/registry/resolve.go b/config/registry/resolve.go index dfcca8f9e3..c47281a293 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -63,6 +63,7 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { return out, err } declared := declaredKeys(registered) + undeliverable := EnvCannotDeliver() out.Values = make(map[string]any, len(declared)) for key, v := range defaults { @@ -75,7 +76,7 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { // order, which is why nothing exports it. for _, values := range []map[string]any{ fileValues(from.File), - envValues(declared, from.LookupEnv), + envValues(declared, undeliverable, from.LookupEnv), from.Flags, } { for key, v := range values { @@ -128,7 +129,7 @@ func declaredKeys(registered []Section) map[string]bool { func defaultValues(mode Mode, registered []Section) (map[string]any, error) { out := map[string]any{} for _, s := range registered { - values, err := sectionValues(s.Name, s.Defaults(mode)) + values, err := sectionValues(s.Prefix, s.Defaults(mode)) if err != nil { return out, fmt.Errorf("section %q default for mode %q: %w", s.Name, mode, err) } @@ -252,7 +253,7 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error { } continue } - path := prefix + "." + tag + path := join(prefix, tag) if fv.Kind() == reflect.Struct && !isLeaf(fv.Type()) { if err := walkValues(fv, path, out); err != nil { return err @@ -272,12 +273,19 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error { // declared is passed in rather than read here, so this shares Resolve's snapshot. Reading the registry // again would ask for a key the caller's declared set does not hold, and the answer would come back // only to be reported as one no section declares. -func envValues(declared map[string]bool, lookup func(string) (string, bool)) map[string]any { +func envValues(declared map[string]bool, undeliverable map[string]string, + lookup func(string) (string, bool)) map[string]any { if lookup == nil { return nil } out := map[string]any{} for key := range declared { + // A key no variable can carry is left to the sources that can. Resolving it would put a string + // at the top of the order for a reader that takes the exact type, and installing that stops the + // node. What an operator loses is the channel; what they keep is a node that boots. + if _, refused := undeliverable[key]; refused { + continue + } // An empty value is treated as unset. A variable exported empty is far more often a shell // artefact than a deliberate empty string, and the two are indistinguishable here. The cost is // that clearing a key by exporting it empty reads as touching nothing, and Overrides will not diff --git a/config/registry/rootkeys_test.go b/config/registry/rootkeys_test.go new file mode 100644 index 0000000000..6bcc1a2a41 --- /dev/null +++ b/config/registry/rootkeys_test.go @@ -0,0 +1,211 @@ +package registry_test + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// nodeWide is a probe for the settings written at the top of a file rather than inside a table. +type nodeWide struct { + Pruning string `mapstructure:"pruning"` + HaltHeight uint64 `mapstructure:"halt-height"` + Concurrency int `mapstructure:"concurrency-workers"` +} + +// TestARootSectionDeclaresKeysWithNoPrefix is the whole of what registering root keys adds. +// +// Some settings are node-wide and are written at the top of a file. Giving them a section would rename +// them, and a renamed key is one an operator's existing file no longer reaches. +func TestARootSectionDeclaresKeysWithNoPrefix(t *testing.T) { + registry.Reset() + registry.RegisterRootKeys("base", &nodeWide{}, func(registry.Mode) any { + return nodeWide{Pruning: "nothing", Concurrency: 4} + }) + for _, d := range registry.Defects() { + t.Fatalf("registering root keys was refused: %v", d.Err) + } + + section, ok := registry.Lookup("base") + if !ok { + t.Fatal("the section did not register under its name, so nothing can look it up or report on it") + } + if section.Prefix != "" { + t.Errorf("the section carries prefix %q, and one here renames every key it declares", section.Prefix) + } + if got := strings.Join(section.Keys, ","); got != "concurrency-workers,halt-height,pruning" { + t.Errorf("derived %q, want the three keys with no prefix. A leading segment is a key no operator "+ + "writes", got) + } + + // The default has to render under the same prefix-free names, or a declared key states no value and + // the resolution is refused rather than short. + resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got := resolved.Values["pruning"]; got != "nothing" { + t.Errorf("pruning resolved to %#v, want %q", got, "nothing") + } +} + +// TestARootKeyAndASectionCannotShareAName holds a limit of the file format, not a matter of taste. +// +// TOML cannot express a value for pruning and a table under pruning in one file, so one of the two is +// unwritable and which one an operator lost would depend on where in the file they wrote it. Registration +// order is not something an operator can see, so the refusal cannot depend on it either. +func TestARootKeyAndASectionCannotShareAName(t *testing.T) { + nested := func() (string, any, func(registry.Mode) any) { + return "pruning", &struct { + Mode string `mapstructure:"mode"` + }{}, func(registry.Mode) any { + return struct { + Mode string `mapstructure:"mode"` + }{Mode: "nothing"} + } + } + root := func() (string, any, func(registry.Mode) any) { + return "base", &struct { + Pruning string `mapstructure:"pruning"` + }{}, func(registry.Mode) any { + return struct { + Pruning string `mapstructure:"pruning"` + }{Pruning: "nothing"} + } + } + + t.Run("the section registers first", func(t *testing.T) { + registry.Reset() + registry.RegisterSection(nested()) + registry.RegisterRootKeys(root()) + if _, ok := registry.Lookup("base"); ok { + t.Error("the root section registered a key that is also a section name. A file cannot hold " + + "both, so one of them is unreachable and nothing says which") + } + if len(registry.Defects()) != 1 { + t.Errorf("recorded %d defects, want one naming the collision", len(registry.Defects())) + } + }) + + t.Run("the root key registers first", func(t *testing.T) { + registry.Reset() + registry.RegisterRootKeys(root()) + registry.RegisterSection(nested()) + if _, ok := registry.Lookup("pruning"); ok { + t.Error("a section registered under a name a root key already holds") + } + if len(registry.Defects()) != 1 { + t.Errorf("recorded %d defects, want one naming the collision", len(registry.Defects())) + } + }) +} + +// TestTwoSectionsCannotDeclareTheSameKey was impossible while every key carried its section's name. +// +// Two prefixes cannot collide. Two root sections can, and the default rendered for such a key would be +// whichever section the walk reached last. +func TestTwoSectionsCannotDeclareTheSameKey(t *testing.T) { + registry.Reset() + same := func(name string) { + registry.RegisterRootKeys(name, &struct { + Pruning string `mapstructure:"pruning"` + }{}, func(registry.Mode) any { + return struct { + Pruning string `mapstructure:"pruning"` + }{Pruning: "nothing"} + }) + } + same("base") + same("other") + + if _, ok := registry.Lookup("other"); ok { + t.Fatal("both sections declared the same key. One default renders over the other and which one " + + "wins depends on the order the sections are walked, so the value a node runs is not decided " + + "by anything an operator or a reviewer can see") + } + defects := registry.Defects() + if len(defects) != 1 { + t.Fatalf("recorded %d defects, want one", len(defects)) + } + // Named as one key two sections declare, rather than as two spellings of one variable, which is what + // the environment check would have called it. + if got := defects[0].Err.Error(); !strings.Contains(got, "and so does") { + t.Errorf("the refusal reads %q, and an identical key is not an environment spelling collision", got) + } +} + +// TestAKeyTheEnvironmentCannotDeliverIsLeftToTheOtherSources is what refusing a channel buys. +// +// The environment carries one string per name. A reader taking its value's exact type cannot be handed +// one, so resolving the variable installs a value that stops the node. Skipping it means the file's value +// applies and the node runs. +func TestAKeyTheEnvironmentCannotDeliverIsLeftToTheOtherSources(t *testing.T) { + registry.Reset() + registry.RegisterSection("probe", &struct { + Rows []any `mapstructure:"rows"` + Plain string `mapstructure:"plain"` + }{}, func(registry.Mode) any { + return struct { + Rows []any `mapstructure:"rows"` + Plain string `mapstructure:"plain"` + }{Rows: []any{}, Plain: "from the default"} + }) + registry.RefuseFromEnvironment("probe.rows", "its reader takes the exact type rather than casting") + for _, d := range registry.Defects() { + t.Fatalf("the registration was refused: %v", d.Err) + } + + // Both variables are set. Only the one the environment can carry is allowed to answer. + resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{ + LookupEnv: func(name string) (string, bool) { + switch name { + case "SEID_PROBE_ROWS": + return "chain_id=pacific-1", true + case "SEID_PROBE_PLAIN": + return "from the environment", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + if got := resolved.Values["probe.rows"]; !reflect.DeepEqual(got, []any{}) { + t.Errorf("probe.rows resolved to %#v (%T), want the default it was left to. Its reader takes a "+ + "list of rows, so installing the environment's string stops the node", got, got) + } + if got := resolved.Values["probe.plain"]; got != "from the environment" { + t.Errorf("probe.plain resolved to %#v; refusing one key's channel closed another's", got) + } + sort.Strings(resolved.Overrides) + if got := strings.Join(resolved.Overrides, ","); got != "probe.plain" { + t.Errorf("overrides are %q, want only probe.plain. A key nothing supplied is not one an operator "+ + "has taken responsibility for", got) + } +} + +// TestRefusingAChannelWithoutAReasonIsItselfRefused keeps the exemption from being unexplainable. +// +// A key left out of the environment layer is one whose variable does nothing, and an operator told that +// has to be told why. A refusal with no reason gives a diagnostic nothing to print. +func TestRefusingAChannelWithoutAReasonIsItselfRefused(t *testing.T) { + registry.Reset() + registry.RefuseFromEnvironment("probe.rows", "") + if len(registry.Defects()) != 1 { + t.Fatalf("recorded %d defects, want one naming the key with no reason", len(registry.Defects())) + } + if _, refused := registry.EnvCannotDeliver()["probe.rows"]; refused { + t.Error("the key was refused from the environment anyway. Its variable would then be ignored " + + "with nothing able to say why, which is worse than either resolving it or not") + } + + registry.Reset() + registry.RefuseFromEnvironment("probe.rows", "its reader takes the exact type") + if _, refused := registry.EnvCannotDeliver()["probe.rows"]; !refused { + t.Error("a refusal carrying a reason was not recorded") + } +} From f7dc6bb69d2a30e8f69dcbd42ec9ffedefcd5887 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 15:33:04 -0700 Subject: [PATCH 05/25] config: say why a field excluded from configuration declares no key The rule was right and the reason recorded for it was not, which matters because it is the reason the remaining sections will cite. It said a declared key would be written at override precedence and land on top of the value that code assigned. That cannot happen for either field it named: the app layer assigns the receipt store's retention after the reader has returned, so the assignment is last and wins. It also said such a node would silently keep nothing, and the field's own comment says the opposite, that keeping zero versions means keeping everything. An operator handed that sentence during an incident looks for missing receipts and finds a full disk. The reproducible reason is the one every other refusal here rests on. A key for such a field is one an operator can write that the assignment then discards, so it reaches no field. A field with no tag stays a defect for the same reason read from the other end, because it would declare a key derived from a field name and no operator writes that. The two look alike in a diff and mean opposite things, which is why the package's own contract now states the distinction rather than leaving it in a comment beside one branch. Two of the four sections held their declared keys against a written-out list of the same strings. That is a second statement of the key set, which is what a section exists to remove: a tag and the list move together and the reader keeps asking for the old spelling. Both now hold against the constants their reader passes to Get. The admin server's reader was spelling its two keys inline, so it has constants for them now, and its registration no longer recites the derived keys in prose that drifts the moment a tag moves. One assertion is gone because it could not fail, the exact key set having been compared three lines above it. Two declared values are also stated elsewhere in the binary, and each now says so where it is declared. The wasm query gas limit resolves to ten times what the template writes into a generated file, so a node provisioned by the binary runs the smaller number and a node whose file predates the section runs the declared one; whoever renders declared values into a file has to decide which survives, and that limit bounds the work one smart query can ask of a node serving queries to anyone. The receipt store's database directory resolves to an empty string, and the emptiness carries the meaning: the app layer fills it from the host, keeping the former path for a node that already holds the store there. A path written into a file is one host's answer and names an empty directory on another. One comment said the contract debug switch has no key any reader resolves. It is read from the node-wide trace flag, so its key belongs to the root of the file rather than to that section, which also means the section's three keys do not determine the configuration the module ends up with. A new test asks the whole set at once. Two refusals depend on what else has registered, neither is visible from inside either section, and the section that loses is dropped whole with every key it declared. Registering a section whose key collides with the receipt store's leaves all four section suites green and fails only this one. --- admin/config.go | 10 ++++- admin/register.go | 4 +- admin/register_test.go | 13 ++++-- cmd/seid/cmd/registry_sections_test.go | 51 ++++++++++++++++++++++++ config/registry/doc.go | 5 +++ config/registry/registry.go | 12 +++--- config/registry/spec_test.go | 15 ++++--- giga/executor/config/register_test.go | 7 ++++ sei-db/config/receipt_register.go | 17 ++++++-- sei-db/config/receipt_register_test.go | 38 +++++++++--------- sei-wasmd/x/wasm/config_register.go | 16 ++++++-- sei-wasmd/x/wasm/config_register_test.go | 28 +++++++++---- 12 files changed, 165 insertions(+), 51 deletions(-) create mode 100644 cmd/seid/cmd/registry_sections_test.go diff --git a/admin/config.go b/admin/config.go index d2d0be3c78..25c2545c3a 100644 --- a/admin/config.go +++ b/admin/config.go @@ -13,6 +13,12 @@ const ( DefaultAddress = "127.0.0.1:9095" ) +// The keys this package's reader resolves. +const ( + flagAdminEnabled = "admin_server.admin_enabled" + flagAdminAddress = "admin_server.admin_address" +) + // Config defines configuration for the admin gRPC server. type Config struct { // Enabled controls whether the admin gRPC server starts. @@ -29,10 +35,10 @@ var DefaultConfig = Config{ // ReadConfig reads admin config from app options (Viper-backed). func ReadConfig(opts servertypes.AppOptions) (Config, error) { cfg := DefaultConfig - if v := opts.Get("admin_server.admin_enabled"); v != nil { + if v := opts.Get(flagAdminEnabled); v != nil { cfg.Enabled = cast.ToBool(v) } - if v := opts.Get("admin_server.admin_address"); v != nil { + if v := opts.Get(flagAdminAddress); v != nil { if s := cast.ToString(v); s != "" { cfg.Address = s } diff --git a/admin/register.go b/admin/register.go index 3d4bfba2b6..08f138249f 100644 --- a/admin/register.go +++ b/admin/register.go @@ -9,8 +9,8 @@ const SectionName = "admin_server" // Registration puts this section in the configuration registry. // -// The keys derive from the mapstructure tags, so they are admin_server.admin_enabled and -// admin_server.admin_address, which are the strings this package's reader already resolves. +// The keys derive from the mapstructure tags, and the reader resolves the same strings through the +// constants beside it, so a rename moves one occurrence and the test holds the two together. func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } diff --git a/admin/register_test.go b/admin/register_test.go index 2085f6498b..4120c38cd4 100644 --- a/admin/register_test.go +++ b/admin/register_test.go @@ -2,6 +2,7 @@ package admin import ( "reflect" + "sort" "testing" "github.com/sei-protocol/sei-chain/config/registry" @@ -9,15 +10,21 @@ import ( // TestTheDeclaredKeysAreTheKeysThisReaderResolves holds the declaration against the reader. // -// This package names its keys only in its mapstructure tags, so the check is that the registry derives -// exactly the two the reader resolves and no third. +// The tags derive the keys and the constants below are what the reader passes to Get, so this compares +// two statements that are edited for different reasons rather than one written out twice. func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused: %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{"admin_server.admin_address", "admin_server.admin_enabled"} + want := []string{flagAdminAddress, flagAdminEnabled} + sort.Strings(want) if got := section.Keys; !reflect.DeepEqual(got, want) { t.Errorf("declared keys are %v, want %v", got, want) } diff --git a/cmd/seid/cmd/registry_sections_test.go b/cmd/seid/cmd/registry_sections_test.go new file mode 100644 index 0000000000..e0dc016ae4 --- /dev/null +++ b/cmd/seid/cmd/registry_sections_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" +) + +// TestEverySectionThisBinaryDeclaresIsUsable is the check no single section can make. +// +// A section registers during its own package's initialisation, and a registration the registry cannot use +// is recorded rather than panicked, so a section that failed to register is absent rather than loud. Two +// of the refusals depend on what else has registered: two sections declaring one key, and two keys that +// collapse onto one environment variable. Neither is visible from inside either section, and the section +// that loses is dropped whole, with every key it declared. +// +// This package links every section a node's configuration reaches, so asking here is asking about the set +// a node actually gets. Nothing is enumerated, so a section added later is covered without this file +// changing. +func TestEverySectionThisBinaryDeclaresIsUsable(t *testing.T) { + for _, defect := range registry.Defects() { + t.Errorf("%s was refused, so none of its keys is declared: %v", defect.Section, defect.Err) + } + if len(registry.Sections()) == 0 { + t.Fatal("no section registered, so the checks above hold for an empty set. This package links " + + "the packages that register, and one of those imports has gone") + } +} + +// TestEveryDeclaredKeyResolvesForEveryMode covers the half of a registration a section's own test cannot. +// +// Registering validates the struct a section declares against. Whether its defaults can state one value +// for every key it declared is checked when something resolves them, and until now nothing did outside the +// registry's own tests. A default that arrives short is refused rather than filled, so the failure is an +// error here instead of a key resolving to a zero nobody chose. +func TestEveryDeclaredKeyResolvesForEveryMode(t *testing.T) { + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Errorf("mode %q does not resolve: %v", mode, err) + continue + } + for _, section := range registry.Sections() { + for _, key := range section.Keys { + if _, ok := resolved.Values[key]; !ok { + t.Errorf("mode %q: %s declares %s and it did not resolve", mode, section.Name, key) + } + } + } + } +} diff --git a/config/registry/doc.go b/config/registry/doc.go index d1955c08f3..ebf63e3d99 100644 --- a/config/registry/doc.go +++ b/config/registry/doc.go @@ -71,6 +71,11 @@ // tag, an unexported field carrying a tag, two fields declaring one path, a struct that declares no // key, a struct that contains itself, and two keys that collapse onto one environment variable. // +// A field tagged "-" is the deliberate opposite and is not a defect. That tag excludes a field from +// configuration, so the field declares no key at all rather than one resolving to a default. The +// distinction matters because a missing tag and a "-" tag look alike in a diff: one is a key nothing +// names reaching a field, and the other is a field nothing configures. +// // A key segment is also refused if it is upper-case, or if it carries a dot or a space. That rule // holds for the section name and for a field's tag alike, since both become segments of the same // dotted key and answer to the same sources. diff --git a/config/registry/registry.go b/config/registry/registry.go index ea15452d0b..4ec7796758 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -315,12 +315,14 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool return "", true, false, nil } if name == "-" { - // The tag mapstructure honours for a field configuration does not reach. Something else in the - // program assigns it: the receipt store's KeepRecent comes from the global min-retain-blocks - // flag at the app layer, and its ExternalPruning from whatever constructs the collector. + // The tag that excludes a field from configuration. Something else in the program assigns the + // field, so no reader resolves a key for it. // - // So it declares no key rather than declaring one that resolves to a default. A declared key is - // written at override precedence, which would put the default over the value that code assigned. + // It declares no key. Declaring one would put a key in the space that reaches no field, which an + // operator can write and nothing answers, and that is what every other refusal here exists to + // prevent. A field with no tag stays a defect for the same reason read from the other end: it + // would declare a key derived from a field name, which is a key no operator writes. The two look + // alike in a diff and mean opposite things. return "", false, true, nil } if name == "" { diff --git a/config/registry/spec_test.go b/config/registry/spec_test.go index dd88b2693b..cddb627238 100644 --- a/config/registry/spec_test.go +++ b/config/registry/spec_test.go @@ -1352,16 +1352,15 @@ func TestARefusalInsideASquashedBaseIsReported(t *testing.T) { // TestAFieldExcludedFromConfigDeclaresNoKey covers the tag that means "not from configuration". // -// mapstructure reads "-" as skip this field, and a config struct uses it for a field something else in -// the program assigns: the receipt store's KeepRecent comes from the global min-retain-blocks flag at the -// app layer, and its ExternalPruning from whatever constructs the collector. +// mapstructure reads "-" as skip this field, and a configuration struct uses it for a field something else +// in the program assigns. // -// Such a field declares no key. Declaring one that resolved to the default would be worse than refusing -// the section: a declared key is written at override precedence, so the default would land on top of the -// value that code assigned, and a node with min-retain-blocks set would silently keep nothing. +// Such a field declares no key. Declaring one that resolved to a default would put a key in the space that +// reaches no field: an operator could write it and the assignment would discard whatever they wrote. // -// An untagged field stays a defect. The two look alike and mean opposite things: one is a field the author -// excluded, the other is a field configuration cannot reach because nothing names it. +// An untagged field stays a defect. The two look alike in a diff and mean opposite things: one is a field +// the author excluded from configuration, the other is a field configuration cannot reach because nothing +// names it. func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { type excluded struct { Kept string `mapstructure:"kept"` diff --git a/giga/executor/config/register_test.go b/giga/executor/config/register_test.go index d1e9e5203d..4a7aef2754 100644 --- a/giga/executor/config/register_test.go +++ b/giga/executor/config/register_test.go @@ -2,6 +2,7 @@ package config import ( "reflect" + "sort" "testing" "github.com/sei-protocol/sei-chain/config/registry" @@ -14,12 +15,18 @@ import ( // Checked against the constants rather than against a written-out list, so a rename of either moves both // or fails here. func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused: %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, FlagOCCEnabled} + sort.Strings(want) if got := section.Keys; !reflect.DeepEqual(got, want) { t.Errorf("declared keys are %v, want the keys the reader asks for, %v", got, want) } diff --git a/sei-db/config/receipt_register.go b/sei-db/config/receipt_register.go index 945ae73780..11fd6e25f2 100644 --- a/sei-db/config/receipt_register.go +++ b/sei-db/config/receipt_register.go @@ -10,12 +10,23 @@ const ReceiptStoreSectionName = "receipt-store" // Registration puts this section in the configuration registry. // // Two of the struct's fields carry the tag that excludes a field from configuration, so they declare no -// key: KeepRecent is derived from the global min-retain-blocks flag at the app layer, and ExternalPruning -// is set by whatever constructs the garbage collector. Declaring a key for either would put a default over -// the value that code assigns. +// key. KeepRecent is assigned from the global min-retain-blocks flag at the app layer, after this reader +// has returned, and ExternalPruning by whatever constructs the garbage collector. A key for either would +// be one an operator can write that the assignment then discards, which is a key reaching no field. +// +// The reader resolves one further key that this section does not declare: the retired spelling of the +// backend, which it answers by refusing to start. Declaring it would offer an operator a key whose only +// outcome is a stopped node. func init() { registry.RegisterSection(ReceiptStoreSectionName, &ReceiptStoreConfig{}, receiptStoreDefaults) } // receiptStoreDefaults is what this section resolves to for a node that has written nothing. +// +// The database directory resolves to an empty string, and the emptiness carries meaning rather than +// standing in for a path nobody chose. The app layer fills it only while it is empty, and what it fills +// it with depends on the host: a node that already holds the store at its former path keeps using that +// path, and any other node gets the current one. So a caller that renders this value into a file has to +// leave it empty. A path written there is one host's answer, and on a host whose store sits at the other +// path it names an empty directory. func receiptStoreDefaults(registry.Mode) any { return DefaultReceiptStoreConfig() } diff --git a/sei-db/config/receipt_register_test.go b/sei-db/config/receipt_register_test.go index 373ce6183b..7981fb0430 100644 --- a/sei-db/config/receipt_register_test.go +++ b/sei-db/config/receipt_register_test.go @@ -2,6 +2,7 @@ package config import ( "reflect" + "sort" "testing" "github.com/sei-protocol/sei-chain/config/registry" @@ -9,35 +10,36 @@ import ( // TestTheDeclaredKeysAreTheKeysThisReaderResolves holds the declaration against the reader. // -// Two of the struct's fields are excluded from configuration and must declare nothing. KeepRecent is -// derived from the global min-retain-blocks flag at the app layer and ExternalPruning is set by whatever -// constructs the collector, so a key for either would be written at override precedence over the value -// that code assigns. +// Six keys, which is every key the reader resolves and takes a value from. Two of the struct's fields are +// excluded from configuration and declare nothing, because the app layer assigns them after this reader +// has returned and a key for either is one an operator writes that the assignment discards. The reader +// resolves a seventh key, the retired spelling of the backend, only to refuse to start; a key whose one +// outcome is a stopped node is not one to offer. func TestTheDeclaredKeysAreTheKeysThisReaderResolves(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == ReceiptStoreSectionName { + t.Fatalf("%s was refused: %v", ReceiptStoreSectionName, defect.Err) + } + } section, ok := registry.Lookup(ReceiptStoreSectionName) if !ok { t.Fatalf("%s is not registered, so nothing resolves its keys", ReceiptStoreSectionName) } + // The constants this package's own reader passes to Get, rather than the same strings written again. + // A second list agrees with itself while the reader asks for something else. want := []string{ - "receipt-store.async-write-buffer", - "receipt-store.db-directory", - "receipt-store.enable-read-write-metrics", - "receipt-store.log-filter-parallelism", - "receipt-store.prune-interval-seconds", - "receipt-store.rs-backend", + flagRSAsyncWriteBuffer, + flagRSDBDirectory, + flagRSReadWriteMetrics, + flagRSLogFilterParallelism, + flagRSPruneIntervalSeconds, + flagRSBackend, } + sort.Strings(want) if got := section.Keys; !reflect.DeepEqual(got, want) { t.Errorf("declared keys are\n %v\nwant\n %v", got, want) } - for _, excluded := range []string{"receipt-store.keep-recent", "receipt-store.external-pruning"} { - for _, key := range section.Keys { - if key == excluded { - t.Errorf("%s is declared. Nothing sources it from configuration, so installing it would "+ - "put a default over the value the app layer assigns", excluded) - } - } - } } // TestTheDefaultsAreWhatTheNodeAlreadyRuns keeps the section from restating the values by hand. diff --git a/sei-wasmd/x/wasm/config_register.go b/sei-wasmd/x/wasm/config_register.go index 0ca9482380..515af57778 100644 --- a/sei-wasmd/x/wasm/config_register.go +++ b/sei-wasmd/x/wasm/config_register.go @@ -27,14 +27,24 @@ type wasmSchema struct { // Registration puts this section in the configuration registry. // -// Three keys, matching the three flag constants above. Two settings of types.WasmConfig are deliberately -// absent: ContractDebugMode has no key any reader resolves, and lru_size is written into app.toml by the -// template and read by nothing, so declaring either would put a key in the space that reaches no field. +// Three keys, matching the three flag constants the module declares. Two settings of types.WasmConfig are +// deliberately absent, for different reasons. The contract debug switch is read from the node-wide trace +// flag, so its key belongs to the root of the file rather than to this section and this section cannot +// declare it; a consequence worth knowing is that these three keys do not determine the whole +// configuration the module ends up with. The cache size written as lru_size is put into app.toml by the +// template and read by nothing, so declaring it would offer a key that reaches no field. func init() { registry.RegisterSection(SectionName, &wasmSchema{}, defaults) } // defaults is what this section resolves to for a node that has written nothing. +// +// The query gas limit is the one value here that the binary states twice. This is what a file with no +// wasm section resolves to, and the template writes a tenth of it into every file it generates, so a node +// provisioned by the binary runs the smaller number and a node whose file predates the section runs this +// one. Whoever renders declared values into a file has to decide which of the two survives, and the +// decision is not this section's to make: the limit bounds the work one smart query can ask of a node +// that serves queries to anyone. func defaults(registry.Mode) any { live := types.DefaultWasmConfig() schema := wasmSchema{ diff --git a/sei-wasmd/x/wasm/config_register_test.go b/sei-wasmd/x/wasm/config_register_test.go index b0263a01d7..4be6cc7299 100644 --- a/sei-wasmd/x/wasm/config_register_test.go +++ b/sei-wasmd/x/wasm/config_register_test.go @@ -2,6 +2,8 @@ package wasm import ( "reflect" + "sort" + "strconv" "testing" "github.com/sei-protocol/sei-chain/config/registry" @@ -15,22 +17,30 @@ import ( // safe while something holds it against the first. These are the flag constants the module registers and // reads. func TestTheDeclaredKeysAreTheFlagsThisModuleReads(t *testing.T) { + for _, defect := range registry.Defects() { + if defect.Section == SectionName { + t.Fatalf("%s was refused: %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{flagWasmMemoryCacheSize, flagWasmQueryGasLimit, flagWasmSimulationGasLimit} + sort.Strings(want) if got := section.Keys; !reflect.DeepEqual(got, want) { t.Errorf("declared keys are %v, want the flags this module reads, %v", got, want) } } -// TestTheDefaultsCarryTheLiveWasmConfig keeps the schema's values from drifting from the real ones. +// TestTheDefaultsAreTheModuleDeclaredOnes keeps the schema's values from drifting from the struct's. // // The schema restates three settings of types.WasmConfig, so nothing stops those values diverging from -// what DefaultWasmConfig returns except this. -func TestTheDefaultsCarryTheLiveWasmConfig(t *testing.T) { +// what DefaultWasmConfig returns except this. It compares against that struct and against nothing else: +// the query gas limit the template writes into a generated file is a tenth of the one here, and this test +// is not the place that reconciles them. +func TestTheDefaultsAreTheModuleDeclaredOnes(t *testing.T) { live := types.DefaultWasmConfig() got, ok := defaults(registry.ModeValidator).(wasmSchema) if !ok { @@ -44,9 +54,13 @@ func TestTheDefaultsCarryTheLiveWasmConfig(t *testing.T) { t.Errorf("query_gas_limit resolves to %d, want the live %d", got.QueryGasLimit, live.SmartQueryGasLimit) } // Absent is a meaning of its own here: unset means the consensus block gas limit applies, so an unset - // live value has to resolve to no text rather than to a zero. - if live.SimulationGasLimit == nil && got.SimulationGasLimit != "" { - t.Errorf("simulation_gas_limit resolves to %q where the live value is unset. A number here claims "+ - "a limit the node does not apply", got.SimulationGasLimit) + // live value resolves to no text rather than to a zero, and a set one resolves to its digits. + want := "" + if live.SimulationGasLimit != nil { + want = strconv.FormatUint(*live.SimulationGasLimit, 10) + } + if got.SimulationGasLimit != want { + t.Errorf("simulation_gas_limit resolves to %q, want %q. Unset means the consensus block gas limit "+ + "applies, and a number here claims a limit the node does not apply", got.SimulationGasLimit, want) } } From 84660827a5aeb8befba57d7c145824f146e516ed Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 15:58:21 -0700 Subject: [PATCH 06/25] config: answer the store section per mode, and measure the divergences Two of the state store's settings mean something different depending on what kind of node asks, and this section answered the same for all four. An archive node exists to keep history, and it was declaring a retention of a hundred thousand versions. The binary already says otherwise, in the mode rules it applies when it writes a file: an archive node keeps everything, and a validator and a seed run with the store off. Those rules are now where this section's answer comes from, so a change to them moves this too. That is the correction the pinned defect asks for. The record of it says to pin how configuration resolves today and correct it in the versioned manager, and this registry is the versioned manager, so declaring the rendered value would have pinned the defect a second time in the place meant to fix it. Nothing runs differently yet, because nothing consumes the registry on a boot path, and the direction matters more than the timing: state store pruning deletes inside the store on a timer, the archive volumes are protected against being deleted rather than against being emptied, and pruning frees disk so nothing that watches disk would fire. The rest of this change replaces prose with measurement. The declared values for the two storage sections are not what their readers produce for a file missing the keys, and the comment describing which keys those are was wrong three ways: it named four of the six store settings, missed two, named a commitment setting that does not in fact differ, and missed the one that selects how a node commits. The write mode is read through a presence check and then rewritten unconditionally, so a node with nothing written commits in the derived mode rather than the one that key carries. A comment cannot fail when it is wrong. So the set is measured against the readers now, per mode, and recorded as data: a key that starts diverging fails, and so does one that stops, which means guarding a read has to account for its row rather than quietly making a sentence stale. Each key set is also held against this package's own read-site record, which is kept for another purpose and held against a golden file, rather than against a list written beside it in the same commit. The record spells its keys with the reader's constants and the section derives them from tags, so a rename on either side alone fails. One comment said the other keys under the commitment section's flat key-value name have no reader. Four of them are read by the Cosmos server's own reader, so they belong to whoever registers that section, and saying they reach nothing would have closed the door on declaring them. The whole-registry defect sweep here is now scoped to the four sections this file registers. A refusal that depends on what else has registered is not this package's to answer for, and the sweep that covers it lives where every section is linked. --- app/config_register.go | 47 ++++--- app/config_register_agreement_test.go | 179 +++++++++++++++++++++++++ app/config_register_test.go | 182 +++++++++++++++++--------- 3 files changed, 321 insertions(+), 87 deletions(-) create mode 100644 app/config_register_agreement_test.go diff --git a/app/config_register.go b/app/config_register.go index 8d17c6a21d..855aa53a1e 100644 --- a/app/config_register.go +++ b/app/config_register.go @@ -1,7 +1,9 @@ package app import ( + "github.com/sei-protocol/sei-chain/app/params" "github.com/sei-protocol/sei-chain/config/registry" + srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" "github.com/sei-protocol/sei-chain/sei-db/config" ) @@ -16,9 +18,8 @@ const ( // Registration puts this package's configuration sections in the registry. // // The owning package registers its own sections, so the struct, the values and the keys come from one -// place and cannot drift apart. Three of the four declare a schema rather than the type their reader -// fills, and each says why on the schema itself; the keys still derive from mapstructure tags, so a -// section's spelling and its reader's own constants stay the same strings. +// place and cannot drift apart. The keys derive from mapstructure tags, so a section's spelling and its +// reader's own constants stay the same strings. func init() { registry.RegisterSection(LightInvarianceSectionName, &LightInvarianceConfig{}, lightInvarianceDefaults) registry.RegisterSection(GenesisSectionName, &genesisSchema{}, genesisDefaults) @@ -81,18 +82,18 @@ type stateStoreSchema struct { // stateStoreDefaults is what this section resolves to for a node that has written nothing. // -// The declared defaults, which is what seid init renders into app.toml, so a generated file reproduces a -// freshly initialised node. +// Answered per mode, because two of these settings mean something different depending on what kind of node +// asks. An archive node exists to keep history, so it keeps every version; a validator and a seed serve no +// queries, so the store is off for them. Both come from the mode rules the binary already states rather +// than being written again here, so a change to those rules moves this too. // -// That is not what parseSSConfigs produces for a file missing these keys. It starts from the declared -// defaults and then assigns eleven of its twelve fields straight from a lookup with no check that the key -// was present, so an absent key casts to a zero and clobbers the default beside it: the store reads as -// disabled, with no backend, keeping every version, and committing synchronously. Only ss-snapshot-enable -// is guarded, and its own comment at the read says why. So a node whose app.toml predates one of the other -// keys runs the clobbered value today and the declared default once something installs this section, and -// guarding the remaining reads is what makes those the same thing. -func stateStoreDefaults(registry.Mode) any { - live := config.DefaultStateStoreConfig() +// This is the one section here whose declared values are not what its reader produces for a file missing +// the keys, and the divergences are measured rather than described. A test names each one and what a node +// runs today, so a read that gains a presence check has to account for it. +func stateStoreDefaults(mode registry.Mode) any { + server := srvconfig.DefaultConfig() + params.SetAppConfigByMode(server, params.NodeMode(mode)) + live := server.StateStore return stateStoreSchema{ Enable: live.Enable, DBDirectory: live.DBDirectory, @@ -109,11 +110,11 @@ func stateStoreDefaults(registry.Mode) any { } } -// stateCommitFlatKVSchema declares the one flat key-value setting that has a key of its own. +// stateCommitFlatKVSchema declares the one flat key-value key this package's reader resolves. // -// A nested segment, because the key is state-commit.flatkv.enable-read-write-metrics. The rest of the -// flat key-value configuration has no keys: nothing reads them from configuration, so declaring them -// would give an operator settings a written value could not change. +// A nested segment, because the key is state-commit.flatkv.enable-read-write-metrics. Four further keys +// under that name are read by the Cosmos server's own configuration reader and not by this one, so they +// belong to whoever registers that reader's section rather than to this one. type stateCommitFlatKVSchema struct { EnableReadWriteMetrics bool `mapstructure:"enable-read-write-metrics"` } @@ -153,14 +154,12 @@ type stateCommitSchema struct { // stateCommitDefaults is what this section resolves to for a node that has written nothing. // -// The declared defaults, which is what seid init renders into app.toml. Eighteen of parseSCConfigs' twenty -// reads already check that the key was present, so for those the declared default is also what an absent -// key resolves to today. The two that do not are sc-enable and sc-directory, and sc-enable is the one that -// matters: an absent key reads as false, and SetupSeiDB stops a node with state commitment off, so no -// running node has that key missing. Resolving it to true is what every working node already has written. +// The declared defaults. Two of them are not what this section's reader produces for a file missing the +// key, and a test names which two and what a node runs instead. // // The same values for every mode. How often a node snapshots and how much proof history it serves are -// decisions about disk and load that an operator writes down. +// decisions about disk and load that an operator writes down, and nothing in the binary makes either +// follow from what kind of node is asking. func stateCommitDefaults(registry.Mode) any { live := config.DefaultStateCommitConfig() return stateCommitSchema{ diff --git a/app/config_register_agreement_test.go b/app/config_register_agreement_test.go new file mode 100644 index 0000000000..ca84b68182 --- /dev/null +++ b/app/config_register_agreement_test.go @@ -0,0 +1,179 @@ +package app + +import ( + "fmt" + "sort" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/testutil/configtest" +) + +// whatANodeRunsToday is what each diverging key resolves to for a file carrying no keys at all. +// +// Every entry is a read that takes no account of whether the key was present, or a value another key +// transforms afterwards. Held separately from the modes because the reader takes no mode: it produces one +// answer, and which modes disagree with it depends on what the section declares. +var whatANodeRunsToday = map[string]string{ + FlagSSEnable: "false", + FlagSSBackend: "", + FlagSSAsyncWriterBuffer: "0", + FlagSSKeepRecent: "0", + FlagSSPruneInterval: "0", + FlagSSImportNumWorkers: "0", + FlagSCEnable: "false", + FlagSCWriteMode: "auto", +} + +// whyItMatters says what a node gets today, for the keys where that is worth stating. +var whyItMatters = map[string]string{ + FlagSSPruneInterval: "pruning is off, in the store and in the write-ahead log, so installing the " + + "declared value starts deleting what the node was retaining", + FlagSSKeepRecent: "every version is kept, so for an archive node what is declared and what runs " + + "agree about keeping history and for the others they do not", + FlagSCEnable: "state commitment reads as disabled, and a node started that way stops, which is why " + + "no running node has this key missing", + FlagSCWriteMode: "another key transforms this one after it is read, so the mode a node commits " + + "through is derived rather than carried by this key", +} + +// theDivergences is which keys disagree with the reader, per mode. +// +// Per mode because the section answers per mode for two of these settings and the reader does not answer +// per mode at all. An archive node declares the retention the reader also produces, so that key agrees for +// archive and disagrees everywhere else; the store toggle is the reverse. +var theDivergences = map[registry.Mode][]string{ + registry.ModeValidator: {FlagSSBackend, FlagSSAsyncWriterBuffer, FlagSSKeepRecent, + FlagSSPruneInterval, FlagSSImportNumWorkers, FlagSCEnable, FlagSCWriteMode}, + registry.ModeSeed: {FlagSSBackend, FlagSSAsyncWriterBuffer, FlagSSKeepRecent, + FlagSSPruneInterval, FlagSSImportNumWorkers, FlagSCEnable, FlagSCWriteMode}, + registry.ModeFull: {FlagSSEnable, FlagSSBackend, FlagSSAsyncWriterBuffer, FlagSSKeepRecent, + FlagSSPruneInterval, FlagSSImportNumWorkers, FlagSCEnable, FlagSCWriteMode}, + registry.ModeArchive: {FlagSSEnable, FlagSSBackend, FlagSSAsyncWriterBuffer, + FlagSSPruneInterval, FlagSSImportNumWorkers, FlagSCEnable, FlagSCWriteMode}, +} + +// readerValues is what each section's reader produces for a file carrying no keys at all. +// +// Written as a map from key to the field that key fills, because that pairing is what the comparison +// needs and neither the reader nor the section states it: the reader takes a key and assigns a field, and +// the section declares a key and a value. +func readerValues(t *testing.T) map[string]string { + t.Helper() + ss := parseSSConfigs(configtest.AppOpts{}) + sc := parseSCConfigs(configtest.AppOpts{}) + return map[string]string{ + FlagSSEnable: fmt.Sprint(ss.Enable), + FlagSSDirectory: fmt.Sprint(ss.DBDirectory), + FlagSSBackend: fmt.Sprint(ss.Backend), + FlagSSAsyncWriterBuffer: fmt.Sprint(ss.AsyncWriteBuffer), + FlagSSKeepRecent: fmt.Sprint(ss.KeepRecent), + FlagSSPruneInterval: fmt.Sprint(ss.PruneIntervalSeconds), + FlagSSImportNumWorkers: fmt.Sprint(ss.ImportNumWorkers), + FlagSSReadWriteMetrics: fmt.Sprint(ss.EnableReadWriteMetrics), + FlagSSSnapshotEnable: fmt.Sprint(ss.SnapshotEnable), + FlagEVMSSDirectory: fmt.Sprint(ss.EVMDBDirectory), + FlagEVMSSSeparateDBs: fmt.Sprint(ss.SeparateEVMSubDBs), + FlagEVMSSSplit: fmt.Sprint(ss.EVMSplit), + FlagSCEnable: fmt.Sprint(sc.Enable), + FlagSCDirectory: fmt.Sprint(sc.Directory), + FlagSCAsyncCommitBuffer: fmt.Sprint(sc.MemIAVLConfig.AsyncCommitBuffer), + FlagSCSnapshotKeepRecent: fmt.Sprint(sc.MemIAVLConfig.SnapshotKeepRecent), + FlagSCSnapshotInterval: fmt.Sprint(sc.MemIAVLConfig.SnapshotInterval), + FlagSCSnapshotMinTimeInterval: fmt.Sprint(sc.MemIAVLConfig.SnapshotMinTimeInterval), + FlagSCSnapshotWriterLimit: fmt.Sprint(sc.MemIAVLConfig.SnapshotWriterLimit), + FlagSCSnapshotPrefetchThreshold: fmt.Sprint(sc.MemIAVLConfig.SnapshotPrefetchThreshold), + FlagSCSnapshotWriteRateMBps: fmt.Sprint(sc.MemIAVLConfig.SnapshotWriteRateMBps), + FlagSCHistoricalProofMaxInFlight: fmt.Sprint(sc.HistoricalProofMaxInFlight), + FlagSCHistoricalProofRateLimit: fmt.Sprint(sc.HistoricalProofRateLimit), + FlagSCHistoricalProofBurst: fmt.Sprint(sc.HistoricalProofBurst), + FlagSCWriteMode: fmt.Sprint(sc.WriteMode), + FlagSCWriteModeEnableAuto: fmt.Sprint(sc.WriteModeEnableAuto), + FlagSCHashLoggerEnable: fmt.Sprint(sc.HashLogger.Enable), + FlagSCHashLoggerDirectory: fmt.Sprint(sc.HashLogger.Directory), + FlagSCHashLoggerBlocksToRetain: fmt.Sprint(sc.HashLogger.BlocksToRetain), + FlagSCHashLoggerTargetFileSize: fmt.Sprint(sc.HashLogger.TargetFileSize), + FlagSCHashLoggerMaxDiskSize: fmt.Sprint(sc.HashLogger.MaxDiskSize), + FlagSCFlatKVReadWriteMetrics: fmt.Sprint(sc.FlatKVConfig.EnableReadWriteMetrics), + } +} + +// TestTheDivergencesFromTheReaderAreTheRecordedOnes measures what the doc comments describe. +// +// The two storage sections declare defaults their readers do not produce for a file missing the keys, +// because most of those reads take no account of whether the key was present. Prose describing which keys +// those are cannot fail when it is wrong, and it was: it named four of the six store settings and one +// commitment setting that does not in fact differ, and missed the setting that selects how a node commits. +// +// So the set is measured here rather than described. A key that starts diverging fails this test, and so +// does one that stops: guarding a read means deleting its row, which is what makes the reconciliation +// something a change has to account for rather than something a comment claims. +func TestTheDivergencesFromTheReaderAreTheRecordedOnes(t *testing.T) { + reader := readerValues(t) + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + recorded, named := theDivergences[mode] + if !named { + t.Fatalf("mode %q has no record here, so a mode was added and this was not revisited", mode) + } + listed := make(map[string]bool, len(recorded)) + for _, key := range recorded { + listed[key] = true + } + + var measured []string + for key, got := range reader { + declared, declares := resolved.Values[key] + if !declares { + t.Errorf("mode %q: %s is read by this package and no section declares it", mode, key) + continue + } + if fmt.Sprint(declared) == got { + if listed[key] { + t.Errorf("mode %q: %s no longer diverges, both sides being %v. Take it off that "+ + "mode's list, so the list stays the set of keys installing this section changes", + mode, key, declared) + } + continue + } + measured = append(measured, key) + if !listed[key] { + t.Errorf("mode %q: %s declares %v and its reader produces %q for a file with no keys, and "+ + "nothing records that. Installing this section changes what such a node runs. %s", + mode, key, declared, got, whyItMatters[key]) + } + if want, stated := whatANodeRunsToday[key]; stated && want != got { + t.Errorf("mode %q: %s is recorded as producing %q and produces %q", mode, key, want, got) + } + } + + sort.Strings(measured) + if len(measured) != len(recorded) { + t.Errorf("mode %q: measured %d divergences and %d are recorded: %v", + mode, len(measured), len(recorded), measured) + } + } +} + +// TestEveryKeyThisPackageDeclaresIsOneItsReadersFill holds the two lists against each other. +// +// The declared keys come from the schemas and the read keys from the map above, so a key on one side only +// is either a setting an operator writes that no reader fills, or one this package reads and nothing +// declares. +func TestEveryKeyThisPackageDeclaresIsOneItsReadersFill(t *testing.T) { + reader := readerValues(t) + for _, section := range []string{StateStoreSectionName, StateCommitSectionName} { + registered, ok := registry.Lookup(section) + if !ok { + t.Fatalf("%s is not registered", section) + } + for _, key := range registered.Keys { + if _, filled := reader[key]; !filled { + t.Errorf("%s declares %s and no field above is paired with it", section, key) + } + } + } +} diff --git a/app/config_register_test.go b/app/config_register_test.go index bcac7b8940..5aa6a24fe0 100644 --- a/app/config_register_test.go +++ b/app/config_register_test.go @@ -2,95 +2,135 @@ package app import ( "reflect" + "sort" "testing" "github.com/sei-protocol/sei-chain/config/registry" "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/testutil/configtest" ) -// requireSectionResolves holds one section's resolved keys and values against what its reader asks for. +// manifestKeys returns the keys a section's read-site record names, plus any named here. // -// Resolving is what to compare against rather than the registered struct, because the resolved map is -// what a caller reads: it carries the key a tag produced and the value that tag's field held. A -// comparison of struct to struct agrees with itself while two tags are on the wrong fields, since each -// field still holds the value the test names for it. This one does not, because the swap moves the value -// to the other key. -// -// The values come from the reader's own constants and its own defaults, so a renamed key or a changed -// default fails here rather than being restated correctly in two places and wrongly in a third. -func requireSectionResolves(t *testing.T, mode registry.Mode, section string, want map[string]any) { +// The record is this package's own statement of which keys each reader looks up, kept for another purpose +// and held against a golden file. Taking the key set from it means a section's declaration is compared +// against something maintained under a different discipline, rather than against a list written beside it +// by the same hand in the same commit. +func manifestKeys(specs []configtest.KeySpec, also ...string) []string { + out := make([]string, 0, len(specs)+len(also)) + for _, spec := range specs { + out = append(out, spec.Key) + } + out = append(out, also...) + sort.Strings(out) + return out +} + +// requireDeclares holds a section's declared keys against the record of what its reader looks up. +func requireDeclares(t *testing.T, section string, want []string) { t.Helper() + for _, defect := range registry.Defects() { + if defect.Section == section { + t.Fatalf("%s was refused, so none of its keys is declared: %v", section, defect.Err) + } + } registered, ok := registry.Lookup(section) if !ok { t.Fatalf("%s is not registered, so nothing resolves its keys", section) } + if !reflect.DeepEqual(registered.Keys, want) { + t.Errorf("%s declares\n %v\nand its read-site record names\n %v\nA key on one side only is either "+ + "a setting an operator writes that no reader fills, or one this package reads and nothing "+ + "declares", section, registered.Keys, want) + } +} + +// requireResolves holds a section's resolved values against what its reader's own defaults hold. +// +// Resolving is what to compare against rather than the registered struct, because the resolved map carries +// the key a tag produced and the value that tag's field held. A comparison of struct to struct agrees with +// itself while two tags sit on the wrong fields, since each field still holds the value the test names for +// it. The swap moves the value to the other key, and this notices. +func requireResolves(t *testing.T, mode registry.Mode, section string, want map[string]any) { + t.Helper() resolved, err := registry.Resolve(mode, registry.Sources{}) if err != nil { t.Fatalf("mode %q: %v", mode, err) } - - declared := make(map[string]bool, len(registered.Keys)) - for _, key := range registered.Keys { - declared[key] = true - expected, named := want[key] - if !named { - t.Errorf("mode %q: %s declares %s and nothing here names a value for it, so either its reader "+ - "resolves the key and this list is short, or no reader does and an operator has a setting "+ - "that changes nothing", mode, section, key) - continue - } + for key, expected := range want { if got := resolved.Values[key]; !reflect.DeepEqual(got, expected) { - t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", mode, key, got, got, expected, expected) - } - } - for key := range want { - if !declared[key] { - t.Errorf("mode %q: %s does not declare %s, which its reader resolves, so that setting stays "+ - "answered by whatever answers it today and nothing reports it", mode, section, key) + t.Errorf("mode %q: %s resolves to %#v (%T), want %#v (%T)", + mode, key, got, got, expected, expected) } } } -// TestLightInvarianceResolves covers the one section registered as the type its reader fills. -// -// Every mode, because this section's defaults are the same for all of them: the check compares the bank -// module's recorded total supply against what the store holds, which is a property of every node. -func TestLightInvarianceResolves(t *testing.T) { +// TestLightInvarianceDeclaresAndResolves covers the one section registered as the type its reader fills. +func TestLightInvarianceDeclaresAndResolves(t *testing.T) { + requireDeclares(t, LightInvarianceSectionName, manifestKeys(lightInvarianceKeys)) for _, mode := range registry.Modes() { - requireSectionResolves(t, mode, LightInvarianceSectionName, map[string]any{ + requireResolves(t, mode, LightInvarianceSectionName, map[string]any{ flagSupplyEnabled: DefaultLightInvarianceConfig.SupplyEnabled, }) } } -// TestGenesisResolves holds the genesis schema against the reader's own constants. +// TestGenesisDeclaresAndResolves holds the genesis schema against the record and the reader's defaults. // -// The two keys carry different types, which is what makes the pairing worth asserting: the schema states -// the tags in one place and the reader looks them up in another, and nothing but this holds the two -// together. -func TestGenesisResolves(t *testing.T) { +// The record names one of the two keys as a row and the other beside it, because that one is read as a type +// assertion rather than a guarded cast and a row would predict the wrong resolution. Both are this +// package's, so both are declared. +func TestGenesisDeclaresAndResolves(t *testing.T) { + requireDeclares(t, GenesisSectionName, manifestKeys(genesisKeys, flagGenesisImportFile)) for _, mode := range registry.Modes() { - requireSectionResolves(t, mode, GenesisSectionName, map[string]any{ + requireResolves(t, mode, GenesisSectionName, map[string]any{ flagGenesisStreamImport: DefaultGenesisConfig.StreamGenesisImport, flagGenesisImportFile: DefaultGenesisConfig.GenesisStreamFile, }) } } -// TestStateStoreResolves holds the state store schema against every key parseSSConfigs resolves. +// TestStateStoreDeclaresEveryKeyItsReaderResolves holds the schema against the read-site record. +func TestStateStoreDeclaresEveryKeyItsReaderResolves(t *testing.T) { + requireDeclares(t, StateStoreSectionName, manifestKeys(ssKeys)) +} + +// TestStateStoreResolvesWhatEachKindOfNodeNeeds is the mode-varying half of this section. // -// Twelve keys, including ss-snapshot-enable, which is the one read that checks whether the key was -// present. A schema short of a key its reader resolves leaves that setting undeclared, so it keeps -// whatever answers it today and no diagnostic names it. -func TestStateStoreResolves(t *testing.T) { +// Two of these settings mean something different depending on what kind of node asks, and the values are +// written out here rather than taken from the same rules the section reads. An archive node exists to keep +// history, so a retention that pruned it would be the one declaration here that destroys data, and it +// would do so with nothing to alert on, because pruning frees disk rather than filling it. +func TestStateStoreResolvesWhatEachKindOfNodeNeeds(t *testing.T) { + byMode := map[registry.Mode]struct { + enable bool + keepRecent int + }{ + registry.ModeValidator: {enable: false, keepRecent: 100000}, + registry.ModeSeed: {enable: false, keepRecent: 100000}, + registry.ModeFull: {enable: true, keepRecent: 100000}, + registry.ModeArchive: {enable: true, keepRecent: 0}, + } + for _, mode := range registry.Modes() { + want, named := byMode[mode] + if !named { + t.Fatalf("mode %q has no expectation here, so a mode was added and this was not revisited", mode) + } + requireResolves(t, mode, StateStoreSectionName, map[string]any{ + FlagSSEnable: want.enable, + FlagSSKeepRecent: want.keepRecent, + }) + } +} + +// TestStateStoreResolvesItsOtherValuesTheSameForEveryMode covers the ten settings a mode does not change. +func TestStateStoreResolvesItsOtherValuesTheSameForEveryMode(t *testing.T) { live := config.DefaultStateStoreConfig() for _, mode := range registry.Modes() { - requireSectionResolves(t, mode, StateStoreSectionName, map[string]any{ - FlagSSEnable: live.Enable, + requireResolves(t, mode, StateStoreSectionName, map[string]any{ FlagSSDirectory: live.DBDirectory, FlagSSBackend: live.Backend, FlagSSAsyncWriterBuffer: live.AsyncWriteBuffer, - FlagSSKeepRecent: live.KeepRecent, FlagSSPruneInterval: live.PruneIntervalSeconds, FlagSSImportNumWorkers: live.ImportNumWorkers, FlagSSReadWriteMetrics: live.EnableReadWriteMetrics, @@ -102,16 +142,25 @@ func TestStateStoreResolves(t *testing.T) { } } -// TestStateCommitResolves holds the state commit schema against every key parseSCConfigs resolves. +// TestStateCommitDeclaresEveryKeyItsReaderResolves holds the schema against the read-site record. +// +// Twenty keys: the seventeen the record holds as rows, and three it names beside them because each has a +// target of its own. The four keys under this section's flat key-value name that only the Cosmos server's +// reader resolves are not among them, and are not this section's to declare. +func TestStateCommitDeclaresEveryKeyItsReaderResolves(t *testing.T) { + requireDeclares(t, StateCommitSectionName, manifestKeys(scKeys, + FlagSCWriteMode, FlagSCWriteModeEnableAuto, FlagSCHashLoggerTargetFileSize)) +} + +// TestStateCommitResolvesTheModuleDeclaredValues covers the value side of the same registration. // -// Twenty keys, one of them a segment below the section, since the flat key-value read is -// state-commit.flatkv.enable-read-write-metrics. The write mode is a plain string here because the -// reader parses a written name into its own type, and comparing values is what holds it to that: the -// named type carries the same text and is not the same value. -func TestStateCommitResolves(t *testing.T) { +// The write mode is a plain string here because the reader parses a written name into its own type, and +// comparing values is what holds it to that: the named type carries the same text and is not the same +// value. +func TestStateCommitResolvesTheModuleDeclaredValues(t *testing.T) { live := config.DefaultStateCommitConfig() for _, mode := range registry.Modes() { - requireSectionResolves(t, mode, StateCommitSectionName, map[string]any{ + requireResolves(t, mode, StateCommitSectionName, map[string]any{ FlagSCEnable: live.Enable, FlagSCDirectory: live.Directory, FlagSCAsyncCommitBuffer: live.MemIAVLConfig.AsyncCommitBuffer, @@ -138,9 +187,8 @@ func TestStateCommitResolves(t *testing.T) { // TestStateCommitWriteModeDefaultIsOneTheReaderAccepts covers the one declared value that is parsed text. // -// Every other declared default is a value its reader uses as it stands. This one is a name the reader -// turns into a mode, so a default nothing parses would put a value in a generated file that stops the -// node it was generated for. +// Every other declared value is used as it stands. This one is a name the reader turns into a mode, so a +// default nothing parses would put a value in a generated file that stops the node it was generated for. func TestStateCommitWriteModeDefaultIsOneTheReaderAccepts(t *testing.T) { resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) if err != nil { @@ -155,12 +203,20 @@ func TestStateCommitWriteModeDefaultIsOneTheReaderAccepts(t *testing.T) { } } -// TestEverySectionThisPackageRegistersIsWellFormed covers what the registry itself refuses. +// TestTheSectionsThisPackageRegistersAreUsable covers what the registry refuses. // -// A section with a tag the registry cannot read is reported rather than returned, so a defect here is a -// section that registered and declares nothing a caller can resolve. -func TestEverySectionThisPackageRegistersIsWellFormed(t *testing.T) { +// Scoped to the four names this file registers. The whole-registry sweep belongs where every section is +// linked, because a refusal that depends on what else registered is not this package's to answer for. +func TestTheSectionsThisPackageRegistersAreUsable(t *testing.T) { + mine := map[string]bool{ + LightInvarianceSectionName: true, + GenesisSectionName: true, + StateStoreSectionName: true, + StateCommitSectionName: true, + } for _, defect := range registry.Defects() { - t.Errorf("%s is registered and defective: %v", defect.Section, defect.Err) + if mine[defect.Section] { + t.Errorf("%s was refused, so none of its keys is declared: %v", defect.Section, defect.Err) + } } } From 0e1d87072ac92404824d64af6c1c5e052ce59ede Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 21 Aug 2026 07:25:11 -0700 Subject: [PATCH 07/25] config: state each comment's own subject Four comments said more than their subject. The light-invariance default now states the mode decision and leaves what the check compares to the check. The genesis schema says why it exists and leaves what holds it to the test that holds it. The helper that resolves says once that it renders every section, so a failure naming another one is read correctly. And the write-mode default is asked for every mode rather than one, which is how the tests beside it ask. --- app/config_register.go | 9 +++------ app/config_register_test.go | 28 ++++++++++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/config_register.go b/app/config_register.go index 855aa53a1e..aca7c71889 100644 --- a/app/config_register.go +++ b/app/config_register.go @@ -29,18 +29,15 @@ func init() { // lightInvarianceDefaults is what this section resolves to for a node that has written nothing. // -// The same value for every mode, and on. The check compares the bank module's recorded total supply -// against what the store holds, which is a correctness property of every node rather than of one kind, -// so a mode-varying default would stop some nodes noticing that they had diverged. +// The same value for every mode, and on. What the check compares is a property of every node rather than +// of one kind, so a mode that resolved it off would stop those nodes noticing they had diverged. func lightInvarianceDefaults(registry.Mode) any { return DefaultLightInvarianceConfig } // genesisSchema declares the keys the genesis import reader resolves. // // A schema and not a transport: nothing decodes into it. The type the reader fills is // genesistypes.GenesisImportConfig, which carries no mapstructure tags at all, so no key can be derived -// from it. Declaring the spelling here is what lets the registry name the keys the reader looks up, and -// the test holds these tags against the reader's own constants because nothing keeps them together by -// construction. +// from it. Declaring the spelling here is what lets the registry name the keys the reader looks up. type genesisSchema struct { StreamImport bool `mapstructure:"stream-import"` ImportFile string `mapstructure:"import-file"` diff --git a/app/config_register_test.go b/app/config_register_test.go index 5aa6a24fe0..35547461ea 100644 --- a/app/config_register_test.go +++ b/app/config_register_test.go @@ -47,6 +47,10 @@ func requireDeclares(t *testing.T, section string, want []string) { // requireResolves holds a section's resolved values against what its reader's own defaults hold. // +// Resolving renders every registered section, so a section elsewhere whose defaults cannot state a value +// for a key it declares fails here too. The registry names that section in the error, so the message +// points at the real one rather than at whichever test asked. +// // Resolving is what to compare against rather than the registered struct, because the resolved map carries // the key a tag produced and the value that tag's field held. A comparison of struct to struct agrees with // itself while two tags sit on the wrong fields, since each field still holds the value the test names for @@ -190,16 +194,20 @@ func TestStateCommitResolvesTheModuleDeclaredValues(t *testing.T) { // Every other declared value is used as it stands. This one is a name the reader turns into a mode, so a // default nothing parses would put a value in a generated file that stops the node it was generated for. func TestStateCommitWriteModeDefaultIsOneTheReaderAccepts(t *testing.T) { - resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) - if err != nil { - t.Fatalf("%v", err) - } - declared, ok := resolved.Values[FlagSCWriteMode].(string) - if !ok { - t.Fatalf("%s resolves to %T, and the reader parses text", FlagSCWriteMode, resolved.Values[FlagSCWriteMode]) - } - if _, err := config.ParseSCWriteMode(declared); err != nil { - t.Errorf("%s resolves to %q, which this binary's own reader refuses: %v", FlagSCWriteMode, declared, err) + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + declared, ok := resolved.Values[FlagSCWriteMode].(string) + if !ok { + t.Fatalf("mode %q: %s resolves to %T, and the reader parses text", + mode, FlagSCWriteMode, resolved.Values[FlagSCWriteMode]) + } + if _, err := config.ParseSCWriteMode(declared); err != nil { + t.Errorf("mode %q: %s resolves to %q, which this binary's own reader refuses: %v", + mode, FlagSCWriteMode, declared, err) + } } } From 12fe6c06c7c01ab0c3565a6f5b0baaf36b6dd2a6 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 21 Aug 2026 07:27:45 -0700 Subject: [PATCH 08/25] config: name the untagged field, and say each thing once The failure messages for an excluded field still claimed the mechanism the comment above them no longer does: that a declared key would land over whatever assigns the field. They now say what is true, that such a key is one an operator can write and the assignment discards. The half of that test covering an untagged field asserted only that some refusal was recorded, so it passed on a refusal raised for any other reason. It now requires the message to name the field, and changing the name it looks for fails it. The registry's contract adds the one thing its new rule leaves unsaid: the exclusion tag is meaningful only on an exported field, because an unexported one carrying any tag is refused before the tag is read. The giga executor's default says why this section does not vary by mode without stating it as a rule for every section, since another section in this work does vary and the binary is what decides which. The fixture in the registry's own spec no longer describes that package as one that would register, because it now does. The wasm section's defaults function takes a name that does not collide with a local of the same name elsewhere in that package, and its test asks every mode rather than one. --- config/registry/doc.go | 3 ++- config/registry/spec_test.go | 21 ++++++++++++++------- giga/executor/config/register.go | 6 +++--- sei-wasmd/x/wasm/config_register.go | 6 +++--- sei-wasmd/x/wasm/config_register_test.go | 10 ++++++++-- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/config/registry/doc.go b/config/registry/doc.go index ebf63e3d99..f49e13b6ea 100644 --- a/config/registry/doc.go +++ b/config/registry/doc.go @@ -74,7 +74,8 @@ // A field tagged "-" is the deliberate opposite and is not a defect. That tag excludes a field from // configuration, so the field declares no key at all rather than one resolving to a default. The // distinction matters because a missing tag and a "-" tag look alike in a diff: one is a key nothing -// names reaching a field, and the other is a field nothing configures. +// names reaching a field, and the other is a field nothing configures. It is meaningful only on an +// exported field, since an unexported one carrying any tag is refused before the tag is read. // // A key segment is also refused if it is upper-case, or if it carries a dot or a space. That rule // holds for the section name and for a field's tag alike, since both become segments of the same diff --git a/config/registry/spec_test.go b/config/registry/spec_test.go index cddb627238..9091b8a965 100644 --- a/config/registry/spec_test.go +++ b/config/registry/spec_test.go @@ -29,8 +29,9 @@ import ( // authoring check, and it reads its order from Source's declaration rather than from its caller's // argument order, so no caller can reorder its way to a different answer. -// gigaSection mirrors what the giga executor's own package would register. The struct under test -// is the real one, so the key comparison below measures the live reader rather than a copy of it. +// gigaSection re-registers the giga executor's section with a default that varies by mode, so the +// mode property below has something to measure. The struct is the real one, so the key comparison +// measures the live reader rather than a copy of it. const gigaSection = "giga_executor" func registerGiga(t *testing.T) { @@ -1376,8 +1377,8 @@ func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { } if got, want := registry.Keys(), []string{"probe.kept"}; !reflect.DeepEqual(got, want) { - t.Fatalf("declared keys are %v, want %v. An excluded field declaring a key would have that key "+ - "written at override precedence over whatever assigned the field", got, want) + t.Fatalf("declared keys are %v, want %v. A key for an excluded field is one an operator can write "+ + "that whatever assigns the field then discards", got, want) } resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) @@ -1385,7 +1386,8 @@ func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { t.Fatalf("resolving a section with an excluded field: %v", err) } if _, present := resolved.Values["probe.assigned"]; present { - t.Error("the excluded field resolved to a value, so installing it would overwrite what assigns it") + t.Error("the excluded field resolved to a value, so an operator could write a key that reaches no " + + "field") } // An untagged field means the opposite and stays a defect. @@ -1395,7 +1397,12 @@ func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { } registry.Reset() registry.RegisterSection("probe", &untagged{}, func(registry.Mode) any { return &untagged{} }) - if len(registry.Defects()) == 0 { - t.Error("a field with no tag at all registered cleanly, so a key nothing names reaches no field") + defects := registry.Defects() + if len(defects) == 0 { + t.Fatal("a field with no tag at all registered cleanly, so a key nothing names reaches no field") + } + // Named, so this cannot pass on a refusal raised for some other reason. + if got := defects[0].Err.Error(); !strings.Contains(got, "Forgotten") { + t.Errorf("the refusal reads %q and does not name the untagged field", got) } } diff --git a/giga/executor/config/register.go b/giga/executor/config/register.go index c22802ed11..271a65805d 100644 --- a/giga/executor/config/register.go +++ b/giga/executor/config/register.go @@ -21,7 +21,7 @@ func init() { // defaults is what this section resolves to for a node that has written nothing. // -// The same values for every mode, because that is what a node runs today. A mode-varying default would -// change what an archive node does, which is a decision about how the executor should behave rather than -// a consequence of describing it here. +// The same values for every mode. Nothing in the binary makes either setting follow from what kind of +// node is asking, so a default that varied here would be this section inventing a rule rather than +// stating one. func defaults(registry.Mode) any { return DefaultConfig } diff --git a/sei-wasmd/x/wasm/config_register.go b/sei-wasmd/x/wasm/config_register.go index 515af57778..45be17d511 100644 --- a/sei-wasmd/x/wasm/config_register.go +++ b/sei-wasmd/x/wasm/config_register.go @@ -34,10 +34,10 @@ type wasmSchema struct { // configuration the module ends up with. The cache size written as lru_size is put into app.toml by the // template and read by nothing, so declaring it would offer a key that reaches no field. func init() { - registry.RegisterSection(SectionName, &wasmSchema{}, defaults) + registry.RegisterSection(SectionName, &wasmSchema{}, sectionDefaults) } -// defaults is what this section resolves to for a node that has written nothing. +// sectionDefaults is what this section resolves to for a node that has written nothing. // // The query gas limit is the one value here that the binary states twice. This is what a file with no // wasm section resolves to, and the template writes a tenth of it into every file it generates, so a node @@ -45,7 +45,7 @@ func init() { // one. Whoever renders declared values into a file has to decide which of the two survives, and the // decision is not this section's to make: the limit bounds the work one smart query can ask of a node // that serves queries to anyone. -func defaults(registry.Mode) any { +func sectionDefaults(registry.Mode) any { live := types.DefaultWasmConfig() schema := wasmSchema{ MemoryCacheSize: live.MemoryCacheSize, diff --git a/sei-wasmd/x/wasm/config_register_test.go b/sei-wasmd/x/wasm/config_register_test.go index 4be6cc7299..085d485aa3 100644 --- a/sei-wasmd/x/wasm/config_register_test.go +++ b/sei-wasmd/x/wasm/config_register_test.go @@ -42,9 +42,15 @@ func TestTheDeclaredKeysAreTheFlagsThisModuleReads(t *testing.T) { // is not the place that reconciles them. func TestTheDefaultsAreTheModuleDeclaredOnes(t *testing.T) { live := types.DefaultWasmConfig() - got, ok := defaults(registry.ModeValidator).(wasmSchema) + for _, mode := range registry.Modes() { + if got := sectionDefaults(mode); !reflect.DeepEqual(got, sectionDefaults(registry.ModeValidator)) { + t.Errorf("mode %q resolves differently from the others, and nothing in the module makes "+ + "either setting follow from what kind of node is asking", mode) + } + } + got, ok := sectionDefaults(registry.ModeValidator).(wasmSchema) if !ok { - t.Fatalf("defaults returned %T, want wasmSchema", defaults(registry.ModeValidator)) + t.Fatalf("defaults returned %T, want wasmSchema", sectionDefaults(registry.ModeValidator)) } if got.MemoryCacheSize != live.MemoryCacheSize { From d87787d0279011ed81301ecd42415b9f78c0be98 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 21 Aug 2026 08:14:51 -0700 Subject: [PATCH 09/25] config: answer the upstream sections per kind of node Three of these settings mean something different depending on what kind of node asks, and all five sections answered the same for every one. The binary already states the rules, in what it applies when it writes a file, and every section here now answers through them. Each of the three matters in a different direction. A full node and an archive node exist to serve queries, and both interfaces that serve them were declared closed. A validator is meant to expose as little as it can, and gRPC was declared open on every one of them, which is the opposite of what the rule beside it says it is for. And the number of blocks a node retains was declared as keeping everything for a full node, where the rule prunes at a hundred thousand. The rules are read rather than restated, so one added later moves these sections with nothing here changing, and the test writes the three values out by kind of node so a change to the rules fails and gets looked at. Resolving every mode as a validator, opening gRPC on a validator, and changing the retention each fail it. The two sections no rule touches answer through the same function, so there is one place a mode is applied rather than a decision per section about whether to apply it. --- config/cosmosbase/cosmosbase.go | 43 +++++++++++++++------- config/cosmosbase/cosmosbase_test.go | 54 ++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/config/cosmosbase/cosmosbase.go b/config/cosmosbase/cosmosbase.go index 9b2312b6e4..ad64b5eff7 100644 --- a/config/cosmosbase/cosmosbase.go +++ b/config/cosmosbase/cosmosbase.go @@ -7,6 +7,7 @@ package cosmosbase import ( + "github.com/sei-protocol/sei-chain/app/params" "github.com/sei-protocol/sei-chain/config/registry" srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" ) @@ -44,6 +45,21 @@ func init() { "in the configuration file instead") } +// forMode is the server configuration a node of this kind is meant to run. +// +// The upstream defaults with the binary's own mode rules applied. Every section here answers through this, +// so a section states what a kind of node is meant to run rather than what the type holds before any mode +// is considered, and a rule added to those rules later moves these sections with nothing here changing. +// +// Three settings differ by mode today and each of them matters in a different direction. A node that +// serves queries needs the interfaces that serve them; a validator is meant to expose as little as it can; +// and how many blocks a node retains is a decision about its disk. +func forMode(mode registry.Mode) *srvconfig.Config { + out := srvconfig.DefaultConfig() + params.SetAppConfigByMode(out, params.NodeMode(mode)) + return out +} + // baseDefaults is what the node-wide settings resolve to for a node that has written nothing. // // The upstream defaults, unchanged by mode. Every one of these keys is read with a casting getter and no @@ -61,30 +77,31 @@ func init() { // started with no pruning key written prunes on the standard schedule and this states that it would keep // everything. Whoever resolves for a running node has to supply the flag values to get the answer that // node uses. -func baseDefaults(registry.Mode) any { return srvconfig.DefaultConfig().BaseConfig } +func baseDefaults(mode registry.Mode) any { return forMode(mode).BaseConfig } // apiDefaults is what the REST interface settings resolve to for a node that has written nothing. // -// The interface is off, for every mode. seid init turns it on for a full node and an archive node, so -// those carry it written, and a node whose file lacks the key does not serve REST whatever kind it is. -func apiDefaults(registry.Mode) any { return srvconfig.DefaultConfig().API } +// On for a full node and an archive node, off for a validator and a seed. Serving queries is what the +// first two are for, and the second two are meant to expose as little as they can. +func apiDefaults(mode registry.Mode) any { return forMode(mode).API } // grpcDefaults is what the gRPC settings resolve to for a node that has written nothing. // -// The interface is on, which is the upstream default, and seid init writes it off for a validator and a -// seed. Six of these eleven keys are read only when the key is present, so for those the declared default -// is also what an absent key resolves to today. +// On for a full node and an archive node, off for a validator and a seed, which is the same rule the REST +// interface follows and for the same reason. The upstream default is on for every kind, so declaring that +// would state an open interface on the nodes meant to expose the least. // -// The six durations are declared as durations and written into a file as text, which is the shape the -// reader parses back. -func grpcDefaults(registry.Mode) any { return srvconfig.DefaultConfig().GRPC } +// Six of these eleven keys are read only when the key is present, so for those the declared value is also +// what an absent key resolves to today. The six durations are declared as durations and written into a +// file as text, which is the shape the reader parses back. +func grpcDefaults(mode registry.Mode) any { return forMode(mode).GRPC } // stateSyncDefaults is what the snapshot settings resolve to for a node that has written nothing. // // All three keys are read with a casting getter and no presence check, and the retention is the one that // inverts: it is declared as keeping two snapshots and an absent key casts to zero, which the file format // documents as keeping every snapshot. -func stateSyncDefaults(registry.Mode) any { return srvconfig.DefaultConfig().StateSync } +func stateSyncDefaults(mode registry.Mode) any { return forMode(mode).StateSync } // telemetrySchema declares the keys the metric settings reader resolves. // @@ -115,8 +132,8 @@ type telemetrySchema struct { // The label set is empty, which is what the upstream default holds, so there is nothing to convert into // the untyped rows the reader takes. A test holds that emptiness, because a default that gained rows would // need converting and would otherwise reach the reader as the shape it refuses. -func telemetryDefaults(registry.Mode) any { - live := srvconfig.DefaultConfig().Telemetry +func telemetryDefaults(mode registry.Mode) any { + live := forMode(mode).Telemetry return telemetrySchema{ ServiceName: live.ServiceName, Enabled: live.Enabled, diff --git a/config/cosmosbase/cosmosbase_test.go b/config/cosmosbase/cosmosbase_test.go index 1220db25bd..baf2bef75f 100644 --- a/config/cosmosbase/cosmosbase_test.go +++ b/config/cosmosbase/cosmosbase_test.go @@ -5,6 +5,7 @@ import ( "sort" "testing" + "github.com/sei-protocol/sei-chain/app/params" "github.com/sei-protocol/sei-chain/config/registry" "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" "github.com/sei-protocol/sei-chain/sei-cosmos/server" @@ -181,14 +182,54 @@ func TestTheLabelSetIsRefusedFromTheEnvironment(t *testing.T) { } } -// TestDefaultsAreTheUpstreamOnesForEveryMode covers the value side of all five registrations. +// TestEachKindOfNodeResolvesTheInterfacesItIsFor is the mode-varying part of these sections. // -// Unchanged by mode, which is the decision worth pinning. seid init writes three of these keys per mode, -// so a node it provisioned carries them as written values; these are what a node with nothing written -// runs. -func TestDefaultsAreTheUpstreamOnesForEveryMode(t *testing.T) { +// Three settings differ by kind of node, and the values are written out here rather than taken from the +// same rules the sections read, so a change to those rules fails this and gets looked at. Each matters in a +// different direction. A node that serves queries needs the two interfaces that serve them, and declaring +// them closed would take a service away from one. A validator is meant to expose as little as it can, and +// declaring gRPC open would state the opposite of that on every validator. And how many blocks a node +// keeps is a decision about its disk. +func TestEachKindOfNodeResolvesTheInterfacesItIsFor(t *testing.T) { + byMode := map[registry.Mode]struct { + api, grpc bool + retain uint64 + }{ + registry.ModeValidator: {api: false, grpc: false, retain: 0}, + registry.ModeSeed: {api: false, grpc: false, retain: 0}, + registry.ModeFull: {api: true, grpc: true, retain: 100000}, + registry.ModeArchive: {api: true, grpc: true, retain: 0}, + } + for _, mode := range registry.Modes() { + want, named := byMode[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, expected := range map[string]any{ + "api.enable": want.api, + "grpc.enable": want.grpc, + "min-retain-blocks": want.retain, + } { + if got := resolved.Values[key]; !reflect.DeepEqual(got, expected) { + t.Errorf("mode %q: %s resolves to %#v, want %#v", mode, key, got, expected) + } + } + } +} + +// TestDefaultsAreTheUpstreamOnesApartFromTheModeRules covers everything a mode does not change. +// +// Compared against the upstream defaults with the same mode rules applied, so this holds the sections to +// carrying the whole of that configuration rather than a subset of it, and the three settings the rules +// touch are pinned by name above. +func TestDefaultsAreTheUpstreamOnesApartFromTheModeRules(t *testing.T) { for _, mode := range registry.Modes() { live := srvconfig.DefaultConfig() + params.SetAppConfigByMode(live, params.NodeMode(mode)) for _, c := range []struct { section string got any @@ -200,7 +241,8 @@ func TestDefaultsAreTheUpstreamOnesForEveryMode(t *testing.T) { {StateSyncSectionName, stateSyncDefaults(mode), live.StateSync}, } { if !reflect.DeepEqual(c.got, c.want) { - t.Errorf("mode %q: %s resolves to something other than the upstream default", mode, c.section) + t.Errorf("mode %q: %s resolves to something other than that mode's upstream configuration", + mode, c.section) } } From dc4a458221b754b210d1b2ea051f308821f5df24 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 21 Aug 2026 08:39:18 -0700 Subject: [PATCH 10/25] config: hand out values nothing else holds, and answer the EVM interfaces per mode A resolved list was the section's own list. A section's default is usually a package-level variable, so a slice field handed out the array that variable holds, and one in-place write by a caller rewrote it for the whole process: every later resolution, and every reader that copies the same struct. Two of the five lists this reaches are deny lists, so the rewrite is silent and it is a security control. The registry already copies a section's keys for exactly this reason and said so in a comment; values now get the same guarantee, at the one function both walks pass through. Removing it fails a test that writes into a resolved list and asks the section's default what it holds. The EVM interfaces answer per kind of node. A full node and an archive node serve queries, which is what those interfaces are for. A validator and a seed serve none, and this section declared both open for every kind, which puts a public request surface on the node that holds a signing key. The rule already exists in this binary. It could not be read from here, because the package that owns the node mode imports this one, so the rule moved to the registry, which is a leaf both sides reach, and the node-mode type now delegates to it rather than stating it twice. Forgetting archive in that one statement now fails a test. Each section also holds its keys to the values their fields hold, not to its own defaults struct compared with itself. That comparison agreed with itself while two tags sat on the wrong fields: the key set stays identical and every field still holds the value it always did, so an endpoint and a directory, or a deny list and an origin list, change places unnoticed. Both of those swaps now fail. Each section reports its own refusal. A registration the registry cannot use is recorded rather than raised, and these tests inferred it from a lookup coming back empty, which threw away the sentence saying why. Two tests are gone because they restated checks that already exist a few files away, where the message is better. Two comments are corrected: enabling replay opens a client without reaching the endpoint, so an unreachable one surfaces during replay rather than at startup, and the two machine-derived values are not one case, because the worker pool re-measures when it is given a value that is not positive and the simulation limit reads zero as no limit at all. The registry now states what a resolved value's type depends on, because it resolves values and does not convert them: a default arrives as its field's type, a file as whatever the format decodes to, and an environment variable as one string. --- app/params/config.go | 6 +- config/registry/detach_test.go | 65 +++++++++++++++++++++ config/registry/registry.go | 11 ++++ config/registry/resolve.go | 43 +++++++++++++- evmrpc/config/register.go | 32 ++++++----- evmrpc/config/register_test.go | 97 +++++++++++++++++++++----------- x/evm/blocktest/register_test.go | 47 ++++++++++------ x/evm/querier/register_test.go | 36 +++++++----- x/evm/replay/register.go | 7 ++- x/evm/replay/register_test.go | 66 ++++++++++------------ 10 files changed, 290 insertions(+), 120 deletions(-) create mode 100644 config/registry/detach_test.go 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 index 7cb5c2209c..30d76881e6 100644 --- a/evmrpc/config/register.go +++ b/evmrpc/config/register.go @@ -8,23 +8,29 @@ 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, all fifty-seven of -// them, so the registry derives what a node reads rather than restating a list this long. +// 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 declared defaults, unchanged by mode, because that is what such a node runs: nothing consults the -// node's kind while reading these keys, so a file missing them serves both interfaces whatever kind of -// node it is. +// 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. // -// A node seid init provisioned is a different case and needs no help from here. That path writes the two -// interface toggles per mode, closing them for a validator and a seed, so those nodes carry written values -// and a written value is what resolves. -// -// Two of these values come from the machine rather than from a decision: the simulation call limit is the -// processor count and the worker pool is twice it, capped. They describe the host that asked, so a caller -// that renders them into a file carries one host's sizing to whatever reads that file next. -func defaults(registry.Mode) any { return DefaultConfig } +// 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 index a2269efa32..23eab42ea5 100644 --- a/evmrpc/config/register_test.go +++ b/evmrpc/config/register_test.go @@ -2,7 +2,6 @@ package config import ( "reflect" - "runtime" "sort" "testing" @@ -13,12 +12,17 @@ import ( // // 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 fifty-seven keys again in the same file, and a rename that moves one and not the -// other compiles. +// 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, @@ -44,49 +48,74 @@ func TestDeclaredKeysAreTheOnesItsReaderResolves(t *testing.T) { if !ok { t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) } - if !reflect.DeepEqual(section.Keys, want) { - t.Errorf("%s declares %d keys and its reader resolves %d.\ndeclared: %v\nresolved: %v", - SectionName, len(section.Keys), len(want), section.Keys, want) + 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) } } -// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// TestEachKindOfNodeResolvesTheInterfacesItIsFor is the mode-varying part of this section. // -// Unchanged by mode, which is the decision worth pinning. seid init writes the two interface toggles per -// mode, so a validator it provisioned carries them as written values. These are what a node with nothing -// written runs, and no read of these keys consults the node's kind. -func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { +// 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() { - got, ok := defaults(mode).(Config) - if !ok { - t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + 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) } - if !reflect.DeepEqual(got, DefaultConfig) { - t.Errorf("mode %q resolves to a value other than the reader's own default", mode) + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) } - if !got.HTTPEnabled || !got.WSEnabled { - t.Errorf("mode %q resolves an interface closed. A node whose file lacks these keys serves "+ - "both, so resolving one closed would take an interface away from a running node", mode) + 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) + } } } } -// TestTheTwoHostDerivedValuesDescribeThisHost covers the two defaults that are measurements. +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. // -// Every other value here is a decision someone wrote down and is the same on any machine. These two are -// the processor count and twice it, so they describe whichever host resolved them. Nothing here can make -// that portable, and stating it is what keeps a caller from rendering them into a file as though it were. -func TestTheTwoHostDerivedValuesDescribeThisHost(t *testing.T) { - got, ok := defaults(registry.ModeValidator).(Config) - if !ok { - t.Fatalf("defaults returned %T, want the type its reader fills", defaults(registry.ModeValidator)) +// 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) } - if got.MaxConcurrentSimulationCalls != runtime.NumCPU() { - t.Errorf("%s resolves to %d and this host has %d processors", - flagMaxConcurrentSimulationCalls, got.MaxConcurrentSimulationCalls, runtime.NumCPU()) - } - if want := min(MaxWorkerPoolSize, runtime.NumCPU()*2); got.WorkerPoolSize != want { - t.Errorf("%s resolves to %d, want %d on a host with %d processors", - flagWorkerPoolSize, got.WorkerPoolSize, want, runtime.NumCPU()) + 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_test.go b/x/evm/blocktest/register_test.go index d5ef96ed60..035a267866 100644 --- a/x/evm/blocktest/register_test.go +++ b/x/evm/blocktest/register_test.go @@ -11,37 +11,50 @@ import ( // 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 a few lines away, and a rename that moves one and not the other -// compiles. +// 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) { - want := []string{flagEnabled, flagTestDataPath} - sort.Strings(want) - + 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) } } -// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. // -// Off for every mode, which is the value worth pinning: a mode that resolved this on would have those -// nodes replay recorded data instead of serving the chain. -func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { +// 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() { - got, ok := defaults(mode).(Config) - if !ok { - t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) } - if got != DefaultConfig { - t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + 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 got.Enabled { - t.Errorf("mode %q resolves the block-test harness on", mode) + 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_test.go b/x/evm/querier/register_test.go index 42d2d799a0..4721542400 100644 --- a/x/evm/querier/register_test.go +++ b/x/evm/querier/register_test.go @@ -8,33 +8,41 @@ import ( "github.com/sei-protocol/sei-chain/config/registry" ) -// TestDeclaredKeysAreTheOnesItsReaderResolves holds the derived keys against the reader's own constant. +// 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 and there is no second list to fall behind. What remains is the constant ReadConfig looks up, which -// states the same key again a few lines away, and a rename that moves one and not the other compiles. +// 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) { - want := []string{flagGasLimit} - sort.Strings(want) - + 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) } } -// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. -func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { +// 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() { - got, ok := defaults(mode).(Config) - if !ok { - t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) } - if got != DefaultConfig { - t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + 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 index fa3e1a291b..e6672b4ad2 100644 --- a/x/evm/replay/register.go +++ b/x/evm/replay/register.go @@ -20,7 +20,8 @@ func init() { // 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 application construction dial the -// endpoint and fail when it cannot reach it, so a mode whose defaults turned it on would stop those nodes -// booting. The endpoint itself is a fixed third-party address, which is another reason no mode implies it. +// 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 index 49079caaba..2cb3231650 100644 --- a/x/evm/replay/register_test.go +++ b/x/evm/replay/register_test.go @@ -10,57 +10,49 @@ import ( // 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 a few lines away, and a rename that moves one and not the other -// compiles. +// 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) { - want := []string{flagEnabled, flagEthRPC, flagEthDataDir, flagContractStateChecks} - sort.Strings(want) - + 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) } } -// TestTheWrittenSpellingOfTheStateCheckIsNotDeclared covers a name that is written and never read. -// -// The app.toml template renders eth_replay_contract_state_checks and the reader looks up -// contract_state_checks, so every generated file carries a name nothing resolves. Declaring that name -// would add a key an operator can set and no reader answers, which is the one outcome worse than the -// mismatch itself: a value that looks as though it applied. -func TestTheWrittenSpellingOfTheStateCheckIsNotDeclared(t *testing.T) { - section, ok := registry.Lookup(SectionName) - if !ok { - t.Fatalf("%s is not registered, so nothing resolves its keys", SectionName) - } - for _, key := range section.Keys { - if key == SectionName+".eth_replay_contract_state_checks" { - t.Errorf("%s is declared and no reader looks it up", key) - } - } -} - -// TestDefaultsAreTheReaderOwnForEveryMode covers the value side of the same registration. +// TestEachKeyResolvesToTheValueItsFieldHolds covers the binding a key set cannot show. // -// Off for every mode, which is the value worth pinning: turning replay on makes application construction -// dial the endpoint, so a mode that resolved it on would stop those nodes booting. -func TestDefaultsAreTheReaderOwnForEveryMode(t *testing.T) { +// 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() { - got, ok := defaults(mode).(Config) - if !ok { - t.Fatalf("mode %q: defaults returned %T, want the type its reader fills", mode, defaults(mode)) + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) } - if got != DefaultConfig { - t.Errorf("mode %q resolves to %+v, want the reader's own default %+v", mode, got, DefaultConfig) + 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 got.Enabled { - t.Errorf("mode %q resolves replay on, which makes those nodes dial %q at construction", - mode, got.EthRPC) + 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]) } } } From db12097f433b7e316ac8ab2a733dc6b31a26e09b Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 21 Aug 2026 11:27:46 -0700 Subject: [PATCH 11/25] config: refuse a mode nothing declares, and report a variable that did nothing A resolution answered for any string. A section's defaults answer per mode, and a mode this package does not know reached whatever each section does with an argument it cannot match, which for these five is the rules answering as though it were a full node. So an empty string, a capitalised name, or one with a trailing space resolved the interfaces a full node serves onto whichever node asked, with no error. It is refused now, naming the four. A refusal of the environment channel is recorded by a key, so a slip in the spelling named a key no section declares. The channel would never have offered that key, so the refusal covered nothing while reading as though it did, and the key it was written for went on resolving from a variable. Both sets exist for the first time when something resolves, because a refusal may be recorded before the section declaring its key registers, so that is where they are compared. The reason a refusal carries is required and had no consumer. The channel was skipped before the variable was read, so the one fact a diagnostic needs, that an operator set it, was discarded at the cheapest possible point. The variable is read now and its value still thrown away, and the key comes back named, so a required reason is one somebody can be told. A refused key nobody set is not reported, because a value nobody chose is not news. A refusal also names the section that declares the key, so a refused key is attributable the way every other defect is. It was putting the key where the section belongs, which made a defect read as though a key had registered and made a scoped sweep skip it. Four of the metric section's seven hand-copied values were held against nothing. That is the one section here that has to restate its values, so it is the one where a field can be assigned from its neighbour, and assigning the hostname toggle from the enabled toggle survived the suite. Every one of the seven is now held as the key it resolves to rather than as a struct field, because a struct compared with itself agrees while two values sit on the wrong fields. Five comments said things the code does not. The node-wide settings claimed to be unchanged by mode while one of their own keys answers per mode. A count of non-zero defaults was wrong. Two different counts of six read as one, and the pair the sentence lost is read through a clamp that does nothing for an absent key. The package's reason for existing named a vendored tree, when other sections register inside one and the real obstacle is an import edge. And a paragraph named two sections that belong to another change. --- config/cosmosbase/cosmosbase.go | 62 +++++++------- config/cosmosbase/cosmosbase_test.go | 64 ++++++++++---- config/registry/environment.go | 8 +- config/registry/resolve.go | 55 +++++++++++- config/registry/rootkeys_test.go | 120 ++++++++++++++++++++++++++- 5 files changed, 254 insertions(+), 55 deletions(-) diff --git a/config/cosmosbase/cosmosbase.go b/config/cosmosbase/cosmosbase.go index ad64b5eff7..7def33ed90 100644 --- a/config/cosmosbase/cosmosbase.go +++ b/config/cosmosbase/cosmosbase.go @@ -1,9 +1,12 @@ // Package cosmosbase registers the configuration sections whose keys belong to the Cosmos server. // -// These sections have no owning package inside this repository. Their structs and their readers live in -// sei-cosmos, which this repository vendors rather than authors, so there is nowhere upstream to put a -// registration that this repository's registry would see. A section belongs here only when its keys are -// upstream's; a section this repository owns registers in the package that owns its struct. +// These five register here rather than beside the structs they describe, and the reason is an import edge. +// The mode rules their defaults answer through live in app/params, which imports the upstream server +// configuration, so that package cannot ask for them without a cycle. A vendored tree is not itself the +// obstacle: other sections do register inside one. +// +// A section belongs here only when its keys are upstream's and that edge is in the way. Everything else +// registers in the package that owns its struct, so the struct, the values and the keys stay together. package cosmosbase import ( @@ -24,14 +27,13 @@ const ( StateSyncSectionName = "state-sync" ) -// GlobalLabelsKey is the metric label set, which is the one key here no environment variable can supply. -const GlobalLabelsKey = TelemetrySectionName + ".global-labels" +// globalLabelsKey is the metric label set, which is the one key here no environment variable can supply. +const globalLabelsKey = TelemetrySectionName + ".global-labels" // Registration puts the upstream server's configuration sections in the registry. // // Four of the five register the upstream struct directly, because their mapstructure tags already name the -// keys their reader resolves. That is worth stating rather than assuming: the two SeiDB sections needed a -// schema precisely because their tags name something else. +// keys their reader resolves. func init() { registry.RegisterRootKeys(BaseSectionName, &srvconfig.BaseConfig{}, baseDefaults) registry.RegisterSection(APISectionName, &srvconfig.APIConfig{}, apiDefaults) @@ -39,7 +41,7 @@ func init() { registry.RegisterSection(TelemetrySectionName, &telemetrySchema{}, telemetryDefaults) registry.RegisterSection(StateSyncSectionName, &srvconfig.StateSyncConfig{}, stateSyncDefaults) - registry.RefuseFromEnvironment(GlobalLabelsKey, + registry.RefuseFromEnvironment(TelemetrySectionName, globalLabelsKey, "the metric label set is a list of name and value rows, and its reader takes that exact shape "+ "rather than casting what it finds, so no single environment string can supply it. Write it "+ "in the configuration file instead") @@ -60,23 +62,24 @@ func forMode(mode registry.Mode) *srvconfig.Config { return out } -// baseDefaults is what the node-wide settings resolve to for a node that has written nothing. -// -// The upstream defaults, unchanged by mode. Every one of these keys is read with a casting getter and no -// check that the key was present, so an absent key casts to a zero and clobbers the default beside it. -// Five of the fourteen have a non-zero default, and the pruning strategy is the one that matters, because -// an empty strategy is not a strategy. -// -// Three keys elsewhere in this package vary by node mode, and none of them varies here. seid init writes -// the interface toggles and the block retention per mode, so a node it provisioned carries those as -// written values, and a written value is what resolves. These are what a node with nothing written runs. -// -// One value here is not what a running node uses today, and it is worth knowing which. The pruning -// strategy is declared as keeping everything, while the command line registers a flag of the same name -// defaulting to the standard strategy, and a bound flag is a source of its own below the file. So a node -// started with no pruning key written prunes on the standard schedule and this states that it would keep -// everything. Whoever resolves for a running node has to supply the flag values to get the answer that -// node uses. +// baseDefaults is what the node-wide settings resolve to for a node of this kind. +// +// One of these keys answers per mode: how many blocks a node retains, which is a hundred thousand for a +// full node and everything for the rest. The other two mode-varying keys in this package are the interface +// toggles, which belong to the sections that own them. +// +// Every one of these keys is read with a casting getter and no check that the key was present, so an +// absent key casts to a zero and clobbers the default beside it. Which keys those are, and what a node +// resolves for each instead, belongs in a measurement rather than in a count here. +// +// Several of these are not what a running node resolves today, and the causes differ: a bound command flag +// of the same name carries its own default below the file, and the command that assembles the server +// configuration overrides some of them before a node starts. The pruning strategy is the one worth naming, +// because the flag defaults it to the standard schedule while this declares it keeps everything. +// +// A caller resolving for a running node therefore has to supply that node's flag values, and only the ones +// an operator actually set. A flag nobody typed still reports a default, and this resolution ranks flags +// above the file, so passing defaults would put every one of them over an operator's own value. func baseDefaults(mode registry.Mode) any { return forMode(mode).BaseConfig } // apiDefaults is what the REST interface settings resolve to for a node that has written nothing. @@ -91,9 +94,10 @@ func apiDefaults(mode registry.Mode) any { return forMode(mode).API } // interface follows and for the same reason. The upstream default is on for every kind, so declaring that // would state an open interface on the nodes meant to expose the least. // -// Six of these eleven keys are read only when the key is present, so for those the declared value is also -// what an absent key resolves to today. The six durations are declared as durations and written into a -// file as text, which is the shape the reader parses back. +// Six of these eleven keys are read only when the key is present. Two more are durations read through a +// clamp that rescues a negative value and does nothing for an absent one, so those two are unguarded and +// their clobber leaves no trace. The durations are declared as durations and written into a file as text, +// which is the shape the reader parses back. func grpcDefaults(mode registry.Mode) any { return forMode(mode).GRPC } // stateSyncDefaults is what the snapshot settings resolve to for a node that has written nothing. diff --git a/config/cosmosbase/cosmosbase_test.go b/config/cosmosbase/cosmosbase_test.go index baf2bef75f..e038e15435 100644 --- a/config/cosmosbase/cosmosbase_test.go +++ b/config/cosmosbase/cosmosbase_test.go @@ -86,7 +86,7 @@ func TestTheMetricKeysAreTheOnesItsReaderResolves(t *testing.T) { requireDeclares(t, TelemetrySectionName, []string{ "telemetry.service-name", "telemetry.enabled", "telemetry.enable-hostname", "telemetry.enable-hostname-label", "telemetry.enable-service-label", - "telemetry.prometheus-retention-time", GlobalLabelsKey, + "telemetry.prometheus-retention-time", globalLabelsKey, }) } @@ -150,10 +150,10 @@ func TestTheUpstreamDefaultCarriesNoLabels(t *testing.T) { // variable installs a value the reader refuses, and it refuses in the first statement of the whole server // configuration. The node stops. Leaving the channel out means the file's value applies and the node runs. func TestTheLabelSetIsRefusedFromTheEnvironment(t *testing.T) { - reason, refused := registry.EnvCannotDeliver()[GlobalLabelsKey] + reason, refused := registry.EnvCannotDeliver()[globalLabelsKey] if !refused { t.Fatalf("%s is not refused from the environment, so a variable naming it resolves to a string "+ - "and installing that stops the node", GlobalLabelsKey) + "and installing that stops the node", globalLabelsKey) } if reason == "" { t.Error("the refusal carries no reason, so an operator whose variable is ignored cannot be told why") @@ -161,7 +161,7 @@ func TestTheLabelSetIsRefusedFromTheEnvironment(t *testing.T) { resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{ LookupEnv: func(name string) (string, bool) { - if name == registry.EnvName(GlobalLabelsKey) { + if name == registry.EnvName(globalLabelsKey) { return "chain_id=pacific-1", true } return "", false @@ -170,14 +170,14 @@ func TestTheLabelSetIsRefusedFromTheEnvironment(t *testing.T) { if err != nil { t.Fatalf("Resolve: %v", err) } - if got := resolved.Values[GlobalLabelsKey]; !reflect.DeepEqual(got, []any{}) { + if got := resolved.Values[globalLabelsKey]; !reflect.DeepEqual(got, []any{}) { t.Errorf("%s resolved to %#v (%T), want the declared default it was left to", - GlobalLabelsKey, got, got) + globalLabelsKey, got, got) } for _, key := range resolved.Overrides { - if key == GlobalLabelsKey { + if key == globalLabelsKey { t.Errorf("%s is reported as a value an operator supplied, and the variable did nothing", - GlobalLabelsKey) + globalLabelsKey) } } } @@ -246,21 +246,51 @@ func TestDefaultsAreTheUpstreamOnesApartFromTheModeRules(t *testing.T) { } } - metrics, ok := telemetryDefaults(mode).(telemetrySchema) - if !ok { + if _, ok := telemetryDefaults(mode).(telemetrySchema); !ok { t.Fatalf("mode %q: the metric defaults returned %T, want the schema", mode, telemetryDefaults(mode)) } - if metrics.Enabled != live.Telemetry.Enabled || - metrics.PrometheusRetentionTime != live.Telemetry.PrometheusRetentionTime || - metrics.ServiceName != live.Telemetry.ServiceName { - t.Errorf("mode %q: the metric defaults are not the upstream ones: %+v", mode, metrics) + // Every field the schema copies by hand, held against the upstream value, and held as the + // resolved key rather than as a struct field. The section that has to restate its values is the + // one where a field can be assigned from the wrong neighbour, and a struct comparison would not + // see it: each field still holds a value, and the count still matches. + requireResolvesTelemetry(t, mode, live.Telemetry) + } +} + +// requireResolvesTelemetry holds every key the metric schema declares against the upstream value. +func requireResolvesTelemetry(t *testing.T, mode registry.Mode, live telemetry.Config) { + t.Helper() + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("mode %q: %v", mode, err) + } + for key, want := range map[string]any{ + "telemetry.service-name": live.ServiceName, + "telemetry.enabled": live.Enabled, + "telemetry.enable-hostname": live.EnableHostname, + "telemetry.enable-hostname-label": live.EnableHostnameLabel, + "telemetry.enable-service-label": live.EnableServiceLabel, + "telemetry.prometheus-retention-time": live.PrometheusRetentionTime, + globalLabelsKey: []any{}, + } { + 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) } } } -// TestEverySectionHereRegistersCleanly covers what the registry itself refuses. -func TestEverySectionHereRegistersCleanly(t *testing.T) { +// TestTheSectionsThisPackageRegistersAreUsable covers what the registry refuses. +// +// Scoped to the five names this file registers. A refusal that depends on what else has registered is +// not this package's to answer for, and the sweep that covers it belongs where every section is linked. +func TestTheSectionsThisPackageRegistersAreUsable(t *testing.T) { + mine := map[string]bool{ + BaseSectionName: true, APISectionName: true, GRPCSectionName: true, + TelemetrySectionName: true, StateSyncSectionName: true, + } for _, defect := range registry.Defects() { - t.Errorf("%s is registered and defective: %v", defect.Section, defect.Err) + if mine[defect.Section] { + t.Errorf("%s was refused, so none of its keys is declared: %v", defect.Section, defect.Err) + } } } diff --git a/config/registry/environment.go b/config/registry/environment.go index ef2e013ed3..d3f440b178 100644 --- a/config/registry/environment.go +++ b/config/registry/environment.go @@ -17,12 +17,16 @@ var envCannotDeliver = map[string]string{} // start, so the difference is recorded rather than assumed. A value silently doing nothing is the failure // this whole surface exists to remove, which is why the reason is required and not optional. // +// section is the section that declares the key, so a refused key is attributable to a registration the +// way every other defect is. Whether the key is one that section declares is answered when something +// resolves, because a refusal may be recorded before the registration it belongs to. +// // Called from the owning package, beside its registration, so the reason sits with the code that knows it. -func RefuseFromEnvironment(key, reason string) { +func RefuseFromEnvironment(section, key, reason string) { mu.Lock() defer mu.Unlock() if reason == "" { - defects = append(defects, Defect{Section: key, Err: fmt.Errorf( + defects = append(defects, Defect{Section: section, Err: fmt.Errorf( "refusing %q from the environment with no reason; an operator whose variable is ignored has "+ "to be told why", key)}) return diff --git a/config/registry/resolve.go b/config/registry/resolve.go index c47281a293..5ea1c2bbbe 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -16,6 +16,12 @@ type Resolved struct { // The keys an operator has taken responsibility for, as distinct from the ones tracking the // binary's judgement. This is what a diff renders. Overrides []string + // Ignored are declared keys an environment variable was set for and could not supply, sorted. + // + // Separate from Unknown because the two are different mistakes. An unknown key is one nothing reads. + // An ignored one is read, and the operator reached for the one channel that cannot carry it, so the + // value they wrote elsewhere is what applies. EnvCannotDeliver says why, per key. + Ignored []string // Unknown are keys a source carried that no section declares, sorted. // // Reported rather than an error, because what to do about one is the caller's decision: a @@ -37,6 +43,16 @@ type Sources struct { Flags map[string]any } +// known reports whether this package declares defaults for a mode. +func known(mode Mode) bool { + for _, m := range Modes() { + if m == mode { + return true + } + } + return false +} + // Resolve reduces a node's configuration sources to one value per declared key. // // The precedence is stated once, in this function, and a caller cannot reorder its way to a different @@ -54,6 +70,16 @@ type Sources struct { func Resolve(mode Mode, from Sources) (Resolved, error) { var out Resolved + // Refused before anything is resolved, because a section's defaults answer per mode and a mode this + // package does not know reaches whatever each section does with an argument it cannot match. What that + // is varies by section and none of them is a decision anyone made: the upstream mode rules answer for + // an unrecognised mode as though it were a full node, so an empty string, a capitalised name or one + // with a trailing space resolves the interfaces a full node serves onto whatever asked. + if !known(mode) { + return out, fmt.Errorf("%q is not a mode this binary declares defaults for; the modes are %v", + mode, Modes()) + } + // One snapshot, read once and passed everywhere below. Every part of the answer has to describe the // same registry: asking again leaves a window a concurrent registration fits through, and a section // arriving in that window is declared by one part of the answer and not by another. @@ -64,6 +90,16 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { } declared := declaredKeys(registered) undeliverable := EnvCannotDeliver() + // A refusal is recorded by a key, and a key that no section declares is one the environment layer + // would never have offered anyway, so the refusal protects nothing and reads as though it did. Held + // here because a refusal may be recorded before the section that declares its key registers, so this + // is the first point both sets exist. + for key := range undeliverable { + if !declared[key] { + return out, fmt.Errorf("%q is refused from the environment and no section declares it, so the "+ + "refusal covers nothing", key) + } + } out.Values = make(map[string]any, len(declared)) for key, v := range defaults { @@ -74,9 +110,11 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { unknown := map[string]bool{} // Lowest precedence first, so a later source overwrites an earlier one. The one statement of the // order, which is why nothing exports it. + fromEnv, ignored := envValues(declared, undeliverable, from.LookupEnv) + out.Ignored = ignored for _, values := range []map[string]any{ fileValues(from.File), - envValues(declared, undeliverable, from.LookupEnv), + fromEnv, from.Flags, } { for key, v := range values { @@ -274,16 +312,24 @@ func walkValues(v reflect.Value, prefix string, out map[string]any) error { // again would ask for a key the caller's declared set does not hold, and the answer would come back // only to be reported as one no section declares. func envValues(declared map[string]bool, undeliverable map[string]string, - lookup func(string) (string, bool)) map[string]any { + lookup func(string) (string, bool)) (map[string]any, []string) { if lookup == nil { - return nil + return nil, nil } out := map[string]any{} + var ignored []string for key := range declared { // A key no variable can carry is left to the sources that can. Resolving it would put a string // at the top of the order for a reader that takes the exact type, and installing that stops the // node. What an operator loses is the channel; what they keep is a node that boots. + // + // The variable is still read, and the value still discarded. Asking is what turns this from a + // silent skip into something a caller can report: a reason nothing can attach to an operator's + // own action is a reason nobody is ever told. if _, refused := undeliverable[key]; refused { + if v, set := lookup(EnvName(key)); set && v != "" { + ignored = append(ignored, key) + } continue } // An empty value is treated as unset. A variable exported empty is far more often a shell @@ -294,7 +340,8 @@ func envValues(declared map[string]bool, undeliverable map[string]string, out[key] = v } } - return out + sort.Strings(ignored) + return out, ignored } // fileValues normalises a configuration file's keys to lower case. diff --git a/config/registry/rootkeys_test.go b/config/registry/rootkeys_test.go index 6bcc1a2a41..a8c4083c87 100644 --- a/config/registry/rootkeys_test.go +++ b/config/registry/rootkeys_test.go @@ -153,7 +153,7 @@ func TestAKeyTheEnvironmentCannotDeliverIsLeftToTheOtherSources(t *testing.T) { Plain string `mapstructure:"plain"` }{Rows: []any{}, Plain: "from the default"} }) - registry.RefuseFromEnvironment("probe.rows", "its reader takes the exact type rather than casting") + registry.RefuseFromEnvironment("probe", "probe.rows", "its reader takes the exact type rather than casting") for _, d := range registry.Defects() { t.Fatalf("the registration was refused: %v", d.Err) } @@ -194,7 +194,7 @@ func TestAKeyTheEnvironmentCannotDeliverIsLeftToTheOtherSources(t *testing.T) { // has to be told why. A refusal with no reason gives a diagnostic nothing to print. func TestRefusingAChannelWithoutAReasonIsItselfRefused(t *testing.T) { registry.Reset() - registry.RefuseFromEnvironment("probe.rows", "") + registry.RefuseFromEnvironment("probe", "probe.rows", "") if len(registry.Defects()) != 1 { t.Fatalf("recorded %d defects, want one naming the key with no reason", len(registry.Defects())) } @@ -204,8 +204,122 @@ func TestRefusingAChannelWithoutAReasonIsItselfRefused(t *testing.T) { } registry.Reset() - registry.RefuseFromEnvironment("probe.rows", "its reader takes the exact type") + registry.RefuseFromEnvironment("probe", "probe.rows", "its reader takes the exact type") if _, refused := registry.EnvCannotDeliver()["probe.rows"]; !refused { t.Error("a refusal carrying a reason was not recorded") } } + +// TestAModeThisBinaryDoesNotDeclareIsRefused closes a resolution that answered for anything. +// +// A section's defaults answer per mode, and a mode this package does not know reaches whatever each +// section does with an argument it cannot match. Nothing about that is a decision anyone made: the mode +// rules these sections read answer for an unrecognised mode as though it were a full node, so an empty +// string, a capitalised name or one with a trailing space resolved the interfaces a full node serves onto +// whichever node asked. +func TestAModeThisBinaryDoesNotDeclareIsRefused(t *testing.T) { + registry.Reset() + registry.RegisterSection("probe", &struct { + Serves bool `mapstructure:"serves"` + }{}, func(mode registry.Mode) any { + return struct { + Serves bool `mapstructure:"serves"` + }{Serves: mode == registry.ModeFull || mode == registry.ModeArchive} + }) + for _, d := range registry.Defects() { + t.Fatalf("the probe was refused: %v", d.Err) + } + + for _, mode := range registry.Modes() { + if _, err := registry.Resolve(mode, registry.Sources{}); err != nil { + t.Errorf("mode %q is declared and did not resolve: %v", mode, err) + } + } + for _, mode := range []registry.Mode{"", "Validator", "validator ", "VALIDATOR", "sentry"} { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err == nil { + t.Errorf("mode %q resolved, to serves=%v. A mode nothing declares has no answer, and the one "+ + "it reached is whatever the rules do with an argument they cannot match", + mode, resolved.Values["probe.serves"]) + } + } +} + +// TestARefusalNamingAKeyNothingDeclaresIsRefused keeps a refusal from covering nothing. +// +// A refusal is recorded by a key, so a slip in the spelling names a key no section declares. The +// environment layer would never have offered that key, so the refusal protects nothing while reading as +// though it did, and the key it was meant to cover resolves from the environment as before. +// +// Answered when something resolves rather than when the refusal is recorded, because a refusal may be +// recorded before the section declaring its key registers. Resolving is the first point both sets exist. +func TestARefusalNamingAKeyNothingDeclaresIsRefused(t *testing.T) { + registry.Reset() + registry.RegisterSection("probe", &struct { + Rows []any `mapstructure:"rows"` + }{}, func(registry.Mode) any { + return struct { + Rows []any `mapstructure:"rows"` + }{Rows: []any{}} + }) + registry.RefuseFromEnvironment("probe", "probe.rowz", "a slip in the spelling") + + if _, err := registry.Resolve(registry.ModeFull, registry.Sources{}); err == nil { + t.Error("a refusal naming a key nothing declares was accepted, so it covers nothing and the key " + + "it was written for still resolves from the environment") + } +} + +// TestAVariableSetForARefusedKeyIsReported is what makes the required reason worth requiring. +// +// The channel is skipped and the value discarded, which is the point. But an operator who set the variable +// believes otherwise, and a reason nothing can attach to their own action is a reason nobody is told. So +// the variable is still read, and the key comes back named. +func TestAVariableSetForARefusedKeyIsReported(t *testing.T) { + registry.Reset() + registry.RegisterSection("probe", &struct { + Rows []any `mapstructure:"rows"` + Plain string `mapstructure:"plain"` + }{}, func(registry.Mode) any { + return struct { + Rows []any `mapstructure:"rows"` + Plain string `mapstructure:"plain"` + }{Rows: []any{}, Plain: "from the default"} + }) + registry.RefuseFromEnvironment("probe", "probe.rows", "its reader takes the exact type") + for _, d := range registry.Defects() { + t.Fatalf("the probe was refused: %v", d.Err) + } + + resolved, err := registry.Resolve(registry.ModeFull, registry.Sources{ + LookupEnv: func(name string) (string, bool) { + if name == registry.EnvName("probe.rows") { + return "chain_id=pacific-1", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + if got := strings.Join(resolved.Ignored, ","); got != "probe.rows" { + t.Errorf("the ignored variables are %q, want probe.rows. An operator set it and nothing here can "+ + "tell them it did nothing", got) + } + if !reflect.DeepEqual(resolved.Values["probe.rows"], []any{}) { + t.Errorf("probe.rows resolved to %#v, and the channel was supposed to be skipped", + resolved.Values["probe.rows"]) + } + + // A refused key nobody set is not news, so it is not reported. + quiet, err := registry.Resolve(registry.ModeFull, registry.Sources{ + LookupEnv: func(string) (string, bool) { return "", false }, + }) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if len(quiet.Ignored) != 0 { + t.Errorf("a refused key nobody set is reported as ignored: %v", quiet.Ignored) + } +} From eb53ea6e3ba2599b14793cc9bbb9bfcda969e208 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Sat, 22 Aug 2026 16:57:24 -0700 Subject: [PATCH 12/25] config: the contract says what the registry now does Four statements in the package contract described the previous shape. A second entry point exists, for the settings written at the top of a file rather than inside a table, and the contract showed one. The list of what makes a registration unusable no longer enumerated: two sections declaring one key and a top-level key sharing a section's name both became possible once a key could sit at the root, and a refusal of the environment carrying no reason is refused too. The resolution order had gained a per-key hole in one channel and did not say so. And the first step of adding a section told an author to use the name as the first segment of every key, which is false for a section whose keys have none. A mode this package does not declare is also refused now, and the contract says that where it says a default answers per mode. --- config/registry/doc.go | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/config/registry/doc.go b/config/registry/doc.go index d1955c08f3..ed2177d788 100644 --- a/config/registry/doc.go +++ b/config/registry/doc.go @@ -36,7 +36,13 @@ // serve. // // The third argument answers per node mode, because a validator and a seed node do not default -// alike. +// alike. A mode this package does not declare is refused rather than answered for: what a section +// does with an argument it cannot match is not a decision anybody made. +// +// Some settings are node-wide and are written at the top of a file rather than inside a table. +// RegisterRootKeys declares those: the name it takes is what a lookup and a report are keyed by and is +// not part of any key, so the keys are the tags alone. Giving such a section a segment would rename +// every key it declares, and a renamed key is one an operator's existing file no longer reaches. // // # Defaults // @@ -56,6 +62,14 @@ // value and a default are otherwise indistinguishable once merged. The second is why a typo in an // operator's file is visible rather than silently dropped. // +// One channel has a per-key hole. A reader that takes its value's exact type cannot be handed the one +// string an environment carries, so a section may refuse that channel for such a key, and the file's +// value applies instead of a value that would stop the node. The variable is still read and its value +// still discarded, and the key is reported as ignored, because a channel that quietly does nothing is +// the failure this package exists to remove. A refusal carries the reason an operator is owed, and one +// naming a key no section declares is refused in turn: it would cover nothing while reading as though +// it covered something. +// // Resolve either answers for every declared key or returns an error naming what it could not answer // for. A caller is never handed a resolution with a hole in it. // @@ -71,6 +85,18 @@ // tag, an unexported field carrying a tag, two fields declaring one path, a struct that declares no // key, a struct that contains itself, and two keys that collapse onto one environment variable. // +// Two more become possible once a key can sit at the top of a file, and neither could happen while +// every key carried its section's name. Two sections declaring one key have one default rendered over +// the other, and which one depends on the order the sections are walked. And a key at the top of the +// file that is also a section's name cannot be written at all, because a file holding both a value for +// that name and a table under it is not valid TOML, so one of the two is unreachable and nothing says +// which. Both are refused in either registration order, since registration order is not something an +// operator can see. +// +// Refusing the environment for a key is itself refused when it carries no reason. An operator told +// their variable does nothing has to be told why, and a refusal with nothing to print is worse than +// resolving the variable or leaving it alone. +// // A key segment is also refused if it is upper-case, or if it carries a dot or a space. That rule // holds for the section name and for a field's tag alike, since both become segments of the same // dotted key and answer to the same sources. @@ -85,7 +111,9 @@ // // # Adding a Section // -// 1. Give the section a name, and use it as the first segment of every key it declares. +// 1. Give the section a name, and use it as the first segment of every key it declares. A section +// whose settings sit at the top of the file instead declares root keys, and its name is then a +// handle for lookups and reports rather than part of any key. // 2. Register the struct the reader already uses, with a per-mode default. // 3. Assert the registration produced no Defect. // 4. Hold the derived key names against the reader, so a key that reaches nothing fails. From 02878d03e23113462e19338c361e6fbbede020d0 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Sat, 22 Aug 2026 17:08:29 -0700 Subject: [PATCH 13/25] config: drop a guard with no instance, and name the gap it leaves refuseOverlap refused two collisions and only one of them could happen. Two sections declaring one key is already refused by the environment check, which two identical keys reach by answering to one variable, so that arm was a second guard on a case already covered. The other arm, a key at the top of the file sharing a section's name, was the only one it alone caught, and it has no instance: one section declares keys at the top of the file and none of its fourteen names is a section's. So the code goes and the fact stays. The contract names the collision among the things this package does not guard, with what makes it reachable, because a second such section is where it starts to matter. The prototype found that out by hand: it named the section holding config.toml's top-level keys after the file rather than after the node, because the client file declares a top-level key called node and a node section could not have coexisted with it. The one case the removed guard described better is named better now where it is still refused. Two identical keys were being reported as two spellings of one environment variable, and the reason a dot and a hyphen are the same character to the environment is not the reason a key collides with itself. --- config/registry/doc.go | 16 +++---- config/registry/registry.go | 71 +++++--------------------------- config/registry/rootkeys_test.go | 60 +++------------------------ 3 files changed, 25 insertions(+), 122 deletions(-) diff --git a/config/registry/doc.go b/config/registry/doc.go index ed2177d788..bc1659ecec 100644 --- a/config/registry/doc.go +++ b/config/registry/doc.go @@ -85,13 +85,10 @@ // tag, an unexported field carrying a tag, two fields declaring one path, a struct that declares no // key, a struct that contains itself, and two keys that collapse onto one environment variable. // -// Two more become possible once a key can sit at the top of a file, and neither could happen while -// every key carried its section's name. Two sections declaring one key have one default rendered over -// the other, and which one depends on the order the sections are walked. And a key at the top of the -// file that is also a section's name cannot be written at all, because a file holding both a value for -// that name and a table under it is not valid TOML, so one of the two is unreachable and nothing says -// which. Both are refused in either registration order, since registration order is not something an -// operator can see. +// One more becomes possible once a key can sit at the top of a file, and it could not happen while every +// key carried its section's name: two sections declaring one key, where one default renders over the +// other and which one depends on the order the sections are walked. The environment check refuses it, +// because two identical keys answer to one variable. // // Refusing the environment for a key is itself refused when it carries no reason. An operator told // their variable does nothing has to be told why, and a refusal with nothing to print is worse than @@ -108,6 +105,11 @@ // - Not a file format. Nothing here reads or writes a configuration file. // - Not a validator. A section may state rules about its own values; this package invents none. // - Not wired. No section is registered by this package and no reader is migrated onto it. +// - Not a guard against a key and a table sharing one name. A key at the top of the file that is also +// a section's name cannot be written at all, because no file holds both a value for that name and a +// table under it, so one of the two settings is unreachable and nothing says which. One section +// declares keys at the top of the file today and none of its names is a section's, so the collision +// has no instance; a second such section is where it becomes reachable. // // # Adding a Section // diff --git a/config/registry/registry.go b/config/registry/registry.go index a9753d270d..06d472a15b 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -106,10 +106,6 @@ func record(name, prefix string, prototype any, defaults func(Mode) any) { defects = append(defects, Defect{Section: name, Err: fmt.Errorf("section registered twice")}) return } - if err := refuseOverlap(name, prefix, keys); err != nil { - defects = append(defects, Defect{Section: name, Err: err}) - return - } if err := envNamesAreDistinct(keys); err != nil { defects = append(defects, Defect{Section: name, Err: err}) return @@ -118,61 +114,6 @@ func record(name, prefix string, prototype any, defaults func(Mode) any) { } } -// refuseOverlap rejects a registration whose keys cannot coexist with what is already registered. -// Callers hold mu. -// -// Two shapes of overlap, and neither could happen while every key carried its section's name. A key two -// sections both declare has one default rendered over the other, and which one depends on the order the -// sections are walked. And a root key that is also a section's name cannot be written at all: a file -// holding both a value for that name and a table under it is not valid TOML, so one of the two is -// unreachable and nothing says which. -// -// The first shape reaches the environment check below as well, which would refuse it for the wrong -// reason: two spellings of one variable, when the keys are in fact the same key. This names it as itself. -func refuseOverlap(name, prefix string, keys []string) error { - declaredBy := map[string]string{} - sectionNamed := map[string]string{} - for _, s := range sections { - for _, key := range s.Keys { - declaredBy[key] = s.Name - } - if s.Prefix != "" { - sectionNamed[s.Prefix] = s.Name - } - } - - for _, key := range keys { - if owner, taken := declaredBy[key]; taken { - return fmt.Errorf("%s declares %q and so does %s; one default renders over the other and "+ - "which one wins depends on the order the sections are walked", name, key, owner) - } - if prefix != "" { - continue - } - if owner, taken := sectionNamed[key]; taken { - return fmt.Errorf("%s declares %q at the root of the file and %s is a section of that name; "+ - "a file cannot hold both a value for %q and a table under it, so one of them is "+ - "unreachable", name, key, owner, key) - } - } - - if prefix == "" { - return nil - } - for _, s := range sections { - if s.Prefix != "" { - continue - } - for _, key := range s.Keys { - if key == prefix { - return fmt.Errorf("%s is a section named %q and %s declares %q at the root of the file; "+ - "a file cannot hold both a table and a value under that name", name, prefix, s.Name, key) - } - } - } - return nil -} - // envNamesAreDistinct refuses keys that share one environment spelling. Callers hold mu. // // Dots and hyphens both become underscores, so two keys differing only in that punctuation answer to @@ -189,7 +130,17 @@ func envNamesAreDistinct(adding []string) error { } for _, key := range adding { env := EnvName(key) - if other, taken := spellings[env]; taken { + other, taken := spellings[env] + switch { + case taken && other == key: + // Two sections declaring one key, which a prefix made impossible and a key at the root of the + // file does not. One section's default renders over the other's and which one depends on the + // order the sections are walked, so the value a node runs is decided by nothing an operator + // or a reviewer can see. Named as the one key it is, because the spelling reason below is not + // the reason here. + return fmt.Errorf("%q is declared by two sections; one default renders over the other and "+ + "which one wins depends on the order the sections are walked", key) + case taken: return fmt.Errorf("%q and %q both answer to %s, because a dot and a hyphen are the same "+ "character to the environment, so one of them can never be set from it", other, key, env) } diff --git a/config/registry/rootkeys_test.go b/config/registry/rootkeys_test.go index a8c4083c87..d0a698d135 100644 --- a/config/registry/rootkeys_test.go +++ b/config/registry/rootkeys_test.go @@ -52,61 +52,11 @@ func TestARootSectionDeclaresKeysWithNoPrefix(t *testing.T) { } } -// TestARootKeyAndASectionCannotShareAName holds a limit of the file format, not a matter of taste. -// -// TOML cannot express a value for pruning and a table under pruning in one file, so one of the two is -// unwritable and which one an operator lost would depend on where in the file they wrote it. Registration -// order is not something an operator can see, so the refusal cannot depend on it either. -func TestARootKeyAndASectionCannotShareAName(t *testing.T) { - nested := func() (string, any, func(registry.Mode) any) { - return "pruning", &struct { - Mode string `mapstructure:"mode"` - }{}, func(registry.Mode) any { - return struct { - Mode string `mapstructure:"mode"` - }{Mode: "nothing"} - } - } - root := func() (string, any, func(registry.Mode) any) { - return "base", &struct { - Pruning string `mapstructure:"pruning"` - }{}, func(registry.Mode) any { - return struct { - Pruning string `mapstructure:"pruning"` - }{Pruning: "nothing"} - } - } - - t.Run("the section registers first", func(t *testing.T) { - registry.Reset() - registry.RegisterSection(nested()) - registry.RegisterRootKeys(root()) - if _, ok := registry.Lookup("base"); ok { - t.Error("the root section registered a key that is also a section name. A file cannot hold " + - "both, so one of them is unreachable and nothing says which") - } - if len(registry.Defects()) != 1 { - t.Errorf("recorded %d defects, want one naming the collision", len(registry.Defects())) - } - }) - - t.Run("the root key registers first", func(t *testing.T) { - registry.Reset() - registry.RegisterRootKeys(root()) - registry.RegisterSection(nested()) - if _, ok := registry.Lookup("pruning"); ok { - t.Error("a section registered under a name a root key already holds") - } - if len(registry.Defects()) != 1 { - t.Errorf("recorded %d defects, want one naming the collision", len(registry.Defects())) - } - }) -} - // TestTwoSectionsCannotDeclareTheSameKey was impossible while every key carried its section's name. // // Two prefixes cannot collide. Two root sections can, and the default rendered for such a key would be -// whichever section the walk reached last. +// whichever section the walk reached last. Refused by the environment check, which two identical keys +// reach by answering to one variable, and named as the one key it is rather than as two spellings. func TestTwoSectionsCannotDeclareTheSameKey(t *testing.T) { registry.Reset() same := func(name string) { @@ -130,9 +80,9 @@ func TestTwoSectionsCannotDeclareTheSameKey(t *testing.T) { if len(defects) != 1 { t.Fatalf("recorded %d defects, want one", len(defects)) } - // Named as one key two sections declare, rather than as two spellings of one variable, which is what - // the environment check would have called it. - if got := defects[0].Err.Error(); !strings.Contains(got, "and so does") { + // Named as one key two sections declare rather than as two spellings of one variable, which is the + // reason the same check gives for the collision it was written for. + if got := defects[0].Err.Error(); !strings.Contains(got, "is declared by two sections") { t.Errorf("the refusal reads %q, and an identical key is not an environment spelling collision", got) } } From 9b2d183a163b9dada421d4784aaf390a86ad79d1 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Sun, 23 Aug 2026 10:01:57 -0700 Subject: [PATCH 14/25] config: a declared value is what seid init writes, and the record measures where a node differs These sections said their values were what a node with nothing written resolves. They are not, and the difference was carried in four paragraphs of prose with one of the counts wrong. What they are is what seid init writes for a kind of node: the upstream defaults with the binary's own mode rules applied is exactly the pipeline that renders a generated app.toml, so a declared value is what that file would have held. That is a claim about a real pipeline in this binary rather than a judgement, so it can be held, and it is what a caller writing a configuration file wants. Where a node with nothing written resolves something else is now measured. The reader is driven with the start command's flags bound, the way a booting node binds them, because seventeen of these keys are also flags and a flag's registration default is what an absent key reaches before the lookup comes back empty. Twelve keys differ, and the measurement corrected the prose twice over: seven keys the paragraphs implied differ do not once the flags are bound, and the gRPC toggle does, which no paragraph named. Of the two interface toggles it is the only one that diverges, because its flag defaults the interface on while a generated validator file writes it off. A key that starts diverging fails, and so does one that stops, so guarding a read has to account for its row. Dropping the flag binding fails it too, which is what keeps the record measuring what a node gets rather than what the reader says in isolation. --- config/cosmosbase/agreement_test.go | 200 ++++++++++++++++++++++++++++ config/cosmosbase/cosmosbase.go | 33 ++--- 2 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 config/cosmosbase/agreement_test.go diff --git a/config/cosmosbase/agreement_test.go b/config/cosmosbase/agreement_test.go new file mode 100644 index 0000000000..a13982ecca --- /dev/null +++ b/config/cosmosbase/agreement_test.go @@ -0,0 +1,200 @@ +package cosmosbase + +import ( + "fmt" + "sort" + "testing" + + "github.com/spf13/viper" + + "github.com/sei-protocol/sei-chain/config/registry" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" +) + +// whatANodeRunsToday is what each diverging key resolves to for a configuration carrying no keys. +// +// A declared value is what seid init writes for a kind of node. That is not what a node with nothing +// written resolves, and these are the keys where the two differ. Most are reads that take no account of +// whether the key was present, so an absent key casts to a zero and the default beside it is lost. +// +// Held as text because the two sides carry different Go types for the same key often enough that comparing +// values would be comparing shapes. What matters here is which keys disagree and what a node gets instead. +var whatANodeRunsToday = map[string]string{ + "api.address": "", + "api.max-open-connections": "0", + "api.rpc-max-body-bytes": "0", + "api.rpc-read-timeout": "0", + "api.swagger": "false", + "grpc.enable": "true", + "minimum-gas-prices": "", + "occ-enabled": "false", + "pruning": "default", + "pruning-keep-every": "", + "telemetry.enabled": "false", + "telemetry.prometheus-retention-time": "0", +} + +// whyItMatters says what a node gets today, for the keys where that is worth stating. +var whyItMatters = map[string]string{ + "pruning": "a command flag of this name carries the standard schedule below the file, so a node with " + + "nothing written prunes on that schedule where a generated file would have said keep everything", + "grpc.enable": "a command flag of this name defaults the interface on, so a validator with nothing " + + "written serves gRPC where a generated file would have written it off. This is the one interface " + + "toggle of the two that diverges; the REST one agrees", + "api.max-open-connections": "zero is unlimited, so the ceiling a generated file states is simply " + + "absent from a node that never wrote it, and the same holds for the body-size ceiling beside it", + "minimum-gas-prices": "an empty price refuses to start, so this key is one no running node can " + + "actually have unwritten", + "occ-enabled": "the transaction execution path, and no command flag carries it, so an absent key " + + "reads as off where a generated file says on", +} + +// readerValues is what a node resolves for a configuration carrying none of these keys. +// +// Driven through the reader rather than reasoned about, because the reader is the authority on what an +// absent key resolves to and its answer differs per key: some reads check that the key was present, most +// do not, and two are rescued by a clamp that does nothing for an absent value. +// +// The start command's flags are bound first, the way a booting node binds them, and that is what makes +// this the answer a node gets rather than the answer the reader gives in isolation. Seventeen of these keys +// are also command flags, so a flag's registration default is what an absent key reaches before the lookup +// comes back empty. Without the binding, a key like the gRPC toggle reads as its type's zero and the +// comparison would report agreement where a node disagrees. +// +// One key has to be supplied. The metric label set is the first thing the reader asks for and it refuses a +// configuration without it, so a reader handed nothing at all answers for no key at all. +func readerValues(t *testing.T) map[string]string { + t.Helper() + v := viper.New() + start := server.StartCmd(nil, t.TempDir(), nil) + if err := v.BindPFlags(start.Flags()); err != nil { + t.Fatalf("bind the start flags: %v", err) + } + v.Set(globalLabelsKey, []any{}) + cfg, err := srvconfig.GetConfig(v) + if err != nil { + t.Fatalf("the reader refused a configuration carrying only the label set: %v", err) + } + + return map[string]string{ + "minimum-gas-prices": fmt.Sprint(cfg.MinGasPrices), + "pruning": fmt.Sprint(cfg.Pruning), + "pruning-keep-recent": fmt.Sprint(cfg.PruningKeepRecent), + "pruning-keep-every": fmt.Sprint(cfg.PruningKeepEvery), + "pruning-interval": fmt.Sprint(cfg.PruningInterval), + "halt-height": fmt.Sprint(cfg.HaltHeight), + "halt-time": fmt.Sprint(cfg.HaltTime), + "freeze-height": fmt.Sprint(cfg.FreezeHeight), + "min-retain-blocks": fmt.Sprint(cfg.MinRetainBlocks), + "inter-block-cache": fmt.Sprint(cfg.InterBlockCache), + "compaction-interval": fmt.Sprint(cfg.CompactionInterval), + "concurrency-workers": fmt.Sprint(cfg.ConcurrencyWorkers), + "occ-enabled": fmt.Sprint(cfg.OccEnabled), + "api.enable": fmt.Sprint(cfg.API.Enable), + "api.swagger": fmt.Sprint(cfg.API.Swagger), + "api.address": fmt.Sprint(cfg.API.Address), + "api.enabled-unsafe-cors": fmt.Sprint(cfg.API.EnableUnsafeCORS), + "api.max-open-connections": fmt.Sprint(cfg.API.MaxOpenConnections), + "api.rpc-read-timeout": fmt.Sprint(cfg.API.RPCReadTimeout), + "api.rpc-write-timeout": fmt.Sprint(cfg.API.RPCWriteTimeout), + "api.rpc-max-body-bytes": fmt.Sprint(cfg.API.RPCMaxBodyBytes), + "grpc.enable": fmt.Sprint(cfg.GRPC.Enable), + "grpc.address": fmt.Sprint(cfg.GRPC.Address), + "grpc.max-recv-msg-size": fmt.Sprint(cfg.GRPC.MaxRecvMsgSize), + "grpc.max-open-connections": fmt.Sprint(cfg.GRPC.MaxOpenConnections), + "grpc.max-connection-idle": fmt.Sprint(cfg.GRPC.MaxConnectionIdle), + "grpc.max-connection-age": fmt.Sprint(cfg.GRPC.MaxConnectionAge), + "grpc.max-connection-age-grace": fmt.Sprint(cfg.GRPC.MaxConnectionAgeGrace), + "grpc.keepalive-time": fmt.Sprint(cfg.GRPC.KeepaliveTime), + "grpc.keepalive-timeout": fmt.Sprint(cfg.GRPC.KeepaliveTimeout), + "grpc.keepalive-min-time": fmt.Sprint(cfg.GRPC.KeepaliveMinTime), + "grpc.keepalive-permit-without-stream": fmt.Sprint(cfg.GRPC.KeepalivePermitWithoutStream), + "telemetry.service-name": fmt.Sprint(cfg.Telemetry.ServiceName), + "telemetry.enabled": fmt.Sprint(cfg.Telemetry.Enabled), + "telemetry.enable-hostname": fmt.Sprint(cfg.Telemetry.EnableHostname), + "telemetry.enable-hostname-label": fmt.Sprint(cfg.Telemetry.EnableHostnameLabel), + "telemetry.enable-service-label": fmt.Sprint(cfg.Telemetry.EnableServiceLabel), + "telemetry.prometheus-retention-time": fmt.Sprint(cfg.Telemetry.PrometheusRetentionTime), + "state-sync.snapshot-interval": fmt.Sprint(cfg.StateSync.SnapshotInterval), + "state-sync.snapshot-keep-recent": fmt.Sprint(cfg.StateSync.SnapshotKeepRecent), + "state-sync.snapshot-directory": fmt.Sprint(cfg.StateSync.SnapshotDirectory), + "index-events": fmt.Sprint(cfg.IndexEvents), + globalLabelsKey: fmt.Sprint(cfg.Telemetry.GlobalLabels), + } +} + +// TestTheDivergencesFromTheReaderAreTheRecordedOnes measures what a comment used to count. +// +// A declared value is what seid init writes for a kind of node, and for a good number of these keys that is +// not what a node with nothing written resolves. Which keys those are was carried in prose, in four +// paragraphs, and one of the counts was wrong. Prose cannot fail when it is wrong. +// +// So the set is measured. A key that starts diverging fails, and so does one that stops, which means +// guarding a read has to account for its row rather than quietly making a sentence stale. +// +// Run for the mode whose declared values match the reader's own mode-blind answer most closely, because +// the reader takes no mode and comparing every mode against it would report the mode rules as divergences. +// The mode-varying keys are held by name in the test beside this one. +func TestTheDivergencesFromTheReaderAreTheRecordedOnes(t *testing.T) { + reader := readerValues(t) + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + var measured []string + for key, got := range reader { + declared, declares := resolved.Values[key] + if !declares { + t.Errorf("%s is read by the upstream reader and no section here declares it", key) + continue + } + if fmt.Sprint(declared) == got { + if _, listed := whatANodeRunsToday[key]; listed { + t.Errorf("%s no longer diverges, both sides being %v. Take it off the record, so the "+ + "record stays the set of keys a generated file states differently from a node that "+ + "never wrote them", key, declared) + } + continue + } + measured = append(measured, key) + want, listed := whatANodeRunsToday[key] + switch { + case !listed: + t.Errorf("%s is declared as %v and a node with nothing written resolves %q, and nothing "+ + "records that. %s", key, declared, got, whyItMatters[key]) + case want != got: + t.Errorf("%s is recorded as resolving %q and resolves %q", key, want, got) + } + } + + sort.Strings(measured) + if len(measured) != len(whatANodeRunsToday) { + t.Errorf("measured %d divergences and %d are recorded: %v", + len(measured), len(whatANodeRunsToday), measured) + } +} + +// TestEveryKeyTheseSectionsDeclareIsOneTheReaderResolves holds the two lists against each other. +// +// The reader's side is written out above, which is a second statement of the same key set. It is the only +// statement available: this reader looks its keys up as inline strings rather than through constants, so +// there is nothing to compare a tag against. A key on one side only is either a setting an operator writes +// that no reader fills, or one the reader fills that no section here declares. +func TestEveryKeyTheseSectionsDeclareIsOneTheReaderResolves(t *testing.T) { + reader := readerValues(t) + for _, section := range []string{ + BaseSectionName, APISectionName, GRPCSectionName, TelemetrySectionName, StateSyncSectionName, + } { + registered, ok := registry.Lookup(section) + if !ok { + t.Fatalf("%s is not registered", section) + } + for _, key := range registered.Keys { + if _, filled := reader[key]; !filled { + t.Errorf("%s declares %s and no field above is paired with it", section, key) + } + } + } +} diff --git a/config/cosmosbase/cosmosbase.go b/config/cosmosbase/cosmosbase.go index 7def33ed90..e4588a4800 100644 --- a/config/cosmosbase/cosmosbase.go +++ b/config/cosmosbase/cosmosbase.go @@ -47,11 +47,17 @@ func init() { "in the configuration file instead") } -// forMode is the server configuration a node of this kind is meant to run. +// forMode is the server configuration seid init writes for a node of this kind. // -// The upstream defaults with the binary's own mode rules applied. Every section here answers through this, -// so a section states what a kind of node is meant to run rather than what the type holds before any mode -// is considered, and a rule added to those rules later moves these sections with nothing here changing. +// The upstream defaults with the binary's own mode rules applied, which is exactly the pipeline that +// produces a generated app.toml: seid init builds this and renders it through the template. So a declared +// value here is what that file would have held, and a caller writing a configuration file writes what the +// binary would have written. +// +// That is what a declared value states, and it is deliberately not what a node with nothing written +// resolves. Those differ for a good number of these keys, because most are read with no check that the key +// was present and several are bound to a command flag carrying its own default below the file. The set is +// measured rather than counted, in the agreement test beside this one. // // Three settings differ by mode today and each of them matters in a different direction. A node that // serves queries needs the interfaces that serve them; a validator is meant to expose as little as it can; @@ -72,23 +78,18 @@ func forMode(mode registry.Mode) *srvconfig.Config { // absent key casts to a zero and clobbers the default beside it. Which keys those are, and what a node // resolves for each instead, belongs in a measurement rather than in a count here. // -// Several of these are not what a running node resolves today, and the causes differ: a bound command flag -// of the same name carries its own default below the file, and the command that assembles the server -// configuration overrides some of them before a node starts. The pruning strategy is the one worth naming, -// because the flag defaults it to the standard schedule while this declares it keeps everything. -// -// A caller resolving for a running node therefore has to supply that node's flag values, and only the ones -// an operator actually set. A flag nobody typed still reports a default, and this resolution ranks flags -// above the file, so passing defaults would put every one of them over an operator's own value. +// A caller resolving for a running node has to supply that node's flag values, and only the ones an +// operator actually set. A flag nobody typed still reports a default, and this resolution ranks flags above +// the file, so passing defaults would put every one of them over an operator's own value. func baseDefaults(mode registry.Mode) any { return forMode(mode).BaseConfig } -// apiDefaults is what the REST interface settings resolve to for a node that has written nothing. +// apiDefaults is what the REST interface settings resolve to for a node of this kind. // // On for a full node and an archive node, off for a validator and a seed. Serving queries is what the // first two are for, and the second two are meant to expose as little as they can. func apiDefaults(mode registry.Mode) any { return forMode(mode).API } -// grpcDefaults is what the gRPC settings resolve to for a node that has written nothing. +// grpcDefaults is what the gRPC settings resolve to for a node of this kind. // // On for a full node and an archive node, off for a validator and a seed, which is the same rule the REST // interface follows and for the same reason. The upstream default is on for every kind, so declaring that @@ -100,7 +101,7 @@ func apiDefaults(mode registry.Mode) any { return forMode(mode).API } // which is the shape the reader parses back. func grpcDefaults(mode registry.Mode) any { return forMode(mode).GRPC } -// stateSyncDefaults is what the snapshot settings resolve to for a node that has written nothing. +// stateSyncDefaults is what the snapshot settings resolve to for a node of this kind. // // All three keys are read with a casting getter and no presence check, and the retention is the one that // inverts: it is declared as keeping two snapshots and an absent key casts to zero, which the file format @@ -128,7 +129,7 @@ type telemetrySchema struct { GlobalLabels []any `mapstructure:"global-labels"` } -// telemetryDefaults is what the metric settings resolve to for a node that has written nothing. +// telemetryDefaults is what the metric settings resolve to for a node of this kind. // // Read out of the upstream defaults rather than written again here, so a changed default moves both at // once and this states only which key carries which setting. From 3f9d618db415f951e48d0e9b4ec40478c3802703 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Sun, 23 Aug 2026 10:22:45 -0700 Subject: [PATCH 15/25] config: name which generator a declared value follows This binary writes an app.toml two ways and they disagree on four keys. The provisioning command applies the mode rules and renders the result; a node starting without a file runs a second pipeline that applies no mode rules at all and carries overrides of its own. So it writes the standard pruning strategy where the command writes keeping everything, a metric retention of sixty against seven thousand two hundred, the REST interface on for a validator against off, and a pruning interval drawn at random on every run. A declared value follows the command an operator runs to provision a node. That was already true and the comment said only that seid writes it, which is ninety per cent of a fact. --- config/cosmosbase/cosmosbase.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/config/cosmosbase/cosmosbase.go b/config/cosmosbase/cosmosbase.go index e4588a4800..cc38c75030 100644 --- a/config/cosmosbase/cosmosbase.go +++ b/config/cosmosbase/cosmosbase.go @@ -47,12 +47,18 @@ func init() { "in the configuration file instead") } -// forMode is the server configuration seid init writes for a node of this kind. -// -// The upstream defaults with the binary's own mode rules applied, which is exactly the pipeline that -// produces a generated app.toml: seid init builds this and renders it through the template. So a declared -// value here is what that file would have held, and a caller writing a configuration file writes what the -// binary would have written. +// forMode is the server configuration the seid init command writes for a node of this kind. +// +// The upstream defaults with the binary's own mode rules applied, which is the pipeline that command +// builds and renders through the template. So a declared value here is what that file would have held, and +// a caller writing a configuration file writes what that command would have written. +// +// Named by the command, because this binary generates a file two ways and they do not agree. A node +// starting without one gets a file from a second pipeline that applies no mode rules at all and carries +// overrides of its own, so it writes the standard pruning strategy where this writes keeping everything, +// a metric retention of sixty where this writes seven thousand two hundred, the REST interface on for a +// validator where this writes it off, and a pruning interval drawn at random each time it runs. This +// follows the command an operator runs to provision a node, not the file a node writes for itself. // // That is what a declared value states, and it is deliberately not what a node with nothing written // resolves. Those differ for a good number of these keys, because most are read with no check that the key From e244418bfe985b1c27fde56fa075839e01806136 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Sun, 23 Aug 2026 17:33:53 -0700 Subject: [PATCH 16/25] config: the archive retention departs on purpose, and says so A declared value is what the seid init command writes for a kind of node. This section departs from that once: the retention an archive node keeps. The mode rules set it to keep everything and the command does not write that, because the type it renders declares a state store field of its own and fills it from the mode-blind default, so the rule is applied and then discarded. PLT-955 records that and records the decision, which is to pin what a node resolves today and correct it in the versioned declaration rather than at the point that loses it. So the departure is intended, and it is now held rather than asserted. It fails if the command starts carrying the rule, which is the day the departure should be deleted. It fails if this section stops departing, which would declare a retention on the one kind of node whose purpose is keeping what it would prune. Both directions are checked, because a departure nothing measures cannot be told from an oversight. --- app/config_register.go | 16 ++++++++++---- app/config_register_test.go | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/config_register.go b/app/config_register.go index aca7c71889..e7ab04ba8f 100644 --- a/app/config_register.go +++ b/app/config_register.go @@ -77,16 +77,24 @@ type stateStoreSchema struct { EVMSplit bool `mapstructure:"evm-ss-split"` } -// stateStoreDefaults is what this section resolves to for a node that has written nothing. +// stateStoreDefaults is what the seid init command writes for a node of this kind, with one deliberate +// departure. // // Answered per mode, because two of these settings mean something different depending on what kind of node // asks. An archive node exists to keep history, so it keeps every version; a validator and a seed serve no // queries, so the store is off for them. Both come from the mode rules the binary already states rather // than being written again here, so a change to those rules moves this too. // -// This is the one section here whose declared values are not what its reader produces for a file missing -// the keys, and the divergences are measured rather than described. A test names each one and what a node -// runs today, so a read that gains a presence check has to account for it. +// The departure is the retention an archive node keeps. The mode rules set it to keep everything and the +// command does not write that, because the type it renders declares a state store field of its own and +// fills it from the mode-blind default, so the rule is applied and then discarded. PLT-955 records that, +// and records the decision: pin what a node resolves today and correct it here, in the versioned +// declaration, rather than at the point that loses it. So this states the rule and the command states the +// value the rule was overwritten by, and the test beside this holds both, because a departure nothing +// measures is indistinguishable from an oversight. +// +// The declared values are also not what this section's reader produces for a file missing the keys, which +// is a different comparison and measured separately. func stateStoreDefaults(mode registry.Mode) any { server := srvconfig.DefaultConfig() params.SetAppConfigByMode(server, params.NodeMode(mode)) diff --git a/app/config_register_test.go b/app/config_register_test.go index 35547461ea..7fe25c5c86 100644 --- a/app/config_register_test.go +++ b/app/config_register_test.go @@ -228,3 +228,46 @@ func TestTheSectionsThisPackageRegistersAreUsable(t *testing.T) { } } } + +// TestTheArchiveRetentionDepartsFromWhatTheCommandWrites measures the one deliberate departure. +// +// A declared value is what the seid init command writes for a kind of node. This section departs from that +// in exactly one place: the retention an archive node keeps. The mode rules set it to keep everything, and +// the command does not write that, because the type it renders declares a state store field of its own and +// fills it from the mode-blind default, so the rule is applied and then thrown away. +// +// PLT-955 records the defect and the decision to correct it in the versioned declaration rather than at the +// point that loses it. So the departure is intended, and it is held here for two reasons. It fails if the +// command starts writing the rule, which is the day this departure should be deleted. And it fails if this +// section stops departing, which would put a retention on the one kind of node whose purpose is keeping +// what it would prune. +func TestTheArchiveRetentionDepartsFromWhatTheCommandWrites(t *testing.T) { + live := config.DefaultStateStoreConfig() + + // What the command renders for an archive node: the mode rules are applied to the server + // configuration, and then the type it renders fills its own state store field from the mode-blind + // default, which is what reaches the file. + written := live.KeepRecent + if written == 0 { + t.Fatalf("the mode-blind default retention is already zero, so this departure measures nothing " + + "and the comparison below holds for any declaration") + } + + resolved, err := registry.Resolve(registry.ModeArchive, registry.Sources{}) + if err != nil { + t.Fatalf("%v", err) + } + declared := resolved.Values[FlagSSKeepRecent] + + if declared == written { + t.Errorf("%s resolves to %v for an archive node, which is what the command writes. Either the "+ + "command now carries the mode rule, in which case this departure and its note should go, or "+ + "this section stopped departing and an archive node is declared to prune the history it "+ + "exists to keep", FlagSSKeepRecent, declared) + } + if declared != 0 { + t.Errorf("%s resolves to %v for an archive node, want zero. The mode rule keeps every version, "+ + "and departing from the command is only defensible while this states that rule", + FlagSSKeepRecent, declared) + } +} From 7fc9154a16063a20c02810fc6361b9fcf6ebffc6 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 24 Aug 2026 08:30:01 -0700 Subject: [PATCH 17/25] config: name what these declared values are These four said their values are what a node with nothing written resolves. They are what the seid init command writes for a kind of node, which is a different statement and a checkable one: that command applies the same mode rule to this section's own defaults and renders the result, and for the EVM section it passes what it applied through rather than refilling it from a mode-blind copy, so a declared value here is the value that reaches the file. --- evmrpc/config/register.go | 8 ++++++-- x/evm/blocktest/register.go | 2 +- x/evm/querier/register.go | 2 +- x/evm/replay/register.go | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/evmrpc/config/register.go b/evmrpc/config/register.go index 30d76881e6..3e0790895b 100644 --- a/evmrpc/config/register.go +++ b/evmrpc/config/register.go @@ -14,9 +14,13 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. // -// The two interface toggles answer per kind of node. A full node and an archive node serve queries, which +// That command applies the same mode rule to this section's own defaults and renders the result, and what +// it renders is passed through rather than refilled from a mode-blind copy, so a declared value here is the +// value that reaches the file. +// +// The two interface toggles are what the rule changes. 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. diff --git a/x/evm/blocktest/register.go b/x/evm/blocktest/register.go index 293cd89ea5..14e3f87ba6 100644 --- a/x/evm/blocktest/register.go +++ b/x/evm/blocktest/register.go @@ -14,7 +14,7 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. // // 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. diff --git a/x/evm/querier/register.go b/x/evm/querier/register.go index 532f1f3530..243d701f98 100644 --- a/x/evm/querier/register.go +++ b/x/evm/querier/register.go @@ -14,7 +14,7 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. // // 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. diff --git a/x/evm/replay/register.go b/x/evm/replay/register.go index e6672b4ad2..3aacb732b6 100644 --- a/x/evm/replay/register.go +++ b/x/evm/replay/register.go @@ -18,7 +18,7 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. // // 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 From 517f2402e911e117d6e34bedd10cda3049bbabed Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 24 Aug 2026 08:33:35 -0700 Subject: [PATCH 18/25] config: the wasm query limit is one statement, and it is the one a node runs The section declared this module's own default and the command writes a tenth of it, so the two disagreed by a factor of ten on the only bound on the work one smart query can ask of a node that serves queries to anyone. Declaring the larger one meant a caller rendering a file from these values would loosen that bound, and a test named for the module's default invited exactly that reading. The number now lives once, beside the section, and the command reads it. So a declared value is what reaches an operator's file, and the generated file is unchanged, which the command's own characterization suite confirms. Raising it to the module's default fails a test rather than silently widening a public surface. The module's default is still what a node whose file carries no wasm section resolves. That is a different question, and it is held as one. Three other sections say what their declared values are rather than what a node with nothing written resolves. --- admin/register.go | 2 +- cmd/seid/cmd/app_config.go | 3 ++- giga/executor/config/register.go | 2 +- sei-db/config/receipt_register.go | 2 +- sei-wasmd/x/wasm/config_register.go | 23 +++++++++++++-------- sei-wasmd/x/wasm/config_register_test.go | 26 ++++++++++++++++-------- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/admin/register.go b/admin/register.go index 08f138249f..a7be61ccba 100644 --- a/admin/register.go +++ b/admin/register.go @@ -15,5 +15,5 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. func defaults(registry.Mode) any { return DefaultConfig } diff --git a/cmd/seid/cmd/app_config.go b/cmd/seid/cmd/app_config.go index b984c12c2a..7a48229074 100644 --- a/cmd/seid/cmd/app_config.go +++ b/cmd/seid/cmd/app_config.go @@ -7,6 +7,7 @@ import ( gigaconfig "github.com/sei-protocol/sei-chain/giga/executor/config" srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm" "github.com/sei-protocol/sei-chain/x/evm/blocktest" "github.com/sei-protocol/sei-chain/x/evm/querier" "github.com/sei-protocol/sei-chain/x/evm/replay" @@ -44,7 +45,7 @@ func NewCustomAppConfig(baseConfig *srvconfig.Config, evmConfig evmrpcconfig.Con StateStore: seidbconfig.DefaultStateStoreConfig(), ReceiptStore: seidbconfig.DefaultReceiptStoreConfig(), WASM: WASMConfig{ - QueryGasLimit: 300000, + QueryGasLimit: wasm.GeneratedQueryGasLimit, LruSize: 1, }, EVM: evmConfig, diff --git a/giga/executor/config/register.go b/giga/executor/config/register.go index 271a65805d..4d82e70356 100644 --- a/giga/executor/config/register.go +++ b/giga/executor/config/register.go @@ -19,7 +19,7 @@ func init() { registry.RegisterSection(SectionName, &Config{}, defaults) } -// defaults is what this section resolves to for a node that has written nothing. +// defaults is what the seid init command writes for a node of this kind. // // The same values for every mode. Nothing in the binary makes either setting follow from what kind of // node is asking, so a default that varied here would be this section inventing a rule rather than diff --git a/sei-db/config/receipt_register.go b/sei-db/config/receipt_register.go index 11fd6e25f2..60ce5fc7f8 100644 --- a/sei-db/config/receipt_register.go +++ b/sei-db/config/receipt_register.go @@ -21,7 +21,7 @@ func init() { registry.RegisterSection(ReceiptStoreSectionName, &ReceiptStoreConfig{}, receiptStoreDefaults) } -// receiptStoreDefaults is what this section resolves to for a node that has written nothing. +// receiptStoreDefaults is what the seid init command writes for a node of this kind. // // The database directory resolves to an empty string, and the emptiness carries meaning rather than // standing in for a path nobody chose. The app layer fills it only while it is empty, and what it fills diff --git a/sei-wasmd/x/wasm/config_register.go b/sei-wasmd/x/wasm/config_register.go index 45be17d511..646f219300 100644 --- a/sei-wasmd/x/wasm/config_register.go +++ b/sei-wasmd/x/wasm/config_register.go @@ -10,6 +10,13 @@ import ( // SectionName is this section's name in the configuration key space. const SectionName = "wasm" +// GeneratedQueryGasLimit is the smart-query gas limit a generated app.toml carries. +// +// A tenth of what this module's own default holds, and it is the value every node provisioned by the +// binary runs. Declared here, beside the section, and read by the command that renders the file, so the +// number that reaches an operator and the number this section states are one statement. +const GeneratedQueryGasLimit uint64 = 300_000 + // wasmSchema names the keys this module's reader resolves. // // A schema rather than types.WasmConfig itself, which carries no mapstructure tags at all, so registering @@ -37,19 +44,19 @@ func init() { registry.RegisterSection(SectionName, &wasmSchema{}, sectionDefaults) } -// sectionDefaults is what this section resolves to for a node that has written nothing. +// sectionDefaults is what the seid init command writes for a node of this kind. // -// The query gas limit is the one value here that the binary states twice. This is what a file with no -// wasm section resolves to, and the template writes a tenth of it into every file it generates, so a node -// provisioned by the binary runs the smaller number and a node whose file predates the section runs this -// one. Whoever renders declared values into a file has to decide which of the two survives, and the -// decision is not this section's to make: the limit bounds the work one smart query can ask of a node -// that serves queries to anyone. +// The query gas limit is a tenth of what this module's own default holds, and that is deliberate: it is +// the number the binary writes into every file it generates, so it is what every provisioned node runs. +// Declaring the module's larger default instead would have a caller rendering a file that loosens the +// only bound on the work one smart query can ask of a node serving queries to anyone. The module's +// default is still what a node whose file has no wasm section resolves, which is a different question and +// recorded as one. func sectionDefaults(registry.Mode) any { live := types.DefaultWasmConfig() schema := wasmSchema{ MemoryCacheSize: live.MemoryCacheSize, - QueryGasLimit: live.SmartQueryGasLimit, + QueryGasLimit: GeneratedQueryGasLimit, } if live.SimulationGasLimit != nil { schema.SimulationGasLimit = strconv.FormatUint(*live.SimulationGasLimit, 10) diff --git a/sei-wasmd/x/wasm/config_register_test.go b/sei-wasmd/x/wasm/config_register_test.go index 085d485aa3..0a2655c11b 100644 --- a/sei-wasmd/x/wasm/config_register_test.go +++ b/sei-wasmd/x/wasm/config_register_test.go @@ -34,13 +34,12 @@ func TestTheDeclaredKeysAreTheFlagsThisModuleReads(t *testing.T) { } } -// TestTheDefaultsAreTheModuleDeclaredOnes keeps the schema's values from drifting from the struct's. +// TestTheDefaultsAreWhatTheCommandWrites keeps the schema's values from drifting from what a file carries. // -// The schema restates three settings of types.WasmConfig, so nothing stops those values diverging from -// what DefaultWasmConfig returns except this. It compares against that struct and against nothing else: -// the query gas limit the template writes into a generated file is a tenth of the one here, and this test -// is not the place that reconciles them. -func TestTheDefaultsAreTheModuleDeclaredOnes(t *testing.T) { +// The schema restates three settings of types.WasmConfig, so nothing stops those values diverging except +// this. Two of them come from that struct. The third, the query gas limit, comes from what the command +// renders, which is a tenth of what the struct holds, and both halves of that are held below. +func TestTheDefaultsAreWhatTheCommandWrites(t *testing.T) { live := types.DefaultWasmConfig() for _, mode := range registry.Modes() { if got := sectionDefaults(mode); !reflect.DeepEqual(got, sectionDefaults(registry.ModeValidator)) { @@ -56,8 +55,19 @@ func TestTheDefaultsAreTheModuleDeclaredOnes(t *testing.T) { if got.MemoryCacheSize != live.MemoryCacheSize { t.Errorf("memory_cache_size resolves to %d, want the live %d", got.MemoryCacheSize, live.MemoryCacheSize) } - if got.QueryGasLimit != live.SmartQueryGasLimit { - t.Errorf("query_gas_limit resolves to %d, want the live %d", got.QueryGasLimit, live.SmartQueryGasLimit) + // The limit the command writes, not the module's own default. The two differ by a factor of ten and + // both facts are held: declaring the larger one would have a caller rendering a file that loosens the + // only bound on what one smart query can ask of a node, and the larger one is still what a node whose + // file carries no wasm section resolves. + if got.QueryGasLimit != GeneratedQueryGasLimit { + t.Errorf("query_gas_limit resolves to %d, want the %d the command writes", + got.QueryGasLimit, GeneratedQueryGasLimit) + } + if GeneratedQueryGasLimit >= live.SmartQueryGasLimit { + t.Errorf("the limit the command writes, %d, is no longer below this module's own default, %d. "+ + "If they have converged the distinction here is spurious and should go; if the command's "+ + "limit has grown past the module's, a generated file now loosens the bound rather than "+ + "tightening it", GeneratedQueryGasLimit, live.SmartQueryGasLimit) } // Absent is a meaning of its own here: unset means the consensus block gas limit applies, so an unset // live value resolves to no text rather than to a zero, and a set one resolves to its digits. From 905df2064259b14cb4d9f6a0eb16c5795a5abcdf Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 24 Aug 2026 10:42:49 -0700 Subject: [PATCH 19/25] refactor(config): name the conditions a field tag has to pass tagOf carried its rationale inline, which meant the steps were never named. Each condition that needed explaining is now a predicate whose name says why it is there, and the rationale moved to that predicate's doc comment. The tag excluding a field from configuration and the spelling no written key can match are the two that carried comments. Splitting the tag into a name and the squash option is a step of its own, so the body reads as a sequence rather than parsing in place. Behaviour is unchanged and no test moved. Co-Authored-By: Claude Opus 5 (1M context) --- config/registry/registry.go | 49 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/config/registry/registry.go b/config/registry/registry.go index 4ec7796758..9026fe80ac 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -300,13 +300,7 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool "unreachable through their tags", prefix, f.Name) } - parts := strings.Split(tag, ",") - name = parts[0] - for _, opt := range parts[1:] { - if opt == "squash" { - squash = true - } - } + name, squash = parseTag(tag) if squash { if name != "" { return "", false, false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other", @@ -314,15 +308,7 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool } return "", true, false, nil } - if name == "-" { - // The tag that excludes a field from configuration. Something else in the program assigns the - // field, so no reader resolves a key for it. - // - // It declares no key. Declaring one would put a key in the space that reaches no field, which an - // operator can write and nothing answers, and that is what every other refusal here exists to - // prevent. A field with no tag stays a defect for the same reason read from the other end: it - // would declare a key derived from a field name, which is a key no operator writes. The two look - // alike in a diff and mean opposite things. + if assignedOutsideConfiguration(name) { return "", false, true, nil } if name == "" { @@ -333,7 +319,7 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool "subtree the struct does not have, and neither a dot nor a space survives a round trip "+ "through a configuration source", prefix, f.Name, name, bad) } - if name != strings.ToLower(name) { + if neverMatchesAWrittenKey(name) { return "", false, false, fmt.Errorf("%s.%s names %q, which is not lower case; a configuration "+ "source enumerates lower-cased, so this key would never match a written one", prefix, f.Name, name) @@ -341,6 +327,35 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool return name, false, false, nil } +// parseTag splits a mapstructure tag into the name it gives a field and whether it squashes. +func parseTag(tag string) (name string, squash bool) { + parts := strings.Split(tag, ",") + for _, opt := range parts[1:] { + if opt == "squash" { + squash = true + } + } + return parts[0], squash +} + +// assignedOutsideConfiguration reports whether a tag excludes its field from configuration. +// +// Something else in the program assigns such a field, so no reader resolves a key for it, and declaring +// one would put a key in the space that reaches no field. An untagged field is refused for the same +// reason read from the other end: it would declare a key derived from a field name, which is a key no +// operator writes. The two look alike in a diff and mean opposite things. +func assignedOutsideConfiguration(name string) bool { + return name == "-" +} + +// neverMatchesAWrittenKey reports whether a key segment is spelled so that no written value can reach it. +// +// A configuration source enumerates its keys lower-cased, so a segment carrying an upper-case letter is +// one an operator can write and nothing answers. +func neverMatchesAWrittenKey(name string) bool { + return name != strings.ToLower(name) +} + // unaddressableChar returns the first character in a key segment that no configuration source can // carry, and whether there was one. // From a8fe04fcfd9285f2983fee9c1d86704de7181a27 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 24 Aug 2026 11:31:05 -0700 Subject: [PATCH 20/25] feat(config): declare the peer-to-peer and remote procedure call sections The node's own configuration file carries 141 keys and no section declared any of them. These two are the first, and the registry needed two things before they could be. A section can now leave out a path the struct carries. Two kinds of field earn it. One a reader refuses outright, where writing the key stops the node, so declaring it would put a setting in the space whose only effect is an outage. And one whose absence is itself the setting, where a default would be this package inventing one. An exclusion naming no field the struct carries is refused, because the field it named can be renamed away and leave the exclusion reading as a deliberate omission while excluding nothing. A field that collects what the decode matched no field for now declares no key. What lands in it is what an operator misspelled, so giving it a key would offer the collector itself as a setting to write. Neither the package defining these settings nor the package deciding them can register them: the struct belongs to the node's configuration package and the rules that vary it by node kind live in the parameters package, which imports that struct. So a third package does it, the same shape the upstream server sections already use. Four keys vary by node kind and a test holds all four by value. Two are listen addresses, so a rule that stopped varying would leave a validator binding the address a query-serving node binds. Co-Authored-By: Claude Opus 5 (1M context) --- config/registry/registry.go | 100 +++++++++- config/registry/resolve.go | 5 + config/registry/spec_test.go | 83 +++++++++ config/tendermintbase/tendermintbase.go | 62 +++++++ config/tendermintbase/tendermintbase_test.go | 186 +++++++++++++++++++ 5 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 config/tendermintbase/tendermintbase.go create mode 100644 config/tendermintbase/tendermintbase_test.go diff --git a/config/registry/registry.go b/config/registry/registry.go index 92855231b0..ba5a1a437f 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -51,6 +51,13 @@ type Section struct { Prefix string // Keys are the dotted paths this section declares, sorted. Keys []string + // Excluded are dotted paths the struct carries that this section deliberately does not declare, + // sorted. + // + // Kept rather than discarded because both walks have to agree. The type walk decides what is declared + // and the value walk decides what is stated, and a path dropped from one and not the other makes a + // section that either declares a key nothing answers or answers a key it never declared. + Excluded []string // Defaults returns the section's default for a mode. Defaults func(Mode) any } @@ -87,7 +94,20 @@ var ( // It never panics. A registration this package cannot use is recorded as a Defect and the // section is not registered. func RegisterSection(name string, prototype any, defaults func(Mode) any) { - record(name, name, prototype, defaults) + record(name, name, prototype, defaults, nil) +} + +// RegisterSectionExcluding records a section, leaving out paths the struct carries that are not settings. +// +// Each excluding path is relative to the section, so "max-outbound-connections" rather than the dotted key +// it becomes. A path matching nothing the struct declares is refused, because an exclusion covering +// nothing reads as though it covered something. +// +// Two kinds of field earn this. One a reader refuses outright, where writing the key stops the node, so +// declaring it would put a setting in the space whose only effect is an outage. And one whose absence is +// itself the setting, where any default would be this package inventing one. +func RegisterSectionExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { + record(name, name, prototype, defaults, excluding) } // RegisterRootKeys records a section whose keys sit at the root of the file, with no section of their own. @@ -98,12 +118,23 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) { // Some settings are node-wide and are written at the top of a file rather than inside a table. Giving them // a section would rename them, and a renamed key is one an operator's existing file no longer reaches. func RegisterRootKeys(name string, prototype any, defaults func(Mode) any) { - record(name, "", prototype, defaults) + record(name, "", prototype, defaults, nil) +} + +// RegisterRootKeysExcluding records a root-key section, leaving out paths that are not settings. +// +// RegisterSectionExcluding says which fields earn an exclusion and how a path is spelled. +func RegisterRootKeysExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { + record(name, "", prototype, defaults, excluding) } // record is the one path both registrations take. -func record(name, prefix string, prototype any, defaults func(Mode) any) { +func record(name, prefix string, prototype any, defaults func(Mode) any, excluding []string) { keys, err := deriveKeys(name, prefix, prototype) + var excluded []string + if err == nil { + keys, excluded, err = withoutExcluded(prefix, keys, excluding) + } mu.Lock() defer mu.Unlock() @@ -121,8 +152,48 @@ func record(name, prefix string, prototype any, defaults func(Mode) any) { defects = append(defects, Defect{Section: name, Err: err}) return } - sections[name] = Section{Name: name, Prefix: prefix, Keys: keys, Defaults: defaults} + sections[name] = Section{ + Name: name, Prefix: prefix, Keys: keys, Excluded: excluded, Defaults: defaults, + } + } +} + +// withoutExcluded splits derived paths into the ones a section declares and the ones it does not. +// +// An exclusion is spelled relative to the section, so this is where it becomes the dotted key both walks +// compare against. One matching no derived path is refused: the field it named was renamed or removed, and +// an exclusion for a field that is gone stops excluding anything while still reading as a deliberate +// omission. +func withoutExcluded(prefix string, derived, excluding []string) (keys, excluded []string, err error) { + if len(excluding) == 0 { + return derived, nil, nil + } + drop := make(map[string]bool, len(excluding)) + for _, rel := range excluding { + key := rel + if prefix != "" { + key = prefix + "." + rel + } + drop[key] = true + } + for _, key := range derived { + if drop[key] { + excluded = append(excluded, key) + delete(drop, key) + continue + } + keys = append(keys, key) + } + if len(drop) > 0 { + missing := make([]string, 0, len(drop)) + for key := range drop { + missing = append(missing, key) + } + sort.Strings(missing) + return nil, nil, fmt.Errorf("%v is excluded and the struct declares no such key, so the "+ + "exclusion covers nothing", missing) } + return keys, excluded, nil } // envNamesAreDistinct refuses keys that share one environment spelling. Callers hold mu. @@ -353,7 +424,10 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool "unreachable through their tags", prefix, f.Name) } - name, squash = parseTag(tag) + name, squash, remain := parseTag(tag) + if remain { + return "", false, true, nil + } if squash { if name != "" { return "", false, false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other", @@ -380,15 +454,23 @@ func tagOf(f reflect.StructField, prefix string) (name string, squash, skip bool return name, false, false, nil } -// parseTag splits a mapstructure tag into the name it gives a field and whether it squashes. -func parseTag(tag string) (name string, squash bool) { +// parseTag splits a mapstructure tag into the name it gives a field and the options that change what the +// field is. +// +// A squashed field contributes its own fields at this level rather than a segment of its own. A remaining +// field is where the decode puts what it matched no field for, so it declares no key: what lands in it is +// what an operator misspelled, and giving it a key would offer the collector itself as a setting. +func parseTag(tag string) (name string, squash, remain bool) { parts := strings.Split(tag, ",") for _, opt := range parts[1:] { - if opt == "squash" { + switch opt { + case "squash": squash = true + case "remain": + remain = true } } - return parts[0], squash + return parts[0], squash, remain } // assignedOutsideConfiguration reports whether a tag excludes its field from configuration. diff --git a/config/registry/resolve.go b/config/registry/resolve.go index 9db27c1506..09020e846c 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -180,6 +180,11 @@ func defaultValues(mode Mode, registered []Section) (map[string]any, error) { if err != nil { return out, fmt.Errorf("section %q default for mode %q: %w", s.Name, mode, err) } + // The same paths the declaration left out. Both walks read the one struct, so a path dropped from + // the declared side and kept here would be a value under a key nothing declares. + for _, key := range s.Excluded { + delete(values, key) + } if err := matchesDeclaration(s.Keys, values); err != nil { return out, fmt.Errorf("section %q default for mode %q: %w", s.Name, mode, err) } diff --git a/config/registry/spec_test.go b/config/registry/spec_test.go index 9091b8a965..83f5591a6d 100644 --- a/config/registry/spec_test.go +++ b/config/registry/spec_test.go @@ -1406,3 +1406,86 @@ func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { t.Errorf("the refusal reads %q and does not name the untagged field", got) } } + +// TestAnExclusionDropsAPathFromBothWalks holds the property that makes an exclusion usable. +// +// A section is walked twice, once as a type to decide what it declares and once as a value to decide what +// it states. An exclusion that reached one walk and not the other would leave a section declaring a key +// nothing answers, or answering a key it never declared, and the registry refuses both. So it has to reach +// both, and the only way to see that is through a resolution. +func TestAnExclusionDropsAPathFromBothWalks(t *testing.T) { + type leftOut struct { + Kept string `mapstructure:"kept"` + Dropped string `mapstructure:"dropped"` + } + registry.RegisterSectionExcluding("exclusion_both_walks", &leftOut{}, func(registry.Mode) any { + return leftOut{Kept: "a", Dropped: "b"} + }, "dropped") + + registered, ok := registry.Lookup("exclusion_both_walks") + if !ok { + t.Fatalf("not registered; Defects: %v", registry.Defects()) + } + if want := []string{"exclusion_both_walks.kept"}; !reflect.DeepEqual(registered.Keys, want) { + t.Errorf("declares %v, want %v", registered.Keys, want) + } + + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if _, answered := resolved.Values["exclusion_both_walks.dropped"]; answered { + t.Error("the excluded path is answered, so the value walk kept what the type walk dropped") + } + if got := resolved.Values["exclusion_both_walks.kept"]; got != "a" { + t.Errorf("the kept key answers %#v; excluding one path must not drop the others", got) + } +} + +// TestAnExclusionCoveringNothingIsRefused keeps a stale exclusion from reading as a deliberate omission. +// +// The field an exclusion names can be renamed or removed. Left alone, the exclusion then excludes nothing +// while still saying in the source that this section deliberately leaves a setting out. +func TestAnExclusionCoveringNothingIsRefused(t *testing.T) { + type present struct { + Kept string `mapstructure:"kept"` + } + registry.RegisterSectionExcluding("exclusion_covers_nothing", &present{}, func(registry.Mode) any { + return present{Kept: "a"} + }, "renamed-away") + + if _, ok := registry.Lookup("exclusion_covers_nothing"); ok { + t.Fatal("the section registered with an exclusion naming no field it carries") + } + var found bool + for _, d := range registry.Defects() { + if d.Section == "exclusion_covers_nothing" && strings.Contains(d.Err.Error(), "covers nothing") { + found = true + } + } + if !found { + t.Errorf("no defect says the exclusion covers nothing; Defects: %v", registry.Defects()) + } +} + +// TestAFieldCollectingUnmatchedKeysDeclaresNone covers the one tag option that has no name. +// +// A remaining field is where the decode puts what it matched no field for, so what lands in it is what an +// operator misspelled. No exclusion can reach it, because an exclusion names a key and this field has none. +func TestAFieldCollectingUnmatchedKeysDeclaresNone(t *testing.T) { + type collector struct { + Kept string `mapstructure:"kept"` + Other map[string]any `mapstructure:",remain"` + } + registry.RegisterSection("remaining_field", &collector{}, func(registry.Mode) any { + return collector{Kept: "a"} + }) + + registered, ok := registry.Lookup("remaining_field") + if !ok { + t.Fatalf("a struct carrying a remaining field was refused; Defects: %v", registry.Defects()) + } + if want := []string{"remaining_field.kept"}; !reflect.DeepEqual(registered.Keys, want) { + t.Errorf("declares %v, want %v; the collector itself is not a setting", registered.Keys, want) + } +} diff --git a/config/tendermintbase/tendermintbase.go b/config/tendermintbase/tendermintbase.go new file mode 100644 index 0000000000..60f39177a6 --- /dev/null +++ b/config/tendermintbase/tendermintbase.go @@ -0,0 +1,62 @@ +package tendermintbase + +import ( + "github.com/sei-protocol/sei-chain/app/params" + "github.com/sei-protocol/sei-chain/config/registry" + tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" +) + +// The names these sections have in the configuration key space. +const ( + P2PSectionName = "p2p" + RPCSectionName = "rpc" +) + +// Registration puts these sections in the configuration registry. +// +// Neither the package that defines these settings nor the package that decides them can register them. The +// struct they are read into belongs to the node's own configuration package, and the rules that vary them +// by node kind live in the parameters package, which imports that struct. So the importing direction is +// already fixed and only a third package can see both. +// +// The keys derive from the struct's mapstructure tags, which is what the node's reader decodes through, so +// a key here is a key that reader resolves rather than a second spelling of it. +func init() { + registry.RegisterSectionExcluding(P2PSectionName, &tmcfg.P2PConfig{}, p2pDefaults, + "max-outbound-connections") + registry.RegisterSection(RPCSectionName, &tmcfg.RPCConfig{}, rpcDefaults) +} + +// forMode is the configuration the seid init command writes for a kind of node. +// +// Pinned to that command's own pipeline rather than restated here: the defaults the node's package +// declares, then the mode rules the binary applies to them. A declared value is therefore what a +// generated file carries, and a change to either half moves this with it. +// +// The mode is written onto the configuration before the rules run, because the rules read it from there +// rather than taking it as an argument. +func forMode(mode registry.Mode) *tmcfg.Config { + out := tmcfg.DefaultConfig() + out.Mode = string(mode) + params.SetTendermintConfigByMode(out) + return out +} + +// The one path this section does not declare. +// +// The outbound connection ceiling is a pointer the defaults leave unset, and unset is what selects the +// behaviour: the node derives a ceiling from the total connection limit instead. Declaring it would need a +// default, and any number written here would be this package inventing one that no generated file carries. + +// p2pDefaults is what a generated file carries for the peer-to-peer section. +// +// Answered per mode. Three of these settings follow from what kind of node is asking: a validator refuses +// duplicate addresses, a seed accepts them and raises its connection ceiling because serving peers is what +// it exists for, and a node that serves queries binds an address where a validator leaves the default. +func p2pDefaults(mode registry.Mode) any { return *forMode(mode).P2P } + +// rpcDefaults is what a generated file carries for the remote procedure call section. +// +// Answered per mode, for the listen address alone: a node that serves queries binds one and a validator +// does not. +func rpcDefaults(mode registry.Mode) any { return *forMode(mode).RPC } diff --git a/config/tendermintbase/tendermintbase_test.go b/config/tendermintbase/tendermintbase_test.go new file mode 100644 index 0000000000..974e557109 --- /dev/null +++ b/config/tendermintbase/tendermintbase_test.go @@ -0,0 +1,186 @@ +package tendermintbase + +import ( + "fmt" + "reflect" + "sort" + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/config/registry" + tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" +) + +// whatVariesByNodeKind is every key these sections answer differently depending on the kind of node. +// +// Held by name and value rather than described, because two of these decide what a node exposes to the +// network. A rule that stopped varying would leave a validator binding the address a query-serving node +// binds, and a comment saying otherwise cannot fail. +var whatVariesByNodeKind = map[string]map[registry.Mode]string{ + "p2p.laddr": { + registry.ModeValidator: "tcp://127.0.0.1:26656", + registry.ModeSeed: "tcp://127.0.0.1:26656", + registry.ModeFull: "tcp://0.0.0.0:26656", + registry.ModeArchive: "tcp://0.0.0.0:26656", + }, + "rpc.laddr": { + registry.ModeValidator: "tcp://127.0.0.1:26657", + registry.ModeSeed: "tcp://127.0.0.1:26657", + registry.ModeFull: "tcp://0.0.0.0:26657", + registry.ModeArchive: "tcp://0.0.0.0:26657", + }, + "p2p.max-connections": { + registry.ModeValidator: "100", + registry.ModeSeed: "1000", + registry.ModeFull: "100", + registry.ModeArchive: "100", + }, + "p2p.allow-duplicate-ip": { + registry.ModeValidator: "false", + registry.ModeSeed: "true", + registry.ModeFull: "false", + registry.ModeArchive: "false", + }, +} + +// TestWhatVariesByNodeKindIsTheRecordedSet measures the mode rules through the declared values. +// +// A key that starts varying fails and so does one that stops, which means changing a rule has to account +// for its row here. Two rows are the reason this is measured rather than stated: the listen addresses are +// what put a request surface on a node, and a validator holds a signing key. +func TestWhatVariesByNodeKindIsTheRecordedSet(t *testing.T) { + byMode := map[registry.Mode]map[string]any{} + for _, mode := range registry.Modes() { + resolved, err := registry.Resolve(mode, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve(%s): %v", mode, err) + } + byMode[mode] = resolved.Values + } + + var measured []string + for key := range byMode[registry.ModeValidator] { + if !strings.HasPrefix(key, P2PSectionName+".") && !strings.HasPrefix(key, RPCSectionName+".") { + continue + } + seen := map[string]bool{} + for _, mode := range registry.Modes() { + seen[fmt.Sprint(byMode[mode][key])] = true + } + if len(seen) == 1 { + if _, recorded := whatVariesByNodeKind[key]; recorded { + t.Errorf("%s is recorded as varying by node kind and answers the same for every mode. "+ + "Take it off the record, so the record stays the set of keys a generated file writes "+ + "differently per kind of node", key) + } + continue + } + measured = append(measured, key) + want, recorded := whatVariesByNodeKind[key] + if !recorded { + t.Errorf("%s varies by node kind and nothing records it", key) + continue + } + for _, mode := range registry.Modes() { + if got := fmt.Sprint(byMode[mode][key]); got != want[mode] { + t.Errorf("%s for %s is %q, recorded as %q", key, mode, got, want[mode]) + } + } + } + + sort.Strings(measured) + if len(measured) != len(whatVariesByNodeKind) { + t.Errorf("measured %d keys varying by node kind and %d are recorded: %v", + len(measured), len(whatVariesByNodeKind), measured) + } +} + +// TestTheDeclaredKeysAreTheOnesTheReaderDecodes holds the declaration to the struct the node decodes into. +// +// Derived from that struct's own tags, so this asserts the count rather than the spelling: a renamed tag +// moves the reader and the declaration together, and there is no third statement to drift from. +func TestTheDeclaredKeysAreTheOnesTheReaderDecodes(t *testing.T) { + for _, tc := range []struct { + section string + proto any + exclude int + }{ + {P2PSectionName, &tmcfg.P2PConfig{}, 1}, + {RPCSectionName, &tmcfg.RPCConfig{}, 0}, + } { + registered, ok := registry.Lookup(tc.section) + if !ok { + t.Errorf("%s is not registered; Defects: %v", tc.section, registry.Defects()) + continue + } + tagged := taggedFields(reflect.TypeOf(tc.proto).Elem()) + if want := tagged - tc.exclude; len(registered.Keys) != want { + t.Errorf("%s declares %d keys and the struct carries %d tagged fields with %d excluded, "+ + "so %d were expected", tc.section, len(registered.Keys), tagged, tc.exclude, want) + } + if len(registered.Excluded) != tc.exclude { + t.Errorf("%s excludes %v and %d exclusions were expected", + tc.section, registered.Excluded, tc.exclude) + } + } +} + +// TestTheExcludedPathIsTheOneWithNoDefault names why the one exclusion is there. +// +// The outbound ceiling is a pointer the node's defaults leave unset, and unset is the setting: the node +// derives a ceiling from the total limit instead. A default here would be invented. If the node ever gives +// it one, this fails and the key should be declared rather than excluded. +func TestTheExcludedPathIsTheOneWithNoDefault(t *testing.T) { + registered, ok := registry.Lookup(P2PSectionName) + if !ok { + t.Fatalf("%s is not registered", P2PSectionName) + } + if want := []string{P2PSectionName + ".max-outbound-connections"}; !reflect.DeepEqual(registered.Excluded, want) { + t.Fatalf("excluded is %v, want %v", registered.Excluded, want) + } + if got := tmcfg.DefaultP2PConfig().MaxOutboundConnections; got != nil { + t.Errorf("the node now defaults the outbound ceiling to %v, so it states a value and belongs "+ + "declared rather than excluded", *got) + } +} + +// taggedFields counts the fields of a struct that carry a mapstructure name, following the same rules the +// registry derives keys by: a squashed field contributes its own, and a dash or a remaining field none. +func taggedFields(t reflect.Type) int { + n := 0 + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag, ok := f.Tag.Lookup("mapstructure") + if !ok || f.PkgPath != "" { + continue + } + parts := strings.Split(tag, ",") + opts := parts[1:] + if hasOpt(opts, "remain") || parts[0] == "-" { + continue + } + ft := f.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if hasOpt(opts, "squash") { + n += taggedFields(ft) + continue + } + if ft.Kind() == reflect.Struct && ft.String() != "time.Time" && ft.String() != "big.Int" { + n += taggedFields(ft) + continue + } + n++ + } + return n +} + +func hasOpt(opts []string, want string) bool { + for _, o := range opts { + if o == want { + return true + } + } + return false +} From 261a3083627d89e36029a7791f1b19dfb47099a2 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 24 Aug 2026 12:14:35 -0700 Subject: [PATCH 21/25] fix(config): no section declares the node's root directory Five of these sections carry a root directory field tagged the same as the key at the top of the file, and the node fills every one of them from the command line after the file is read. So the file never carries the value and each section states the empty string for it. Declaring it hands whatever delivers these values an empty root to write over a running node's, and a node that cannot find its data directory, its genesis file or its signing key does not start. Two of the five are here. The test checks every registered section rather than the five, so a section added later that carries the same field fails rather than shipping the same hole. Co-Authored-By: Claude Opus 5 (1M context) --- config/tendermintbase/tendermintbase.go | 16 +++++++++-- config/tendermintbase/tendermintbase_test.go | 29 +++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/config/tendermintbase/tendermintbase.go b/config/tendermintbase/tendermintbase.go index 60f39177a6..56f0316602 100644 --- a/config/tendermintbase/tendermintbase.go +++ b/config/tendermintbase/tendermintbase.go @@ -23,8 +23,9 @@ const ( // a key here is a key that reader resolves rather than a second spelling of it. func init() { registry.RegisterSectionExcluding(P2PSectionName, &tmcfg.P2PConfig{}, p2pDefaults, - "max-outbound-connections") - registry.RegisterSection(RPCSectionName, &tmcfg.RPCConfig{}, rpcDefaults) + filledFromTheCommandLine, "max-outbound-connections") + registry.RegisterSectionExcluding(RPCSectionName, &tmcfg.RPCConfig{}, rpcDefaults, + filledFromTheCommandLine) } // forMode is the configuration the seid init command writes for a kind of node. @@ -42,7 +43,16 @@ func forMode(mode registry.Mode) *tmcfg.Config { return out } -// The one path this section does not declare. +// filledFromTheCommandLine is the path five of these sections carry and none declares. +// +// Each holds a root directory field tagged the same as the one at the top of the file, and the node fills +// every one of them from the command line after the file is read. So the file never carries the value, and +// what these sections state for it is the empty string. Declaring it would hand a delivery an empty root to +// write over a running node's, and a node that cannot find its data directory, its genesis file or its +// signing key does not start. +const filledFromTheCommandLine = "home" + +// The other path the peer-to-peer section does not declare. // // The outbound connection ceiling is a pointer the defaults leave unset, and unset is what selects the // behaviour: the node derives a ceiling from the total connection limit instead. Declaring it would need a diff --git a/config/tendermintbase/tendermintbase_test.go b/config/tendermintbase/tendermintbase_test.go index 974e557109..a6f0d9ab0d 100644 --- a/config/tendermintbase/tendermintbase_test.go +++ b/config/tendermintbase/tendermintbase_test.go @@ -3,6 +3,7 @@ package tendermintbase import ( "fmt" "reflect" + "slices" "sort" "strings" "testing" @@ -105,8 +106,8 @@ func TestTheDeclaredKeysAreTheOnesTheReaderDecodes(t *testing.T) { proto any exclude int }{ - {P2PSectionName, &tmcfg.P2PConfig{}, 1}, - {RPCSectionName, &tmcfg.RPCConfig{}, 0}, + {P2PSectionName, &tmcfg.P2PConfig{}, 2}, + {RPCSectionName, &tmcfg.RPCConfig{}, 1}, } { registered, ok := registry.Lookup(tc.section) if !ok { @@ -135,8 +136,8 @@ func TestTheExcludedPathIsTheOneWithNoDefault(t *testing.T) { if !ok { t.Fatalf("%s is not registered", P2PSectionName) } - if want := []string{P2PSectionName + ".max-outbound-connections"}; !reflect.DeepEqual(registered.Excluded, want) { - t.Fatalf("excluded is %v, want %v", registered.Excluded, want) + if !slices.Contains(registered.Excluded, P2PSectionName+".max-outbound-connections") { + t.Fatalf("excluded is %v and does not name the outbound ceiling", registered.Excluded) } if got := tmcfg.DefaultP2PConfig().MaxOutboundConnections; got != nil { t.Errorf("the node now defaults the outbound ceiling to %v, so it states a value and belongs "+ @@ -184,3 +185,23 @@ func hasOpt(opts []string, want string) bool { } return false } + +// TestNoSectionDeclaresTheRootDirectory covers a field five of these sections carry. +// +// Each holds a root directory tagged the same as the key at the top of the file, and the node fills every +// one from the command line after the file is read. So each states the empty string, and a delivery that +// wrote a declared value would blank the root a running node found its data, its genesis file and its +// signing key under. +// +// Checked across every registered section rather than the five, so a section added later that carries the +// same field fails here instead of shipping the same hole. +func TestNoSectionDeclaresTheRootDirectory(t *testing.T) { + for _, s := range registry.Sections() { + for _, key := range s.Keys { + if key == "home" || strings.HasSuffix(key, ".home") { + t.Errorf("%s declares %q, and the node fills that field from the command line after the "+ + "file is read, so what this section states for it is the empty string", s.Name, key) + } + } + } +} From 8da6eddbe73a1b8fea5982ab32cc5c32d5d1c1ab Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 25 Aug 2026 14:16:21 -0700 Subject: [PATCH 22/25] docs(config): say what an exclusion claims that a dash tag does not The two look like one idea in two places and they are opposite claims about the field. A dash tag says nothing decodes it: it is not configuration and no file reaches it. An exclusion says the field is configuration, the reader that owns its file decodes it, and this key space does not offer it. Tagging one of these paths would take the setting away from that reader, so the choice is not a matter of convenience and the comment now says which is which. Co-Authored-By: Claude Opus 5 (1M context) --- config/registry/registry.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/registry/registry.go b/config/registry/registry.go index 41c2630b09..22c49c900b 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -106,6 +106,12 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) { // Two kinds of field earn this. One a reader refuses outright, where writing the key stops the node, so // declaring it would put a setting in the space whose only effect is an outage. And one whose absence is // itself the setting, where any default would be this package inventing one. +// +// A field can also carry "-" as its mapstructure name, and that says something different rather than the +// same thing in another place. The tag says nothing decodes the field: it is not configuration, and no file +// reaches it. An exclusion says the opposite about the field and less about this package: the field is +// configuration, the reader that owns its file decodes it, and this key space does not offer it. Tagging one +// of these paths would take the setting away from that reader. func RegisterSectionExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { record(name, name, prototype, defaults, excluding) } From ca21fc913f29a50540082b4d46b0dd3ef888a44a Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 25 Aug 2026 15:25:51 -0700 Subject: [PATCH 23/25] fix(config): a resolution over a diminished key space is refused Six defects in the registry, each of which resolved cleanly and reported success. Sections and Lookup copied Keys and returned Excluded by reference. The exclusion list is read on every Resolve, so a caller writing into what it was handed dropped a path from one walk and not the other, and every later resolution failed for every mode. Both accessors now go through one detach, so a slice field added later is copied without anyone remembering to copy it twice. Resolve answered happily for a key space that had lost a section. A registration this package cannot use is a section missing from the space, and every key it declared then read as one nothing declares, so an operator's written value was reported as unknown rather than applied. Two sections colliding is the case that matters: the loser is dropped whole and which one loses follows package initialisation order, so one binary answered differently for reasons no caller could see. Refused before anything resolves. Sources.File had an unstated shape. Handed the nested shape a configuration reader produces, every key an operator wrote sat a level below where a dotted path matched it, so the resolution carried pure defaults and named the table as the one unknown key. A caller installing that result writes the declared set over the file it was reading. The shape is now stated on the field and refused, detected by a table holding a declared key so a key whose own field is a map is left alone. A section whose every declared path was excluded registered with no keys. It answered nothing while taking its name, so the real registration under that name was later refused as a duplicate. Refused after the exclusions are known, which is where the sibling guards already sit. An exclusion repeated in one call collapsed into the same map entry, so the second covered nothing and the guard for that could not see it. The tag claim was wrong: the decoder honours "-" when rendering a struct into a map and not when decoding a map into a struct. Stated as intent rather than a barrier. Two tests measured a mechanism. The storage-escape test covered Keys through both accessors and not Excluded, and wrote by index rather than appending, which is what kept it honest: every section registered today has capacity equal to length, so an append reallocates and would have passed. Four tests resolved without resetting and passed on the position of a neighbour; the new refusal exposed them, and the package now passes under twenty shuffle seeds where two of the first four failed. Co-Authored-By: Claude Opus 5 (1M context) --- config/registry/detach_test.go | 1 + config/registry/registry.go | 42 ++++++++++++---- config/registry/resolve.go | 48 ++++++++++++++++++ config/registry/spec_test.go | 90 +++++++++++++++++++++++++++------- 4 files changed, 153 insertions(+), 28 deletions(-) diff --git a/config/registry/detach_test.go b/config/registry/detach_test.go index 2ddd63e39a..00904f1e11 100644 --- a/config/registry/detach_test.go +++ b/config/registry/detach_test.go @@ -75,6 +75,7 @@ func TestAResolvedListIsTheCallersToWriteInto(t *testing.T) { // Driven through a second resolution rather than by inspecting the variable, because what matters is not // that a copy was taken but that the next caller is unaffected. func TestSortingAResolvedNestedListLeavesTheSectionsOwnDefaultAlone(t *testing.T) { + registry.Reset() type nested struct { Deny map[string][]string `mapstructure:"deny"` } diff --git a/config/registry/registry.go b/config/registry/registry.go index 22c49c900b..df686aa6e3 100644 --- a/config/registry/registry.go +++ b/config/registry/registry.go @@ -108,10 +108,15 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) { // itself the setting, where any default would be this package inventing one. // // A field can also carry "-" as its mapstructure name, and that says something different rather than the -// same thing in another place. The tag says nothing decodes the field: it is not configuration, and no file -// reaches it. An exclusion says the opposite about the field and less about this package: the field is -// configuration, the reader that owns its file decodes it, and this key space does not offer it. Tagging one -// of these paths would take the setting away from that reader. +// same thing in another place. The tag says the field is not configuration and no reader offers it. An +// exclusion says the opposite about the field and less about this package: the field is configuration, the +// reader that owns its file decodes it, and this key space does not offer it. Tagging one of these paths +// would take the setting away from that reader. +// +// The tag is a statement of intent and not a barrier. The decoder in use honours "-" when it renders a +// struct into a map and not when it decodes a map into a struct, where it takes the name literally, so a +// key spelled "-" reaches every field in the struct tagged that way at once. Nothing offers such a key and +// no operator writes one, which is why this is stated rather than guarded. func RegisterSectionExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { record(name, name, prototype, defaults, excluding) } @@ -141,6 +146,13 @@ func record(name, prefix string, prototype any, defaults func(Mode) any, excludi if err == nil { keys, excluded, err = withoutExcluded(prefix, keys, excluding) } + // Refused after the exclusions are known, because deriveKeys can only see a struct that declares + // nothing and this catches the section whose every declared path was then excluded. Such a section + // answers no key while still taking its name, so the real registration under that name is later + // refused as a duplicate, and its defaults are still rendered on every resolution. + if err == nil && len(keys) == 0 { + err = fmt.Errorf("every path it declares is excluded (%v), so the section declares nothing", excluded) + } mu.Lock() defer mu.Unlock() @@ -180,6 +192,9 @@ func withoutExcluded(prefix string, derived, excluding []string) (keys, excluded if prefix != "" { key = prefix + "." + rel } + if drop[key] { + return nil, nil, fmt.Errorf("%s is excluded twice, and the second one covers nothing", key) + } drop[key] = true } for _, key := range derived { @@ -237,14 +252,24 @@ func envNamesAreDistinct(adding []string) error { return nil } +// detached returns a copy of the section that shares no storage with the registry's own. +// +// Every path out of the mutex goes through this, so a slice field added to Section later is copied +// without anyone having to remember to copy it at each accessor. A caller that sorts or writes into +// what it was handed reaches its own storage, and both walks keep reading what registration decided. +func (s Section) detached() Section { + s.Keys = append([]string(nil), s.Keys...) + s.Excluded = append([]string(nil), s.Excluded...) + return s +} + // Sections returns every registered section, sorted by name. func Sections() []Section { mu.RLock() defer mu.RUnlock() out := make([]Section, 0, len(sections)) for _, s := range sections { - s.Keys = append([]string(nil), s.Keys...) - out = append(out, s) + out = append(out, s.detached()) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out @@ -255,10 +280,7 @@ func Lookup(name string) (Section, bool) { mu.RLock() defer mu.RUnlock() s, ok := sections[name] - // Copied, so a caller sorting or writing into Keys cannot reach the registry's own storage from - // outside the mutex. Defects copies for the same reason. - s.Keys = append([]string(nil), s.Keys...) - return s, ok + return s.detached(), ok } // Defects returns every registration this package could not use. diff --git a/config/registry/resolve.go b/config/registry/resolve.go index a6cecc6896..b483d0f9ce 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -48,6 +48,12 @@ type Resolved struct { // supply. LookupEnv is a function rather than a map because an environment cannot be enumerated for a // prefix, since a variable is only findable if you already know its name. type Sources struct { + // File is one flat map whose keys are whole dotted paths, matched without regard to case. + // + // Flat rather than nested, because that is the shape the other two sources carry: a flag name and + // an environment variable are each one string naming one key. A decoded configuration file is the + // wrong shape, since a table there is a nested map and every key an operator wrote sits a level + // below where anything matches it. Resolve refuses that shape rather than resolving past it. File map[string]any LookupEnv func(string) (string, bool) Flags map[string]any @@ -90,6 +96,17 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { mode, Modes()) } + // Refused before anything is resolved, because a registration this package could not use is a + // section missing from the key space, and every key it declared then reads as one no section + // declares. An operator's written value for such a key is reported as unknown rather than applied, + // which is the hole this function promises never to hand out. Two sections colliding is the case + // that matters: the loser is dropped whole and which one loses follows package initialisation + // order, so the same binary can answer differently for reasons no caller can see. + if bad := Defects(); len(bad) > 0 { + return out, fmt.Errorf("the registry could not use %d registration(s), so the key space is "+ + "incomplete: %v", len(bad), bad) + } + // One snapshot, read once and passed everywhere below. Every part of the answer has to describe the // same registry: asking again leaves a window a concurrent registration fits through, and a section // arriving in that window is declared by one part of the answer and not by another. @@ -99,6 +116,9 @@ func Resolve(mode Mode, from Sources) (Resolved, error) { return out, err } declared := declaredKeys(registered) + if err := refuseANestedFile(from.File, declared); err != nil { + return out, err + } undeliverable := keysNoVariableCanCarry(defaults) out.Values = make(map[string]any, len(declared)) @@ -456,6 +476,34 @@ func envValues(declared map[string]bool, undeliverable map[string]string, // // A source enumerates lower-cased while a file may not be written that way, and a key that differed // only in case would resolve as unknown while the operator's value went nowhere. +// refuseANestedFile refuses a file source whose tables were left nested. +// +// The shape a configuration reader hands back, and it resolves to pure defaults without complaint: every +// key an operator wrote sits one level below where a dotted path matches it, so nothing is applied, and +// the table's own name is the one thing reported, as a key no section declares. A caller that then +// installs what it resolved writes the whole declared set over the file it was trying to read. +// +// Detected by a table holding a declared key, which is that mistake and nothing else. A declared key +// whose own field is a map is left alone, since the value an operator writes for it is a table. +func refuseANestedFile(values map[string]any, declared map[string]bool) error { + for key, v := range values { + if _, isTable := v.(map[string]any); !isTable { + continue + } + lower := strings.ToLower(key) + if declared[lower] { + continue + } + for d := range declared { + if strings.HasPrefix(d, lower+".") { + return fmt.Errorf("File[%q] is a table holding %q, so the source is nested where this "+ + "reads one flat map of whole dotted keys", key, d) + } + } + } + return nil +} + func fileValues(values map[string]any) map[string]any { if values == nil { return nil diff --git a/config/registry/spec_test.go b/config/registry/spec_test.go index 83f5591a6d..ad1d68331b 100644 --- a/config/registry/spec_test.go +++ b/config/registry/spec_test.go @@ -2,6 +2,7 @@ package registry_test import ( "reflect" + "slices" "sort" "strings" "sync" @@ -1033,33 +1034,43 @@ func requireEnvCollisionRefused(t *testing.T, spelling string) { // every other caller's declared set. func TestAReadKeySliceCannotReachTheRegistry(t *testing.T) { registry.Reset() - registry.RegisterSection("s", &struct { - B string `mapstructure:"b"` - A string `mapstructure:"a"` - }{}, func(registry.Mode) any { - return struct { - B string `mapstructure:"b"` - A string `mapstructure:"a"` - }{} - }) + type shape struct { + B string `mapstructure:"b"` + A string `mapstructure:"a"` + Gone string `mapstructure:"gone"` + } + registry.RegisterSectionExcluding("s", &shape{}, + func(registry.Mode) any { return shape{} }, "gone") requireNoDefects(t) - want := []string{"s.a", "s.b"} + // Both slices, through both accessors. The exclusion list is read on every Resolve, so a caller + // that reached it would not corrupt one key: it would drop a path from one walk and not the other, + // and every later resolution would fail for every mode. for _, read := range []struct { - name string - keys func() []string + name string + want []string + slice func() []string }{ - {"Lookup", func() []string { s, _ := registry.Lookup("s"); return s.Keys }}, - {"Sections", func() []string { return registry.Sections()[0].Keys }}, + {"Lookup.Keys", []string{"s.a", "s.b"}, + func() []string { s, _ := registry.Lookup("s"); return s.Keys }}, + {"Lookup.Excluded", []string{"s.gone"}, + func() []string { s, _ := registry.Lookup("s"); return s.Excluded }}, + {"Sections.Keys", []string{"s.a", "s.b"}, + func() []string { return registry.Sections()[0].Keys }}, + {"Sections.Excluded", []string{"s.gone"}, + func() []string { return registry.Sections()[0].Excluded }}, } { t.Run(read.name, func(t *testing.T) { - got := read.keys() + // Written by index rather than appended, because every section registered today has a + // capacity equal to its length, so an append reallocates and would pass while the + // registry's own storage was still reachable. + got := read.slice() for i := range got { got[i] = "clobbered" } - if after := read.keys(); !reflect.DeepEqual(after, want) { - t.Errorf("writing into the slice %s returned left the registry declaring %v, want %v", - read.name, after, want) + if after := read.slice(); !reflect.DeepEqual(after, read.want) { + t.Errorf("writing into the slice %s returned left the registry holding %v, want %v", + read.name, after, read.want) } }) } @@ -1414,6 +1425,7 @@ func TestAFieldExcludedFromConfigDeclaresNoKey(t *testing.T) { // nothing answers, or answering a key it never declared, and the registry refuses both. So it has to reach // both, and the only way to see that is through a resolution. func TestAnExclusionDropsAPathFromBothWalks(t *testing.T) { + registry.Reset() type leftOut struct { Kept string `mapstructure:"kept"` Dropped string `mapstructure:"dropped"` @@ -1447,6 +1459,7 @@ func TestAnExclusionDropsAPathFromBothWalks(t *testing.T) { // The field an exclusion names can be renamed or removed. Left alone, the exclusion then excludes nothing // while still saying in the source that this section deliberately leaves a setting out. func TestAnExclusionCoveringNothingIsRefused(t *testing.T) { + registry.Reset() type present struct { Kept string `mapstructure:"kept"` } @@ -1473,6 +1486,7 @@ func TestAnExclusionCoveringNothingIsRefused(t *testing.T) { // A remaining field is where the decode puts what it matched no field for, so what lands in it is what an // operator misspelled. No exclusion can reach it, because an exclusion names a key and this field has none. func TestAFieldCollectingUnmatchedKeysDeclaresNone(t *testing.T) { + registry.Reset() type collector struct { Kept string `mapstructure:"kept"` Other map[string]any `mapstructure:",remain"` @@ -1489,3 +1503,43 @@ func TestAFieldCollectingUnmatchedKeysDeclaresNone(t *testing.T) { t.Errorf("declares %v, want %v; the collector itself is not a setting", registered.Keys, want) } } + +// TestANestedFileSourceIsRefusedRatherThanResolvedPast measures what a wrong shape used to cost. +// +// File is one flat map of whole dotted keys. Handed a decoded configuration file instead, every key an +// operator wrote sits a level below where anything matches it, so the resolution carried pure defaults +// and reported the table's name as the one key nothing declares. A caller installing that result writes +// the binary's own defaults over the file it was reading, which is how an empty value reaches a setting +// something outside the binary had patched. +// +// So the shape is refused. Asserted through what an operator loses rather than through the error, because +// an error nobody returns and a value nobody applies look the same from the outside. +func TestANestedFileSourceIsRefusedRatherThanResolvedPast(t *testing.T) { + registry.Reset() + type peers struct { + Persistent string `mapstructure:"persistent-peers"` + } + registry.RegisterSection("net", &peers{}, + func(registry.Mode) any { return peers{Persistent: ""} }) + requireNoDefects(t) + + const written = "node-id@10.0.0.1:26656" + nested := map[string]any{"net": map[string]any{"persistent-peers": written}} + if _, err := registry.Resolve(registry.ModeValidator, registry.Sources{File: nested}); err == nil { + t.Fatal("a nested file source resolved, so a caller installing the result would have written " + + "the declared empty value over the peer list the file carried") + } + + // The flat shape of the same file still resolves, so the refusal is of the shape and not of the key. + flat := map[string]any{"net.persistent-peers": written} + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{File: flat}) + if err != nil { + t.Fatalf("the flat shape was refused: %v", err) + } + if got := resolved.Values["net.persistent-peers"]; got != written { + t.Errorf("net.persistent-peers resolved to %v, want the written %q", got, written) + } + if !slices.Contains(resolved.Overrides, "net.persistent-peers") { + t.Errorf("Overrides is %v, so an install would skip the one key the file wrote", resolved.Overrides) + } +} From 561d5dbefe1426330f143cff19f652b958ec310a Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 25 Aug 2026 15:29:31 -0700 Subject: [PATCH 24/25] fix(config): the peer-to-peer section states what it leaves out and why Three corrections to what this section declares. The dial hook is left out. Its own comment calls it a testing parameter, but nothing in the tree reads it and the generated file does not write it, so declaring it offered a key that changes nothing about how the node runs. That is the rule this file already applies to the root directory, and the reason it gives now names the field rather than sitting in prose. The connection ceiling carried its rationale in a comment attached to nothing, below the constant it was about and above a function it was not. Both paths this section leaves out are now constants whose names say why, so the registration reads as the list of reasons rather than a list of strings. The peer seeds are the harder one. The init command sets them after the pipeline this package mirrors, from the chain identifier, which is a runtime input no node kind carries. So no answer keyed on mode can be right, and the declared empty string is not what a generated file holds on either public network. Excluding the key would be worse: the template writes it into every file, and a key this space refuses is one an operator's own file reports as unknown. It stays declared, and what it costs is now stated where forMode makes the claim it breaks, with a test naming the writer so a third key joining the class fails rather than becoming a declared default nothing questions. forMode also reached the mode rules by a different route than the command does for an archive node, and the two agreed only because the rules answer alike for both. Stated, with a pointer to the test that keeps them together. Verified by mutation: excluding the seeds rather than declaring them, adding a key to the list with no writer named, and making the declaration carry the seeds so the row stops diverging are each caught. Co-Authored-By: Claude Opus 5 (1M context) --- config/tendermintbase/tendermintbase.go | 39 ++++++++++++--- config/tendermintbase/tendermintbase_test.go | 52 +++++++++++++++++++- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/config/tendermintbase/tendermintbase.go b/config/tendermintbase/tendermintbase.go index 56f0316602..ae5dd40362 100644 --- a/config/tendermintbase/tendermintbase.go +++ b/config/tendermintbase/tendermintbase.go @@ -23,7 +23,7 @@ const ( // a key here is a key that reader resolves rather than a second spelling of it. func init() { registry.RegisterSectionExcluding(P2PSectionName, &tmcfg.P2PConfig{}, p2pDefaults, - filledFromTheCommandLine, "max-outbound-connections") + filledFromTheCommandLine, derivedFromTheConnectionLimit, readByNothing) registry.RegisterSectionExcluding(RPCSectionName, &tmcfg.RPCConfig{}, rpcDefaults, filledFromTheCommandLine) } @@ -31,11 +31,16 @@ func init() { // forMode is the configuration the seid init command writes for a kind of node. // // Pinned to that command's own pipeline rather than restated here: the defaults the node's package -// declares, then the mode rules the binary applies to them. A declared value is therefore what a -// generated file carries, and a change to either half moves this with it. +// declares, then the mode rules the binary applies to them. So a declared value is what a generated file +// carries for every key but the ones in filledByTheGenerator, and a change to either half moves this +// with it. That command goes on to set those from inputs no mode carries, which is why they are named +// rather than described. // // The mode is written onto the configuration before the rules run, because the rules read it from there -// rather than taking it as an argument. +// rather than taking it as an argument. The command reaches the same rules by a different route for an +// archive node, writing the full-node mode before it runs them, and the two agree only because the rules +// answer alike for both. A rule that stopped answering alike would leave this the more specific of the +// two answers, so the test holding what varies by node kind is what keeps them together. func forMode(mode registry.Mode) *tmcfg.Config { out := tmcfg.DefaultConfig() out.Mode = string(mode) @@ -52,11 +57,29 @@ func forMode(mode registry.Mode) *tmcfg.Config { // signing key does not start. const filledFromTheCommandLine = "home" -// The other path the peer-to-peer section does not declare. +// derivedFromTheConnectionLimit is the ceiling this section leaves out because unset is the setting. // -// The outbound connection ceiling is a pointer the defaults leave unset, and unset is what selects the -// behaviour: the node derives a ceiling from the total connection limit instead. Declaring it would need a -// default, and any number written here would be this package inventing one that no generated file carries. +// The field is a pointer the defaults leave unset, and unset is what selects the behaviour: the node +// derives a ceiling from the total connection limit instead. Declaring it would need a default, and any +// number written here would be this package inventing one that no generated file carries. +const derivedFromTheConnectionLimit = "max-outbound-connections" + +// readByNothing is the dial hook this section leaves out because no code reads it. +// +// The field is declared beside the peer-to-peer settings and its own comment calls it a testing parameter, +// but nothing in the tree reads it and the generated file does not write it. Declaring it would offer a +// key that changes nothing about how the node runs. +const readByNothing = "test-dial-fail" + +// filledByTheGenerator are keys the init command sets after the pipeline forMode mirrors, from an input a +// mode does not carry. +// +// Declared rather than excluded, because the generated file writes each one into every node's +// configuration, and a key this space refuses is a key an operator's own file reports as unknown. What +// they cost is the invariant on forMode: for these, a declared value is not what a generated file +// carries. The peer seeds come from the chain identifier, which is a runtime input and not a node kind, +// so no answer keyed on mode can be right. A writer takes the value from the generator instead. +var filledByTheGenerator = []string{P2PSectionName + ".bootstrap-peers"} // p2pDefaults is what a generated file carries for the peer-to-peer section. // diff --git a/config/tendermintbase/tendermintbase_test.go b/config/tendermintbase/tendermintbase_test.go index a6f0d9ab0d..1722340469 100644 --- a/config/tendermintbase/tendermintbase_test.go +++ b/config/tendermintbase/tendermintbase_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/sei-protocol/sei-chain/app/seeds" "github.com/sei-protocol/sei-chain/config/registry" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" ) @@ -106,7 +107,7 @@ func TestTheDeclaredKeysAreTheOnesTheReaderDecodes(t *testing.T) { proto any exclude int }{ - {P2PSectionName, &tmcfg.P2PConfig{}, 2}, + {P2PSectionName, &tmcfg.P2PConfig{}, 3}, {RPCSectionName, &tmcfg.RPCConfig{}, 1}, } { registered, ok := registry.Lookup(tc.section) @@ -205,3 +206,52 @@ func TestNoSectionDeclaresTheRootDirectory(t *testing.T) { } } } + +// TestWhatTheGeneratorFillsIsNotWhatTheDeclarationStates names the writer for each key on the list. +// +// A declared value is what a generated file carries, and these are the keys where that is not true +// because the init command sets them after the pipeline forMode mirrors. Held as a list with a test +// rather than a sentence, so a third key joining the class fails here instead of becoming a declared +// default nothing questions. +// +// Each row asserts both halves: the key is declared, so it is not quietly excluded, and what the +// generator supplies is something the declaration does not state. A key that stopped diverging fails +// too, which is what makes this a record of the class rather than a note about two keys. +func TestWhatTheGeneratorFillsIsNotWhatTheDeclarationStates(t *testing.T) { + // The generator's own source for each key, read from it rather than restated, so a change there + // moves this. A chain identifier is needed for the peer seeds because that is the input the mode + // rules do not carry, and the public networks are the ones an operator runs. + supplied := map[string]string{ + P2PSectionName + ".bootstrap-peers": seeds.BootstrapPeers("pacific-1"), + } + + resolved, err := registry.Resolve(registry.ModeValidator, registry.Sources{}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + for _, key := range filledByTheGenerator { + want, named := supplied[key] + if !named { + t.Errorf("%s is on the list and no writer is named for it, so nothing measures that the "+ + "generator fills it", key) + continue + } + declared, declares := resolved.Values[key] + if !declares { + t.Errorf("%s is on the list and no section declares it; a key the generated file carries "+ + "belongs in the key space", key) + continue + } + if want == "" { + t.Errorf("%s names a writer that supplies nothing, so the row records no divergence", key) + continue + } + if fmt.Sprint(declared) == want { + t.Errorf("%s is declared as %q and the generator supplies the same, so it no longer "+ + "diverges. Take it off the list, and off forMode's exception", declared, want) + } + } + if len(supplied) != len(filledByTheGenerator) { + t.Errorf("%d writers are named and %d keys are listed", len(supplied), len(filledByTheGenerator)) + } +} From a4c11efc3fed8c2844f545b7e8c511af7eb48fcd Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 25 Aug 2026 16:04:21 -0700 Subject: [PATCH 25/25] fix(config): the nested-source refusal reads as a sentence The message opened with the field's Go name, which staticcheck refuses for an error string and which read as a symbol rather than as a description of what the caller did. Co-Authored-By: Claude Opus 5 (1M context) --- config/registry/resolve.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/registry/resolve.go b/config/registry/resolve.go index b483d0f9ce..3beda34c7d 100644 --- a/config/registry/resolve.go +++ b/config/registry/resolve.go @@ -496,8 +496,8 @@ func refuseANestedFile(values map[string]any, declared map[string]bool) error { } for d := range declared { if strings.HasPrefix(d, lower+".") { - return fmt.Errorf("File[%q] is a table holding %q, so the source is nested where this "+ - "reads one flat map of whole dotted keys", key, d) + return fmt.Errorf("the file source holds a table at %q carrying %q, so it is nested "+ + "where this reads one flat map of whole dotted keys", key, d) } } }